From 798a36bd6cea58c54f9edf6136eb544ad7e60b68 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 20:53:14 +0700 Subject: [PATCH 01/12] 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); -- 2.54.0 From 296708244e8e72ea43cd40a85f2c52812a228442 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 21:18:31 +0700 Subject: [PATCH 02/12] feat(enakgame): budget controller recommendations and analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 --- docs/rfc-enakgame.md | 29 +- internal/app/app.go | 11 +- internal/constants/enakgame.go | 32 ++ internal/entities/enakgame.go | 7 +- internal/handler/enakgame_admin_handler.go | 48 +++ internal/handler/enakgame_db_test.go | 8 +- internal/models/enakgame.go | 167 +++++++- .../processor/enakgame_admin_processor.go | 2 +- .../processor/enakgame_analytics_db_test.go | 80 ++++ .../processor/enakgame_analytics_processor.go | 153 ++++++++ .../enakgame_analytics_processor_test.go | 78 ++++ .../game_budget_controller_db_test.go | 164 ++++++++ .../game_budget_controller_processor.go | 368 ++++++++++++++++++ .../game_budget_controller_processor_test.go | 101 +++++ .../game_budget_metrics_processor.go | 7 +- internal/processor/game_budget_processor.go | 15 + internal/processor/reward_calculator.go | 96 ++++- internal/processor/reward_calculator_test.go | 35 ++ .../enakgame_analytics_repository.go | 129 ++++++ internal/repository/enakgame_repository.go | 52 ++- internal/router/router.go | 6 + internal/service/enakgame_service.go | 50 ++- ...dget_controller_to_reward_configs.down.sql | 7 + ...budget_controller_to_reward_configs.up.sql | 19 + ...3_index_game_sessions_org_started.down.sql | 1 + ...113_index_game_sessions_org_started.up.sql | 3 + ...x_wallet_transactions_org_created.down.sql | 1 + ...dex_wallet_transactions_org_created.up.sql | 3 + 28 files changed, 1647 insertions(+), 25 deletions(-) create mode 100644 internal/processor/enakgame_analytics_db_test.go create mode 100644 internal/processor/enakgame_analytics_processor.go create mode 100644 internal/processor/enakgame_analytics_processor_test.go create mode 100644 internal/processor/game_budget_controller_db_test.go create mode 100644 internal/processor/game_budget_controller_processor.go create mode 100644 internal/processor/game_budget_controller_processor_test.go create mode 100644 internal/repository/enakgame_analytics_repository.go create mode 100644 migrations/000112_add_budget_controller_to_reward_configs.down.sql create mode 100644 migrations/000112_add_budget_controller_to_reward_configs.up.sql create mode 100644 migrations/000113_index_game_sessions_org_started.down.sql create mode 100644 migrations/000113_index_game_sessions_org_started.up.sql create mode 100644 migrations/000114_index_wallet_transactions_org_created.down.sql create mode 100644 migrations/000114_index_wallet_transactions_org_created.up.sql diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index 1b85d96..70ae45f 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -804,6 +804,29 @@ membuat forecast = budget, lalu batasi dengan step maksimum dan min/max multipli §31). Rekomendasi ditampilkan ke admin. Bila disetujui, admin membuat **reward config versi baru** (§5.2). Tidak ada perubahan reward tanpa versi baru dan tanpa audit. +Implementasi (EG-901, EG-902): + +- Hanya budget `GLOBAL` (yang membayar base reward). Budget `EVENT` ditolak; tambahan + event diatur di event-nya. +- Target = (budget − realized) / (forecast − realized), karena hanya biaya ke depan yang + ikut berubah bila reward diubah. Target dibatasi ±step dari 1 lalu dibulatkan ke bawah + ke dua desimal. Bisa turun **atau naik**. +- Multiplier tiap game diukur terhadap **base**: versi terakhir yang ditulis admin. + Versi buatan Budget Controller menyimpan `base_config_id`, `multiplier`, dan + `budget_id` (migrasi 000112), dan aturannya selalu dihitung ulang dari base (dibulatkan + ke bawah), jadi pembulatan tidak menumpuk. Min/max berlaku untuk multiplier kumulatif + ini. Admin yang menulis versi baru memulai base baru di 1. +- Cooldown per organisasi: setelah rekomendasi diterima, rekomendasi berikutnya baru bisa + diterima setelah `cooldown_days`. +- Guardrail disimpan di `game_budgets.thresholds` bersama warning/critical: + `max_step_percent` (default 10), `min_multiplier_percent` (50), + `max_multiplier_percent` (150), `cooldown_days` (7). Nilai default ini **sementara**, + menunggu §19.2 #4. +- Terima: `POST /budgets/:id/recommendation/accept` dengan `multiplier` yang dilihat + admin. Rekomendasi dihitung ulang di dalam transaksi; bila berbeda, tidak ada yang + berubah. Satu transaksi: versi lama `RETIRED`, versi baru langsung `ACTIVE`, audit + `source = budget_controller` per config dan `RECOMMENDATION_ACCEPTED` di budget. + `GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat, tambahkan snapshot harian, bukan cache yang di-invalidate. @@ -833,7 +856,8 @@ Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama | Games | CRUD, `PUT /:id/status` | | Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi | | Events | CRUD, `PUT /:id/status` | -| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation` | +| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation`, `POST /:id/recommendation/accept` | +| Analytics | `GET /analytics/games?from=&to=&game_id=`, `GET /analytics/economy?from=&to=` (tanggal Asia/Jakarta, maks 366 hari) | | Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` | | Redemptions | `GET` list, `GET /:id` dengan atribusi cost | | Sessions | `GET` list + filter `flagged` | @@ -974,7 +998,8 @@ Dari PRD §43: 1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6. 2. **Event stacking.** Sementara memakai default PRD §16. 3. **Budget exhaustion policy**, untuk budget global dan budget event. -4. **Threshold Budget Controller.** +4. **Threshold Budget Controller.** Sementara memakai default di §10 (step 10%, + multiplier 50%–150%, cooldown 7 hari), bisa diubah per budget. 5. **Timeout reservasi voucher.** Dengan §7.4, hanya relevan untuk voucher eksternal. Tidak ada yang memblokir langkah 1–7 di §17. Nomor 3 dan 4 harus diputuskan sebelum langkah diff --git a/internal/app/app.go b/internal/app/app.go index 36fd6fc..a1b7a2f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -604,6 +604,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con inventoryService := service.NewInventoryService(processors.inventoryProcessor) orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor) + enakGameAudit := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)) + gameBudgetMetrics := processor.NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(a.db), repository.NewGameBudgetMetricsRepository(a.db)) fileService := service.NewFileServiceImpl(processors.fileProcessor) var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor) analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor) @@ -679,10 +681,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con 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)), + gameBudgetMetrics, + processor.NewGameBudgetControllerProcessor(repository.NewGameBudgetRepository(a.db), gameBudgetMetrics, repository.NewEnakGameRepository(a.db), + enakGameAudit, repos.txManager), processor.NewGameEventProcessor(repository.NewGameEventRepository(a.db), repository.NewEnakGameRepository(a.db), repository.NewGameBudgetRepository(a.db), - processor.NewAuditLogger(repository.NewAuditLogRepository(a.db)), repos.txManager), - processors.voucherAdminProcessor), + enakGameAudit, repos.txManager), + processors.voucherAdminProcessor, + processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(a.db))), enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor), } } diff --git a/internal/constants/enakgame.go b/internal/constants/enakgame.go index 9cee3b6..d0ae9fc 100644 --- a/internal/constants/enakgame.go +++ b/internal/constants/enakgame.go @@ -72,6 +72,9 @@ const ( AuditEntityVoucher = "VOUCHER" ) +// audit_logs.action on a GAME_BUDGET whose recommendation was accepted. +const AuditActionRecommendationAccepted = "RECOMMENDATION_ACCEPTED" + // game_reward_counters.scope_type: what an Economy Guard counter counts for (§5.8). const ( GameRewardScopeUser = "USER" @@ -144,3 +147,32 @@ const ( GameEventStatusEnded = "ENDED" GameEventStatusCancelled = "CANCELLED" ) + +// Budget Controller guardrails of a budget that sets none (PRD §31). Pending RFC +// §19.2 #4. +const ( + // Percent a recommendation may move rewards by, either way. + GameBudgetMaxStepDefault = int64(10) + // Bounds of a game's multiplier, in percent of the configuration its admin wrote. + GameBudgetMinMultiplierDefault = int64(50) + GameBudgetMaxMultiplierDefault = int64(150) + // Days after an accepted recommendation before the next one, in the organization. + // The same as the forecast window, so the next one sees the effect of the last. + GameBudgetCooldownDaysDefault = int64(7) +) + +// What a Budget Controller recommendation says (docs/rfc-enakgame.md §10). Only +// RECOMMENDED can be accepted. +const ( + GameBudgetRecommended = "RECOMMENDED" + // The forecast meets the budget closely enough that rewards stay. + GameBudgetRecommendationNoChange = "NO_CHANGE" + // The last accepted recommendation of the organization is too recent. + GameBudgetRecommendationCooldown = "COOLDOWN" + // Every game is already at its min or max multiplier. + GameBudgetRecommendationAtLimit = "AT_LIMIT" + // No cost in the forecast window to extrapolate from. + GameBudgetRecommendationNoData = "INSUFFICIENT_DATA" + // The budget's period has not started or has no day left after today. + GameBudgetRecommendationOutOfPeriod = "OUT_OF_PERIOD" +) diff --git a/internal/entities/enakgame.go b/internal/entities/enakgame.go index 797595b..290e632 100644 --- a/internal/entities/enakgame.go +++ b/internal/entities/enakgame.go @@ -97,7 +97,12 @@ type GameRewardConfig struct { 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"` + // Set together on a version made by accepting a Budget Controller recommendation + // (§10): the admin's version it scales, by how much, and for which budget. + BaseConfigID *uuid.UUID `gorm:"type:uuid" json:"base_config_id"` + Multiplier *float64 `gorm:"type:numeric(6,4)" json:"multiplier"` + BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` } func (GameRewardConfig) TableName() string { return "game_reward_configs" } diff --git a/internal/handler/enakgame_admin_handler.go b/internal/handler/enakgame_admin_handler.go index c91869c..e228eaa 100644 --- a/internal/handler/enakgame_admin_handler.go +++ b/internal/handler/enakgame_admin_handler.go @@ -237,6 +237,54 @@ func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method) } +// BudgetRecommendation is GET /marketing/enakgame/budgets/:id/recommendation. +func (h *EnakGameAdminHandler) BudgetRecommendation(c *gin.Context) { + const method = "EnakGameAdminHandler::BudgetRecommendation" + id, ok := pathID(c, "id", method) + if !ok { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.BudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id), method) +} + +// AcceptBudgetRecommendation is POST /marketing/enakgame/budgets/:id/recommendation/accept. +func (h *EnakGameAdminHandler) AcceptBudgetRecommendation(c *gin.Context) { + const method = "EnakGameAdminHandler::AcceptBudgetRecommendation" + 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.AcceptBudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id, body), method) +} + +// GameAnalytics is GET /marketing/enakgame/analytics/games?from=&to=&game_id=. +func (h *EnakGameAdminHandler) GameAnalytics(c *gin.Context) { + const method = "EnakGameAdminHandler::GameAnalytics" + var q models.EnakGameAnalyticsQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.GameAnalytics(ctx, appcontext.FromGinContext(ctx), q), method) +} + +// EconomyAnalytics is GET /marketing/enakgame/analytics/economy?from=&to=. +func (h *EnakGameAdminHandler) EconomyAnalytics(c *gin.Context) { + const method = "EnakGameAdminHandler::EconomyAnalytics" + var q models.EnakGameAnalyticsQuery + if !bindQuery(c, &q, method) { + return + } + ctx := c.Request.Context() + util.HandleResponse(c.Writer, c.Request, h.service.EconomyAnalytics(ctx, appcontext.FromGinContext(ctx), q), method) +} + // CreateVoucher is POST /marketing/enakgame/vouchers. func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) { const method = "EnakGameAdminHandler::CreateVoucher" diff --git a/internal/handler/enakgame_db_test.go b/internal/handler/enakgame_db_test.go index 5377ac0..6802818 100644 --- a/internal/handler/enakgame_db_test.go +++ b/internal/handler/enakgame_db_test.go @@ -38,11 +38,13 @@ func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHand wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) customers := repository.NewWalletMoveRepository(db) spendable := repository.NewWalletQueryRepository(db) + metrics := processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)) admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService( - processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), - processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db)), + processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), metrics, + processor.NewGameBudgetControllerProcessor(budgets, metrics, games, audit, txm), processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, txm), - processor.NewVoucherAdminProcessor(vouchers, audit, txm))) + processor.NewVoucherAdminProcessor(vouchers, audit, txm), + processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(db)))) customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService( processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets, repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index affa1fb..c1d9bd3 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -84,13 +84,26 @@ type GameRewardConfig struct { EffectiveAt *time.Time `json:"effective_at"` CreatedBy uuid.UUID `json:"created_by"` Reason *string `json:"reason"` - CreatedAt time.Time `json:"created_at"` + // Set on a version made by accepting a Budget Controller recommendation: the + // admin's version it scales, by how much, and for which budget. + BaseConfigID *uuid.UUID `json:"base_config_id"` + Multiplier *float64 `json:"multiplier"` + BudgetID *uuid.UUID `json:"budget_id"` + CreatedAt time.Time `json:"created_at"` } -// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32). +// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32), and the +// guardrails of the Budget Controller (PRD §31). type GameBudgetThresholds struct { Warning *int64 `json:"warning,omitempty"` Critical *int64 `json:"critical,omitempty"` + // Percent one recommendation may move rewards by, either way. + MaxStepPercent *int64 `json:"max_step_percent,omitempty"` + // Bounds of a game's multiplier, in percent of the configuration its admin wrote. + MinMultiplierPercent *int64 `json:"min_multiplier_percent,omitempty"` + MaxMultiplierPercent *int64 `json:"max_multiplier_percent,omitempty"` + // Days after an accepted recommendation before the organization gets another. + CooldownDays *int64 `json:"cooldown_days,omitempty"` } // GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and @@ -368,6 +381,156 @@ type GameBudgetExposure struct { Points int64 `json:"points"` } +// GameBudgetRecommendation is what the Budget Controller suggests for a global budget +// (docs/rfc-enakgame.md §10, PRD §29–§31): a multiplier on every game's reward that +// brings the forecast to the budget, within the guardrails. +type GameBudgetRecommendation struct { + BudgetID uuid.UUID `json:"budget_id"` + // RECOMMENDED, NO_CHANGE, COOLDOWN, AT_LIMIT, INSUFFICIENT_DATA or OUT_OF_PERIOD. + // Only RECOMMENDED can be accepted. + State string `json:"state"` + Message string `json:"message"` + + Metrics GameBudgetMetrics `json:"metrics"` + // The guardrails used: the budget's own, or the defaults for those it does not set. + Guardrails GameBudgetThresholds `json:"guardrails"` + + // The multiplier that would make the forecast meet the budget, before the + // guardrails; nil when there is no cost to extrapolate. + TargetMultiplier *float64 `json:"target_multiplier"` + // The target within one step, rounded down to two decimals. Accepting sends it back. + Multiplier float64 `json:"multiplier"` + // When the organization may accept again, during a cooldown. + CooldownUntil *time.Time `json:"cooldown_until,omitempty"` + // The games whose reward would change, each with its new version. + Games []GameRewardAdjustment `json:"games"` +} + +// GameRewardAdjustment is how accepting a recommendation changes a game's reward: a +// new version of its active configuration, with the base's amounts scaled. +type GameRewardAdjustment struct { + GameID uuid.UUID `json:"game_id"` + GameName string `json:"game_name"` + // The active version, and the admin's version both are measured against. + RewardConfigID uuid.UUID `json:"reward_config_id"` + Version int `json:"version"` + BaseConfigID uuid.UUID `json:"base_config_id"` + RewardType string `json:"reward_type"` + + CurrentMultiplier float64 `json:"current_multiplier"` + NewMultiplier float64 `json:"new_multiplier"` + CurrentRules json.RawMessage `json:"current_rules"` + NewRules json.RawMessage `json:"new_rules"` + CurrentMaxReward int64 `json:"current_max_reward"` + NewMaxReward int64 `json:"new_max_reward"` +} + +// GameBudgetRecommendationAcceptInput accepts the recommendation the admin saw. When +// the recommendation changed since, nothing is applied. +type GameBudgetRecommendationAcceptInput struct { + Multiplier *float64 `json:"multiplier"` + Reason *string `json:"reason"` +} + +// GameBudgetRecommendationAccepted is what accepting made: one active version per +// game. +type GameBudgetRecommendationAccepted struct { + BudgetID uuid.UUID `json:"budget_id"` + Multiplier float64 `json:"multiplier"` + RewardConfigs []GameRewardConfig `json:"reward_configs"` +} + +// EnakGameAnalyticsQuery is a range of days in Asia/Jakarta, both ends included. +type EnakGameAnalyticsQuery struct { + From string `form:"from"` + To string `form:"to"` + // Games analytics only: one game instead of all. + GameID string `form:"game_id"` +} + +// EnakGameAnalytics is how an organization's games were played over a range of days +// (PRD §36 Game), by the day each session started. +type EnakGameAnalytics struct { + From string `json:"from"` + To string `json:"to"` + Totals EnakGameStats `json:"totals"` + Games []EnakGameGameStats `json:"games"` +} + +type EnakGameGameStats struct { + GameID uuid.UUID `json:"game_id"` + GameName string `json:"game_name"` + EnakGameStats +} + +type EnakGameStats struct { + // Sessions started, whatever became of them. + Plays int64 `json:"plays"` + Completed int64 `json:"completed"` + Refunded int64 `json:"refunded"` + Expired int64 `json:"expired"` + Flagged int64 `json:"flagged"` + // Customers who started at least one session. + Players int64 `json:"players"` + // Over completed sessions that reported a score; nil when none did. + AverageScore *float64 `json:"average_score"` + // EnakCoin per completed session, and per play. + AverageReward float64 `json:"average_reward"` + RewardPerPlay float64 `json:"reward_per_play"` + CoinIssued int64 `json:"coin_issued"` + // EnakCoin paid to start, and the part refunded. + EntryCostPaid int64 `json:"entry_cost_paid"` + CoinRefunded int64 `json:"coin_refunded"` +} + +// EnakGameEconomyAnalytics is how EnakCoin and EnakPoint moved in an organization over +// a range of days (PRD §36 Economy), from the ledger. +type EnakGameEconomyAnalytics struct { + From string `json:"from"` + To string `json:"to"` + Coin EnakGameCoinFlows `json:"coin"` + Point EnakGamePointFlows `json:"point"` + // Every ledger type that moved, for what the headline numbers leave out. + ByType []WalletFlowTotals `json:"by_type"` +} + +type EnakGameCoinFlows struct { + // New EnakCoin: game rewards, earning less its reversals, migration and upward + // adjustments. + Generated int64 `json:"generated"` + GameRewards int64 `json:"game_rewards"` + // Entry costs, less the refunded ones. + SpentOnGames int64 `json:"spent_on_games"` + // Exchanged into EnakPoint. + Exchanged int64 `json:"exchanged"` + // SpentOnGames + Exchanged. + Spent int64 `json:"spent"` + Expired int64 `json:"expired"` + // Held by customers at the end of the range. + Outstanding int64 `json:"outstanding"` +} + +type EnakGamePointFlows struct { + // From shopping, less its reversals. + Earned int64 `json:"earned"` + // Received from exchanging EnakCoin. + Exchanged int64 `json:"exchanged"` + // Spent on vouchers, less the refunded redemptions. + Redeemed int64 `json:"redeemed"` + Expired int64 `json:"expired"` + // Held by customers at the end of the range. + Balance int64 `json:"balance"` +} + +// WalletFlowTotals is what one ledger type moved in one currency. +type WalletFlowTotals struct { + Currency string `json:"currency"` + Type string `json:"type"` + Credit int64 `json:"credit"` + Debit int64 `json:"debit"` + Transactions int64 `json:"transactions"` +} + // GameEventInput creates or changes an event (§5.5). On a change, fields left out // keep their value. type GameEventInput struct { diff --git a/internal/processor/enakgame_admin_processor.go b/internal/processor/enakgame_admin_processor.go index e7e54ef..3097aa6 100644 --- a/internal/processor/enakgame_admin_processor.go +++ b/internal/processor/enakgame_admin_processor.go @@ -443,7 +443,7 @@ 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, + Reason: c.Reason, BaseConfigID: c.BaseConfigID, Multiplier: c.Multiplier, BudgetID: c.BudgetID, CreatedAt: c.CreatedAt, } } diff --git a/internal/processor/enakgame_analytics_db_test.go b/internal/processor/enakgame_analytics_db_test.go new file mode 100644 index 0000000..481fe69 --- /dev/null +++ b/internal/processor/enakgame_analytics_db_test.go @@ -0,0 +1,80 @@ +package processor + +import ( + "testing" + + "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" +) + +// EG-903 against Postgres. Needs TEST_DATABASE_URL; see +// internal/repository/wallet_repository_test.go. +func TestEnakGameAnalytics_AgainstPostgres(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + analytics := NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(e.db)) + + // Sessions and ledger rows are stamped with the test clock and the database's, + // both now: a range around today holds them all. + today := walletDay(e.now) + q := models.EnakGameAnalyticsQuery{From: today.AddDate(0, 0, -1).Format("2006-01-02"), To: today.AddDate(0, 0, 1).Format("2006-01-02")} + + tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10) + run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased, + `{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20) + e.coins(e.alice, 10, nil) + e.coins(e.bob, 10, nil) + for i, key := range []string{"tap-1", "tap-2"} { + started, err := e.sessions.Start(ctx, e.alice, tap.ID, key) + require.NoError(t, err, i) + _, err = e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err, i) + } + _, err := e.sessions.Start(ctx, e.bob, tap.ID, "tap-3") // left open + require.NoError(t, err) + started, err := e.sessions.Start(ctx, e.alice, run.ID, "run-1") + require.NoError(t, err) + _, err = e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{Score: ptr(int64(150))}) + require.NoError(t, err) + + games, err := analytics.Games(ctx, e.orgA, q) + require.NoError(t, err) + assert.Equal(t, models.EnakGameStats{ + Plays: 4, Completed: 3, Players: 2, AverageScore: ptr(150.0), + AverageReward: 13.33, RewardPerPlay: 10, CoinIssued: 40, EntryCostPaid: 8, + }, games.Totals) + require.Len(t, games.Games, 2) + assert.Equal(t, tap.ID, games.Games[0].GameID, "most played first") + assert.EqualValues(t, 3, games.Games[0].Plays) + assert.EqualValues(t, 2, games.Games[0].Players) + assert.Nil(t, games.Games[0].AverageScore, "FIXED reports no score") + assert.EqualValues(t, 20, games.Games[1].CoinIssued) + + one, err := analytics.Games(ctx, e.orgA, models.EnakGameAnalyticsQuery{From: q.From, To: q.To, GameID: run.ID.String()}) + require.NoError(t, err) + assert.EqualValues(t, 1, one.Totals.Plays) + require.Len(t, one.Games, 1) + + other, err := analytics.Games(ctx, e.orgB, q) + require.NoError(t, err) + assert.Zero(t, other.Totals.Plays, "another organization's sessions never show") + assert.Empty(t, other.Games) + + economy, err := analytics.Economy(ctx, e.orgA, q) + require.NoError(t, err) + assert.Equal(t, models.EnakGameCoinFlows{ + Generated: 60, GameRewards: 40, SpentOnGames: 8, Spent: 8, Outstanding: 52, + }, economy.Coin) + assert.Zero(t, economy.Point) + + // A range before anything happened. + empty, err := analytics.Economy(ctx, e.orgA, models.EnakGameAnalyticsQuery{From: "2020-01-01", To: "2020-01-31"}) + require.NoError(t, err) + assert.Zero(t, empty.Coin) + assert.Empty(t, empty.ByType) +} diff --git a/internal/processor/enakgame_analytics_processor.go b/internal/processor/enakgame_analytics_processor.go new file mode 100644 index 0000000..63e2aea --- /dev/null +++ b/internal/processor/enakgame_analytics_processor.go @@ -0,0 +1,153 @@ +package processor + +import ( + "context" + "math" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +// enakGameAnalyticsMaxDays bounds a range, so one request cannot scan years of ledger. +const enakGameAnalyticsMaxDays = 366 + +// EnakGameAnalyticsProcessor answers the EnakGame dashboards (docs/tasks-enakgame.md +// EG-903, PRD §36) for an organization and a range of days in Asia/Jakarta. +type EnakGameAnalyticsProcessor struct { + analytics repository.EnakGameAnalyticsRepository +} + +func NewEnakGameAnalyticsProcessor(analytics repository.EnakGameAnalyticsRepository) *EnakGameAnalyticsProcessor { + return &EnakGameAnalyticsProcessor{analytics: analytics} +} + +// analyticsRange reads from and to, both days included, as [start, end). +func analyticsRange(q models.EnakGameAnalyticsQuery) (start, end time.Time, err error) { + from, err := time.Parse("2006-01-02", strings.TrimSpace(q.From)) + if err != nil { + return start, end, enakGameRejected("from must be a date like 2026-10-01") + } + to, err := time.Parse("2006-01-02", strings.TrimSpace(q.To)) + if err != nil { + return start, end, enakGameRejected("to must be a date like 2026-10-31") + } + if to.Before(from) { + return start, end, enakGameRejected("to cannot be before from") + } + if daysBetween(from, to)+1 > enakGameAnalyticsMaxDays { + return start, end, enakGameRejected("the range can span at most %d days", enakGameAnalyticsMaxDays) + } + return jakartaMidnight(from), jakartaMidnight(to.AddDate(0, 0, 1)), nil +} + +// Games is how the organization's games were played, by the day each session +// started. +func (p *EnakGameAnalyticsProcessor) Games(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameAnalytics, error) { + start, end, err := analyticsRange(q) + if err != nil { + return nil, err + } + var gameID *uuid.UUID + if s := strings.TrimSpace(q.GameID); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return nil, enakGameRejected("game_id must be a UUID") + } + gameID = &id + } + rows, err := p.analytics.SessionStats(ctx, organizationID, start, end, gameID) + if err != nil { + return nil, err + } + out := &models.EnakGameAnalytics{From: strings.TrimSpace(q.From), To: strings.TrimSpace(q.To), Games: []models.EnakGameGameStats{}} + for _, row := range rows { + if row.GameID == nil { + out.Totals = gameStats(row) + continue + } + g := models.EnakGameGameStats{GameID: *row.GameID, EnakGameStats: gameStats(row)} + if row.GameName != nil { + g.GameName = *row.GameName + } + out.Games = append(out.Games, g) + } + return out, nil +} + +func gameStats(r repository.GameSessionStats) models.EnakGameStats { + s := models.EnakGameStats{ + Plays: r.Plays, Completed: r.Completed, Refunded: r.Refunded, Expired: r.Expired, Flagged: r.Flagged, + Players: r.Players, AverageScore: r.AverageScore, CoinIssued: r.CoinIssued, + EntryCostPaid: r.EntryCost, CoinRefunded: r.CoinRefunded, + } + if r.Completed > 0 { + s.AverageReward = ratio(r.CoinIssued, r.Completed) + } + if r.Plays > 0 { + s.RewardPerPlay = ratio(r.CoinIssued, r.Plays) + } + return s +} + +// ratio is part / whole with two decimals. +func ratio(part, whole int64) float64 { + return math.Round(float64(part)*100/float64(whole)) / 100 +} + +// Economy is how EnakCoin and EnakPoint moved in the organization, from the ledger. +func (p *EnakGameAnalyticsProcessor) Economy(ctx context.Context, organizationID uuid.UUID, q models.EnakGameAnalyticsQuery) (*models.EnakGameEconomyAnalytics, error) { + start, end, err := analyticsRange(q) + if err != nil { + return nil, err + } + flows, err := p.analytics.WalletFlows(ctx, organizationID, start, end) + if err != nil { + return nil, err + } + balances, err := p.analytics.BalancesAt(ctx, organizationID, end) + if err != nil { + return nil, err + } + out := economy(flows, balances) + out.From, out.To = strings.TrimSpace(q.From), strings.TrimSpace(q.To) + return out, nil +} + +// economy turns ledger totals into the headline numbers. Transfers move value between +// customers and count in none of them; ByType still lists them. +func economy(flows []repository.WalletFlow, balances map[string]int64) *models.EnakGameEconomyAnalytics { + type key struct{ currency, txType string } + byKey := make(map[key]repository.WalletFlow, len(flows)) + out := &models.EnakGameEconomyAnalytics{ByType: make([]models.WalletFlowTotals, 0, len(flows))} + for _, f := range flows { + byKey[key{f.Currency, f.Type}] = f + out.ByType = append(out.ByType, models.WalletFlowTotals{ + Currency: f.Currency, Type: f.Type, Credit: f.Credit, Debit: f.Debit, Transactions: f.Transactions, + }) + } + coin := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyCoin, txType}] } + point := func(txType string) repository.WalletFlow { return byKey[key{constants.WalletCurrencyPoint, txType}] } + + c := &out.Coin + c.GameRewards = coin(constants.WalletTxTypeGameReward).Credit + c.Generated = c.GameRewards + coin(constants.WalletTxTypeEarn).Credit - coin(constants.WalletTxTypeEarnReversal).Debit + + coin(constants.WalletTxTypeMigration).Credit + coin(constants.WalletTxTypeAdjustment).Credit + c.SpentOnGames = coin(constants.WalletTxTypeGameSpend).Debit - coin(constants.WalletTxTypeGameSpendRefund).Credit + c.Exchanged = coin(constants.WalletTxTypeExchangeOut).Debit + c.Spent = c.SpentOnGames + c.Exchanged + c.Expired = coin(constants.WalletTxTypeExpire).Debit + c.Outstanding = balances[constants.WalletCurrencyCoin] + + pt := &out.Point + pt.Earned = point(constants.WalletTxTypeEarn).Credit - point(constants.WalletTxTypeEarnReversal).Debit + pt.Exchanged = point(constants.WalletTxTypeExchangeIn).Credit + pt.Redeemed = point(constants.WalletTxTypeRewardRedeem).Debit - point(constants.WalletTxTypeRewardRedeemRefund).Credit + pt.Expired = point(constants.WalletTxTypeExpire).Debit + pt.Balance = balances[constants.WalletCurrencyPoint] + return out +} diff --git a/internal/processor/enakgame_analytics_processor_test.go b/internal/processor/enakgame_analytics_processor_test.go new file mode 100644 index 0000000..3217c76 --- /dev/null +++ b/internal/processor/enakgame_analytics_processor_test.go @@ -0,0 +1,78 @@ +package processor + +import ( + "testing" + "time" + + "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" +) + +func TestAnalyticsRange(t *testing.T) { + start, end, err := analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-10-01", To: "2026-10-31"}) + require.NoError(t, err) + assert.Equal(t, time.Date(2026, 9, 30, 17, 0, 0, 0, time.UTC), start.UTC(), "midnight in Jakarta") + assert.Equal(t, time.Date(2026, 10, 31, 17, 0, 0, 0, time.UTC), end.UTC(), "the last day included") + + _, _, err = analyticsRange(models.EnakGameAnalyticsQuery{From: "2026-01-01", To: "2027-01-01"}) + require.NoError(t, err, "366 days") + for name, q := range map[string]models.EnakGameAnalyticsQuery{ + "no from": {To: "2026-10-31"}, + "no to": {From: "2026-10-01"}, + "backwards": {From: "2026-10-31", To: "2026-10-01"}, + "too long": {From: "2026-01-01", To: "2027-01-02"}, + "not dates": {From: "1 Oct", To: "31 Oct"}, + } { + _, _, err := analyticsRange(q) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } +} + +func TestGameStats(t *testing.T) { + s := gameStats(repository.GameSessionStats{Plays: 3, Completed: 2, CoinIssued: 25, EntryCost: 6, CoinRefunded: 2}) + assert.EqualValues(t, 12.5, s.AverageReward) + assert.InDelta(t, 8.33, s.RewardPerPlay, 1e-9) + assert.EqualValues(t, 6, s.EntryCostPaid) + + s = gameStats(repository.GameSessionStats{}) + assert.Zero(t, s.AverageReward, "no division by zero") + assert.Zero(t, s.RewardPerPlay) + assert.Nil(t, s.AverageScore) +} + +// EG-903: the economy's headline numbers from the ledger's totals. +func TestEconomy(t *testing.T) { + coin, point := constants.WalletCurrencyCoin, constants.WalletCurrencyPoint + flows := []repository.WalletFlow{ + {Currency: coin, Type: constants.WalletTxTypeGameReward, Credit: 500, Transactions: 40}, + {Currency: coin, Type: constants.WalletTxTypeMigration, Credit: 100, Transactions: 2}, + {Currency: coin, Type: constants.WalletTxTypeAdjustment, Credit: 30, Debit: 10, Transactions: 2}, + {Currency: coin, Type: constants.WalletTxTypeGameSpend, Debit: 200, Transactions: 50}, + {Currency: coin, Type: constants.WalletTxTypeGameSpendRefund, Credit: 8, Transactions: 2}, + {Currency: coin, Type: constants.WalletTxTypeExchangeOut, Debit: 120, Transactions: 3}, + {Currency: coin, Type: constants.WalletTxTypeExpire, Debit: 15, Transactions: 1}, + {Currency: coin, Type: constants.WalletTxTypeTransferOut, Debit: 50, Transactions: 1}, + {Currency: coin, Type: constants.WalletTxTypeTransferIn, Credit: 50, Transactions: 1}, + {Currency: point, Type: constants.WalletTxTypeEarn, Credit: 1_000, Transactions: 9}, + {Currency: point, Type: constants.WalletTxTypeEarnReversal, Debit: 100, Transactions: 1}, + {Currency: point, Type: constants.WalletTxTypeExchangeIn, Credit: 120, Transactions: 3}, + {Currency: point, Type: constants.WalletTxTypeRewardRedeem, Debit: 700, Transactions: 7}, + {Currency: point, Type: constants.WalletTxTypeRewardRedeemRefund, Credit: 100, Transactions: 1}, + {Currency: point, Type: constants.WalletTxTypeExpire, Debit: 40, Transactions: 2}, + } + e := economy(flows, map[string]int64{coin: 900, point: 2_500}) + + assert.Equal(t, models.EnakGameCoinFlows{ + Generated: 630, GameRewards: 500, SpentOnGames: 192, Exchanged: 120, Spent: 312, Expired: 15, Outstanding: 900, + }, e.Coin, "transfers count nowhere") + assert.Equal(t, models.EnakGamePointFlows{Earned: 900, Exchanged: 120, Redeemed: 600, Expired: 40, Balance: 2_500}, e.Point) + assert.Len(t, e.ByType, len(flows)) + + empty := economy(nil, map[string]int64{}) + assert.Zero(t, empty.Coin) + assert.NotNil(t, empty.ByType, "an empty list, not null") +} diff --git a/internal/processor/game_budget_controller_db_test.go b/internal/processor/game_budget_controller_db_test.go new file mode 100644 index 0000000..b0a309d --- /dev/null +++ b/internal/processor/game_budget_controller_db_test.go @@ -0,0 +1,164 @@ +package processor + +import ( + "encoding/json" + "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" +) + +// realizedCost records a completed redemption whose face value the budget paid, +// recognized at at. +func (e *enakGameEnv) realizedCost(voucherID, budgetID uuid.UUID, amount int64, at time.Time) { + e.t.Helper() + debit, redemption := uuid.New(), uuid.New() + require.NoError(e.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(e.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, voucherID, redemption.String(), amount, debit, at).Error) + require.NoError(e.t, e.db.Exec(`INSERT INTO voucher_redemption_costs (redemption_id, budget_id, source_type, points, cost, recognized_at) VALUES (?, ?, 'GAME_REWARD', 1, ?, ?)`, + redemption, budgetID, amount, at).Error) +} + +func (e *enakGameEnv) activeConfig(gameID uuid.UUID) *entities.GameRewardConfig { + e.t.Helper() + c, err := repository.NewEnakGameRepository(e.db).GetActiveRewardConfig(e.ctx(), e.orgA, gameID) + require.NoError(e.t, err) + return c +} + +// EG-901 and EG-902 against Postgres, from the PRD §30 numbers. Needs +// TEST_DATABASE_URL; see internal/repository/wallet_repository_test.go. +func TestGameBudgetController_AgainstPostgres(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + e.now = time.Date(2026, 10, 21, 12, 0, 0, 0, walletDisplayLocation) + budgets := repository.NewGameBudgetRepository(e.db) + metrics := NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(e.db)) + controller := NewGameBudgetControllerProcessor(budgets, metrics, repository.NewEnakGameRepository(e.db), + NewAuditLogger(repository.NewAuditLogRepository(e.db)), e.txm) + controller.now = func() time.Time { return e.now } + + october := e.globalBudget(e.orgA, e.now) + tap := e.gameWith("tap", entities.GameResultRules{}, constants.GameRewardTypeFixed, `{"amount": 10}`, 10) + run := e.gameWith("run", entities.GameResultRules{}, constants.GameRewardTypeScoreBased, + `{"bands": [{"min": 0, "max": 100, "amount": 5}, {"min": 101, "amount": 20}]}`, 20) + tapV1, runV1 := e.activeConfig(tap.ID), e.activeConfig(run.ID) + + // Rp60M realized, Rp5,5M a day over the last week, 10 days left → Rp115M. + voucher := e.staticVoucher(1_000, 1, 1) + e.realizedCost(voucher.ID, october.ID, 21_500_000, time.Date(2026, 10, 5, 10, 0, 0, 0, walletDisplayLocation)) + for day := 15; day <= 21; day++ { + e.realizedCost(voucher.ID, october.ID, 5_500_000, time.Date(2026, 10, day, 0, 0, 0, 0, walletDisplayLocation)) + } + + rec, err := controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameBudgetRecommended, rec.State) + assert.EqualValues(t, 115_000_000, rec.Metrics.ForecastCost) + assert.InDelta(t, 0.7272, *rec.TargetMultiplier, 1e-9) + assert.EqualValues(t, 0.9, rec.Multiplier, "one step of 10%") + assert.EqualValues(t, 10, *rec.Guardrails.MaxStepPercent) + require.Len(t, rec.Games, 2) + assert.Equal(t, run.ID, rec.Games[0].GameID, "by game name") + assert.JSONEq(t, `{"bands":[{"min":0,"max":100,"amount":4},{"min":101,"amount":18}]}`, string(rec.Games[0].NewRules)) + assert.EqualValues(t, 18, rec.Games[0].NewMaxReward) + assert.JSONEq(t, `{"amount":9}`, string(rec.Games[1].NewRules)) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA), "showing changes nothing") + + // Only what the admin saw is applied. + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.85)}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + assert.Zero(t, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL`, e.orgA)) + + accepted, err := controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{ + Multiplier: ptr(0.9), Reason: ptr("Burn rate terlalu tinggi"), + }) + require.NoError(t, err) + require.Len(t, accepted.RewardConfigs, 2) + tapV2 := e.activeConfig(tap.ID) + assert.Equal(t, 2, tapV2.Version) + assert.JSONEq(t, `{"amount":9}`, string(tapV2.Rules)) + assert.EqualValues(t, 9, tapV2.MaxReward) + assert.EqualValues(t, 0.9, *tapV2.Multiplier) + assert.Equal(t, tapV1.ID, *tapV2.BaseConfigID) + assert.Equal(t, october.ID, *tapV2.BudgetID) + assert.Equal(t, "Burn rate terlalu tinggi", *tapV2.Reason) + assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, tapV1.ID)) + assert.EqualValues(t, 1, e.count(`SELECT COUNT(*) FROM game_reward_configs WHERE id = ? AND status = 'RETIRED'`, runV1.ID)) + assert.Equal(t, []string{"CREATED", constants.AuditActionRecommendationAccepted}, e.auditActions(constants.AuditEntityGameBudget, october.ID)) + assert.Equal(t, []string{"ACTIVATED", "CREATED"}, e.auditActions(constants.AuditEntityGameRewardConfig, tapV2.ID)) + assert.EqualValues(t, 7, e.count(`SELECT COUNT(*) FROM audit_logs WHERE organization_id = ? AND source = ?`, e.orgA, constants.AuditSourceBudgetController), + "per game: retired, created, activated; and the budget") + + // The game pays the new amount. + e.coins(e.alice, 2, nil) + started, err := e.sessions.Start(ctx, e.alice, tap.ID, "after") + require.NoError(t, err) + done, err := e.sessions.Complete(ctx, e.alice, started.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + assert.EqualValues(t, 9, done.RewardTotal) + + // Not again within the cooldown, though the forecast is still over. + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameBudgetRecommendationCooldown, rec.State) + require.NotNil(t, rec.CooldownUntil) + assert.WithinDuration(t, e.now.AddDate(0, 0, 7), *rec.CooldownUntil, time.Second) + assert.EqualValues(t, 0.81, rec.Games[1].NewMultiplier, "what it would be") + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)}) + assert.ErrorIs(t, err, ErrEnakGameRejected) + + // No cooldown and a floor of 85%: 0.81 stops at 0.85, scaled from version 1. + current, err := e.budgets.GetBudget(ctx, e.orgA, october.ID) + require.NoError(t, err) + in := GameBudgetInputFrom(current) + in.Thresholds.CooldownDays, in.Thresholds.MinMultiplierPercent = ptr(int64(0)), ptr(int64(85)) + _, err = e.budgets.UpdateBudget(ctx, e.orgA, e.manager, october.ID, in) + require.NoError(t, err) + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + require.Equal(t, constants.GameBudgetRecommended, rec.State) + _, err = controller.Accept(ctx, e.orgA, e.manager, october.ID, models.GameBudgetRecommendationAcceptInput{Multiplier: ptr(0.9)}) + require.NoError(t, err) + tapV3 := e.activeConfig(tap.ID) + assert.EqualValues(t, 0.85, *tapV3.Multiplier) + assert.JSONEq(t, `{"amount":8}`, string(tapV3.Rules), "10 × 0.85, rounded down") + assert.Equal(t, tapV1.ID, *tapV3.BaseConfigID, "always from the admin's version") + + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + assert.Equal(t, constants.GameBudgetRecommendationAtLimit, rec.State) + assert.Empty(t, rec.Games) + + // An admin's new version starts over from 1. + config, err := e.admin.CreateRewardConfig(ctx, e.orgA, e.manager, tap.ID, models.GameRewardConfigInput{ + RewardType: constants.GameRewardTypeFixed, Rules: json.RawMessage(`{"amount": 12}`), MaxReward: 12, + }) + require.NoError(t, err) + _, err = e.admin.ActivateRewardConfig(ctx, e.orgA, e.manager, config.ID, models.GameRewardConfigActivateInput{}) + require.NoError(t, err) + rec, err = controller.Recommendation(ctx, e.orgA, october.ID) + require.NoError(t, err) + require.Equal(t, constants.GameBudgetRecommended, rec.State) + require.Len(t, rec.Games, 1) + assert.Equal(t, config.ID, rec.Games[0].BaseConfigID) + assert.JSONEq(t, `{"amount":10}`, string(rec.Games[0].NewRules), "12 × 0.9, rounded down") + + // Event budgets pay for what events add, which is set on the event. + event := e.eventBudget(e.orgA, "Ramadan") + _, err = controller.Recommendation(ctx, e.orgA, event.ID) + assert.ErrorIs(t, err, ErrEnakGameRejected) + _, err = controller.Recommendation(ctx, e.orgB, october.ID) + assert.ErrorIs(t, err, repository.ErrGameBudgetNotFound) +} diff --git a/internal/processor/game_budget_controller_processor.go b/internal/processor/game_budget_controller_processor.go new file mode 100644 index 0000000..bae43ad --- /dev/null +++ b/internal/processor/game_budget_controller_processor.go @@ -0,0 +1,368 @@ +package processor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "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" +) + +// GameBudgetControllerProcessor is the Budget Controller in recommendation mode +// (docs/rfc-enakgame.md §10, D9; PRD §29–§33). For a global budget it computes the +// multiplier that brings the forecast to the budget, keeps it within the guardrails, +// and shows what every game's reward would become. Nothing changes until an admin +// accepts; accepting writes a new active version of each game's configuration, audited +// with source budget_controller. +// +// A game's multiplier is kept against its base, the version its admin wrote last, so +// adjustments do not compound their rounding and min/max mean "of what the admin +// set". An admin writing a new version starts a new base at 1. +type GameBudgetControllerProcessor struct { + budgets repository.GameBudgetRepository + metrics *GameBudgetMetricsProcessor + games repository.EnakGameRepository + audit *AuditLogger + tx TxRunner + now func() time.Time +} + +func NewGameBudgetControllerProcessor(budgets repository.GameBudgetRepository, metrics *GameBudgetMetricsProcessor, games repository.EnakGameRepository, + audit *AuditLogger, tx TxRunner) *GameBudgetControllerProcessor { + return &GameBudgetControllerProcessor{budgets: budgets, metrics: metrics, games: games, audit: audit, tx: tx, now: time.Now} +} + +// budgetGuardrails are a budget's guardrails in percent (PRD §31), with the defaults +// for those it does not set. +type budgetGuardrails struct { + step, min, max, cooldownDays int64 +} + +func guardrailsOf(b *entities.GameBudget) budgetGuardrails { + g := budgetGuardrails{ + step: constants.GameBudgetMaxStepDefault, min: constants.GameBudgetMinMultiplierDefault, + max: constants.GameBudgetMaxMultiplierDefault, cooldownDays: constants.GameBudgetCooldownDaysDefault, + } + var set models.GameBudgetThresholds + if len(b.Thresholds) > 0 { + _ = json.Unmarshal(b.Thresholds, &set) + } + for _, v := range []struct { + from *int64 + to *int64 + }{{set.MaxStepPercent, &g.step}, {set.MinMultiplierPercent, &g.min}, {set.MaxMultiplierPercent, &g.max}, {set.CooldownDays, &g.cooldownDays}} { + if v.from != nil { + *v.to = *v.from + } + } + return g +} + +func (g budgetGuardrails) model() models.GameBudgetThresholds { + step, lo, hi, cooldown := g.step, g.min, g.max, g.cooldownDays + return models.GameBudgetThresholds{MaxStepPercent: &step, MinMultiplierPercent: &lo, MaxMultiplierPercent: &hi, CooldownDays: &cooldown} +} + +// budgetStepMultiplier is the multiplier, in ten-thousandths, that one accepted +// recommendation applies to every game's reward. target is what would make the +// forecast meet the budget, nil when there is no cost to extrapolate from. step is +// target within one step of 1, rounded down to two decimals: down never pays more than +// the forecast allows. state is set when there is nothing to recommend. +// +// The forecast is realized + burn × days left, and only the future part follows the +// rewards, so the target is (budget − realized) / (forecast − realized). +func budgetStepMultiplier(m models.GameBudgetMetrics, g budgetGuardrails) (target *int64, step int64, state string) { + one := rewardMultiplierOne + if m.WindowDays == 0 || m.RemainingDays == 0 { + return nil, one, constants.GameBudgetRecommendationOutOfPeriod + } + future := m.ForecastCost - m.RealizedCost + if future <= 0 { + return nil, one, constants.GameBudgetRecommendationNoData + } + t := int64(math.Floor(float64(m.Amount-m.RealizedCost) * float64(one) / float64(future))) + t = max(t, 0) + step = min(max(t, one-g.step*100), one+g.step*100) + step -= step % 100 + if step == one { + return &t, one, constants.GameBudgetRecommendationNoChange + } + return &t, step, "" +} + +// nextGameMultiplier is a game's multiplier after a step: current × step, rounded +// down, within min and max. A game outside min and max, because they changed since +// its last adjustment, is brought inside, but never moved against the step. +func nextGameMultiplier(current, step int64, g budgetGuardrails) int64 { + next := current * step / rewardMultiplierOne + next = min(max(next, g.min*100), g.max*100) + if (step < rewardMultiplierOne && next > current) || (step > rewardMultiplierOne && next < current) { + return current + } + return next +} + +// rewardAdjustment is a game's part of a recommendation, with what accepting it needs. +type rewardAdjustment struct { + model models.GameRewardAdjustment + active *entities.GameRewardConfig + base *entities.GameRewardConfig + multiplier int64 + rules json.RawMessage + maxReward int64 +} + +func multiplierOf(c *entities.GameRewardConfig) int64 { + if c.Multiplier == nil { + return rewardMultiplierOne + } + return int64(math.Round(*c.Multiplier * float64(rewardMultiplierOne))) +} + +func multiplierValue(m int64) float64 { + return float64(m) / float64(rewardMultiplierOne) +} + +// Recommendation is GET /marketing/enakgame/budgets/:id/recommendation. +func (p *GameBudgetControllerProcessor) Recommendation(ctx context.Context, organizationID, budgetID uuid.UUID) (*models.GameBudgetRecommendation, error) { + budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) + if err != nil { + return nil, err + } + rec, _, err := p.recommend(ctx, budget, p.now()) + return rec, err +} + +func (p *GameBudgetControllerProcessor) recommend(ctx context.Context, budget *entities.GameBudget, now time.Time) (*models.GameBudgetRecommendation, []rewardAdjustment, error) { + if budget.Scope != constants.GameBudgetScopeGlobal { + return nil, nil, enakGameRejected("the Budget Controller only adjusts base rewards, paid by GLOBAL budgets; an event's extra is set on the event") + } + metrics, err := p.metrics.metricsAt(ctx, budget, now) + if err != nil { + return nil, nil, err + } + g := guardrailsOf(budget) + target, step, state := budgetStepMultiplier(*metrics, g) + rec := &models.GameBudgetRecommendation{ + BudgetID: budget.ID, Metrics: *metrics, Guardrails: g.model(), Multiplier: multiplierValue(step), + Games: []models.GameRewardAdjustment{}, + } + if target != nil { + t := multiplierValue(*target) + rec.TargetMultiplier = &t + } + switch state { + case constants.GameBudgetRecommendationOutOfPeriod: + rec.State, rec.Message = state, "the budget's period has not started, or has no day left after today" + return rec, nil, nil + case constants.GameBudgetRecommendationNoData: + rec.State, rec.Message = state, fmt.Sprintf("no voucher cost in the last %d days to forecast from", metrics.WindowDays) + return rec, nil, nil + case constants.GameBudgetRecommendationNoChange: + rec.State, rec.Message = state, "the forecast meets the budget; rewards stay" + return rec, nil, nil + } + + configs, err := p.games.ListActiveRewardConfigs(ctx, budget.OrganizationID) + if err != nil { + return nil, nil, err + } + var adjustments []rewardAdjustment + for i := range configs { + active := &configs[i].GameRewardConfig + current := multiplierOf(active) + next := nextGameMultiplier(current, step, g) + if next == current { + continue + } + base := active + if active.BaseConfigID != nil { + if base, err = p.games.GetRewardConfig(ctx, budget.OrganizationID, *active.BaseConfigID); err != nil { + return nil, nil, err + } + } + a, err := adjust(configs[i].GameName, active, base, next) + if err != nil { + return nil, nil, err + } + adjustments = append(adjustments, a) + rec.Games = append(rec.Games, a.model) + } + + last, err := p.games.LastBudgetControllerChange(ctx, budget.OrganizationID) + if err != nil { + return nil, nil, err + } + var until *time.Time + if last != nil && g.cooldownDays > 0 { + if u := last.AddDate(0, 0, int(g.cooldownDays)); now.Before(u) { + until = &u + } + } + switch { + case len(adjustments) == 0: + rec.State, rec.Message = constants.GameBudgetRecommendationAtLimit, "every game is already at its min or max multiplier" + case until != nil: + rec.State, rec.CooldownUntil = constants.GameBudgetRecommendationCooldown, until + rec.Message = fmt.Sprintf("a recommendation was accepted less than %d days ago", g.cooldownDays) + default: + rec.State = constants.GameBudgetRecommended + rec.Message = fmt.Sprintf("forecast Rp%d against a budget of Rp%d: multiply rewards by %.2f", metrics.ForecastCost, metrics.Amount, rec.Multiplier) + } + return rec, adjustments, nil +} + +// adjust scales a game's base configuration to a multiplier. +func adjust(gameName string, active, base *entities.GameRewardConfig, multiplier int64) (rewardAdjustment, error) { + calculator, err := RewardCalculatorFor(base.RewardType) + if err != nil { + return rewardAdjustment{}, err + } + rules, err := calculator.Scale(json.RawMessage(base.Rules), multiplier) + if err != nil { + return rewardAdjustment{}, enakGameRejected("cannot scale the reward of %s: %v", gameName, err) + } + maxReward, err := scaleRewardAmount(base.MaxReward, multiplier) + if err != nil { + return rewardAdjustment{}, enakGameRejected("cannot scale the max_reward of %s: %v", gameName, err) + } + return rewardAdjustment{ + model: models.GameRewardAdjustment{ + GameID: active.GameID, GameName: gameName, RewardConfigID: active.ID, Version: active.Version, + BaseConfigID: base.ID, RewardType: base.RewardType, + CurrentMultiplier: multiplierValue(multiplierOf(active)), NewMultiplier: multiplierValue(multiplier), + CurrentRules: json.RawMessage(active.Rules), NewRules: rules, + CurrentMaxReward: active.MaxReward, NewMaxReward: maxReward, + }, + active: active, base: base, multiplier: multiplier, rules: rules, maxReward: maxReward, + }, nil +} + +// Accept applies the recommendation the admin saw: for every game it changes, a new +// active version of the configuration replaces the active one, in one transaction. +// When the recommendation is no longer what the admin saw, nothing changes. +func (p *GameBudgetControllerProcessor) Accept(ctx context.Context, organizationID, actor, budgetID uuid.UUID, in models.GameBudgetRecommendationAcceptInput) (*models.GameBudgetRecommendationAccepted, error) { + if in.Multiplier == nil { + return nil, enakGameRejected("multiplier is required: the one the recommendation showed") + } + if err := validateReason(in.Reason); err != nil { + return nil, err + } + seen := int64(math.Round(*in.Multiplier * float64(rewardMultiplierOne))) + + var out *models.GameBudgetRecommendationAccepted + err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { + // One acceptance at a time in the organization, so two cannot both pass the + // cooldown, and none while a budget is being changed. + if err := p.budgets.LockGlobalBudgets(ctx, organizationID); err != nil { + return err + } + budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) + if err != nil { + return err + } + now := p.now() + rec, adjustments, err := p.recommend(ctx, budget, now) + if err != nil { + return err + } + if rec.State != constants.GameBudgetRecommended { + return enakGameRejected("nothing to accept: %s", rec.Message) + } + if seen != int64(math.Round(rec.Multiplier*float64(rewardMultiplierOne))) { + return enakGameRejected("the recommendation is now %.2f; review it again", rec.Multiplier) + } + reason := in.Reason + if reason == nil { + r := fmt.Sprintf("Budget Controller: rewards × %.2f for budget %s", rec.Multiplier, budget.ID) + reason = &r + } + + out = &models.GameBudgetRecommendationAccepted{BudgetID: budget.ID, Multiplier: rec.Multiplier} + configIDs := make([]uuid.UUID, 0, len(adjustments)) + for _, a := range adjustments { + config, err := p.apply(ctx, organizationID, actor, budget.ID, a, now, reason) + if err != nil { + return err + } + out.RewardConfigs = append(out.RewardConfigs, *rewardConfigModel(config)) + configIDs = append(configIDs, config.ID) + } + return p.record(ctx, organizationID, actor, constants.AuditEntityGameBudget, budget.ID, constants.AuditActionRecommendationAccepted, nil, + map[string]any{ + "multiplier": rec.Multiplier, "target_multiplier": rec.TargetMultiplier, "amount": rec.Metrics.Amount, + "realized_cost": rec.Metrics.RealizedCost, "forecast_cost": rec.Metrics.ForecastCost, + "guardrails": rec.Guardrails, "reward_config_ids": configIDs, + }, reason) + }) + if err != nil { + return nil, err + } + return out, nil +} + +// apply retires a game's active configuration and activates its adjusted version. +func (p *GameBudgetControllerProcessor) apply(ctx context.Context, organizationID, actor, budgetID uuid.UUID, a rewardAdjustment, now time.Time, reason *string) (*entities.GameRewardConfig, error) { + // Waits for an admin changing the same game's configuration, then checks the + // recommendation was made from the version still active. + changed := enakGameRejected("%s changed meanwhile; review the recommendation again", a.model.GameName) + game, err := p.games.LockGame(ctx, organizationID, a.active.GameID) + if err != nil { + return nil, err + } + if game.Status == constants.GameStatusArchived { + return nil, changed + } + active, err := p.games.GetActiveRewardConfig(ctx, organizationID, a.active.GameID) + if errors.Is(err, repository.ErrGameRewardConfigNotFound) { + return nil, changed + } + if err != nil { + return nil, err + } + if active.ID != a.active.ID { + return nil, changed + } + moved, err := p.games.SetRewardConfigStatus(ctx, organizationID, active.ID, constants.GameRewardConfigStatusActive, constants.GameRewardConfigStatusRetired) + if err != nil { + return nil, err + } + if !moved { + return nil, changed + } + if err := p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, active.ID, "RETIRED", + map[string]string{"status": constants.GameRewardConfigStatusActive}, map[string]string{"status": constants.GameRewardConfigStatusRetired}, reason); err != nil { + return nil, err + } + + multiplier, baseID, budget := multiplierValue(a.multiplier), a.base.ID, budgetID + config := &entities.GameRewardConfig{ + OrganizationID: organizationID, GameID: active.GameID, RewardType: a.base.RewardType, + Rules: entities.JSONDocument(a.rules), MaxReward: a.maxReward, Status: constants.GameRewardConfigStatusActive, + EffectiveAt: &now, CreatedBy: actor, Reason: reason, BaseConfigID: &baseID, Multiplier: &multiplier, BudgetID: &budget, + } + if err := p.games.CreateRewardConfig(ctx, config); err != nil { + return nil, err + } + if err := p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "CREATED", nil, rewardConfigModel(config), reason); err != nil { + return nil, err + } + return config, p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "ACTIVATED", + nil, map[string]string{"status": constants.GameRewardConfigStatusActive}, reason) +} + +func (p *GameBudgetControllerProcessor) 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.AuditSourceBudgetController, + }) +} diff --git a/internal/processor/game_budget_controller_processor_test.go b/internal/processor/game_budget_controller_processor_test.go new file mode 100644 index 0000000..1135f17 --- /dev/null +++ b/internal/processor/game_budget_controller_processor_test.go @@ -0,0 +1,101 @@ +package processor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +var defaultGuardrails = budgetGuardrails{step: 10, min: 50, max: 150, cooldownDays: 7} + +// EG-901: the multiplier of one recommendation. +func TestBudgetStepMultiplier(t *testing.T) { + // metrics is a running budget of Rp100M: realized so far, forecast at period end. + metrics := func(realized, forecast int64) models.GameBudgetMetrics { + return models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: realized, ForecastCost: forecast, WindowDays: 7, RemainingDays: 10} + } + for name, tc := range map[string]struct { + m models.GameBudgetMetrics + g budgetGuardrails + wantTarget *int64 + wantStep int64 + wantState string + }{ + // PRD §30: Rp40M left for Rp55M forecast → 0.7272, one step down. + "PRD §30": {metrics(60_000_000, 115_000_000), defaultGuardrails, ptr(int64(7272)), 9_000, ""}, + "wider step": {metrics(60_000_000, 115_000_000), budgetGuardrails{step: 30, min: 50, max: 150}, ptr(int64(7272)), 7_200, ""}, + "slightly over": {metrics(60_000_000, 100_400_000), defaultGuardrails, ptr(int64(9900)), 9_900, ""}, + "under budget": {metrics(20_000_000, 60_000_000), defaultGuardrails, ptr(int64(20000)), 11_000, ""}, + "slightly under": {metrics(60_000_000, 99_000_000), defaultGuardrails, ptr(int64(10256)), 10_200, ""}, + "on budget": {metrics(60_000_000, 100_000_000), defaultGuardrails, ptr(int64(10000)), 10_000, constants.GameBudgetRecommendationNoChange}, + "a hair under": {metrics(60_000_000, 99_900_000), defaultGuardrails, ptr(int64(10025)), 10_000, constants.GameBudgetRecommendationNoChange}, + "already over budget": {metrics(110_000_000, 120_000_000), defaultGuardrails, ptr(int64(0)), 9_000, ""}, + "no recent cost": {metrics(60_000_000, 60_000_000), defaultGuardrails, nil, 10_000, constants.GameBudgetRecommendationNoData}, + "not started": {models.GameBudgetMetrics{Amount: 100_000_000}, defaultGuardrails, + nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod}, + "last day": {models.GameBudgetMetrics{Amount: 100_000_000, RealizedCost: 1, ForecastCost: 1, WindowDays: 7}, defaultGuardrails, + nil, 10_000, constants.GameBudgetRecommendationOutOfPeriod}, + } { + target, step, state := budgetStepMultiplier(tc.m, tc.g) + assert.Equal(t, tc.wantTarget, target, name) + assert.Equal(t, tc.wantStep, step, name) + assert.Equal(t, tc.wantState, state, name) + } +} + +// EG-901: a game's multiplier after a step, against its admin's configuration. +func TestNextGameMultiplier(t *testing.T) { + for name, tc := range map[string]struct { + current, step, want int64 + g budgetGuardrails + }{ + "first step down": {10_000, 9_000, 9_000, defaultGuardrails}, + "compounds": {9_000, 9_000, 8_100, defaultGuardrails}, + "rounds down": {8_100, 9_900, 8_019, defaultGuardrails}, + "stops at min": {5_500, 9_000, 5_000, defaultGuardrails}, + "stays at min": {5_000, 9_000, 5_000, defaultGuardrails}, + "stops at max": {14_000, 11_000, 15_000, defaultGuardrails}, + "up from a cut": {8_100, 11_000, 8_910, defaultGuardrails}, + "min raised since: up": {4_000, 11_000, 5_000, defaultGuardrails}, + "min raised since: stays": {4_000, 9_000, 4_000, defaultGuardrails}, + "max lowered since: down": {20_000, 9_000, 15_000, defaultGuardrails}, + "max lowered since: stay": {20_000, 11_000, 20_000, defaultGuardrails}, + "own bounds": {10_000, 9_000, 9_500, budgetGuardrails{step: 10, min: 95, max: 100}}, + } { + assert.Equal(t, tc.want, nextGameMultiplier(tc.current, tc.step, tc.g), name) + } +} + +func TestGuardrailsOf(t *testing.T) { + assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{}), "the defaults") + assert.Equal(t, defaultGuardrails, guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"warning": 50}`)})) + assert.Equal(t, budgetGuardrails{step: 5, min: 80, max: 150, cooldownDays: 0}, + guardrailsOf(&entities.GameBudget{Thresholds: entities.JSONDocument(`{"max_step_percent": 5, "min_multiplier_percent": 80, "cooldown_days": 0}`)})) +} + +func TestGameBudgetGuardrailValidation(t *testing.T) { + in := func(th models.GameBudgetThresholds) models.GameBudgetInput { + return models.GameBudgetInput{Scope: "GLOBAL", Name: "Oktober", PeriodStart: "2026-10-01", PeriodEnd: "2026-10-31", Amount: 1, Thresholds: th} + } + _, err := gameBudgetFromInput(in(models.GameBudgetThresholds{ + MaxStepPercent: ptr(int64(50)), MinMultiplierPercent: ptr(int64(1)), MaxMultiplierPercent: ptr(int64(1000)), CooldownDays: ptr(int64(0)), + })) + assert.NoError(t, err) + for name, th := range map[string]models.GameBudgetThresholds{ + "step 0": {MaxStepPercent: ptr(int64(0))}, + "step 51": {MaxStepPercent: ptr(int64(51))}, + "min 0": {MinMultiplierPercent: ptr(int64(0))}, + "min above 1x": {MinMultiplierPercent: ptr(int64(101))}, + "max below 1x": {MaxMultiplierPercent: ptr(int64(99))}, + "max too high": {MaxMultiplierPercent: ptr(int64(1001))}, + "cooldown -1": {CooldownDays: ptr(int64(-1))}, + "cooldown 91": {CooldownDays: ptr(int64(91))}, + } { + _, err := gameBudgetFromInput(in(th)) + assert.ErrorIs(t, err, ErrEnakGameRejected, name) + } +} diff --git a/internal/processor/game_budget_metrics_processor.go b/internal/processor/game_budget_metrics_processor.go index 3e5bc1d..a89c8e8 100644 --- a/internal/processor/game_budget_metrics_processor.go +++ b/internal/processor/game_budget_metrics_processor.go @@ -49,7 +49,12 @@ func (p *GameBudgetMetricsProcessor) Metrics(ctx context.Context, organizationID if err != nil { return nil, err } - now := p.now() + return p.metricsAt(ctx, budget, p.now()) +} + +// metricsAt computes a budget's metrics as of now. +func (p *GameBudgetMetricsProcessor) metricsAt(ctx context.Context, budget *entities.GameBudget, now time.Time) (*models.GameBudgetMetrics, error) { + var err error today := civilDay(walletDay(now)) start, end := civilDay(budget.PeriodStart), civilDay(budget.PeriodEnd) diff --git a/internal/processor/game_budget_processor.go b/internal/processor/game_budget_processor.go index a0d616a..d97a373 100644 --- a/internal/processor/game_budget_processor.go +++ b/internal/processor/game_budget_processor.go @@ -25,6 +25,9 @@ type GameBudgetProcessor struct { now func() time.Time } +// gameBudgetMaxMultiplierLimit is the highest max_multiplier_percent a budget may set. +const gameBudgetMaxMultiplierLimit = 1000 + func NewGameBudgetProcessor(budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameBudgetProcessor { return &GameBudgetProcessor{budgets: budgets, audit: audit, tx: tx, now: time.Now} } @@ -234,6 +237,18 @@ func gameBudgetFromInput(in models.GameBudgetInput) (*entities.GameBudget, error if t.Warning != nil && t.Critical != nil && *t.Warning > *t.Critical { return nil, enakGameRejected("thresholds.warning cannot be above thresholds.critical") } + switch { + case t.MaxStepPercent != nil && (*t.MaxStepPercent < 1 || *t.MaxStepPercent > 50): + return nil, enakGameRejected("thresholds.max_step_percent must be between 1 and 50") + case t.MinMultiplierPercent != nil && (*t.MinMultiplierPercent < 1 || *t.MinMultiplierPercent > 100): + return nil, enakGameRejected("thresholds.min_multiplier_percent must be between 1 and 100") + case t.MaxMultiplierPercent != nil && (*t.MaxMultiplierPercent < 100 || *t.MaxMultiplierPercent > gameBudgetMaxMultiplierLimit): + // NUMERIC(6,4) on game_reward_configs.multiplier holds far more; this keeps a + // typo from multiplying rewards a hundredfold. + return nil, enakGameRejected("thresholds.max_multiplier_percent must be between 100 and %d", gameBudgetMaxMultiplierLimit) + case t.CooldownDays != nil && (*t.CooldownDays < 0 || *t.CooldownDays > 90): + return nil, enakGameRejected("thresholds.cooldown_days must be between 0 and 90") + } thresholds, err := json.Marshal(t) if err != nil { return nil, err diff --git a/internal/processor/reward_calculator.go b/internal/processor/reward_calculator.go index 10922e9..68ddb90 100644 --- a/internal/processor/reward_calculator.go +++ b/internal/processor/reward_calculator.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math" "math/big" "strings" @@ -60,6 +61,43 @@ type RewardCalculator interface { // 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) + // Scale multiplies every amount in rules by multiplier/10000, rounded down, for + // the Budget Controller (§10). Everything else, such as score bands and weights, + // stays. + Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) +} + +// rewardMultiplierOne is a multiplier of 1 in the ten-thousandths Scale takes. +const rewardMultiplierOne = int64(10_000) + +// scaleRewardAmount is amount × multiplier/10000, rounded down (RFC §19.2 #1). +func scaleRewardAmount(amount, multiplier int64) (int64, error) { + if multiplier < 0 { + return 0, invalidRules("multiplier cannot be negative") + } + if amount != 0 && multiplier > math.MaxInt64/amount { + return 0, invalidRules("amount %d is too large to scale", amount) + } + return amount * multiplier / rewardMultiplierOne, nil +} + +func scaleRewardAmounts(multiplier int64, amounts ...*int64) error { + for _, a := range amounts { + scaled, err := scaleRewardAmount(*a, multiplier) + if err != nil { + return err + } + *a = scaled + } + return nil +} + +func marshalRules(v any) (json.RawMessage, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return b, nil } var rewardCalculators = map[string]RewardCalculator{ @@ -131,14 +169,26 @@ func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardR return *r.Amount, map[string]any{"reward_type": constants.GameRewardTypeFixed}, nil } +func (f fixedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, err := f.parse(rules) + if err != nil { + return nil, err + } + if err := scaleRewardAmounts(multiplier, r.Amount); err != nil { + return nil, err + } + return marshalRules(r) +} + // 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"` + Min *int64 `json:"min"` + // Left out of the last band, also when scaled rules are written back. + Max *int64 `json:"max,omitempty"` Amount *int64 `json:"amount"` } @@ -206,6 +256,19 @@ func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult, return 0, nil, unusableResult("score %d is in no band", score) } +func (s scoreBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, err := s.parse(rules) + if err != nil { + return nil, err + } + for _, b := range r.Bands { + if err := scaleRewardAmounts(multiplier, b.Amount); err != nil { + return nil, err + } + } + return marshalRules(r) +} + // OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}. type outcomeBasedReward struct{} @@ -252,6 +315,21 @@ func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResul return amount, map[string]any{"reward_type": constants.GameRewardTypeOutcomeBased, "outcome": *result.Outcome}, nil } +func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, err := o.parse(rules) + if err != nil { + return nil, err + } + for outcome, amount := range r.Outcomes { + scaled, err := scaleRewardAmount(amount, multiplier) + if err != nil { + return nil, err + } + r.Outcomes[outcome] = scaled + } + return marshalRules(r) +} + // 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. @@ -318,3 +396,17 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng } return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) } + +// Scale changes the prizes, not their odds. +func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) { + r, _, err := p.parse(rules) + if err != nil { + return nil, err + } + for _, e := range r.Table { + if err := scaleRewardAmounts(multiplier, e.Amount); err != nil { + return nil, err + } + } + return marshalRules(r) +} diff --git a/internal/processor/reward_calculator_test.go b/internal/processor/reward_calculator_test.go index 5f6c12e..4646030 100644 --- a/internal/processor/reward_calculator_test.go +++ b/internal/processor/reward_calculator_test.go @@ -144,3 +144,38 @@ func TestRewardCalculator_RejectsInvalidRules(t *testing.T) { assert.ErrorIs(t, err, ErrInvalidRewardRules, name) } } + +// EG-902: the Budget Controller scales amounts, rounded down, and nothing else. +func TestRewardCalculator_Scale(t *testing.T) { + for name, tc := range map[string]struct { + rewardType, rules string + multiplier int64 + want string + }{ + "FIXED ×0.9": {constants.GameRewardTypeFixed, `{"amount": 10}`, 9_000, `{"amount":9}`}, + "FIXED rounds": {constants.GameRewardTypeFixed, `{"amount": 5}`, 9_000, `{"amount":4}`}, + "FIXED to zero": {constants.GameRewardTypeFixed, `{"amount": 1}`, 9_000, `{"amount":0}`}, + "FIXED ×1.1": {constants.GameRewardTypeFixed, `{"amount": 10}`, 11_000, `{"amount":11}`}, + "FIXED ×1": {constants.GameRewardTypeFixed, `{"amount": 7}`, 10_000, `{"amount":7}`}, + "SCORE_BASED": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}`, 8_100, `{"bands":[{"min":0,"max":100,"amount":0},{"min":101,"amount":16}]}`}, + "OUTCOME_BASED": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}`, 9_000, `{"outcomes":{"FAIL":0,"GOOD":9,"PERFECT":18}}`}, + "PROBABILITY": {constants.GameRewardTypeProbability, `{"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}`, 8_500, `{"table":[{"weight":1,"amount":850},{"weight":999,"amount":0}]}`}, + } { + c, err := RewardCalculatorFor(tc.rewardType) + require.NoError(t, err) + scaled, err := c.Scale(json.RawMessage(tc.rules), tc.multiplier) + require.NoError(t, err, name) + assert.JSONEq(t, tc.want, string(scaled), name) + assert.NoError(t, c.Validate(scaled), "%s: scaled rules stay valid", name) + } + + c, _ := RewardCalculatorFor(constants.GameRewardTypeFixed) + _, err := c.Scale(json.RawMessage(`{"amount": 9223372036854775807}`), 15_000) + assert.ErrorIs(t, err, ErrInvalidRewardRules, "overflow is refused, not wrapped") + _, err = c.Scale(json.RawMessage(`{"amount": -1}`), 9_000) + assert.ErrorIs(t, err, ErrInvalidRewardRules) + + v, err := scaleRewardAmount(math.MaxInt64/15_000, 15_000) + require.NoError(t, err) + assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v) +} diff --git a/internal/repository/enakgame_analytics_repository.go b/internal/repository/enakgame_analytics_repository.go new file mode 100644 index 0000000..6a261e6 --- /dev/null +++ b/internal/repository/enakgame_analytics_repository.go @@ -0,0 +1,129 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// GameSessionStats is what an organization's sessions in a range add up to, for one +// game, or for all of them when GameID is nil. +type GameSessionStats struct { + GameID *uuid.UUID + GameName *string + Plays int64 + Completed int64 + Refunded int64 + Expired int64 + Flagged int64 + Players int64 + AverageScore *float64 + CoinIssued int64 + EntryCost int64 + CoinRefunded int64 +} + +// WalletFlow is what one ledger type moved in one currency over a range. +type WalletFlow struct { + Currency string + Type string + Credit int64 + Debit int64 + Transactions int64 +} + +// EnakGameAnalyticsRepository reads EnakGame analytics (docs/tasks-enakgame.md +// EG-903, PRD §36) from the tables that hold the facts, on read. Ranges are [from, to). +type EnakGameAnalyticsRepository interface { + // SessionStats returns one row per game with a session started in the range, + // most played first, then the row for all of them. gameID narrows it to one game. + SessionStats(ctx context.Context, organizationID uuid.UUID, from, to time.Time, gameID *uuid.UUID) ([]GameSessionStats, error) + // WalletFlows sums the organization's ledger in the range per currency and type. + WalletFlows(ctx context.Context, organizationID uuid.UUID, from, to time.Time) ([]WalletFlow, error) + // BalancesAt sums what the organization's customers held just before at. + BalancesAt(ctx context.Context, organizationID uuid.UUID, at time.Time) (map[string]int64, error) +} + +type enakGameAnalyticsRepository struct { + db *gorm.DB +} + +func NewEnakGameAnalyticsRepository(db *gorm.DB) EnakGameAnalyticsRepository { + return &enakGameAnalyticsRepository{db: db} +} + +func (r *enakGameAnalyticsRepository) SessionStats(ctx context.Context, organizationID uuid.UUID, from, to time.Time, gameID *uuid.UUID) ([]GameSessionStats, error) { + filter := "" + args := []any{organizationID, from, to} + if gameID != nil { + filter = "AND s.game_id = ?" + args = append(args, *gameID) + } + // The empty grouping set adds the row for all games, also when there is no + // session at all. + var rows []GameSessionStats + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT s.game_id, g.name AS game_name, + COUNT(*) AS plays, + COUNT(*) FILTER (WHERE s.status = 'COMPLETED') AS completed, + COUNT(*) FILTER (WHERE s.status = 'REFUNDED') AS refunded, + COUNT(*) FILTER (WHERE s.status = 'EXPIRED') AS expired, + COUNT(*) FILTER (WHERE s.flagged) AS flagged, + COUNT(DISTINCT s.customer_id) AS players, + AVG((s.result->>'score')::float8) + FILTER (WHERE s.status = 'COMPLETED' AND jsonb_typeof(s.result->'score') = 'number') AS average_score, + COALESCE(SUM(s.reward_total) FILTER (WHERE s.status = 'COMPLETED'), 0) AS coin_issued, + COALESCE(SUM(s.entry_cost), 0) AS entry_cost, + COALESCE(SUM(s.entry_cost) FILTER (WHERE s.status = 'REFUNDED'), 0) AS coin_refunded + FROM game_sessions s + JOIN games g ON g.id = s.game_id + WHERE s.organization_id = ? AND s.started_at >= ? AND s.started_at < ? `+filter+` + GROUP BY GROUPING SETS ((s.game_id, g.name), ()) + ORDER BY GROUPING(s.game_id), plays DESC, g.name`, args...).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read game session stats: %w", err) + } + return rows, nil +} + +func (r *enakGameAnalyticsRepository) WalletFlows(ctx context.Context, organizationID uuid.UUID, from, to time.Time) ([]WalletFlow, error) { + var rows []WalletFlow + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT currency, type, + COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS credit, + COALESCE(-SUM(amount) FILTER (WHERE amount < 0), 0) AS debit, + COUNT(*) AS transactions + FROM wallet_transactions + WHERE organization_id = ? AND created_at >= ? AND created_at < ? + GROUP BY currency, type + ORDER BY currency, type`, organizationID, from, to).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum wallet flows: %w", err) + } + return rows, nil +} + +func (r *enakGameAnalyticsRepository) BalancesAt(ctx context.Context, organizationID uuid.UUID, at time.Time) (map[string]int64, error) { + // The ledger is signed and append-only, so what it adds up to before at is what + // customers held then. + var rows []struct { + Currency string + Total int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT currency, COALESCE(SUM(amount), 0) AS total + FROM wallet_transactions + WHERE organization_id = ? AND created_at < ? + GROUP BY currency`, organizationID, at).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to sum wallet balances: %w", err) + } + out := make(map[string]int64, len(rows)) + for _, row := range rows { + out[row.Currency] = row.Total + } + return out, nil +} diff --git a/internal/repository/enakgame_repository.go b/internal/repository/enakgame_repository.go index 9c30ef2..403133a 100644 --- a/internal/repository/enakgame_repository.go +++ b/internal/repository/enakgame_repository.go @@ -62,6 +62,19 @@ type EnakGameRepository interface { // 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) + + // ListActiveRewardConfigs returns the active configuration of every game of the + // organization that is not archived, by game name. + ListActiveRewardConfigs(ctx context.Context, organizationID uuid.UUID) ([]ActiveRewardConfig, error) + // LastBudgetControllerChange is when the organization last accepted a Budget + // Controller recommendation, or nil when it never did. + LastBudgetControllerChange(ctx context.Context, organizationID uuid.UUID) (*time.Time, error) +} + +// ActiveRewardConfig is a game's active configuration with the game's name. +type ActiveRewardConfig struct { + entities.GameRewardConfig `gorm:"embedded"` + GameName string } type enakGameRepository struct { @@ -176,14 +189,15 @@ func (r *enakGameRepository) CreateRewardConfig(ctx context.Context, config *ent } 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) + max_reward, status, effective_at, created_by, reason, base_config_id, multiplier, budget_id) SELECT ?, g.organization_id, g.id, COALESCE((SELECT MAX(version) FROM game_reward_configs WHERE game_id = g.id), 0) + 1, - ?, ?::jsonb, ?, ?, ?, ?, ? + ?, ?::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 + config.CreatedBy, config.Reason, config.BaseConfigID, config.Multiplier, config.BudgetID, + config.OrganizationID, config.GameID).Scan(&rows).Error if err != nil { return fmt.Errorf("failed to create reward config: %w", err) } @@ -235,3 +249,35 @@ func (r *enakGameRepository) SetRewardConfigStatus(ctx context.Context, organiza } return result.RowsAffected == 1, nil } + +func (r *enakGameRepository) ListActiveRewardConfigs(ctx context.Context, organizationID uuid.UUID) ([]ActiveRewardConfig, error) { + var configs []ActiveRewardConfig + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT c.*, g.name AS game_name + FROM game_reward_configs c + JOIN games g ON g.id = c.game_id + WHERE c.organization_id = ? AND c.status = ? AND g.status <> ? + ORDER BY g.name, g.id`, + organizationID, constants.GameRewardConfigStatusActive, constants.GameStatusArchived).Scan(&configs).Error + if err != nil { + return nil, fmt.Errorf("failed to list active reward configs: %w", err) + } + return configs, nil +} + +func (r *enakGameRepository) LastBudgetControllerChange(ctx context.Context, organizationID uuid.UUID) (*time.Time, error) { + // effective_at, set by the Budget Controller from its clock, not created_at from + // the database's. + var last []time.Time + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + SELECT effective_at FROM game_reward_configs + WHERE organization_id = ? AND budget_id IS NOT NULL + ORDER BY effective_at DESC LIMIT 1`, organizationID).Scan(&last).Error + if err != nil { + return nil, fmt.Errorf("failed to read the last Budget Controller change: %w", err) + } + if len(last) == 0 { + return nil, nil + } + return &last[0], nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 0f097bc..6f6f666 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -672,6 +672,12 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { 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) + // Accepting writes new reward configuration versions. + enakGame.GET("/budgets/:id/recommendation", r.enakGameAdminHandler.BudgetRecommendation) + enakGame.POST("/budgets/:id/recommendation/accept", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.AcceptBudgetRecommendation) + + enakGame.GET("/analytics/games", r.enakGameAdminHandler.GameAnalytics) + enakGame.GET("/analytics/economy", r.enakGameAdminHandler.EconomyAnalytics) // Events change what rewards cost, like budgets. enakGame.GET("/events", r.enakGameAdminHandler.ListEvents) diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go index 1e5daa5..b240d78 100644 --- a/internal/service/enakgame_service.go +++ b/internal/service/enakgame_service.go @@ -41,6 +41,13 @@ type EnakGameAdminService interface { 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 + BudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response + // AcceptBudgetRecommendation applies the recommendation whose multiplier the body + // sends back. + AcceptBudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response + + GameAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response + EconomyAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *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 @@ -74,16 +81,21 @@ type EnakGameCustomerService interface { } type EnakGameAdminServiceImpl struct { - games *processor.EnakGameAdminProcessor - budgets *processor.GameBudgetProcessor - metrics *processor.GameBudgetMetricsProcessor - events *processor.GameEventProcessor - vouchers *processor.VoucherAdminProcessor + games *processor.EnakGameAdminProcessor + budgets *processor.GameBudgetProcessor + metrics *processor.GameBudgetMetricsProcessor + controller *processor.GameBudgetControllerProcessor + events *processor.GameEventProcessor + vouchers *processor.VoucherAdminProcessor + analytics *processor.EnakGameAnalyticsProcessor } 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} + controller *processor.GameBudgetControllerProcessor, events *processor.GameEventProcessor, vouchers *processor.VoucherAdminProcessor, + analytics *processor.EnakGameAnalyticsProcessor) *EnakGameAdminServiceImpl { + return &EnakGameAdminServiceImpl{ + games: games, budgets: budgets, metrics: metrics, controller: controller, events: events, vouchers: vouchers, analytics: analytics, + } } type EnakGameCustomerServiceImpl struct { @@ -213,6 +225,30 @@ func (s *EnakGameAdminServiceImpl) BudgetMetrics(ctx context.Context, apctx *app return respond(ctx, metrics, err) } +func (s *EnakGameAdminServiceImpl) BudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID) *contract.Response { + rec, err := s.controller.Recommendation(ctx, apctx.OrganizationID, id) + return respond(ctx, rec, err) +} + +func (s *EnakGameAdminServiceImpl) AcceptBudgetRecommendation(ctx context.Context, apctx *appcontext.ContextInfo, id uuid.UUID, body []byte) *contract.Response { + var in models.GameBudgetRecommendationAcceptInput + if resp := decodeStrict(body, &in); resp != nil { + return resp + } + accepted, err := s.controller.Accept(ctx, apctx.OrganizationID, apctx.UserID, id, in) + return respond(ctx, accepted, err) +} + +func (s *EnakGameAdminServiceImpl) GameAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response { + stats, err := s.analytics.Games(ctx, apctx.OrganizationID, q) + return respond(ctx, stats, err) +} + +func (s *EnakGameAdminServiceImpl) EconomyAnalytics(ctx context.Context, apctx *appcontext.ContextInfo, q models.EnakGameAnalyticsQuery) *contract.Response { + stats, err := s.analytics.Economy(ctx, apctx.OrganizationID, q) + return respond(ctx, stats, 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 { diff --git a/migrations/000112_add_budget_controller_to_reward_configs.down.sql b/migrations/000112_add_budget_controller_to_reward_configs.down.sql new file mode 100644 index 0000000..e4a8fdb --- /dev/null +++ b/migrations/000112_add_budget_controller_to_reward_configs.down.sql @@ -0,0 +1,7 @@ +DROP INDEX IF EXISTS idx_game_reward_configs_controller; + +ALTER TABLE game_reward_configs + DROP CONSTRAINT IF EXISTS chk_game_reward_configs_controller, + DROP COLUMN IF EXISTS budget_id, + DROP COLUMN IF EXISTS multiplier, + DROP COLUMN IF EXISTS base_config_id; diff --git a/migrations/000112_add_budget_controller_to_reward_configs.up.sql b/migrations/000112_add_budget_controller_to_reward_configs.up.sql new file mode 100644 index 0000000..7de230d --- /dev/null +++ b/migrations/000112_add_budget_controller_to_reward_configs.up.sql @@ -0,0 +1,19 @@ +-- Reward configurations made by accepting a Budget Controller recommendation +-- (docs/rfc-enakgame.md §10, PRD §29–§31). Such a version scales the amounts of the +-- version an admin wrote last, its base, so a run of adjustments never compounds its +-- rounding and min/max multiplier are measured against what the admin set. A version +-- an admin writes has none of the three, and becomes the base of the next adjustments. +ALTER TABLE game_reward_configs + ADD COLUMN base_config_id UUID REFERENCES game_reward_configs(id), + -- Applied to every amount of the base, rounded down: 0.8100 pays 81%. + ADD COLUMN multiplier NUMERIC(6,4), + -- The global budget whose recommendation was accepted. + ADD COLUMN budget_id UUID REFERENCES game_budgets(id), + ADD CONSTRAINT chk_game_reward_configs_controller CHECK ( + (base_config_id IS NULL AND multiplier IS NULL AND budget_id IS NULL) + OR (base_config_id IS NOT NULL AND multiplier > 0 AND budget_id IS NOT NULL AND effective_at IS NOT NULL)); + +-- The last adjustment of an organization, for the cooldown. effective_at is when the +-- Budget Controller made it, by the application clock. +CREATE INDEX idx_game_reward_configs_controller + ON game_reward_configs(organization_id, effective_at DESC) WHERE budget_id IS NOT NULL; diff --git a/migrations/000113_index_game_sessions_org_started.down.sql b/migrations/000113_index_game_sessions_org_started.down.sql new file mode 100644 index 0000000..cfca100 --- /dev/null +++ b/migrations/000113_index_game_sessions_org_started.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS idx_game_sessions_org_started; diff --git a/migrations/000113_index_game_sessions_org_started.up.sql b/migrations/000113_index_game_sessions_org_started.up.sql new file mode 100644 index 0000000..de5327c --- /dev/null +++ b/migrations/000113_index_game_sessions_org_started.up.sql @@ -0,0 +1,3 @@ +-- EnakGame analytics read an organization's sessions by start time +-- (docs/tasks-enakgame.md EG-903). +CREATE INDEX IF NOT EXISTS idx_game_sessions_org_started ON game_sessions(organization_id, started_at); diff --git a/migrations/000114_index_wallet_transactions_org_created.down.sql b/migrations/000114_index_wallet_transactions_org_created.down.sql new file mode 100644 index 0000000..1fefcc2 --- /dev/null +++ b/migrations/000114_index_wallet_transactions_org_created.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_wallet_transactions_org_created; diff --git a/migrations/000114_index_wallet_transactions_org_created.up.sql b/migrations/000114_index_wallet_transactions_org_created.up.sql new file mode 100644 index 0000000..eb66392 --- /dev/null +++ b/migrations/000114_index_wallet_transactions_org_created.up.sql @@ -0,0 +1,3 @@ +-- EnakGame economy analytics sum an organization's ledger over a date range +-- (docs/tasks-enakgame.md EG-903). One statement, for CONCURRENTLY. +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_wallet_transactions_org_created ON wallet_transactions(organization_id, created_at); -- 2.54.0 From 18e87398bba8dbfd826bd4e4128205f9ebba6ec9 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 21:31:56 +0700 Subject: [PATCH 03/12] feat(enakgame): spin as an EnakGame game; remove the old game flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003). Spin (EG-1001) - PROBABILITY entries take an optional label (a wheel segment). The customer game list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and completing returns the drawn prize, so the client can draw the wheel and stop it on the server's draw. - docs/enakgame-spin.md: the admin steps to set up spin per organization (no seeder) and the customer app flow. An HTTP test plays it end to end. Old game flow removed (EG-1002) - Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their handlers, services, processors, repositories, validators, models, contracts, mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also closes RFC §15 findings 1 and 2 (double charge, spinning another org's game). - Tables games, game_prizes, game_plays and rewards stay for ledger history. entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps game_id but no longer embeds the old game response. games.is_active dropped (EG-1003) - Migration 000115; nothing reads metadata.coin_cost any more. The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests were not run: no test database here. Migration 000115 has not been run anywhere. The customer app must stop calling the removed endpoints before this is deployed. Co-Authored-By: Claude Opus 5.5 --- docs/api-enakpoint.md | 18 +- docs/backoffice-enakpoint.md | 6 +- docs/enakgame-spin.md | 86 ++++ docs/integration-enakpoint.md | 28 +- docs/mobile-customer-enakpoint.md | 28 +- docs/prd-point-coin.md | 6 + internal/app/app.go | 31 +- internal/constants/error.go | 5 - internal/contract/customer_game_contract.go | 60 --- internal/contract/game_contract.go | 49 --- internal/contract/game_play_contract.go | 59 --- internal/contract/game_prize_contract.go | 64 --- internal/contract/omset_tracker_contract.go | 17 +- internal/contract/reward_contract.go | 82 ---- internal/contract/spin_game_contract.go | 20 - internal/entities/entities.go | 3 - internal/entities/game.go | 14 +- internal/entities/game_play.go | 33 -- internal/entities/game_prize.go | 38 -- internal/entities/reward.go | 126 ------ internal/entities/string_slice.go | 38 ++ internal/handler/customer_points_handler.go | 30 -- internal/handler/customer_wallet_db_test.go | 1 - internal/handler/enakgame_db_test.go | 131 ++++++ internal/handler/gamification_handler.go | 35 -- .../gamification_handler_additional.go | 404 ------------------ internal/handler/reward_handler.go | 206 --------- internal/handler/spin_game_handler.go | 71 --- internal/handler/wallet_admin_db_test.go | 2 +- internal/mappers/game_mapper.go | 58 --- internal/mappers/game_play_mapper.go | 54 --- internal/mappers/game_prize_mapper.go | 82 ---- internal/mappers/omset_tracker_mapper.go | 1 - internal/mappers/reward_mapper.go | 138 ------ internal/mappers/spin_game_mapper.go | 80 ---- internal/models/customer_game.go | 59 --- internal/models/enakgame.go | 15 + internal/models/game.go | 41 -- internal/models/game_play.go | 50 --- internal/models/game_prize.go | 56 --- internal/models/omset_tracker.go | 17 +- internal/models/reward.go | 71 --- internal/models/spin_game.go | 37 -- .../processor/customer_points_processor.go | 93 +--- internal/processor/game_play_processor.go | 301 ------------- .../processor/game_play_processor_db_test.go | 70 --- .../processor/game_play_processor_test.go | 245 ----------- internal/processor/game_prize_processor.go | 148 ------- internal/processor/game_processor.go | 139 ------ internal/processor/game_session_complete.go | 1 + internal/processor/game_session_processor.go | 16 + internal/processor/reward_calculator.go | 61 ++- internal/processor/reward_calculator_test.go | 48 +++ internal/processor/reward_processor.go | 149 ------- internal/repository/game_play_repository.go | 170 -------- internal/repository/game_prize_repository.go | 115 ----- internal/repository/game_repository.go | 112 ----- internal/repository/reward_repository.go | 129 ------ internal/router/router.go | 55 +-- internal/service/customer_points_service.go | 20 - internal/service/gamification_service.go | 172 -------- internal/service/reward_service.go | 198 --------- internal/service/spin_game_service.go | 80 ---- .../transformer/gamification_transformer.go | 274 ------------ internal/validator/gamification_validator.go | 215 ---------- internal/validator/reward_validator.go | 263 ------------ .../000115_drop_games_is_active.down.sql | 3 + migrations/000115_drop_games_is_active.up.sql | 4 + 68 files changed, 467 insertions(+), 5064 deletions(-) create mode 100644 docs/enakgame-spin.md delete mode 100644 internal/contract/customer_game_contract.go delete mode 100644 internal/contract/game_contract.go delete mode 100644 internal/contract/game_play_contract.go delete mode 100644 internal/contract/game_prize_contract.go delete mode 100644 internal/contract/reward_contract.go delete mode 100644 internal/contract/spin_game_contract.go delete mode 100644 internal/entities/game_play.go delete mode 100644 internal/entities/game_prize.go delete mode 100644 internal/entities/reward.go create mode 100644 internal/entities/string_slice.go delete mode 100644 internal/handler/reward_handler.go delete mode 100644 internal/handler/spin_game_handler.go delete mode 100644 internal/mappers/game_mapper.go delete mode 100644 internal/mappers/game_play_mapper.go delete mode 100644 internal/mappers/game_prize_mapper.go delete mode 100644 internal/mappers/reward_mapper.go delete mode 100644 internal/mappers/spin_game_mapper.go delete mode 100644 internal/models/customer_game.go delete mode 100644 internal/models/game.go delete mode 100644 internal/models/game_play.go delete mode 100644 internal/models/game_prize.go delete mode 100644 internal/models/reward.go delete mode 100644 internal/models/spin_game.go delete mode 100644 internal/processor/game_play_processor.go delete mode 100644 internal/processor/game_play_processor_db_test.go delete mode 100644 internal/processor/game_play_processor_test.go delete mode 100644 internal/processor/game_prize_processor.go delete mode 100644 internal/processor/game_processor.go delete mode 100644 internal/processor/reward_processor.go delete mode 100644 internal/repository/game_play_repository.go delete mode 100644 internal/repository/game_prize_repository.go delete mode 100644 internal/repository/game_repository.go delete mode 100644 internal/repository/reward_repository.go delete mode 100644 internal/service/reward_service.go delete mode 100644 internal/service/spin_game_service.go delete mode 100644 internal/validator/reward_validator.go create mode 100644 migrations/000115_drop_games_is_active.down.sql create mode 100644 migrations/000115_drop_games_is_active.up.sql diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md index 9b93c95..516ea96 100644 --- a/docs/api-enakpoint.md +++ b/docs/api-enakpoint.md @@ -151,7 +151,7 @@ PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama | POST | `/customer/wallet/exchange` | Ya | Wajib | | GET | `/customer/wallet/transfer/recipient?phone=` | – | – | | POST | `/customer/wallet/transfer` | Ya | Wajib | -| POST | `/customer/spin` | – | – | +| POST | `/customer/enakgame/sessions` | – | Wajib | ### GET /customer/wallet/exchange/preview?coins=30 @@ -210,19 +210,15 @@ Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", " `currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`. -### POST /customer/spin +### Game (EnakGame) -Body `{ "spin_id": "" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). +`POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`: -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb" }, - "coins_remaining": 7 -} -``` +- `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda). +- `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`. +- `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar). -EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong. +Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md). ## POS: earning, void, dan refund diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md index cc78597..d188868 100644 --- a/docs/backoffice-enakpoint.md +++ b/docs/backoffice-enakpoint.md @@ -18,7 +18,7 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a | Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet | | Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet | | PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan | -| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game | +| Biaya main game | `entry_cost` di `/marketing/enakgame/games` | Marketing → EnakGame → game | Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada. @@ -269,7 +269,7 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks ### Biaya main game -Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. +Semua game (spin, raffle, minigame) memakai EnakCoin yang sama dan dikelola di `/marketing/enakgame/games`. Menu lama `/marketing/games`, `/marketing/game-prizes`, dan `/marketing/rewards` sudah dihapus. Biaya per main adalah `entry_cost` game (bilangan bulat ≥ 1), hadiahnya diatur di reward config game itu. Langkah membuat spin ada di [`enakgame-spin.md`](./enakgame-spin.md). Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. ## Pesan error dan checklist @@ -293,7 +293,7 @@ Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalt - [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`. - [ ] Tombol Telusuri ada di setiap baris riwayat. - [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. -- [ ] Form game punya input `coin_cost`. +- [ ] Form game EnakGame punya input `entry_cost`. - [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu. diff --git a/docs/enakgame-spin.md b/docs/enakgame-spin.md new file mode 100644 index 0000000..75a9d01 --- /dev/null +++ b/docs/enakgame-spin.md @@ -0,0 +1,86 @@ +# Spin sebagai Game EnakGame + +**Sumber:** [RFC EnakGame](rfc-enakgame.md) §14, task EG-1001 +**Pembaca:** admin organisasi dan tim backoffice / aplikasi customer + +Spin lama (`POST /customer/spin`) diganti dengan game EnakGame biasa: tipe `SPIN`, +reward `PROBABILITY`, hadiah berupa EnakCoin. Spin dimainkan lewat endpoint session +yang sama dengan game lain, sehingga ikut mendapat idempotency, refund otomatis, budget, +event, dan Economy Guard. + +Tidak ada seeder: setiap organisasi membuat spin-nya sendiri lewat API admin di bawah. +Semua langkah memakai token admin organisasi; langkah 2–4 butuh loyalty manager. + +## Langkah Admin + +### 1. Buat game + +`POST /api/v1/marketing/enakgame/games` + +```json +{ + "name": "Spin Harian", + "slug": "spin", + "type": "SPIN", + "entry_cost": 5, + "status": "ACTIVE", + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html" +} +``` + +`entry_cost` adalah EnakCoin yang dipotong setiap kali spin (minimal 1). + +### 2. Buat reward config `PROBABILITY` + +`POST /api/v1/marketing/enakgame/games/:id/reward-configs` + +```json +{ + "reward_type": "PROBABILITY", + "max_reward": 50, + "rules": { + "table": [ + { "weight": 50, "amount": 0, "label": "Zonk" }, + { "weight": 30, "amount": 3, "label": "3 Coin" }, + { "weight": 15, "amount": 10, "label": "10 Coin" }, + { "weight": 5, "amount": 50, "label": "Jackpot" } + ] + }, + "reason": "Spin pertama" +} +``` + +- Satu baris `table` = satu segmen roda, urut searah gambar roda. +- `weight` bilangan bulat ≥ 1. Peluang segmen = `weight` ÷ total weight (di atas: 50%, + 30%, 15%, 5%). Weight tidak pernah dikirim ke customer. +- `amount` EnakCoin yang didapat (boleh 0). `label` opsional, maksimal 100 karakter, + ditampilkan di roda. +- `max_reward` minimal sebesar `amount` terbesar, kalau tidak hadiah besar terpotong. + +### 3. Aktifkan config + +`POST /api/v1/marketing/enakgame/reward-configs/:id/activate` + +Mengganti hadiah nanti berarti membuat versi config baru lalu mengaktifkannya; session +yang sedang berjalan tetap memakai versi saat dimulai. + +### 4. Pastikan ada budget global bulan berjalan + +`POST /api/v1/marketing/enakgame/budgets` dengan `scope: "GLOBAL"`, bila belum ada. Tanpa +budget global, customer tidak bisa memulai game apa pun. + +## Alur di Aplikasi Customer + +1. `GET /api/v1/customer/enakgame/games`: game spin punya `prizes`, yaitu segmen roda + (`entry`, `label`, `amount`) dalam urutan config. Gambar roda dari sini. +2. `POST /api/v1/customer/enakgame/sessions` dengan `{"game_id": "…"}` dan header + `Idempotency-Key`. Entry cost dipotong di sini. +3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan body `{}`. Server yang + mengundi. Response berisi `prize` (`entry`, `label`, `amount`): putar roda sampai + berhenti di segmen `entry` itu. `reward_total` adalah Coin yang benar-benar masuk, + bisa lebih besar dari `prize.amount` karena event, atau lebih kecil karena limit + harian (`limited_by`). + +Aplikasi tidak boleh mengundi sendiri atau mengirim hadiah: apa pun yang dikirim selain +data hasil diabaikan. diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md index 7e51933..80b21ca 100644 --- a/docs/integration-enakpoint.md +++ b/docs/integration-enakpoint.md @@ -367,23 +367,18 @@ Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default ## 7. Game -`POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. +Game lama (`POST /api/v1/customer/spin`, `GET /customer/games`, +`GET /customer/ferris-wheel`, dan admin `/marketing/games`, `/marketing/game-prizes`, +`/marketing/rewards`) sudah dihapus. Semua game, termasuk spin, sekarang game EnakGame: -Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). -Response: +- Customer: `GET /api/v1/customer/enakgame/games`, lalu + `POST /api/v1/customer/enakgame/sessions` (wajib `Idempotency-Key`, memotong + `entry_cost` EnakCoin), lalu `POST /api/v1/customer/enakgame/sessions/:id/complete` + (server menghitung hadiah EnakCoin). Tanpa PIN. +- Dashboard: game dan biaya per main (`entry_cost`, bilangan bulat ≥ 1) diatur di + `/marketing/enakgame/games`, hadiahnya di reward config. -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb", … }, - "coins_remaining": 7 -} -``` - -EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada -EnakCoin yang terpotong. - -Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. +Langkah admin dan alur aplikasi untuk spin ada di [`enakgame-spin.md`](enakgame-spin.md). --- @@ -549,4 +544,5 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit= **Dashboard** - [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan `expiry_activations` sebelum owner menyimpan setting. -- [ ] Isi `metadata.coin_cost` untuk setiap game. +- [ ] Buat ulang game (termasuk spin) di `/marketing/enakgame/games` dengan `entry_cost` + dan reward config ([`enakgame-spin.md`](enakgame-spin.md)). diff --git a/docs/mobile-customer-enakpoint.md b/docs/mobile-customer-enakpoint.md index 5c01d74..3045441 100644 --- a/docs/mobile-customer-enakpoint.md +++ b/docs/mobile-customer-enakpoint.md @@ -116,7 +116,7 @@ Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik, | Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya | | Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya | | PIN (buat, ganti, lupa) | `/customer/pin/*` | – | -| Game | `POST /customer/spin` | – | +| Game | `GET /customer/enakgame/games`, `POST /customer/enakgame/sessions`, `POST …/sessions/:id/complete` | – | | (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – | --- @@ -524,22 +524,20 @@ Penerima mendapat push `WALLET_TRANSFER_IN`. ## 8. Game (memakai EnakCoin) -`POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. +`POST /api/v1/customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` +sudah dihapus. Semua game, termasuk spin, dimainkan lewat EnakGame. Tanpa PIN. -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb" }, - "coins_remaining": 7 -} -``` +1. `GET /api/v1/customer/enakgame/games`: daftar game dengan `entry_cost` (EnakCoin per + main). Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` + kurang. Spin punya `prizes` untuk menggambar roda. +2. `POST /api/v1/customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan header + `Idempotency-Key` (satu key per tap; retry memakai key yang sama). Response berisi + `session_id` dan `coin_balance` setelah dipotong. +3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan hasil main (`score` + atau `outcome`; spin cukup `{}`). Server yang menentukan hadiah: perbarui saldo dari + `coin_balance`, tampilkan `reward_total`, dan untuk spin hentikan roda di `prize.entry`. -- Setiap game punya biaya sendiri: `metadata.coin_cost` pada data game dari - `GET /api/v1/customer/games` (atau `GET /customer/ferris-wheel`), default 1 bila kosong. - Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` kurang. -- `304`: EnakCoin kurang, game nonaktif, atau hadiah baru saja habis. Tidak ada - EnakCoin yang terpotong; tampilkan pesan dan biarkan customer mencoba lagi. -- Setelah main, perbarui saldo EnakCoin dari `coins_remaining`. +Rincian spin ada di [`enakgame-spin.md`](./enakgame-spin.md). --- diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md index d09ca03..c6e374f 100644 --- a/docs/prd-point-coin.md +++ b/docs/prd-point-coin.md @@ -385,6 +385,12 @@ beredar. Karena itu: ### F8 — Game Memakai EnakCoin +> **Diganti EnakGame (2026-10-07).** Alur game di bawah (`POST /customer/spin`, +> `metadata.coin_cost`, `game_plays`) sudah dihapus. Game sekarang dimainkan lewat +> `/customer/enakgame/sessions` dengan `games.entry_cost`; lihat +> [RFC EnakGame](rfc-enakgame.md) §14 dan [enakgame-spin.md](enakgame-spin.md). +> EnakCoin tetap mata uang untuk bermain game. + - **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin yang sama. `POST /customer/spin` memotong EnakCoin, bukan Token `SPIN`. - Biaya per main diatur per game di `games.metadata.coin_cost` (default 1), sehingga diff --git a/internal/app/app.go b/internal/app/app.go index a1b7a2f..8df41ce 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -149,14 +149,11 @@ func (a *App) Initialize(cfg *config.Config) error { validators.orderIngredientTransactionValidator, services.gamificationService, validators.gamificationValidator, - services.rewardService, - validators.rewardValidator, services.campaignService, validators.campaignValidator, services.customerAuthService, validators.customerAuthValidator, services.customerPointsService, - services.spinGameService, middleware.customerAuthMiddleware, services.userDeviceService, validators.userDeviceValidator, @@ -306,11 +303,7 @@ type repositories struct { accountRepo *repository.AccountRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl tierRepo *repository.TierRepository - gameRepo *repository.GameRepository - gamePrizeRepo *repository.GamePrizeRepository - gamePlayRepo repository.GamePlayRepository omsetTrackerRepo *repository.OmsetTrackerRepository - rewardRepo repository.RewardRepository campaignRepo repository.CampaignRepository campaignRuleRepo repository.CampaignRuleRepository customerAuthRepo repository.CustomerAuthRepository @@ -362,11 +355,7 @@ func (a *App) initRepositories() *repositories { accountRepo: repository.NewAccountRepositoryImpl(a.db), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), tierRepo: repository.NewTierRepository(a.db), - gameRepo: repository.NewGameRepository(a.db), - gamePrizeRepo: repository.NewGamePrizeRepository(a.db), - gamePlayRepo: repository.NewGamePlayRepository(a.db), omsetTrackerRepo: repository.NewOmsetTrackerRepository(a.db), - rewardRepo: repository.NewRewardRepository(a.db), campaignRepo: repository.NewCampaignRepository(a.db), campaignRuleRepo: repository.NewCampaignRuleRepository(a.db), customerAuthRepo: repository.NewCustomerAuthRepository(a.db), @@ -413,11 +402,7 @@ type processors struct { accountProcessor *processor.AccountProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl tierProcessor *processor.TierProcessor - gameProcessor *processor.GameProcessor - gamePrizeProcessor *processor.GamePrizeProcessor - gamePlayProcessor *processor.GamePlayProcessor omsetTrackerProcessor *processor.OmsetTrackerProcessor - rewardProcessor processor.RewardProcessor campaignProcessor processor.CampaignProcessor campaignRuleProcessor processor.CampaignRuleProcessor customerAuthProcessor processor.CustomerAuthProcessor @@ -500,15 +485,11 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), tierProcessor: processor.NewTierProcessor(repos.tierRepo), - gameProcessor: processor.NewGameProcessor(repos.gameRepo), - gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), - gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager), omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), - rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager))), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, @@ -571,11 +552,9 @@ type services struct { accountService service.AccountService orderIngredientTransactionService *service.OrderIngredientTransactionService gamificationService service.GamificationService - rewardService service.RewardService campaignService service.CampaignService customerAuthService service.CustomerAuthService customerPointsService service.CustomerPointsService - spinGameService service.SpinGameService userDeviceService service.UserDeviceService notificationService service.NotificationService productOutletPriceService service.ProductOutletPriceService @@ -622,12 +601,10 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) accountService := service.NewAccountService(processors.accountProcessor) orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager) - gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) - rewardService := service.NewRewardService(processors.rewardProcessor) + gamificationService := service.NewGamificationService(processors.tierProcessor, processors.omsetTrackerProcessor) campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) - spinGameService := service.NewSpinGameService(processors.gamePlayProcessor) userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor) notificationService := service.NewNotificationService(processors.notificationProcessor) @@ -663,11 +640,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con accountService: accountService, orderIngredientTransactionService: orderIngredientTransactionService, gamificationService: gamificationService, - rewardService: rewardService, campaignService: campaignService, customerAuthService: customerAuthService, customerPointsService: customerPointsService, - spinGameService: spinGameService, userDeviceService: userDeviceService, notificationService: notificationService, productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), @@ -726,7 +701,6 @@ type validators struct { accountValidator *validator.AccountValidatorImpl orderIngredientTransactionValidator *validator.OrderIngredientTransactionValidatorImpl gamificationValidator *validator.GamificationValidatorImpl - rewardValidator validator.RewardValidator campaignValidator validator.CampaignValidator customerAuthValidator validator.CustomerAuthValidator userDeviceValidator *validator.UserDeviceValidatorImpl @@ -760,7 +734,6 @@ func (a *App) initValidators() *validators { accountValidator: validator.NewAccountValidator().(*validator.AccountValidatorImpl), orderIngredientTransactionValidator: validator.NewOrderIngredientTransactionValidator().(*validator.OrderIngredientTransactionValidatorImpl), gamificationValidator: validator.NewGamificationValidator(), - rewardValidator: validator.NewRewardValidator(), campaignValidator: validator.NewCampaignValidator(), customerAuthValidator: validator.NewCustomerAuthValidator(), userDeviceValidator: validator.NewUserDeviceValidator(), diff --git a/internal/constants/error.go b/internal/constants/error.go index c953e30..05193ea 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -53,15 +53,10 @@ const ( // Gamification entities CustomerPointsEntity = "customer_points" TierEntity = "tier" - GameEntity = "game" - GamePrizeEntity = "game_prize" - GamePlayEntity = "game_play" OmsetTrackerEntity = "omset_tracker" - RewardEntity = "reward" CampaignEntity = "campaign" CampaignRuleEntity = "campaign_rule" CustomerEntity = "customer" - SpinGameHandlerEntity = "spin_game_handler" UserDeviceServiceEntity = "user_device_service" NotificationServiceEntity = "notification_service" NotificationHandlerEntity = "notification_handler" diff --git a/internal/contract/customer_game_contract.go b/internal/contract/customer_game_contract.go deleted file mode 100644 index a074fb8..0000000 --- a/internal/contract/customer_game_contract.go +++ /dev/null @@ -1,60 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -// Request Contracts -type GetCustomerGamesRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -// Response Contracts -type GetCustomerGamesResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetCustomerGamesResponseData `json:"data,omitempty"` -} - -type GetCustomerGamesResponseData struct { - Games []CustomerGameResponse `json:"games"` -} - -type CustomerGameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CustomerGamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Image *string `json:"image,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Ferris Wheel Game Contracts -type GetFerrisWheelGameRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetFerrisWheelGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetFerrisWheelGameResponseData `json:"data,omitempty"` -} - -type GetFerrisWheelGameResponseData struct { - Game CustomerGameResponse `json:"game"` - Prizes []CustomerGamePrizeResponse `json:"prizes"` -} diff --git a/internal/contract/game_contract.go b/internal/contract/game_contract.go deleted file mode 100644 index 754acf8..0000000 --- a/internal/contract/game_contract.go +++ /dev/null @@ -1,49 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGameRequest struct { - Name string `json:"name" validate:"required"` - Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGameRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `json:"is_active,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamesRequest struct { - Page int `json:"page" form:"page" validate:"min=1"` - Limit int `json:"limit" form:"limit" validate:"min=1,max=100"` - Search string `json:"search" form:"search"` - Type string `json:"type" form:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `json:"is_active" form:"is_active"` - SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"` - SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedGamesResponse struct { - Data []GameResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} diff --git a/internal/contract/game_play_contract.go b/internal/contract/game_play_contract.go deleted file mode 100644 index a7454e9..0000000 --- a/internal/contract/game_play_contract.go +++ /dev/null @@ -1,59 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePlayRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - CoinsUsed int `json:"coins_used" validate:"min=0"` - RandomSeed *string `json:"random_seed,omitempty"` -} - -type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` - CoinsUsed int `json:"coins_used"` - RandomSeed *string `json:"random_seed,omitempty"` - CreatedAt time.Time `json:"created_at"` - Game *GameResponse `json:"game,omitempty"` - Customer *CustomerResponse `json:"customer,omitempty"` - Prize *GamePrizeResponse `json:"prize,omitempty"` -} - -type ListGamePlaysRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search"` - GameID *uuid.UUID `json:"game_id"` - CustomerID *uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"` - SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedGamePlaysResponse struct { - Data []GamePlayResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} - -// PlayGameRequest plays a game for a customer. What it costs is the game's -// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice. -type PlayGameRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` -} - -type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} diff --git a/internal/contract/game_prize_contract.go b/internal/contract/game_prize_contract.go deleted file mode 100644 index e793b80..0000000 --- a/internal/contract/game_prize_contract.go +++ /dev/null @@ -1,64 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePrizeRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - Name string `json:"name" validate:"required"` - Weight int `json:"weight" validate:"min=1"` - Stock int `json:"stock" validate:"min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGamePrizeRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"` - Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Weight int `json:"weight"` - Stock int `json:"stock"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty"` - Metadata map[string]interface{} `json:"metadata"` - Game *GameResponse `json:"game,omitempty"` - FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamePrizesRequest struct { - Page int `json:"page" form:"page" validate:"min=1"` - Limit int `json:"limit" form:"limit" validate:"min=1,max=100"` - Search string `json:"search" form:"search"` - GameID *uuid.UUID `json:"game_id" form:"game_id"` - SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"` - SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedGamePrizesResponse struct { - Data []GamePrizeResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} diff --git a/internal/contract/omset_tracker_contract.go b/internal/contract/omset_tracker_contract.go index 0b14243..a4052eb 100644 --- a/internal/contract/omset_tracker_contract.go +++ b/internal/contract/omset_tracker_contract.go @@ -27,15 +27,14 @@ type AddOmsetRequest struct { } type OmsetTrackerResponse struct { - ID uuid.UUID `json:"id"` - PeriodType string `json:"period_type"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - Total int64 `json:"total"` - GameID *uuid.UUID `json:"game_id,omitempty"` - Game *GameResponse `json:"game,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PeriodType string `json:"period_type"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Total int64 `json:"total"` + GameID *uuid.UUID `json:"game_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListOmsetTrackerRequest struct { diff --git a/internal/contract/reward_contract.go b/internal/contract/reward_contract.go deleted file mode 100644 index 968d26b..0000000 --- a/internal/contract/reward_contract.go +++ /dev/null @@ -1,82 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -// Request Contracts -type CreateRewardRequest struct { - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type UpdateRewardRequest struct { - ID uuid.UUID `json:"id" binding:"required"` - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type ListRewardsRequest struct { - Page int `form:"page" binding:"min=1"` - Limit int `form:"limit" binding:"min=1,max=100"` - Search string `form:"search"` - RewardType string `form:"reward_type"` - MinPoints *int64 `form:"min_points"` - MaxPoints *int64 `form:"max_points"` - HasStock *bool `form:"has_stock"` -} - -type GetRewardRequest struct { - ID uuid.UUID `uri:"id" binding:"required"` -} - -type DeleteRewardRequest struct { - ID uuid.UUID `uri:"id" binding:"required"` -} - -// Response Contracts -type RewardResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - RewardType string `json:"reward_type"` - CostPoints int64 `json:"cost_points"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListRewardsResponse struct { - Rewards []RewardResponse `json:"rewards"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -// Helper structs -type TermsAndConditionsStruct struct { - Sections []TncSectionStruct `json:"sections"` - ExpiryDays int `json:"expiry_days"` -} - -type TncSectionStruct struct { - Title string `json:"title"` - Rules []string `json:"rules"` -} diff --git a/internal/contract/spin_game_contract.go b/internal/contract/spin_game_contract.go deleted file mode 100644 index b234f2b..0000000 --- a/internal/contract/spin_game_contract.go +++ /dev/null @@ -1,20 +0,0 @@ -package contract - -// SpinGameRequest represents the request to play a spin game -type SpinGameRequest struct { - SpinID string `json:"spin_id" validate:"required,uuid"` -} - -// SpinGameResponse represents the response from playing a spin game -type SpinGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *SpinGameResponseData `json:"data,omitempty"` -} - -// SpinGameResponseData contains the game play result -type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index 8ee2e10..1aa2a64 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -28,10 +28,7 @@ func GetAllEntities() []interface{} { &CustomerTokens{}, &Tier{}, &Game{}, - &GamePrize{}, - &GamePlay{}, &OmsetTracker{}, - &Reward{}, &Campaign{}, &CampaignRule{}, &OtpSession{}, diff --git a/internal/entities/game.go b/internal/entities/game.go index 7effc74..ea568f8 100644 --- a/internal/entities/game.go +++ b/internal/entities/game.go @@ -16,11 +16,12 @@ 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"` + // The old games' settings. Nothing reads it any more; their coin_cost became + // entry_cost (migration 000103). + 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. @@ -39,9 +40,6 @@ type Game struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - Prizes []GamePrize `gorm:"foreignKey:GameID" json:"prizes,omitempty"` - Plays []GamePlay `gorm:"foreignKey:GameID" json:"plays,omitempty"` } func (g *Game) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/game_play.go b/internal/entities/game_play.go deleted file mode 100644 index 026ca95..0000000 --- a/internal/entities/game_play.go +++ /dev/null @@ -1,33 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GamePlay struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` - CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"` - PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"` - CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"` - RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"` - Customer Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"` - Prize *GamePrize `gorm:"foreignKey:PrizeID" json:"prize,omitempty"` -} - -func (gp *GamePlay) BeforeCreate(tx *gorm.DB) error { - if gp.ID == uuid.Nil { - gp.ID = uuid.New() - } - return nil -} - -func (GamePlay) TableName() string { - return "game_plays" -} diff --git a/internal/entities/game_prize.go b/internal/entities/game_prize.go deleted file mode 100644 index 8345f38..0000000 --- a/internal/entities/game_prize.go +++ /dev/null @@ -1,38 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GamePrize struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` - Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"` - Weight int `gorm:"not null" json:"weight" validate:"min=1"` - Stock int `gorm:"default:0" json:"stock" validate:"min=0"` - MaxStock *int `gorm:"" json:"max_stock,omitempty"` - Threshold *int64 `gorm:"" json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `gorm:"type:uuid" json:"fallback_prize_id,omitempty"` - Image *string `gorm:"type:varchar(500)" json:"image,omitempty"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"` - FallbackPrize *GamePrize `gorm:"foreignKey:FallbackPrizeID" json:"fallback_prize,omitempty"` - Plays []GamePlay `gorm:"foreignKey:PrizeID" json:"plays,omitempty"` -} - -func (gp *GamePrize) BeforeCreate(tx *gorm.DB) error { - if gp.ID == uuid.Nil { - gp.ID = uuid.New() - } - return nil -} - -func (GamePrize) TableName() string { - return "game_prizes" -} diff --git a/internal/entities/reward.go b/internal/entities/reward.go deleted file mode 100644 index f05e417..0000000 --- a/internal/entities/reward.go +++ /dev/null @@ -1,126 +0,0 @@ -package entities - -import ( - "database/sql/driver" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type RewardType string - -const ( - RewardTypeVoucher RewardType = "VOUCHER" - RewardTypePhysical RewardType = "PHYSICAL" - RewardTypeDigital RewardType = "DIGITAL" - RewardTypeBalance RewardType = "BALANCE" -) - -// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer -type StringSlice []string - -// Scan implements the sql.Scanner interface for StringSlice -func (s *StringSlice) Scan(value interface{}) error { - if value == nil { - *s = StringSlice{} - return nil - } - - var bytes []byte - switch v := value.(type) { - case []byte: - bytes = v - case string: - bytes = []byte(v) - default: - return fmt.Errorf("cannot scan %T into StringSlice", value) - } - - return json.Unmarshal(bytes, s) -} - -// Value implements the driver.Valuer interface for StringSlice -func (s StringSlice) Value() (driver.Value, error) { - if s == nil { - return nil, nil - } - return json.Marshal(s) -} - -type TermsAndConditions struct { - Sections []TncSection `json:"sections"` - ExpiryDays int `json:"expiry_days"` -} - -// Scan implements the sql.Scanner interface for TermsAndConditions -func (t *TermsAndConditions) Scan(value interface{}) error { - if value == nil { - *t = TermsAndConditions{} - return nil - } - - var bytes []byte - switch v := value.(type) { - case []byte: - bytes = v - case string: - bytes = []byte(v) - default: - return fmt.Errorf("cannot scan %T into TermsAndConditions", value) - } - - return json.Unmarshal(bytes, t) -} - -// Value implements the driver.Valuer interface for TermsAndConditions -func (t TermsAndConditions) Value() (driver.Value, error) { - return json.Marshal(t) -} - -type TncSection struct { - Title string `json:"title"` - Rules []string `json:"rules"` -} - -type Reward struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(150);not null" json:"name"` - RewardType RewardType `gorm:"type:varchar(50);not null" json:"reward_type"` - CostPoints int64 `gorm:"type:bigint;not null" json:"cost_points"` - Stock *int `gorm:"type:int" json:"stock,omitempty"` - MaxPerCustomer int `gorm:"type:int;default:1" json:"max_per_customer"` - Tnc *TermsAndConditions `gorm:"type:jsonb" json:"tnc,omitempty"` - Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"` - Images *StringSlice `gorm:"type:jsonb" json:"images,omitempty"` - CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"` -} - -func (Reward) TableName() string { - return "rewards" -} - -func (r *Reward) BeforeCreate(tx *gorm.DB) error { - if r.ID == uuid.Nil { - r.ID = uuid.New() - } - return nil -} - -func (r *Reward) BeforeUpdate(tx *gorm.DB) error { - r.UpdatedAt = time.Now() - return nil -} - -type ListRewardsRequest struct { - Page int `form:"page" binding:"min=1"` - Limit int `form:"limit" binding:"min=1,max=100"` - Search string `form:"search"` - RewardType string `form:"reward_type"` - MinPoints *int64 `form:"min_points"` - MaxPoints *int64 `form:"max_points"` - HasStock *bool `form:"has_stock"` -} diff --git a/internal/entities/string_slice.go b/internal/entities/string_slice.go new file mode 100644 index 0000000..775fbcf --- /dev/null +++ b/internal/entities/string_slice.go @@ -0,0 +1,38 @@ +package entities + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer +type StringSlice []string + +// Scan implements the sql.Scanner interface for StringSlice +func (s *StringSlice) Scan(value interface{}) error { + if value == nil { + *s = StringSlice{} + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("cannot scan %T into StringSlice", value) + } + + return json.Unmarshal(bytes, s) +} + +// Value implements the driver.Valuer interface for StringSlice +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return json.Marshal(s) +} diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 8875d79..a5c49e6 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -93,36 +93,6 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet") } -func (h *CustomerPointsHandler) GetCustomerGames(c *gin.Context) { - ctx := c.Request.Context() - - response, err := h.customerPointsService.GetCustomerGames(ctx) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerGames -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetCustomerGames") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerGames") -} - -func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) { - ctx := c.Request.Context() - - response, err := h.customerPointsService.GetFerrisWheelGame(ctx) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetFerrisWheelGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetFerrisWheelGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame") -} - // GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest // first, filterable by currency, type and date (docs/prd-point-coin.md F6). func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) { diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go index 7a0a6fc..5de2fe0 100644 --- a/internal/handler/customer_wallet_db_test.go +++ b/internal/handler/customer_wallet_db_test.go @@ -98,7 +98,6 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), - nil, ))) gin.SetMode(gin.TestMode) router := gin.New() diff --git a/internal/handler/enakgame_db_test.go b/internal/handler/enakgame_db_test.go index 6802818..044a19a 100644 --- a/internal/handler/enakgame_db_test.go +++ b/internal/handler/enakgame_db_test.go @@ -476,3 +476,134 @@ func TestEnakGameEventEndpoints_AgainstPostgres(t *testing.T) { assert.EqualValues(t, 5, events[0].(map[string]any)["bonus"]) assert.NotContains(t, events[0], "budget_id", "the budget stays internal") } + +// EG-1001: the admin steps of docs/enakgame-spin.md make a spin wheel, and a customer +// plays it through /customer/enakgame/sessions, over HTTP down to Postgres. +func TestEnakGameSpin_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 spin', '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_session_rewards WHERE session_id IN (SELECT id FROM game_sessions 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_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)) + 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: 20, + ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) + return err + })) + + adminHandler, customerHandler := enakGameHandlers(db) + gin.SetMode(gin.TestMode) + router := gin.New() + m := router.Group("/marketing/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, "manager") + c.Request = c.Request.WithContext(ctx) + }) + m.POST("/games", adminHandler.CreateGame) + m.POST("/games/:id/reward-configs", adminHandler.CreateRewardConfig) + m.POST("/reward-configs/:id/activate", adminHandler.ActivateRewardConfig) + m.POST("/budgets", adminHandler.CreateBudget) + 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.POST("/sessions/:id/complete", customerHandler.CompleteSession) + + call := func(method, path, body string, headers ...string) 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()) + require.Equal(t, http.StatusOK, rec.Code, out) + return out["data"].(map[string]any) + } + + // The admin steps. + game := call(http.MethodPost, "/marketing/enakgame/games", + `{"name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "ACTIVE"}`) + gameID := game["id"].(string) + config := call(http.MethodPost, "/marketing/enakgame/games/"+gameID+"/reward-configs", `{"reward_type": "PROBABILITY", "max_reward": 50, + "rules": {"table": [ + {"weight": 50, "amount": 0, "label": "Zonk"}, + {"weight": 30, "amount": 3, "label": "3 Coin"}, + {"weight": 15, "amount": 10, "label": "10 Coin"}, + {"weight": 5, "amount": 50, "label": "Jackpot"}]}}`) + call(http.MethodPost, "/marketing/enakgame/reward-configs/"+config["id"].(string)+"/activate", `{}`) + now := time.Now().In(time.FixedZone("WIB", 7*3600)) + first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + call(http.MethodPost, "/marketing/enakgame/budgets", `{"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}`) + + // The wheel shows the segments, never their odds. + req := httptest.NewRequest(http.MethodGet, "/customer/enakgame/games", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var listed struct { + Data []struct { + ID string `json:"id"` + Prizes []struct { + Entry int `json:"entry"` + Label *string `json:"label"` + Amount int64 `json:"amount"` + } `json:"prizes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) + require.Len(t, listed.Data, 1) + require.Len(t, listed.Data[0].Prizes, 4) + assert.Equal(t, "Jackpot", *listed.Data[0].Prizes[3].Label) + assert.NotContains(t, rec.Body.String(), "weight") + + // Two spins: each lands on a listed segment and pays what it shows. + balance := int64(20) + for _, key := range []string{"spin-1", "spin-2"} { + started := call(http.MethodPost, "/customer/enakgame/sessions", `{"game_id": "`+gameID+`"}`, "Idempotency-Key", key) + balance -= 5 + assert.EqualValues(t, balance, started["coin_balance"]) + done := call(http.MethodPost, "/customer/enakgame/sessions/"+started["session_id"].(string)+"/complete", `{}`) + prize := done["prize"].(map[string]any) + entry := int(prize["entry"].(float64)) + require.True(t, entry >= 1 && entry <= 4, prize) + segment := listed.Data[0].Prizes[entry-1] + assert.Equal(t, *segment.Label, prize["label"]) + assert.EqualValues(t, segment.Amount, prize["amount"]) + assert.EqualValues(t, segment.Amount, done["reward_total"]) + balance += segment.Amount + assert.EqualValues(t, balance, done["coin_balance"]) + } +} diff --git a/internal/handler/gamification_handler.go b/internal/handler/gamification_handler.go index 4c3c606..4e2d9a0 100644 --- a/internal/handler/gamification_handler.go +++ b/internal/handler/gamification_handler.go @@ -1,14 +1,8 @@ package handler import ( - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/logger" "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" "apskel-pos-be/internal/validator" - - "github.com/gin-gonic/gin" ) type GamificationHandler struct { @@ -25,32 +19,3 @@ func NewGamificationHandler( gamificationValidator: gamificationValidator, } } - -// Play Game Handler -func (h *GamificationHandler) PlayGame(c *gin.Context) { - ctx := c.Request.Context() - var req contract.PlayGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidatePlayGameRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::PlayGame -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame") - return - } - - response, err := h.gamificationService.PlayGame(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::PlayGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame") -} diff --git a/internal/handler/gamification_handler_additional.go b/internal/handler/gamification_handler_additional.go index a5bf803..0cc4470 100644 --- a/internal/handler/gamification_handler_additional.go +++ b/internal/handler/gamification_handler_additional.go @@ -168,410 +168,6 @@ func (h *GamificationHandler) GetTierByPoints(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetTierByPoints") } -// Game Handlers -func (h *GamificationHandler) CreateGame(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGame -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGame") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGameRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGame -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGame") - return - } - - response, err := h.gamificationService.CreateGame(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::CreateGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGame") -} - -func (h *GamificationHandler) GetGame(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGame -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGame") - return - } - - response, err := h.gamificationService.GetGame(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::GetGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGame") -} - -func (h *GamificationHandler) ListGames(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListGamesRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGames -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGames") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListGamesRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGames -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGames") - return - } - - response, err := h.gamificationService.ListGames(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGames -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::ListGames") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGames") -} - -func (h *GamificationHandler) GetActiveGames(c *gin.Context) { - ctx := c.Request.Context() - response, err := h.gamificationService.GetActiveGames(ctx) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetActiveGames -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::GetActiveGames") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetActiveGames") -} - -func (h *GamificationHandler) UpdateGame(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame") - return - } - - var req contract.UpdateGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateGameRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateGame -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame") - return - } - - response, err := h.gamificationService.UpdateGame(ctx, id, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::UpdateGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateGame") -} - -func (h *GamificationHandler) DeleteGame(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGame -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteGame") - return - } - - err = h.gamificationService.DeleteGame(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::DeleteGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteGame") -} - -// Game Prize Handlers -func (h *GamificationHandler) CreateGamePrize(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateGamePrizeRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePrize -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePrize") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGamePrizeRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGamePrize -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePrize") - return - } - - response, err := h.gamificationService.CreateGamePrize(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::CreateGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGamePrize") -} - -func (h *GamificationHandler) GetGamePrize(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrize -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePrize") - return - } - - response, err := h.gamificationService.GetGamePrize(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePrize") -} - -func (h *GamificationHandler) ListGamePrizes(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListGamePrizesRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePrizes -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePrizes") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListGamePrizesRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGamePrizes -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePrizes") - return - } - - response, err := h.gamificationService.ListGamePrizes(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePrizes -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::ListGamePrizes") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGamePrizes") -} - -func (h *GamificationHandler) UpdateGamePrize(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize") - return - } - - var req contract.UpdateGamePrizeRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateGamePrizeRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateGamePrize -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize") - return - } - - response, err := h.gamificationService.UpdateGamePrize(ctx, id, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::UpdateGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateGamePrize") -} - -func (h *GamificationHandler) DeleteGamePrize(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGamePrize -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteGamePrize") - return - } - - err = h.gamificationService.DeleteGamePrize(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::DeleteGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteGamePrize") -} - -func (h *GamificationHandler) GetGamePrizesByGameID(c *gin.Context) { - ctx := c.Request.Context() - gameIDStr := c.Param("game_id") - gameID, err := uuid.Parse(gameIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrizesByGameID -> invalid game ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid game ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePrizesByGameID") - return - } - - response, err := h.gamificationService.GetGamePrizesByGameID(ctx, gameID) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrizesByGameID -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetGamePrizesByGameID") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePrizesByGameID") -} - -func (h *GamificationHandler) GetAvailablePrizes(c *gin.Context) { - ctx := c.Request.Context() - gameIDStr := c.Param("game_id") - gameID, err := uuid.Parse(gameIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetAvailablePrizes -> invalid game ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid game ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetAvailablePrizes") - return - } - - response, err := h.gamificationService.GetAvailablePrizes(ctx, gameID) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetAvailablePrizes -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetAvailablePrizes") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetAvailablePrizes") -} - -// Game Play Handlers -func (h *GamificationHandler) CreateGamePlay(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateGamePlayRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePlay -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePlay") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGamePlayRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGamePlay -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePlay") - return - } - - response, err := h.gamificationService.CreateGamePlay(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePlay -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::CreateGamePlay") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGamePlay") -} - -func (h *GamificationHandler) GetGamePlay(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePlay -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePlayEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePlay") - return - } - - response, err := h.gamificationService.GetGamePlay(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePlay -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::GetGamePlay") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePlay") -} - -func (h *GamificationHandler) ListGamePlays(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListGamePlaysRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePlays -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePlays") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListGamePlaysRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGamePlays -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePlays") - return - } - - response, err := h.gamificationService.ListGamePlays(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePlays -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::ListGamePlays") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGamePlays") -} - // Omset Tracker Handlers func (h *GamificationHandler) CreateOmsetTracker(c *gin.Context) { ctx := c.Request.Context() diff --git a/internal/handler/reward_handler.go b/internal/handler/reward_handler.go deleted file mode 100644 index 4988667..0000000 --- a/internal/handler/reward_handler.go +++ /dev/null @@ -1,206 +0,0 @@ -package handler - -import ( - "strconv" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" - "apskel-pos-be/internal/validator" - - "github.com/gin-gonic/gin" -) - -type RewardHandler struct { - rewardService service.RewardService - rewardValidator validator.RewardValidator -} - -func NewRewardHandler(rewardService service.RewardService, rewardValidator validator.RewardValidator) *RewardHandler { - return &RewardHandler{ - rewardService: rewardService, - rewardValidator: rewardValidator, - } -} - -func (h *RewardHandler) CreateReward(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.CreateRewardRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward") - return - } - - validationError, validationErrorCode := h.rewardValidator.ValidateCreateRewardRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::CreateReward -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward") - return - } - - response, err := h.rewardService.CreateReward(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::CreateReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::CreateReward") -} - -func (h *RewardHandler) GetReward(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - if idStr == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::GetReward -> missing ID parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetReward") - return - } - - response, err := h.rewardService.GetReward(ctx, idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetReward") -} - -func (h *RewardHandler) ListRewards(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.ListRewardsRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards") - return - } - - validationError, validationErrorCode := h.rewardValidator.ValidateListRewardsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::ListRewards -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards") - return - } - - response, err := h.rewardService.ListRewards(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::ListRewards") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::ListRewards") -} - -func (h *RewardHandler) UpdateReward(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.UpdateRewardRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward") - return - } - - validationError, validationErrorCode := h.rewardValidator.ValidateUpdateRewardRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::UpdateReward -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward") - return - } - - response, err := h.rewardService.UpdateReward(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::UpdateReward") -} - -func (h *RewardHandler) DeleteReward(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - if idStr == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::DeleteReward -> missing ID parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::DeleteReward") - return - } - - err := h.rewardService.DeleteReward(ctx, idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::DeleteReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::DeleteReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward deleted successfully"), "RewardHandler::DeleteReward") -} - -func (h *RewardHandler) UpdateRewardStock(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - if idStr == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::UpdateRewardStock -> missing ID parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock") - return - } - - stockStr := c.Param("stock") - stock, err := strconv.Atoi(stockStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> invalid stock parameter") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.RewardEntity, "Invalid stock parameter") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock") - return - } - - err = h.rewardService.UpdateRewardStock(ctx, idStr, stock) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateRewardStock") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward stock updated successfully"), "RewardHandler::UpdateRewardStock") -} - -func (h *RewardHandler) GetRewardsByType(c *gin.Context) { - ctx := c.Request.Context() - - rewardType := c.Param("type") - if rewardType == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::GetRewardsByType -> missing type parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "Type parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetRewardsByType") - return - } - - response, err := h.rewardService.GetRewardsByType(ctx, rewardType) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetRewardsByType -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetRewardsByType") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetRewardsByType") -} diff --git a/internal/handler/spin_game_handler.go b/internal/handler/spin_game_handler.go deleted file mode 100644 index b7c033d..0000000 --- a/internal/handler/spin_game_handler.go +++ /dev/null @@ -1,71 +0,0 @@ -package handler - -import ( - "errors" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" - - "github.com/gin-gonic/gin" -) - -type SpinGameHandler struct { - spinGameService service.SpinGameService -} - -func NewSpinGameHandler(spinGameService service.SpinGameService) *SpinGameHandler { - return &SpinGameHandler{ - spinGameService: spinGameService, - } -} - -// PlaySpinGame handles the spin game play request -func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) { - // Get customer ID from JWT token (set by middleware) - customerID, exists := c.Get("customer_id") - if !exists { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.SpinGameHandlerEntity, "Customer ID not found in context"), - }), "SpinGameHandler::PlaySpinGame") - return - } - - // Parse spin ID from request body - var req contract.SpinGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.SpinGameHandlerEntity, err.Error()), - }), "SpinGameHandler::PlaySpinGame") - return - } - - // Convert to model - modelReq := &models.SpinGameRequest{ - SpinID: req.SpinID, - } - - // Play the spin game - response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq) - if err != nil { - // Not enough EnakCoin, an inactive game or a prize that just ran out is the - // customer's to fix, not a server failure. - code := constants.InternalServerErrorCode - if errors.Is(err, processor.ErrGamePlayRefused) { - code = constants.ValidationErrorCode - } - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()), - }), "SpinGameHandler::PlaySpinGame") - return - } - - // Convert model response to contract response - contractResponse := mappers.SpinGameResponseModelToContract(response) - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResponse.Data), "SpinGameHandler::PlaySpinGame") -} diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 7c4de20..9958c87 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -186,7 +186,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { // The customer's own history shows the adjustment too, with the reason in the // description and without the admin's identity fields. customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil))) + processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db)))))) crouter := gin.New() crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions) rec := httptest.NewRecorder() diff --git a/internal/mappers/game_mapper.go b/internal/mappers/game_mapper.go deleted file mode 100644 index 5e7001f..0000000 --- a/internal/mappers/game_mapper.go +++ /dev/null @@ -1,58 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToGameResponse converts a game entity to a game response -func ToGameResponse(game *entities.Game) *models.GameResponse { - if game == nil { - return nil - } - - return &models.GameResponse{ - ID: game.ID, - Name: game.Name, - Type: string(game.Type), - IsActive: game.IsActive, - Metadata: game.Metadata, - CreatedAt: game.CreatedAt, - UpdatedAt: game.UpdatedAt, - } -} - -// ToGameResponses converts a slice of game entities to game responses -func ToGameResponses(games []entities.Game) []models.GameResponse { - responses := make([]models.GameResponse, len(games)) - for i, game := range games { - responses[i] = *ToGameResponse(&game) - } - return responses -} - -// ToGameEntity converts a create game request to a game entity -func ToGameEntity(req *models.CreateGameRequest) *entities.Game { - return &entities.Game{ - Name: req.Name, - Type: entities.GameType(req.Type), - IsActive: req.IsActive, - Metadata: req.Metadata, - } -} - -// UpdateGameEntity updates a game entity with update request data -func UpdateGameEntity(game *entities.Game, req *models.UpdateGameRequest) { - if req.Name != nil { - game.Name = *req.Name - } - if req.Type != nil { - game.Type = entities.GameType(*req.Type) - } - if req.IsActive != nil { - game.IsActive = *req.IsActive - } - if req.Metadata != nil { - game.Metadata = req.Metadata - } -} diff --git a/internal/mappers/game_play_mapper.go b/internal/mappers/game_play_mapper.go deleted file mode 100644 index 659ca51..0000000 --- a/internal/mappers/game_play_mapper.go +++ /dev/null @@ -1,54 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToGamePlayResponse converts a game play entity to a game play response -func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse { - if gamePlay == nil { - return nil - } - - return &models.GamePlayResponse{ - ID: gamePlay.ID, - GameID: gamePlay.GameID, - CustomerID: gamePlay.CustomerID, - PrizeID: gamePlay.PrizeID, - CoinsUsed: gamePlay.CoinsUsed, - RandomSeed: gamePlay.RandomSeed, - CreatedAt: gamePlay.CreatedAt, - Game: ToGameResponse(&gamePlay.Game), - Customer: ToCustomerResponse(&gamePlay.Customer), - Prize: ToGamePrizeResponse(gamePlay.Prize), - } -} - -// ToGamePlayResponses converts a slice of game play entities to game play responses -func ToGamePlayResponses(gamePlays []entities.GamePlay) []models.GamePlayResponse { - responses := make([]models.GamePlayResponse, len(gamePlays)) - for i, gp := range gamePlays { - responses[i] = *ToGamePlayResponse(&gp) - } - return responses -} - -// ToGamePlayResponsesFromPointers converts a slice of game play entity pointers to game play responses -func ToGamePlayResponsesFromPointers(gamePlays []*entities.GamePlay) []models.GamePlayResponse { - responses := make([]models.GamePlayResponse, len(gamePlays)) - for i, gp := range gamePlays { - responses[i] = *ToGamePlayResponse(gp) - } - return responses -} - -// ToGamePlayEntity converts a create game play request to a game play entity -func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay { - return &entities.GamePlay{ - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: req.CoinsUsed, - RandomSeed: req.RandomSeed, - } -} diff --git a/internal/mappers/game_prize_mapper.go b/internal/mappers/game_prize_mapper.go deleted file mode 100644 index bf09fc7..0000000 --- a/internal/mappers/game_prize_mapper.go +++ /dev/null @@ -1,82 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToGamePrizeResponse converts a game prize entity to a game prize response -func ToGamePrizeResponse(gamePrize *entities.GamePrize) *models.GamePrizeResponse { - if gamePrize == nil { - return nil - } - - return &models.GamePrizeResponse{ - ID: gamePrize.ID, - GameID: gamePrize.GameID, - Name: gamePrize.Name, - Weight: gamePrize.Weight, - Stock: gamePrize.Stock, - MaxStock: gamePrize.MaxStock, - Threshold: gamePrize.Threshold, - FallbackPrizeID: gamePrize.FallbackPrizeID, - Image: gamePrize.Image, - Metadata: gamePrize.Metadata, - Game: ToGameResponse(&gamePrize.Game), - FallbackPrize: ToGamePrizeResponse(gamePrize.FallbackPrize), - CreatedAt: gamePrize.CreatedAt, - UpdatedAt: gamePrize.UpdatedAt, - } -} - -// ToGamePrizeResponses converts a slice of game prize entities to game prize responses -func ToGamePrizeResponses(gamePrizes []entities.GamePrize) []models.GamePrizeResponse { - responses := make([]models.GamePrizeResponse, len(gamePrizes)) - for i, gp := range gamePrizes { - responses[i] = *ToGamePrizeResponse(&gp) - } - return responses -} - -// ToGamePrizeEntity converts a create game prize request to a game prize entity -func ToGamePrizeEntity(req *models.CreateGamePrizeRequest) *entities.GamePrize { - return &entities.GamePrize{ - GameID: req.GameID, - Name: req.Name, - Weight: req.Weight, - Stock: req.Stock, - MaxStock: req.MaxStock, - Threshold: req.Threshold, - FallbackPrizeID: req.FallbackPrizeID, - Image: req.Image, - Metadata: req.Metadata, - } -} - -// UpdateGamePrizeEntity updates a game prize entity with update request data -func UpdateGamePrizeEntity(gamePrize *entities.GamePrize, req *models.UpdateGamePrizeRequest) { - if req.Name != nil { - gamePrize.Name = *req.Name - } - if req.Weight != nil { - gamePrize.Weight = *req.Weight - } - if req.Stock != nil { - gamePrize.Stock = *req.Stock - } - if req.MaxStock != nil { - gamePrize.MaxStock = req.MaxStock - } - if req.Threshold != nil { - gamePrize.Threshold = req.Threshold - } - if req.FallbackPrizeID != nil { - gamePrize.FallbackPrizeID = req.FallbackPrizeID - } - if req.Image != nil { - gamePrize.Image = req.Image - } - if req.Metadata != nil { - gamePrize.Metadata = req.Metadata - } -} diff --git a/internal/mappers/omset_tracker_mapper.go b/internal/mappers/omset_tracker_mapper.go index dacf25b..f85c2bd 100644 --- a/internal/mappers/omset_tracker_mapper.go +++ b/internal/mappers/omset_tracker_mapper.go @@ -18,7 +18,6 @@ func ToOmsetTrackerResponse(omsetTracker *entities.OmsetTracker) *models.OmsetTr PeriodEnd: omsetTracker.PeriodEnd, Total: omsetTracker.Total, GameID: omsetTracker.GameID, - Game: ToGameResponse(omsetTracker.Game), CreatedAt: omsetTracker.CreatedAt, UpdatedAt: omsetTracker.UpdatedAt, } diff --git a/internal/mappers/reward_mapper.go b/internal/mappers/reward_mapper.go deleted file mode 100644 index d908ab7..0000000 --- a/internal/mappers/reward_mapper.go +++ /dev/null @@ -1,138 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -func ToRewardResponse(entity *entities.Reward) *models.RewardResponse { - if entity == nil { - return nil - } - - var tnc *models.TermsAndConditionsStruct - if entity.Tnc != nil { - var sections []models.TncSectionStruct - for _, section := range entity.Tnc.Sections { - sections = append(sections, models.TncSectionStruct{ - Title: section.Title, - Rules: section.Rules, - }) - } - tnc = &models.TermsAndConditionsStruct{ - Sections: sections, - ExpiryDays: entity.Tnc.ExpiryDays, - } - } - - var images *[]string - if entity.Images != nil { - imagesSlice := []string(*entity.Images) - images = &imagesSlice - } - - return &models.RewardResponse{ - ID: entity.ID, - Name: entity.Name, - RewardType: string(entity.RewardType), - CostPoints: entity.CostPoints, - Stock: entity.Stock, - MaxPerCustomer: entity.MaxPerCustomer, - Tnc: tnc, - Metadata: entity.Metadata, - Images: images, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - } -} - -func ToRewardEntity(request *contract.CreateRewardRequest) *entities.Reward { - if request == nil { - return nil - } - - var tnc *entities.TermsAndConditions - if request.Tnc != nil { - var sections []entities.TncSection - for _, section := range request.Tnc.Sections { - sections = append(sections, entities.TncSection{ - Title: section.Title, - Rules: section.Rules, - }) - } - tnc = &entities.TermsAndConditions{ - Sections: sections, - ExpiryDays: request.Tnc.ExpiryDays, - } - } - - var images *entities.StringSlice - if request.Images != nil { - stringSlice := entities.StringSlice(*request.Images) - images = &stringSlice - } - - return &entities.Reward{ - Name: request.Name, - RewardType: entities.RewardType(request.RewardType), - CostPoints: request.CostPoints, - Stock: request.Stock, - MaxPerCustomer: request.MaxPerCustomer, - Tnc: tnc, - Metadata: request.Metadata, - Images: images, - } -} - -func ToRewardEntityFromUpdate(request *contract.UpdateRewardRequest) *entities.Reward { - if request == nil { - return nil - } - - var tnc *entities.TermsAndConditions - if request.Tnc != nil { - var sections []entities.TncSection - for _, section := range request.Tnc.Sections { - sections = append(sections, entities.TncSection{ - Title: section.Title, - Rules: section.Rules, - }) - } - tnc = &entities.TermsAndConditions{ - Sections: sections, - ExpiryDays: request.Tnc.ExpiryDays, - } - } - - var images *entities.StringSlice - if request.Images != nil { - stringSlice := entities.StringSlice(*request.Images) - images = &stringSlice - } - - return &entities.Reward{ - ID: request.ID, - Name: request.Name, - RewardType: entities.RewardType(request.RewardType), - CostPoints: request.CostPoints, - Stock: request.Stock, - MaxPerCustomer: request.MaxPerCustomer, - Tnc: tnc, - Metadata: request.Metadata, - Images: images, - } -} - -func ToRewardResponseSlice(entities []entities.Reward) []models.RewardResponse { - if entities == nil { - return nil - } - - responses := make([]models.RewardResponse, len(entities)) - for i, entity := range entities { - responses[i] = *ToRewardResponse(&entity) - } - - return responses -} diff --git a/internal/mappers/spin_game_mapper.go b/internal/mappers/spin_game_mapper.go deleted file mode 100644 index b38be64..0000000 --- a/internal/mappers/spin_game_mapper.go +++ /dev/null @@ -1,80 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" -) - -// SpinGameRequestContractToModel converts spin game request from contract to model -func SpinGameRequestContractToModel(req *contract.SpinGameRequest) *models.SpinGameRequest { - if req == nil { - return nil - } - - return &models.SpinGameRequest{ - SpinID: req.SpinID, - } -} - -// SpinGameResponseModelToContract converts spin game response from model to contract -func SpinGameResponseModelToContract(resp *models.SpinGameResponse) *contract.SpinGameResponse { - if resp == nil { - return nil - } - - return &contract.SpinGameResponse{ - Status: resp.Status, - Message: resp.Message, - Data: SpinGameResponseDataModelToContract(resp.Data), - } -} - -// SpinGameResponseDataModelToContract converts spin game response data from model to contract -func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *contract.SpinGameResponseData { - if data == nil { - return nil - } - - return &contract.SpinGameResponseData{ - GamePlay: GamePlayResponseModelToContract(&data.GamePlay), - PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), - CoinsRemaining: data.CoinsRemaining, - } -} - -// GamePlayResponseModelToContract converts game play response from model to contract -func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.GamePlayResponse { - if resp == nil { - return contract.GamePlayResponse{} - } - - return contract.GamePlayResponse{ - ID: resp.ID, - GameID: resp.GameID, - CustomerID: resp.CustomerID, - PrizeID: resp.PrizeID, - CoinsUsed: resp.CoinsUsed, - RandomSeed: resp.RandomSeed, - CreatedAt: resp.CreatedAt, - Game: nil, // Optional field - can be populated separately if needed - Customer: nil, // Optional field - can be populated separately if needed - Prize: nil, // Optional field - can be populated separately if needed - } -} - -// CustomerGamePrizeResponseModelToContract converts customer game prize response from model to contract -func CustomerGamePrizeResponseModelToContract(resp *models.CustomerGamePrizeResponse) *contract.CustomerGamePrizeResponse { - if resp == nil { - return nil - } - - return &contract.CustomerGamePrizeResponse{ - ID: resp.ID, - GameID: resp.GameID, - Name: resp.Name, - Image: resp.Image, - Metadata: resp.Metadata, - CreatedAt: resp.CreatedAt, - UpdatedAt: resp.UpdatedAt, - } -} diff --git a/internal/models/customer_game.go b/internal/models/customer_game.go deleted file mode 100644 index 6fb7e9d..0000000 --- a/internal/models/customer_game.go +++ /dev/null @@ -1,59 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// Request Models -type GetCustomerGamesRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -// Response Models -type GetCustomerGamesResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetCustomerGamesResponseData `json:"data,omitempty"` -} - -type GetCustomerGamesResponseData struct { - Games []CustomerGameResponse `json:"games"` -} - -type CustomerGameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CustomerGamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Image *string `json:"image,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Ferris Wheel Game Models -type GetFerrisWheelGameRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetFerrisWheelGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetFerrisWheelGameResponseData `json:"data,omitempty"` -} - -type GetFerrisWheelGameResponseData struct { - Game CustomerGameResponse `json:"game"` -} diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index c1d9bd3..e342aef 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -150,6 +150,18 @@ type CustomerEnakGame struct { SessionTTLSeconds int `json:"session_ttl_seconds"` // The events making the game pay more right now, highest priority first. Events []CustomerGameEvent `json:"events"` + // What a PROBABILITY game, such as a spin wheel, can pay, in the order of its + // configuration. Left out for the other reward types. + Prizes []GamePrize `json:"prizes,omitempty"` +} + +// GamePrize is one entry of a PROBABILITY game, such as a segment of a spin wheel, as +// the customer sees it: never its odds. +type GamePrize struct { + // 1 for the first entry of the configuration. + Entry int `json:"entry"` + Label *string `json:"label"` + Amount int64 `json:"amount"` } // GameSessionStart is the response of starting a game (§7.1). @@ -199,6 +211,9 @@ type GameSessionCompletion struct { // The daily limits that made the reward smaller than earned: USER_DAILY, // GAME_DAILY or GLOBAL_DAILY. LimitedBy []string `json:"limited_by,omitempty"` + // The entry a PROBABILITY game drew, for a spin wheel to stop on. Its amount is + // the base reward, before events and limits. + Prize *GamePrize `json:"prize,omitempty"` } type GameSessionRewardParts struct { diff --git a/internal/models/game.go b/internal/models/game.go deleted file mode 100644 index cd7f637..0000000 --- a/internal/models/game.go +++ /dev/null @@ -1,41 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGameRequest struct { - Name string `json:"name" validate:"required"` - Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGameRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `json:"is_active,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamesQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - Type string `query:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `query:"is_active"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} diff --git a/internal/models/game_play.go b/internal/models/game_play.go deleted file mode 100644 index db62ce3..0000000 --- a/internal/models/game_play.go +++ /dev/null @@ -1,50 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePlayRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - CoinsUsed int `json:"coins_used" validate:"min=0"` - RandomSeed *string `json:"random_seed,omitempty"` -} - -type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` - CoinsUsed int `json:"coins_used"` - RandomSeed *string `json:"random_seed,omitempty"` - CreatedAt time.Time `json:"created_at"` - Game *GameResponse `json:"game,omitempty"` - Customer *CustomerResponse `json:"customer,omitempty"` - Prize *GamePrizeResponse `json:"prize,omitempty"` -} - -type ListGamePlaysQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - GameID *uuid.UUID `query:"game_id"` - CustomerID *uuid.UUID `query:"customer_id"` - PrizeID *uuid.UUID `query:"prize_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost. -type PlayGameRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` -} - -type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} diff --git a/internal/models/game_prize.go b/internal/models/game_prize.go deleted file mode 100644 index 2616cf1..0000000 --- a/internal/models/game_prize.go +++ /dev/null @@ -1,56 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePrizeRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - Name string `json:"name" validate:"required"` - Weight int `json:"weight" validate:"min=1"` - Stock int `json:"stock" validate:"min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGamePrizeRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"` - Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Weight int `json:"weight"` - Stock int `json:"stock"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty"` - Metadata map[string]interface{} `json:"metadata"` - Game *GameResponse `json:"game,omitempty"` - FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamePrizesQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - GameID *uuid.UUID `query:"game_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} diff --git a/internal/models/omset_tracker.go b/internal/models/omset_tracker.go index d364663..ea8ad05 100644 --- a/internal/models/omset_tracker.go +++ b/internal/models/omset_tracker.go @@ -27,15 +27,14 @@ type AddOmsetRequest struct { } type OmsetTrackerResponse struct { - ID uuid.UUID `json:"id"` - PeriodType string `json:"period_type"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - Total int64 `json:"total"` - GameID *uuid.UUID `json:"game_id,omitempty"` - Game *GameResponse `json:"game,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PeriodType string `json:"period_type"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Total int64 `json:"total"` + GameID *uuid.UUID `json:"game_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListOmsetTrackerQuery struct { diff --git a/internal/models/reward.go b/internal/models/reward.go deleted file mode 100644 index e2b29a5..0000000 --- a/internal/models/reward.go +++ /dev/null @@ -1,71 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateRewardRequest struct { - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type UpdateRewardRequest struct { - ID uuid.UUID `json:"id" binding:"required"` - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type ListRewardsRequest struct { - Page int `form:"page" binding:"min=1"` - Limit int `form:"limit" binding:"min=1,max=100"` - Search string `form:"search"` - RewardType string `form:"reward_type"` - MinPoints *int64 `form:"min_points"` - MaxPoints *int64 `form:"max_points"` - HasStock *bool `form:"has_stock"` -} - -type RewardResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - RewardType string `json:"reward_type"` - CostPoints int64 `json:"cost_points"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListRewardsResponse struct { - Rewards []RewardResponse `json:"rewards"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -type TermsAndConditionsStruct struct { - Sections []TncSectionStruct `json:"sections"` - ExpiryDays int `json:"expiry_days"` -} - -type TncSectionStruct struct { - Title string `json:"title"` - Rules []string `json:"rules"` -} diff --git a/internal/models/spin_game.go b/internal/models/spin_game.go deleted file mode 100644 index a384b7a..0000000 --- a/internal/models/spin_game.go +++ /dev/null @@ -1,37 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// SpinGameRequest represents the request to play a spin game -type SpinGameRequest struct { - SpinID string `json:"spin_id" validate:"required,uuid"` -} - -// SpinGameResponse represents the response from playing a spin game -type SpinGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *SpinGameResponseData `json:"data,omitempty"` -} - -// SpinGameResponseData contains the game play result -type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} - -// SpinGamePrizeResponse represents a prize won in the spin game -type SpinGamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Image *string `json:"image,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index 6e6095e..7df1744 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -5,20 +5,17 @@ import ( "fmt" "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" "github.com/google/uuid" ) type CustomerPointsProcessor struct { walletQuery *WalletQueryProcessor - gameRepo *repository.GameRepository } -func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor) *CustomerPointsProcessor { return &CustomerPointsProcessor{ walletQuery: walletQuery, - gameRepo: gameRepo, } } @@ -74,94 +71,6 @@ func parseWalletCustomerID(customerID string) (uuid.UUID, error) { return id, nil } -// GetCustomerGamesAPI gets active SPIN games for customers -func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { - // Get active SPIN games - games, err := p.gameRepo.GetActiveSpinGames(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get active SPIN games: %w", err) - } - - // Convert to response format - var gameResponses []models.CustomerGameResponse - for _, game := range games { - var prizeResponses []models.CustomerGamePrizeResponse - for _, prize := range game.Prizes { - prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{ - ID: prize.ID, - GameID: prize.GameID, - Name: prize.Name, - Image: prize.Image, - Metadata: (*map[string]interface{})(&prize.Metadata), - CreatedAt: prize.CreatedAt, - UpdatedAt: prize.UpdatedAt, - }) - } - - gameResponses = append(gameResponses, models.CustomerGameResponse{ - ID: game.ID, - Name: game.Name, - Type: string(game.Type), - IsActive: game.IsActive, - Metadata: (*map[string]interface{})(&game.Metadata), - Prizes: prizeResponses, - CreatedAt: game.CreatedAt, - UpdatedAt: game.UpdatedAt, - }) - } - - return &models.GetCustomerGamesResponse{ - Status: "SUCCESS", - Message: "Customer games retrieved successfully.", - Data: &models.GetCustomerGamesResponseData{ - Games: gameResponses, - }, - }, nil -} - -// GetFerrisWheelGameAPI gets the Ferris Wheel game for customers -func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) { - // Get Ferris Wheel game - game, err := p.gameRepo.GetFerrisWheelGame(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err) - } - - // Convert prizes to response format - var prizeResponses []models.CustomerGamePrizeResponse - for _, prize := range game.Prizes { - prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{ - ID: prize.ID, - GameID: prize.GameID, - Name: prize.Name, - Image: prize.Image, - Metadata: (*map[string]interface{})(&prize.Metadata), - CreatedAt: prize.CreatedAt, - UpdatedAt: prize.UpdatedAt, - }) - } - - // Convert game to response format - gameResponse := models.CustomerGameResponse{ - ID: game.ID, - Name: game.Name, - Type: string(game.Type), - IsActive: game.IsActive, - Metadata: (*map[string]interface{})(&game.Metadata), - Prizes: prizeResponses, - CreatedAt: game.CreatedAt, - UpdatedAt: game.UpdatedAt, - } - - return &models.GetFerrisWheelGameResponse{ - Status: "SUCCESS", - Message: "Ferris Wheel game retrieved successfully.", - Data: &models.GetFerrisWheelGameResponseData{ - Game: gameResponse, - }, - }, nil -} - func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) { id, err := parseWalletCustomerID(customerID) if err != nil { diff --git a/internal/processor/game_play_processor.go b/internal/processor/game_play_processor.go deleted file mode 100644 index e6fef4f..0000000 --- a/internal/processor/game_play_processor.go +++ /dev/null @@ -1,301 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "errors" - "fmt" - "math" - "math/rand" - "time" - - "github.com/google/uuid" -) - -// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game, -// its prizes or their EnakCoin. The message says which. -var ErrGamePlayRefused = errors.New("game cannot be played") - -const ( - gameCoinCostKey = "coin_cost" - defaultGameCoinCost = 1 -) - -type gameReader interface { - GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) -} - -type gamePrizeStock interface { - GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) - DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error -} - -type GamePlayProcessor struct { - gamePlayRepo repository.GamePlayRepository - gameRepo gameReader - gamePrizeRepo gamePrizeStock - wallet *WalletProcessor - spendable spendableReader - tx TxRunner -} - -func NewGamePlayProcessor( - gamePlayRepo repository.GamePlayRepository, - gameRepo gameReader, - gamePrizeRepo gamePrizeStock, - wallet *WalletProcessor, - spendable spendableReader, - tx TxRunner, -) *GamePlayProcessor { - return &GamePlayProcessor{ - gamePlayRepo: gamePlayRepo, - gameRepo: gameRepo, - gamePrizeRepo: gamePrizeRepo, - wallet: wallet, - spendable: spendable, - tx: tx, - } -} - -// CreateGamePlay creates a new game play record -func (p *GamePlayProcessor) CreateGamePlay(ctx context.Context, req *models.CreateGamePlayRequest) (*models.GamePlayResponse, error) { - // Convert request to entity - gamePlay := mappers.ToGamePlayEntity(req) - - // Create game play - err := p.gamePlayRepo.Create(ctx, gamePlay) - if err != nil { - return nil, fmt.Errorf("failed to create game play: %w", err) - } - - return mappers.ToGamePlayResponse(gamePlay), nil -} - -// GetGamePlay retrieves a game play by ID -func (p *GamePlayProcessor) GetGamePlay(ctx context.Context, id uuid.UUID) (*models.GamePlayResponse, error) { - gamePlay, err := p.gamePlayRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game play not found: %w", err) - } - - return mappers.ToGamePlayResponse(gamePlay), nil -} - -// ListGamePlays retrieves game plays with pagination and filtering -func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.ListGamePlaysQuery) (*models.PaginatedResponse[models.GamePlayResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get game plays from repository - gamePlays, total, err := p.gamePlayRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.GameID, - query.CustomerID, - query.PrizeID, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list game plays: %w", err) - } - - // Convert to responses - responses := mappers.ToGamePlayResponsesFromPointers(gamePlays) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.GamePlayResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md -// F8, K1). Every kind of game spends the same EnakCoin, at the game's -// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking -// the prize from stock happen in one transaction, so a play that cannot be completed, -// for instance because the prize just ran out, takes nothing and records nothing. -func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) { - refuse := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...)) - } - game, err := p.gameRepo.GetByID(ctx, req.GameID) - if err != nil { - return nil, fmt.Errorf("game not found: %w", err) - } - if !game.IsActive { - return nil, refuse("game is not active") - } - cost, err := gameCoinCost(game.Metadata) - if err != nil { - return nil, refuse("%s", err.Error()) - } - - var gamePlay *entities.GamePlay - var selectedPrize *models.GamePrizeResponse - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) - if err != nil { - return fmt.Errorf("failed to get available prizes: %w", err) - } - if len(availablePrizes) == 0 { - return refuse("no prizes available") - } - prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) - for i, prize := range availablePrizes { - prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) - } - selectedPrize = p.selectPrizeByWeight(prizeResponses) - - // Random seed kept for audit. - randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) - gamePlay = &entities.GamePlay{ - ID: uuid.New(), - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: int(cost), - RandomSeed: &randomSeed, - } - if selectedPrize != nil { - gamePlay.PrizeID = &selectedPrize.ID - } - - // The EnakCoin go first: the debit locks the wallet, so plays by the same - // customer at the same time queue up instead of spending the same balance. - _, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: req.CustomerID, - Currency: constants.WalletCurrencyCoin, - Type: constants.WalletTxTypeGameSpend, - Amount: cost, - ReferenceType: constants.WalletRefTypeGamePlay, - ReferenceID: gamePlay.ID, - Description: gameSpendDescription(game.Name, selectedPrize), - Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost}, - }}) - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return refuse("not enough EnakCoin: this game costs %d", cost) - } - if err != nil { - return err - } - if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil { - return err - } - if selectedPrize != nil { - err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) - if errors.Is(err, repository.ErrGamePrizeOutOfStock) { - return refuse("the prize just ran out, please play again") - } - if err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err - } - - balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now()) - if err != nil { - return nil, err - } - return &models.PlayGameResponse{ - GamePlay: *mappers.ToGamePlayResponse(gamePlay), - PrizeWon: selectedPrize, - CoinsRemaining: balances[constants.WalletCurrencyCoin], - }, nil -} - -// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a -// whole number of EnakCoin, 1 when it is not set (F8). -func gameCoinCost(metadata entities.Metadata) (int64, error) { - raw, ok := metadata[gameCoinCostKey] - if !ok || raw == nil { - return defaultGameCoinCost, nil - } - var cost int64 - switch v := raw.(type) { - case float64: - if v != math.Trunc(v) { - return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v) - } - cost = int64(v) - case int: - cost = int64(v) - case int64: - cost = v - default: - return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw) - } - if cost < 1 { - return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost) - } - return cost, nil -} - -// gameSpendDescription is what the customer's history shows for a play, frozen when it -// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb". -func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string { - description := "Main " + gameName - if prize != nil { - description += ": dapat " + prize.Name - } - return truncateRunes(description, walletDescriptionLimit) -} - -// selectPrizeByWeight selects a prize based on weight distribution -func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse { - if len(prizes) == 0 { - return nil - } - - // Calculate total weight - totalWeight := 0 - for _, prize := range prizes { - totalWeight += prize.Weight - } - - if totalWeight == 0 { - return nil - } - - // Generate random number - rand.Seed(time.Now().UnixNano()) - randomNumber := rand.Intn(totalWeight) - - // Select prize based on cumulative weight - currentWeight := 0 - for _, prize := range prizes { - currentWeight += prize.Weight - if randomNumber < currentWeight { - return &prize - } - } - - // Fallback to last prize - return &prizes[len(prizes)-1] -} diff --git a/internal/processor/game_play_processor_db_test.go b/internal/processor/game_play_processor_db_test.go deleted file mode 100644 index 095a916..0000000 --- a/internal/processor/game_play_processor_db_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package processor - -import ( - "context" - "testing" - - "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" -) - -// Plays against Postgres, so the game repositories really join the transaction: a -// refused play leaves no game_plays row and no GAME_SPEND. -func TestPlayGame_AgainstPostgres(t *testing.T) { - db, _, player, _ := walletMoveDB(t) - gameID, prizeID := uuid.New(), uuid.New() - // 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) - db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID) - db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID) - db.Exec(`DELETE FROM games WHERE id = ?`, gameID) - }) - - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - in := earn(player, 3, nil) - in.Currency = constants.WalletCurrencyCoin - _, err := wallet.Credit(ctx, in) - return err - })) - p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db), - wallet, repository.NewWalletQueryRepository(db), txm) - play := func() (*models.PlayGameResponse, error) { - return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player}) - } - - res, err := play() - require.NoError(t, err) - assert.Equal(t, int64(1), res.CoinsRemaining) - assert.Equal(t, 2, res.GamePlay.CoinsUsed) - - var spend struct { - ReferenceID uuid.UUID - Amount int64 - } - require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error) - assert.Equal(t, res.GamePlay.ID, spend.ReferenceID) - assert.Equal(t, int64(-2), spend.Amount) - - // The only prize is gone, then the EnakCoin are too short: neither play leaves a row. - _, err = play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error) - _, err = play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - - var plays, spends int64 - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error) - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error) - assert.Equal(t, int64(1), plays) - assert.Equal(t, int64(1), spends) -} diff --git a/internal/processor/game_play_processor_test.go b/internal/processor/game_play_processor_test.go deleted file mode 100644 index 6a43670..0000000 --- a/internal/processor/game_play_processor_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package processor - -import ( - "context" - "errors" - "testing" - - "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" -) - -// snapshot saves everything the fake wallet holds and returns what puts it back, so -// a test transaction can roll back like a real one. -func (f *walletRepoFake) snapshot() func() { - wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets)) - for id, w := range f.wallets { - c := *w - wallets[id] = &c - } - transactions := make([]*entities.WalletTransaction, len(f.transactions)) - for i, t := range f.transactions { - c := *t - transactions[i] = &c - } - lots := make([]*entities.WalletLot, len(f.lots)) - for i, l := range f.lots { - c := *l - lots[i] = &c - } - allocations := append([]entities.WalletLotAllocation(nil), f.allocations...) - return func() { - f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations - } -} - -// rollbackTx runs fn in place and, when it fails, puts back what each save saved. -type rollbackTx struct{ saves []func() func() } - -func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - restores := make([]func(), 0, len(r.saves)) - for _, save := range r.saves { - restores = append(restores, save()) - } - err := fn(ctx) - if err != nil { - for _, restore := range restores { - restore() - } - } - return err -} - -type gameFake struct{ games map[uuid.UUID]*entities.Game } - -func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) { - g, ok := f.games[id] - if !ok { - return nil, errors.New("record not found") - } - return g, nil -} - -type prizeStockFake struct { - prizes []entities.GamePrize - stockErr error -} - -func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var out []entities.GamePrize - for _, p := range f.prizes { - if p.GameID == gameID && p.Stock > 0 { - out = append(out, p) - } - } - return out, nil -} - -func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error { - if f.stockErr != nil { - return f.stockErr - } - for i := range f.prizes { - if f.prizes[i].ID == id { - if f.prizes[i].Stock < amount { - return repository.ErrGamePrizeOutOfStock - } - f.prizes[i].Stock -= amount - return nil - } - } - return repository.ErrGamePrizeOutOfStock -} - -func (f *prizeStockFake) snapshot() func() { - prizes := append([]entities.GamePrize(nil), f.prizes...) - return func() { f.prizes = prizes } -} - -// gamePlayRepoFake records plays; the other methods of the interface are not used. -type gamePlayRepoFake struct { - repository.GamePlayRepository - plays []entities.GamePlay -} - -func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error { - f.plays = append(f.plays, *gp) - return nil -} - -func (f *gamePlayRepoFake) snapshot() func() { - plays := append([]entities.GamePlay(nil), f.plays...) - return func() { f.plays = plays } -} - -type gameTestEnv struct { - *walletMoveEnv - game *entities.Game - prize entities.GamePrize - stock *prizeStockFake - plays *gamePlayRepoFake - player uuid.UUID - p *GamePlayProcessor -} - -func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv { - e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)} - e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata} - e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5} - e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}} - e.plays = &gamePlayRepoFake{} - e.player = e.member("Budi", "081234561234") - tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}} - e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx) - return e -} - -func (e *gameTestEnv) play() (*models.PlayGameResponse, error) { - return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player}) -} - -func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) { - // coin_cost comes back from JSONB as a float64. - e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) - e.earnCoins(t, e.player, 10, nil) - - res, err := e.play() - require.NoError(t, err) - - assert.Equal(t, int64(7), res.CoinsRemaining) - require.NotNil(t, res.PrizeWon) - assert.Equal(t, e.prize.ID, res.PrizeWon.ID) - require.Len(t, e.plays.plays, 1) - play := e.plays.plays[0] - assert.Equal(t, 3, play.CoinsUsed) - assert.Equal(t, e.prize.ID, *play.PrizeID) - assert.Equal(t, 4, e.stock.prizes[0].Stock) - - spend := e.repo.transactions[len(e.repo.transactions)-1] - assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type) - assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency) - assert.Equal(t, int64(-3), spend.Amount) - assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType) - assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play") - assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description) -} - -func TestPlayGame_CostsOneCoinByDefault(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 2, nil) - - res, err := e.play() - require.NoError(t, err) - assert.Equal(t, int64(1), res.CoinsRemaining) - assert.Equal(t, 1, e.plays.plays[0].CoinsUsed) -} - -func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) - e.earnCoins(t, e.player, 2, nil) - - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Empty(t, e.plays.plays) - assert.Equal(t, 5, e.stock.prizes[0].Stock) - assert.Equal(t, int64(2), e.coinBalance(t, e.player)) -} - -func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - rows := len(e.repo.transactions) - - // Another play took the last one between reading the prizes and taking it. - e.stock.stockErr = repository.ErrGamePrizeOutOfStock - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - - // Anything else going wrong with the stock cancels it too. - e.stock.stockErr = errors.New("connection reset") - _, err = e.play() - assert.Error(t, err) - - assert.Empty(t, e.plays.plays) - assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind") - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) -} - -func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) { - for name, metadata := range map[string]entities.Metadata{ - "zero": {"coin_cost": float64(0)}, - "fraction": {"coin_cost": 1.5}, - "not number": {"coin_cost": "2"}, - } { - t.Run(name, func(t *testing.T) { - e := newGameTestEnv(t, metadata) - e.earnCoins(t, e.player, 5, nil) - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) - }) - } - - t.Run("inactive", func(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - e.game.IsActive = false - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - }) - - t.Run("no prizes left", func(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - e.stock.prizes[0].Stock = 0 - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) - }) -} diff --git a/internal/processor/game_prize_processor.go b/internal/processor/game_prize_processor.go deleted file mode 100644 index 6527e3a..0000000 --- a/internal/processor/game_prize_processor.go +++ /dev/null @@ -1,148 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "fmt" - - "github.com/google/uuid" -) - -type GamePrizeProcessor struct { - gamePrizeRepo *repository.GamePrizeRepository -} - -func NewGamePrizeProcessor(gamePrizeRepo *repository.GamePrizeRepository) *GamePrizeProcessor { - return &GamePrizeProcessor{ - gamePrizeRepo: gamePrizeRepo, - } -} - -// CreateGamePrize creates a new game prize -func (p *GamePrizeProcessor) CreateGamePrize(ctx context.Context, req *models.CreateGamePrizeRequest) (*models.GamePrizeResponse, error) { - // Convert request to entity - gamePrize := mappers.ToGamePrizeEntity(req) - - // Create game prize - err := p.gamePrizeRepo.Create(ctx, gamePrize) - if err != nil { - return nil, fmt.Errorf("failed to create game prize: %w", err) - } - - return mappers.ToGamePrizeResponse(gamePrize), nil -} - -// GetGamePrize retrieves a game prize by ID -func (p *GamePrizeProcessor) GetGamePrize(ctx context.Context, id uuid.UUID) (*models.GamePrizeResponse, error) { - gamePrize, err := p.gamePrizeRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game prize not found: %w", err) - } - - return mappers.ToGamePrizeResponse(gamePrize), nil -} - -// GetGamePrizesByGameID retrieves all prizes for a specific game -func (p *GamePrizeProcessor) GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]models.GamePrizeResponse, error) { - gamePrizes, err := p.gamePrizeRepo.GetByGameID(ctx, gameID) - if err != nil { - return nil, fmt.Errorf("failed to get game prizes: %w", err) - } - - return mappers.ToGamePrizeResponses(gamePrizes), nil -} - -// ListGamePrizes retrieves game prizes with pagination and filtering -func (p *GamePrizeProcessor) ListGamePrizes(ctx context.Context, query *models.ListGamePrizesQuery) (*models.PaginatedResponse[models.GamePrizeResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get game prizes from repository - gamePrizes, total, err := p.gamePrizeRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.GameID, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list game prizes: %w", err) - } - - // Convert to responses - responses := mappers.ToGamePrizeResponses(gamePrizes) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.GamePrizeResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// UpdateGamePrize updates an existing game prize -func (p *GamePrizeProcessor) UpdateGamePrize(ctx context.Context, id uuid.UUID, req *models.UpdateGamePrizeRequest) (*models.GamePrizeResponse, error) { - // Get existing game prize - gamePrize, err := p.gamePrizeRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game prize not found: %w", err) - } - - // Update game prize fields - mappers.UpdateGamePrizeEntity(gamePrize, req) - - // Save updated game prize - err = p.gamePrizeRepo.Update(ctx, gamePrize) - if err != nil { - return nil, fmt.Errorf("failed to update game prize: %w", err) - } - - return mappers.ToGamePrizeResponse(gamePrize), nil -} - -// DeleteGamePrize deletes a game prize -func (p *GamePrizeProcessor) DeleteGamePrize(ctx context.Context, id uuid.UUID) error { - // Get existing game prize - _, err := p.gamePrizeRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("game prize not found: %w", err) - } - - // Delete game prize - err = p.gamePrizeRepo.Delete(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete game prize: %w", err) - } - - return nil -} - -// GetAvailablePrizes gets all available prizes for a game (with stock > 0) -func (p *GamePrizeProcessor) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]models.GamePrizeResponse, error) { - gamePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, gameID) - if err != nil { - return nil, fmt.Errorf("failed to get available prizes: %w", err) - } - - return mappers.ToGamePrizeResponses(gamePrizes), nil -} diff --git a/internal/processor/game_processor.go b/internal/processor/game_processor.go deleted file mode 100644 index 6c9dea0..0000000 --- a/internal/processor/game_processor.go +++ /dev/null @@ -1,139 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "fmt" - - "github.com/google/uuid" -) - -type GameProcessor struct { - gameRepo *repository.GameRepository -} - -func NewGameProcessor(gameRepo *repository.GameRepository) *GameProcessor { - return &GameProcessor{ - gameRepo: gameRepo, - } -} - -// CreateGame creates a new game -func (p *GameProcessor) CreateGame(ctx context.Context, req *models.CreateGameRequest) (*models.GameResponse, error) { - // Convert request to entity - game := mappers.ToGameEntity(req) - - // Create game - err := p.gameRepo.Create(ctx, game) - if err != nil { - return nil, fmt.Errorf("failed to create game: %w", err) - } - - return mappers.ToGameResponse(game), nil -} - -// GetGame retrieves a game by ID -func (p *GameProcessor) GetGame(ctx context.Context, id uuid.UUID) (*models.GameResponse, error) { - game, err := p.gameRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game not found: %w", err) - } - - return mappers.ToGameResponse(game), nil -} - -// ListGames retrieves games with pagination and filtering -func (p *GameProcessor) ListGames(ctx context.Context, query *models.ListGamesQuery) (*models.PaginatedResponse[models.GameResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get games from repository - games, total, err := p.gameRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.Type, - query.IsActive, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list games: %w", err) - } - - // Convert to responses - responses := mappers.ToGameResponses(games) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.GameResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// UpdateGame updates an existing game -func (p *GameProcessor) UpdateGame(ctx context.Context, id uuid.UUID, req *models.UpdateGameRequest) (*models.GameResponse, error) { - // Get existing game - game, err := p.gameRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game not found: %w", err) - } - - // Update game fields - mappers.UpdateGameEntity(game, req) - - // Save updated game - err = p.gameRepo.Update(ctx, game) - if err != nil { - return nil, fmt.Errorf("failed to update game: %w", err) - } - - return mappers.ToGameResponse(game), nil -} - -// DeleteGame deletes a game -func (p *GameProcessor) DeleteGame(ctx context.Context, id uuid.UUID) error { - // Get existing game - _, err := p.gameRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("game not found: %w", err) - } - - // Delete game - err = p.gameRepo.Delete(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete game: %w", err) - } - - return nil -} - -// GetActiveGames gets all active games -func (p *GameProcessor) GetActiveGames(ctx context.Context) ([]models.GameResponse, error) { - games, err := p.gameRepo.GetActiveGames(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get active games: %w", err) - } - - return mappers.ToGameResponses(games), nil -} diff --git a/internal/processor/game_session_complete.go b/internal/processor/game_session_complete.go index 272c038..5b83284 100644 --- a/internal/processor/game_session_complete.go +++ b/internal/processor/game_session_complete.go @@ -123,6 +123,7 @@ func (p *GameSessionProcessor) Complete(ctx context.Context, customerID, session if err := json.Unmarshal(session.RewardBreakdown, &stored); err == nil { out.LimitedBy = stored.LimitedBy out.Reward = rewardParts(stored) + out.Prize = drawnPrize(stored.RewardType, stored.Base, stored.Detail) } } return out, nil diff --git a/internal/processor/game_session_processor.go b/internal/processor/game_session_processor.go index a22b21c..387d6df 100644 --- a/internal/processor/game_session_processor.go +++ b/internal/processor/game_session_processor.go @@ -2,6 +2,7 @@ package processor import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -384,11 +385,26 @@ func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UU if err != nil { return nil, err } + configs, err := p.games.ListActiveRewardConfigs(ctx, customer.OrganizationID) + if err != nil { + return nil, err + } + prizes := make(map[uuid.UUID][]models.GamePrize, len(configs)) + for _, c := range configs { + if c.RewardType != constants.GameRewardTypeProbability { + continue + } + // Rules passed Validate when the configuration was made. + if list, err := probabilityPrizes(json.RawMessage(c.Rules)); err == nil { + prizes[c.GameID] = list + } + } 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{}, + Prizes: prizes[g.ID], } for _, e := range events[g.ID] { item.Events = append(item.Events, models.CustomerGameEvent{ diff --git a/internal/processor/reward_calculator.go b/internal/processor/reward_calculator.go index 68ddb90..3d82726 100644 --- a/internal/processor/reward_calculator.go +++ b/internal/processor/reward_calculator.go @@ -12,6 +12,7 @@ import ( "strings" "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" ) var ( @@ -332,12 +333,18 @@ func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json // 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. +// kept in the detail for audit. An entry may carry a label, such as a spin wheel's +// segment ("Zonk", "Jackpot"); the customer sees the entries and the one drawn, never +// the weights. type probabilityReward struct{} +// probabilityLabelLimit bounds a label's length. +const probabilityLabelLimit = 100 + type probabilityEntry struct { - Weight *int64 `json:"weight"` - Amount *int64 `json:"amount"` + Weight *int64 `json:"weight"` + Amount *int64 `json:"amount"` + Label *string `json:"label,omitempty"` } type probabilityRules struct { @@ -363,6 +370,9 @@ func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64, if *e.Amount < 0 { return r, 0, invalidRules("entry %d amount must be 0 or more", i+1) } + if e.Label != nil && (strings.TrimSpace(*e.Label) == "" || len(*e.Label) > probabilityLabelLimit) { + return r, 0, invalidRules("entry %d label must not be empty, at most %d characters", i+1, probabilityLabelLimit) + } total += *e.Weight if total > rewardWeightLimit { return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit) @@ -389,9 +399,13 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng for i, e := range r.Table { cumulative += *e.Weight if roll < cumulative { - return *e.Amount, map[string]any{ + detail := map[string]any{ "reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1, - }, nil + } + if e.Label != nil { + detail["label"] = *e.Label + } + return *e.Amount, detail, nil } } return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) @@ -410,3 +424,40 @@ func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json. } return marshalRules(r) } + +// probabilityPrizes lists the entries of PROBABILITY rules as the customer sees them: +// in order, without their weights. +func probabilityPrizes(rules json.RawMessage) ([]models.GamePrize, error) { + r, _, err := probabilityReward{}.parse(rules) + if err != nil { + return nil, err + } + prizes := make([]models.GamePrize, 0, len(r.Table)) + for i, e := range r.Table { + prizes = append(prizes, models.GamePrize{Entry: i + 1, Label: e.Label, Amount: *e.Amount}) + } + return prizes, nil +} + +// drawnPrize is the entry a PROBABILITY reward drew, read back from its detail; nil +// for another type or when nothing was drawn. +func drawnPrize(rewardType string, base int64, detail map[string]any) *models.GamePrize { + if rewardType != constants.GameRewardTypeProbability { + return nil + } + // A number read back from JSON is a float64; one just drawn is an int. + var entry int + switch v := detail["entry"].(type) { + case int: + entry = v + case float64: + entry = int(v) + default: + return nil + } + prize := &models.GamePrize{Entry: entry, Amount: base} + if label, ok := detail["label"].(string); ok { + prize.Label = &label + } + return prize +} diff --git a/internal/processor/reward_calculator_test.go b/internal/processor/reward_calculator_test.go index 4646030..ac6d2e3 100644 --- a/internal/processor/reward_calculator_test.go +++ b/internal/processor/reward_calculator_test.go @@ -3,12 +3,14 @@ package processor import ( "encoding/json" "math" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" ) // fixedRNG returns the given draws in turn. @@ -179,3 +181,49 @@ func TestRewardCalculator_Scale(t *testing.T) { require.NoError(t, err) assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v) } + +// EG-1001: a spin wheel is a PROBABILITY game whose entries carry labels. +func TestRewardCalculator_SpinPrizes(t *testing.T) { + rules := json.RawMessage(`{"table": [ + {"weight": 50, "amount": 0, "label": "Zonk"}, + {"weight": 45, "amount": 5, "label": "5 Coin"}, + {"weight": 5, "amount": 50}]}`) + c, err := RewardCalculatorFor(constants.GameRewardTypeProbability) + require.NoError(t, err) + require.NoError(t, c.Validate(rules)) + + prizes, err := probabilityPrizes(rules) + require.NoError(t, err) + assert.Equal(t, []models.GamePrize{ + {Entry: 1, Label: ptr("Zonk"), Amount: 0}, {Entry: 2, Label: ptr("5 Coin"), Amount: 5}, {Entry: 3, Amount: 50}, + }, prizes, "no weights") + + base, detail, err := c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{60}}) + require.NoError(t, err) + assert.EqualValues(t, 5, base) + assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, detail)) + + // As read back from reward_breakdown. + stored, err := json.Marshal(detail) + require.NoError(t, err) + var back map[string]any + require.NoError(t, json.Unmarshal(stored, &back)) + assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, back)) + + _, detail, err = c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{99}}) + require.NoError(t, err) + assert.Equal(t, &models.GamePrize{Entry: 3, Amount: 50}, drawnPrize(constants.GameRewardTypeProbability, 50, detail), "no label") + assert.Nil(t, drawnPrize(constants.GameRewardTypeFixed, 5, map[string]any{"reward_type": "FIXED"})) + assert.Nil(t, drawnPrize(constants.GameRewardTypeProbability, 0, nil), "rejected: nothing drawn") + + scaled, err := c.Scale(rules, 9_000) + require.NoError(t, err) + assert.Contains(t, string(scaled), `"label":"Zonk"`, "scaling keeps the labels") + + for name, bad := range map[string]string{ + "empty label": `{"table": [{"weight": 1, "amount": 1, "label": " "}]}`, + "label too long": `{"table": [{"weight": 1, "amount": 1, "label": "` + strings.Repeat("x", 101) + `"}]}`, + } { + assert.ErrorIs(t, c.Validate(json.RawMessage(bad)), ErrInvalidRewardRules, name) + } +} diff --git a/internal/processor/reward_processor.go b/internal/processor/reward_processor.go deleted file mode 100644 index 2f880f1..0000000 --- a/internal/processor/reward_processor.go +++ /dev/null @@ -1,149 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type RewardProcessor interface { - CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) - GetReward(ctx context.Context, id string) (*models.RewardResponse, error) - ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) - UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) - DeleteReward(ctx context.Context, id string) error - UpdateRewardStock(ctx context.Context, id string, newStock int) error - GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) -} - -type rewardProcessor struct { - rewardRepo repository.RewardRepository -} - -func NewRewardProcessor(rewardRepo repository.RewardRepository) RewardProcessor { - return &rewardProcessor{ - rewardRepo: rewardRepo, - } -} - -func (p *rewardProcessor) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) { - // Convert request to entity - entity := mappers.ToRewardEntity(req) - if entity == nil { - return nil, fmt.Errorf("invalid request data") - } - - // Create in repository - if err := p.rewardRepo.Create(ctx, entity); err != nil { - return nil, fmt.Errorf("failed to create reward: %w", err) - } - - // Convert entity to response - response := mappers.ToRewardResponse(entity) - return response, nil -} - -func (p *rewardProcessor) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) { - // Get from repository - entity, err := p.rewardRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get reward: %w", err) - } - - if entity == nil { - return nil, fmt.Errorf("reward not found") - } - - // Convert entity to response - response := mappers.ToRewardResponse(entity) - return response, nil -} - -func (p *rewardProcessor) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) { - // Convert request to entity request - entityReq := &entities.ListRewardsRequest{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - RewardType: req.RewardType, - MinPoints: req.MinPoints, - MaxPoints: req.MaxPoints, - HasStock: req.HasStock, - } - - // Get from repository - entities, total, err := p.rewardRepo.List(ctx, entityReq) - if err != nil { - return nil, fmt.Errorf("failed to list rewards: %w", err) - } - - // Convert entities to response - rewards := mappers.ToRewardResponseSlice(entities) - - response := &models.ListRewardsResponse{ - Rewards: rewards, - Total: total, - Page: req.Page, - Limit: req.Limit, - } - - return response, nil -} - -func (p *rewardProcessor) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) { - // Convert request to entity - entity := mappers.ToRewardEntityFromUpdate(req) - if entity == nil { - return nil, fmt.Errorf("invalid request data") - } - - // Update in repository - if err := p.rewardRepo.Update(ctx, entity); err != nil { - return nil, fmt.Errorf("failed to update reward: %w", err) - } - - // Get updated entity - updatedEntity, err := p.rewardRepo.GetByID(ctx, req.ID.String()) - if err != nil { - return nil, fmt.Errorf("failed to get updated reward: %w", err) - } - - // Convert entity to response - response := mappers.ToRewardResponse(updatedEntity) - return response, nil -} - -func (p *rewardProcessor) DeleteReward(ctx context.Context, id string) error { - // Delete from repository - if err := p.rewardRepo.Delete(ctx, id); err != nil { - return fmt.Errorf("failed to delete reward: %w", err) - } - - return nil -} - -func (p *rewardProcessor) UpdateRewardStock(ctx context.Context, id string, newStock int) error { - // Update stock in repository - if err := p.rewardRepo.UpdateStock(ctx, id, newStock); err != nil { - return fmt.Errorf("failed to update reward stock: %w", err) - } - - return nil -} - -func (p *rewardProcessor) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) { - // Get from repository - entities, err := p.rewardRepo.GetByRewardType(ctx, entities.RewardType(rewardType)) - if err != nil { - return nil, fmt.Errorf("failed to get rewards by type: %w", err) - } - - // Convert entities to response - rewards := mappers.ToRewardResponseSlice(entities) - return rewards, nil -} diff --git a/internal/repository/game_play_repository.go b/internal/repository/game_play_repository.go deleted file mode 100644 index 48c8d01..0000000 --- a/internal/repository/game_play_repository.go +++ /dev/null @@ -1,170 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GamePlayRepository interface { - Create(ctx context.Context, gamePlay *entities.GamePlay) error - GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) - List(ctx context.Context, offset, limit int, search string, gameID, customerID, prizeID *uuid.UUID, sortBy, sortOrder string) ([]*entities.GamePlay, int64, error) - GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) - GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) - CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) - CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) -} - -type gamePlayRepository struct { - db *gorm.DB -} - -func NewGamePlayRepository(db *gorm.DB) GamePlayRepository { - return &gamePlayRepository{ - db: db, - } -} - -func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error { - if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil { - return fmt.Errorf("failed to create game play: %w", err) - } - return nil -} - -func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) { - var gamePlay entities.GamePlay - err := DBFromContext(ctx, r.db).WithContext(ctx). - Preload("Game"). - Preload("Customer"). - Preload("Prize"). - First(&gamePlay, "id = ?", id).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("game play not found") - } - return nil, fmt.Errorf("failed to get game play: %w", err) - } - return &gamePlay, nil -} - -func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search string, gameID, customerID, prizeID *uuid.UUID, sortBy, sortOrder string) ([]*entities.GamePlay, int64, error) { - var gamePlays []*entities.GamePlay - var total int64 - - query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{}) - - // Apply filters - if search != "" { - query = query.Where("random_seed ILIKE ?", "%"+search+"%") - } - if gameID != nil { - query = query.Where("game_id = ?", *gameID) - } - if customerID != nil { - query = query.Where("customer_id = ?", *customerID) - } - if prizeID != nil { - query = query.Where("prize_id = ?", *prizeID) - } - - // Count total records - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count game plays: %w", err) - } - - // Apply sorting - if sortBy != "" { - if sortOrder == "desc" { - query = query.Order(fmt.Sprintf("%s DESC", sortBy)) - } else { - query = query.Order(fmt.Sprintf("%s ASC", sortBy)) - } - } else { - query = query.Order("created_at DESC") - } - - // Apply pagination and preload relations - err := query. - Preload("Game"). - Preload("Customer"). - Preload("Prize"). - Offset(offset). - Limit(limit). - Find(&gamePlays).Error - - if err != nil { - return nil, 0, fmt.Errorf("failed to list game plays: %w", err) - } - - return gamePlays, total, nil -} - -func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) { - var gamePlays []*entities.GamePlay - query := DBFromContext(ctx, r.db).WithContext(ctx). - Where("customer_id = ?", customerID). - Preload("Game"). - Preload("Prize"). - Order("created_at DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&gamePlays).Error - if err != nil { - return nil, fmt.Errorf("failed to get game plays by customer ID: %w", err) - } - - return gamePlays, nil -} - -func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) { - var gamePlays []*entities.GamePlay - query := DBFromContext(ctx, r.db).WithContext(ctx). - Where("game_id = ?", gameID). - Preload("Customer"). - Preload("Prize"). - Order("created_at DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&gamePlays).Error - if err != nil { - return nil, fmt.Errorf("failed to get game plays by game ID: %w", err) - } - - return gamePlays, nil -} - -func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) { - var count int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.GamePlay{}). - Where("customer_id = ?", customerID). - Count(&count).Error - if err != nil { - return 0, fmt.Errorf("failed to count game plays by customer ID: %w", err) - } - return count, nil -} - -func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) { - var count int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.GamePlay{}). - Where("game_id = ?", gameID). - Count(&count).Error - if err != nil { - return 0, fmt.Errorf("failed to count game plays by game ID: %w", err) - } - return count, nil -} diff --git a/internal/repository/game_prize_repository.go b/internal/repository/game_prize_repository.go deleted file mode 100644 index 2b2f13c..0000000 --- a/internal/repository/game_prize_repository.go +++ /dev/null @@ -1,115 +0,0 @@ -package repository - -import ( - "apskel-pos-be/internal/entities" - "context" - "errors" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it. -var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock") - -type GamePrizeRepository struct { - db *gorm.DB -} - -func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository { - return &GamePrizeRepository{db: db} -} - -func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error -} - -func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) { - var gamePrize entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error - if err != nil { - return nil, err - } - return &gamePrize, nil -} - -func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var gamePrizes []entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error - if err != nil { - return nil, err - } - return gamePrizes, nil -} - -func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, search string, gameID *uuid.UUID, sortBy, sortOrder string) ([]entities.GamePrize, int64, error) { - var gamePrizes []entities.GamePrize - var total int64 - - query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize") - - if search != "" { - searchTerm := "%" + search + "%" - query = query.Where("name ILIKE ?", searchTerm) - } - - if gameID != nil { - query = query.Where("game_id = ?", *gameID) - } - - if err := query.Model(&entities.GamePrize{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - if sortBy != "" { - if sortOrder == "" { - sortOrder = "asc" - } - query = query.Order(fmt.Sprintf("game_prizes.%s %s", sortBy, sortOrder)) - } else { - query = query.Order("game_prizes.weight DESC") - } - - err := query.Offset(offset).Limit(limit).Find(&gamePrizes).Error - if err != nil { - return nil, 0, err - } - - return gamePrizes, total, nil -} - -func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error -} - -func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error -} - -// DecreaseStock takes amount from a prize's stock. When less than amount is left it -// changes nothing and returns ErrGamePrizeOutOfStock. -func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error { - res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}). - Where("id = ? AND stock >= ?", id, amount). - Update("stock", gorm.Expr("stock - ?", amount)) - if res.Error != nil { - return fmt.Errorf("failed to decrease prize stock: %w", res.Error) - } - if res.RowsAffected == 0 { - return ErrGamePrizeOutOfStock - } - return nil -} - -func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var gamePrizes []entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize"). - Where("game_id = ? AND stock > 0", gameID). - Order("weight DESC"). - Find(&gamePrizes).Error - if err != nil { - return nil, err - } - return gamePrizes, nil -} diff --git a/internal/repository/game_repository.go b/internal/repository/game_repository.go deleted file mode 100644 index 22d281a..0000000 --- a/internal/repository/game_repository.go +++ /dev/null @@ -1,112 +0,0 @@ -package repository - -import ( - "apskel-pos-be/internal/entities" - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GameRepository struct { - db *gorm.DB -} - -func NewGameRepository(db *gorm.DB) *GameRepository { - return &GameRepository{db: db} -} - -func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error -} - -func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) { - var game entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error - if err != nil { - return nil, err - } - return &game, nil -} - -func (r *GameRepository) List(ctx context.Context, offset, limit int, search, gameType string, isActive *bool, sortBy, sortOrder string) ([]entities.Game, int64, error) { - var games []entities.Game - var total int64 - - query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes") - - if search != "" { - searchTerm := "%" + search + "%" - query = query.Where("name ILIKE ?", searchTerm) - } - - if gameType != "" { - query = query.Where("type = ?", gameType) - } - - if isActive != nil { - query = query.Where("is_active = ?", *isActive) - } - - if err := query.Model(&entities.Game{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - if sortBy != "" { - if sortOrder == "" { - sortOrder = "asc" - } - query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder)) - } else { - query = query.Order("created_at DESC") - } - - err := query.Offset(offset).Limit(limit).Find(&games).Error - if err != nil { - return nil, 0, err - } - - return games, total, nil -} - -func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error -} - -func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error -} - -func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) { - var games []entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error - if err != nil { - return nil, err - } - return games, nil -} - -func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) { - var games []entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx). - Preload("Prizes"). - Where("is_active = ? AND type = ?", true, entities.GameTypeSpin). - Find(&games).Error - if err != nil { - return nil, err - } - return games, nil -} - -func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) { - var game entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx). - Preload("Prizes"). - Where("is_active = ? AND type = ?", true, "SPIN"). - First(&game).Error - if err != nil { - return nil, err - } - return &game, nil -} diff --git a/internal/repository/reward_repository.go b/internal/repository/reward_repository.go deleted file mode 100644 index a143c58..0000000 --- a/internal/repository/reward_repository.go +++ /dev/null @@ -1,129 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/entities" - - "gorm.io/gorm" -) - -type RewardRepository interface { - Create(ctx context.Context, reward *entities.Reward) error - GetByID(ctx context.Context, id string) (*entities.Reward, error) - List(ctx context.Context, req *entities.ListRewardsRequest) ([]entities.Reward, int64, error) - Update(ctx context.Context, reward *entities.Reward) error - Delete(ctx context.Context, id string) error - UpdateStock(ctx context.Context, id string, newStock int) error - GetByRewardType(ctx context.Context, rewardType entities.RewardType) ([]entities.Reward, error) -} - -type rewardRepository struct { - db *gorm.DB -} - -func NewRewardRepository(db *gorm.DB) RewardRepository { - return &rewardRepository{ - db: db, - } -} - -func (r *rewardRepository) Create(ctx context.Context, reward *entities.Reward) error { - if err := r.db.WithContext(ctx).Create(reward).Error; err != nil { - return fmt.Errorf("failed to create reward: %w", err) - } - return nil -} - -func (r *rewardRepository) GetByID(ctx context.Context, id string) (*entities.Reward, error) { - var reward entities.Reward - if err := r.db.WithContext(ctx).Where("id = ?", id).First(&reward).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("reward not found") - } - return nil, fmt.Errorf("failed to get reward: %w", err) - } - return &reward, nil -} - -func (r *rewardRepository) List(ctx context.Context, req *entities.ListRewardsRequest) ([]entities.Reward, int64, error) { - var rewards []entities.Reward - var total int64 - - query := r.db.WithContext(ctx).Model(&entities.Reward{}) - - // Apply filters - if req.Search != "" { - query = query.Where("name ILIKE ?", "%"+req.Search+"%") - } - - if req.RewardType != "" { - query = query.Where("reward_type = ?", req.RewardType) - } - - if req.MinPoints != nil { - query = query.Where("cost_points >= ?", *req.MinPoints) - } - - if req.MaxPoints != nil { - query = query.Where("cost_points <= ?", *req.MaxPoints) - } - - if req.HasStock != nil { - if *req.HasStock { - query = query.Where("stock > 0 OR stock IS NULL") - } else { - query = query.Where("stock <= 0") - } - } - - // Count total records - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count rewards: %w", err) - } - - // Apply pagination - if req.Page > 0 && req.Limit > 0 { - offset := (req.Page - 1) * req.Limit - query = query.Offset(offset).Limit(req.Limit) - } - - // Apply sorting - query = query.Order("created_at DESC") - - if err := query.Find(&rewards).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list rewards: %w", err) - } - - return rewards, total, nil -} - -func (r *rewardRepository) Update(ctx context.Context, reward *entities.Reward) error { - if err := r.db.WithContext(ctx).Save(reward).Error; err != nil { - return fmt.Errorf("failed to update reward: %w", err) - } - return nil -} - -func (r *rewardRepository) Delete(ctx context.Context, id string) error { - if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&entities.Reward{}).Error; err != nil { - return fmt.Errorf("failed to delete reward: %w", err) - } - return nil -} - -func (r *rewardRepository) UpdateStock(ctx context.Context, id string, newStock int) error { - if err := r.db.WithContext(ctx).Model(&entities.Reward{}).Where("id = ?", id).Update("stock", newStock).Error; err != nil { - return fmt.Errorf("failed to update reward stock: %w", err) - } - return nil -} - -func (r *rewardRepository) GetByRewardType(ctx context.Context, rewardType entities.RewardType) ([]entities.Reward, error) { - var rewards []entities.Reward - if err := r.db.WithContext(ctx).Where("reward_type = ?", rewardType).Find(&rewards).Error; err != nil { - return nil, fmt.Errorf("failed to get rewards by type: %w", err) - } - return rewards, nil -} diff --git a/internal/router/router.go b/internal/router/router.go index 6f6f666..c4778f3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -43,11 +43,9 @@ type Router struct { accountHandler *handler.AccountHandler orderIngredientTransactionHandler *handler.OrderIngredientTransactionHandler gamificationHandler *handler.GamificationHandler - rewardHandler *handler.RewardHandler campaignHandler *handler.CampaignHandler customerAuthHandler *handler.CustomerAuthHandler customerPointsHandler *handler.CustomerPointsHandler - spinGameHandler *handler.SpinGameHandler userDeviceHandler *handler.UserDeviceHandler notificationHandler *handler.NotificationHandler selfOrderHandler *handler.SelfOrderHandler @@ -68,7 +66,7 @@ type Router struct { 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, enakGameAdminService service.EnakGameAdminService, enakGameCustomerService service.EnakGameCustomerService, 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, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, 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, @@ -100,11 +98,9 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer accountHandler: handler.NewAccountHandler(accountService, accountValidator), orderIngredientTransactionHandler: handler.NewOrderIngredientTransactionHandler(&orderIngredientTransactionService, orderIngredientTransactionValidator), gamificationHandler: handler.NewGamificationHandler(gamificationService, gamificationValidator), - rewardHandler: handler.NewRewardHandler(rewardService, rewardValidator), campaignHandler: handler.NewCampaignHandler(campaignService, campaignValidator), customerAuthHandler: handler.NewCustomerAuthHandler(customerAuthService, customerAuthValidator), customerPointsHandler: handler.NewCustomerPointsHandler(customerPointsService), - spinGameHandler: handler.NewSpinGameHandler(spinGameService), authMiddleware: authMiddleware, customerAuthMiddleware: customerAuthMiddleware, productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator), @@ -191,11 +187,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.POST("/pin", r.customerPinHandler.CreatePin) customer.PUT("/pin", r.customerPinHandler.ChangePin) customer.POST("/pin/reset", r.customerPinHandler.ResetPin) - 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 + // EnakGame (docs/rfc-enakgame.md §11). /enakgame because /customer/games was // the old spin. enakGame := customer.Group("/enakgame") { @@ -570,38 +563,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { tiers.GET("/by-points/:points", r.gamificationHandler.GetTierByPoints) } - // Games - games := gamification.Group("/games") - { - games.POST("", r.gamificationHandler.CreateGame) - games.GET("", r.gamificationHandler.ListGames) - games.GET("/active", r.gamificationHandler.GetActiveGames) - games.GET("/:id", r.gamificationHandler.GetGame) - games.PUT("/:id", r.gamificationHandler.UpdateGame) - games.DELETE("/:id", r.gamificationHandler.DeleteGame) - } - - // Game Prizes - gamePrizes := gamification.Group("/game-prizes") - { - gamePrizes.POST("", r.gamificationHandler.CreateGamePrize) - gamePrizes.GET("", r.gamificationHandler.ListGamePrizes) - gamePrizes.GET("/:id", r.gamificationHandler.GetGamePrize) - gamePrizes.PUT("/:id", r.gamificationHandler.UpdateGamePrize) - gamePrizes.DELETE("/:id", r.gamificationHandler.DeleteGamePrize) - gamePrizes.GET("/game/:game_id", r.gamificationHandler.GetGamePrizesByGameID) - gamePrizes.GET("/game/:game_id/available", r.gamificationHandler.GetAvailablePrizes) - } - - //// Game Plays - //gamePlays := gamification.Group("/game-plays") - //{ - // gamePlays.POST("", r.gamificationHandler.CreateGamePlay) - // gamePlays.GET("", r.gamificationHandler.ListGamePlays) - // gamePlays.GET("/:id", r.gamificationHandler.GetGamePlay) - // gamePlays.POST("/play", r.gamificationHandler.PlayGame) - //} - // Omset Tracker //omsetTracker := gamification.Group("/omset-tracker") //{ @@ -612,18 +573,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { // omsetTracker.DELETE("/:id", r.gamificationHandler.DeleteOmsetTracker) //} - // Rewards - rewards := gamification.Group("/rewards") - { - rewards.POST("", r.rewardHandler.CreateReward) - rewards.GET("", r.rewardHandler.ListRewards) - rewards.GET("/:id", r.rewardHandler.GetReward) - rewards.PUT("/:id", r.rewardHandler.UpdateReward) - rewards.DELETE("/:id", r.rewardHandler.DeleteReward) - rewards.PUT("/:id/stock/:stock", r.rewardHandler.UpdateRewardStock) - rewards.GET("/type/:type", r.rewardHandler.GetRewardsByType) - } - // Campaigns campaigns := gamification.Group("/campaigns") { diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index 33c0728..2f7ee20 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -13,8 +13,6 @@ type CustomerPointsService interface { GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) - GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) - GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } type customerPointsService struct { @@ -53,24 +51,6 @@ func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerI return response, nil } -func (s *customerPointsService) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) { - response, err := s.customerPointsProcessor.GetCustomerGamesAPI(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get customer games: %w", err) - } - - return response, nil -} - -func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) { - response, err := s.customerPointsProcessor.GetFerrisWheelGameAPI(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err) - } - - return response, nil -} - func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { if customerID == "" { return nil, fmt.Errorf("customer ID is required") diff --git a/internal/service/gamification_service.go b/internal/service/gamification_service.go index 7096fe1..43f2d09 100644 --- a/internal/service/gamification_service.go +++ b/internal/service/gamification_service.go @@ -19,29 +19,6 @@ type GamificationService interface { DeleteTier(ctx context.Context, id uuid.UUID) error GetTierByPoints(ctx context.Context, points int64) (*contract.TierResponse, error) - // Games - CreateGame(ctx context.Context, req *contract.CreateGameRequest) (*contract.GameResponse, error) - GetGame(ctx context.Context, id uuid.UUID) (*contract.GameResponse, error) - ListGames(ctx context.Context, query *contract.ListGamesRequest) (*contract.PaginatedGamesResponse, error) - UpdateGame(ctx context.Context, id uuid.UUID, req *contract.UpdateGameRequest) (*contract.GameResponse, error) - DeleteGame(ctx context.Context, id uuid.UUID) error - GetActiveGames(ctx context.Context) ([]contract.GameResponse, error) - - // Game Prizes - CreateGamePrize(ctx context.Context, req *contract.CreateGamePrizeRequest) (*contract.GamePrizeResponse, error) - GetGamePrize(ctx context.Context, id uuid.UUID) (*contract.GamePrizeResponse, error) - GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) - ListGamePrizes(ctx context.Context, query *contract.ListGamePrizesRequest) (*contract.PaginatedGamePrizesResponse, error) - UpdateGamePrize(ctx context.Context, id uuid.UUID, req *contract.UpdateGamePrizeRequest) (*contract.GamePrizeResponse, error) - DeleteGamePrize(ctx context.Context, id uuid.UUID) error - GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) - - // Game Plays - CreateGamePlay(ctx context.Context, req *contract.CreateGamePlayRequest) (*contract.GamePlayResponse, error) - GetGamePlay(ctx context.Context, id uuid.UUID) (*contract.GamePlayResponse, error) - ListGamePlays(ctx context.Context, query *contract.ListGamePlaysRequest) (*contract.PaginatedGamePlaysResponse, error) - PlayGame(ctx context.Context, req *contract.PlayGameRequest) (*contract.PlayGameResponse, error) - // Omset Tracker CreateOmsetTracker(ctx context.Context, req *contract.CreateOmsetTrackerRequest) (*contract.OmsetTrackerResponse, error) GetOmsetTracker(ctx context.Context, id uuid.UUID) (*contract.OmsetTrackerResponse, error) @@ -53,24 +30,15 @@ type GamificationService interface { type GamificationServiceImpl struct { tierProcessor *processor.TierProcessor - gameProcessor *processor.GameProcessor - gamePrizeProcessor *processor.GamePrizeProcessor - gamePlayProcessor *processor.GamePlayProcessor omsetTrackerProcessor *processor.OmsetTrackerProcessor } func NewGamificationService( tierProcessor *processor.TierProcessor, - gameProcessor *processor.GameProcessor, - gamePrizeProcessor *processor.GamePrizeProcessor, - gamePlayProcessor *processor.GamePlayProcessor, omsetTrackerProcessor *processor.OmsetTrackerProcessor, ) *GamificationServiceImpl { return &GamificationServiceImpl{ tierProcessor: tierProcessor, - gameProcessor: gameProcessor, - gamePrizeProcessor: gamePrizeProcessor, - gamePlayProcessor: gamePlayProcessor, omsetTrackerProcessor: omsetTrackerProcessor, } } @@ -123,146 +91,6 @@ func (s *GamificationServiceImpl) GetTierByPoints(ctx context.Context, points in return transformer.TierModelToResponse(response), nil } -// Game Service Methods -func (s *GamificationServiceImpl) CreateGame(ctx context.Context, req *contract.CreateGameRequest) (*contract.GameResponse, error) { - modelReq := transformer.CreateGameRequestToModel(req) - response, err := s.gameProcessor.CreateGame(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.GameModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGame(ctx context.Context, id uuid.UUID) (*contract.GameResponse, error) { - response, err := s.gameProcessor.GetGame(ctx, id) - if err != nil { - return nil, err - } - return transformer.GameModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) ListGames(ctx context.Context, query *contract.ListGamesRequest) (*contract.PaginatedGamesResponse, error) { - modelQuery := transformer.ListGamesRequestToModel(query) - response, err := s.gameProcessor.ListGames(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedGamesResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) UpdateGame(ctx context.Context, id uuid.UUID, req *contract.UpdateGameRequest) (*contract.GameResponse, error) { - modelReq := transformer.UpdateGameRequestToModel(req) - response, err := s.gameProcessor.UpdateGame(ctx, id, modelReq) - if err != nil { - return nil, err - } - return transformer.GameModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeleteGame(ctx context.Context, id uuid.UUID) error { - return s.gameProcessor.DeleteGame(ctx, id) -} - -func (s *GamificationServiceImpl) GetActiveGames(ctx context.Context) ([]contract.GameResponse, error) { - response, err := s.gameProcessor.GetActiveGames(ctx) - if err != nil { - return nil, err - } - return transformer.GameModelsToResponses(response), nil -} - -// Game Prize Service Methods -func (s *GamificationServiceImpl) CreateGamePrize(ctx context.Context, req *contract.CreateGamePrizeRequest) (*contract.GamePrizeResponse, error) { - modelReq := transformer.CreateGamePrizeRequestToModel(req) - response, err := s.gamePrizeProcessor.CreateGamePrize(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGamePrize(ctx context.Context, id uuid.UUID) (*contract.GamePrizeResponse, error) { - response, err := s.gamePrizeProcessor.GetGamePrize(ctx, id) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) { - response, err := s.gamePrizeProcessor.GetGamePrizesByGameID(ctx, gameID) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelsToResponses(response), nil -} - -func (s *GamificationServiceImpl) ListGamePrizes(ctx context.Context, query *contract.ListGamePrizesRequest) (*contract.PaginatedGamePrizesResponse, error) { - modelQuery := transformer.ListGamePrizesRequestToModel(query) - response, err := s.gamePrizeProcessor.ListGamePrizes(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedGamePrizesResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) UpdateGamePrize(ctx context.Context, id uuid.UUID, req *contract.UpdateGamePrizeRequest) (*contract.GamePrizeResponse, error) { - modelReq := transformer.UpdateGamePrizeRequestToModel(req) - response, err := s.gamePrizeProcessor.UpdateGamePrize(ctx, id, modelReq) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeleteGamePrize(ctx context.Context, id uuid.UUID) error { - return s.gamePrizeProcessor.DeleteGamePrize(ctx, id) -} - -func (s *GamificationServiceImpl) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) { - response, err := s.gamePrizeProcessor.GetAvailablePrizes(ctx, gameID) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelsToResponses(response), nil -} - -// Game Play Service Methods -func (s *GamificationServiceImpl) CreateGamePlay(ctx context.Context, req *contract.CreateGamePlayRequest) (*contract.GamePlayResponse, error) { - modelReq := transformer.CreateGamePlayRequestToModel(req) - response, err := s.gamePlayProcessor.CreateGamePlay(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.GamePlayModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGamePlay(ctx context.Context, id uuid.UUID) (*contract.GamePlayResponse, error) { - response, err := s.gamePlayProcessor.GetGamePlay(ctx, id) - if err != nil { - return nil, err - } - return transformer.GamePlayModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) ListGamePlays(ctx context.Context, query *contract.ListGamePlaysRequest) (*contract.PaginatedGamePlaysResponse, error) { - modelQuery := transformer.ListGamePlaysRequestToModel(query) - response, err := s.gamePlayProcessor.ListGamePlays(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedGamePlaysResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) PlayGame(ctx context.Context, req *contract.PlayGameRequest) (*contract.PlayGameResponse, error) { - modelReq := transformer.PlayGameRequestToModel(req) - response, err := s.gamePlayProcessor.PlayGame(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.PlayGameModelToResponse(response), nil -} - // Omset Tracker Service Methods func (s *GamificationServiceImpl) CreateOmsetTracker(ctx context.Context, req *contract.CreateOmsetTrackerRequest) (*contract.OmsetTrackerResponse, error) { modelReq := transformer.CreateOmsetTrackerRequestToModel(req) diff --git a/internal/service/reward_service.go b/internal/service/reward_service.go deleted file mode 100644 index e1960b6..0000000 --- a/internal/service/reward_service.go +++ /dev/null @@ -1,198 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" -) - -type RewardService interface { - CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) - GetReward(ctx context.Context, id string) (*models.RewardResponse, error) - ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) - UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) - DeleteReward(ctx context.Context, id string) error - UpdateRewardStock(ctx context.Context, id string, newStock int) error - GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) -} - -type rewardService struct { - rewardProcessor processor.RewardProcessor -} - -func NewRewardService(rewardProcessor processor.RewardProcessor) RewardService { - return &rewardService{ - rewardProcessor: rewardProcessor, - } -} - -func (s *rewardService) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) { - // Validate reward type - if err := s.validateRewardType(req.RewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - // Validate cost points - if req.CostPoints <= 0 { - return nil, fmt.Errorf("cost points must be greater than 0") - } - - // Validate stock for physical rewards - if req.RewardType == "PHYSICAL" && req.Stock != nil && *req.Stock <= 0 { - return nil, fmt.Errorf("physical rewards must have positive stock or unlimited stock") - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return nil, fmt.Errorf("max per customer must be greater than 0") - } - - // Create reward - response, err := s.rewardProcessor.CreateReward(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to create reward: %w", err) - } - - return response, nil -} - -func (s *rewardService) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) { - if id == "" { - return nil, fmt.Errorf("reward ID is required") - } - - response, err := s.rewardProcessor.GetReward(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get reward: %w", err) - } - - return response, nil -} - -func (s *rewardService) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) { - // Set default pagination - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - // Validate reward type filter if provided - if req.RewardType != "" { - if err := s.validateRewardType(req.RewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - } - - // Validate points range if provided - if req.MinPoints != nil && req.MaxPoints != nil { - if *req.MinPoints > *req.MaxPoints { - return nil, fmt.Errorf("min points cannot be greater than max points") - } - } - - response, err := s.rewardProcessor.ListRewards(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to list rewards: %w", err) - } - - return response, nil -} - -func (s *rewardService) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) { - if req.ID.String() == "" { - return nil, fmt.Errorf("reward ID is required") - } - - // Validate reward type - if err := s.validateRewardType(req.RewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - // Validate cost points - if req.CostPoints <= 0 { - return nil, fmt.Errorf("cost points must be greater than 0") - } - - // Validate stock for physical rewards - if req.RewardType == "PHYSICAL" && req.Stock != nil && *req.Stock <= 0 { - return nil, fmt.Errorf("physical rewards must have positive stock or unlimited stock") - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return nil, fmt.Errorf("max per customer must be greater than 0") - } - - response, err := s.rewardProcessor.UpdateReward(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to update reward: %w", err) - } - - return response, nil -} - -func (s *rewardService) DeleteReward(ctx context.Context, id string) error { - if id == "" { - return fmt.Errorf("reward ID is required") - } - - err := s.rewardProcessor.DeleteReward(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete reward: %w", err) - } - - return nil -} - -func (s *rewardService) UpdateRewardStock(ctx context.Context, id string, newStock int) error { - if id == "" { - return fmt.Errorf("reward ID is required") - } - - if newStock < 0 { - return fmt.Errorf("stock cannot be negative") - } - - err := s.rewardProcessor.UpdateRewardStock(ctx, id, newStock) - if err != nil { - return fmt.Errorf("failed to update reward stock: %w", err) - } - - return nil -} - -func (s *rewardService) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) { - if rewardType == "" { - return nil, fmt.Errorf("reward type is required") - } - - // Validate reward type - if err := s.validateRewardType(rewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - rewards, err := s.rewardProcessor.GetRewardsByType(ctx, rewardType) - if err != nil { - return nil, fmt.Errorf("failed to get rewards by type: %w", err) - } - - return rewards, nil -} - -func (s *rewardService) validateRewardType(rewardType string) error { - validTypes := []string{"VOUCHER", "PHYSICAL", "DIGITAL", "BALANCE"} - for _, validType := range validTypes { - if rewardType == validType { - return nil - } - } - return fmt.Errorf("invalid reward type: %s. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE", rewardType) -} diff --git a/internal/service/spin_game_service.go b/internal/service/spin_game_service.go deleted file mode 100644 index 42390c2..0000000 --- a/internal/service/spin_game_service.go +++ /dev/null @@ -1,80 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - - "github.com/google/uuid" -) - -type SpinGameService interface { - PlaySpinGame(ctx context.Context, customerID string, req *models.SpinGameRequest) (*models.SpinGameResponse, error) -} - -type spinGameService struct { - gamePlayProcessor *processor.GamePlayProcessor -} - -func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService { - return &spinGameService{ - gamePlayProcessor: gamePlayProcessor, - } -} - -func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, req *models.SpinGameRequest) (*models.SpinGameResponse, error) { - // Validate customer ID - if customerID == "" { - return nil, fmt.Errorf("customer ID is required") - } - - // Parse spin ID to UUID - spinID, err := uuid.Parse(req.SpinID) - if err != nil { - return nil, fmt.Errorf("invalid spin ID format: %w", err) - } - - // Parse customer ID to UUID - customerUUID, err := uuid.Parse(customerID) - if err != nil { - return nil, fmt.Errorf("invalid customer ID format: %w", err) - } - - // PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin. - playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{ - GameID: spinID, - CustomerID: customerUUID, - }) - if err != nil { - return nil, fmt.Errorf("failed to play spin game: %w", err) - } - - // Convert prize to customer-facing format - var customerPrize *models.CustomerGamePrizeResponse - if playResponse.PrizeWon != nil { - customerPrize = &models.CustomerGamePrizeResponse{ - ID: playResponse.PrizeWon.ID, - GameID: playResponse.PrizeWon.GameID, - Name: playResponse.PrizeWon.Name, - Image: playResponse.PrizeWon.Image, - Metadata: &playResponse.PrizeWon.Metadata, - CreatedAt: playResponse.PrizeWon.CreatedAt, - UpdatedAt: playResponse.PrizeWon.UpdatedAt, - } - } - - // Convert to spin game response - response := &models.SpinGameResponse{ - Status: "SUCCESS", - Message: "Spin game completed successfully.", - Data: &models.SpinGameResponseData{ - GamePlay: playResponse.GamePlay, - PrizeWon: customerPrize, - CoinsRemaining: playResponse.CoinsRemaining, - }, - } - - return response, nil -} diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index 8c17e3c..1a85459 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -58,274 +58,6 @@ func PaginatedTiersResponseToContract(model *models.PaginatedResponse[models.Tie } } -// Game Transformers -func CreateGameRequestToModel(req *contract.CreateGameRequest) *models.CreateGameRequest { - return &models.CreateGameRequest{ - Name: req.Name, - Type: req.Type, - IsActive: req.IsActive, - Metadata: req.Metadata, - } -} - -func UpdateGameRequestToModel(req *contract.UpdateGameRequest) *models.UpdateGameRequest { - return &models.UpdateGameRequest{ - Name: req.Name, - Type: req.Type, - IsActive: req.IsActive, - Metadata: req.Metadata, - } -} - -func ListGamesRequestToModel(req *contract.ListGamesRequest) *models.ListGamesQuery { - return &models.ListGamesQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - Type: req.Type, - IsActive: req.IsActive, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func GameModelToResponse(model *models.GameResponse) *contract.GameResponse { - if model == nil { - return nil - } - - return &contract.GameResponse{ - ID: model.ID, - Name: model.Name, - Type: model.Type, - IsActive: model.IsActive, - Metadata: model.Metadata, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - } -} - -func GameModelsToResponses(models []models.GameResponse) []contract.GameResponse { - responses := make([]contract.GameResponse, len(models)) - for i, model := range models { - responses[i] = *GameModelToResponse(&model) - } - return responses -} - -func PaginatedGamesResponseToContract(model *models.PaginatedResponse[models.GameResponse]) *contract.PaginatedGamesResponse { - responses := make([]contract.GameResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *GameModelToResponse(&item) - } - - return &contract.PaginatedGamesResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - -// Game Prize Transformers -func CreateGamePrizeRequestToModel(req *contract.CreateGamePrizeRequest) *models.CreateGamePrizeRequest { - return &models.CreateGamePrizeRequest{ - GameID: req.GameID, - Name: req.Name, - Weight: req.Weight, - Stock: req.Stock, - MaxStock: req.MaxStock, - Threshold: req.Threshold, - FallbackPrizeID: req.FallbackPrizeID, - Metadata: req.Metadata, - } -} - -func UpdateGamePrizeRequestToModel(req *contract.UpdateGamePrizeRequest) *models.UpdateGamePrizeRequest { - return &models.UpdateGamePrizeRequest{ - Name: req.Name, - Weight: req.Weight, - Stock: req.Stock, - MaxStock: req.MaxStock, - Threshold: req.Threshold, - FallbackPrizeID: req.FallbackPrizeID, - Metadata: req.Metadata, - } -} - -func ListGamePrizesRequestToModel(req *contract.ListGamePrizesRequest) *models.ListGamePrizesQuery { - return &models.ListGamePrizesQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - GameID: req.GameID, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func GamePrizeModelToResponse(model *models.GamePrizeResponse) *contract.GamePrizeResponse { - if model == nil { - return nil - } - - var game *contract.GameResponse - if model.Game != nil { - game = GameModelToResponse(model.Game) - } - - var fallbackPrize *contract.GamePrizeResponse - if model.FallbackPrize != nil { - fallbackPrize = GamePrizeModelToResponse(model.FallbackPrize) - } - - return &contract.GamePrizeResponse{ - ID: model.ID, - GameID: model.GameID, - Name: model.Name, - Weight: model.Weight, - Stock: model.Stock, - MaxStock: model.MaxStock, - Threshold: model.Threshold, - FallbackPrizeID: model.FallbackPrizeID, - Metadata: model.Metadata, - Game: game, - FallbackPrize: fallbackPrize, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - } -} - -func GamePrizeModelsToResponses(models []models.GamePrizeResponse) []contract.GamePrizeResponse { - responses := make([]contract.GamePrizeResponse, len(models)) - for i, model := range models { - responses[i] = *GamePrizeModelToResponse(&model) - } - return responses -} - -func PaginatedGamePrizesResponseToContract(model *models.PaginatedResponse[models.GamePrizeResponse]) *contract.PaginatedGamePrizesResponse { - responses := make([]contract.GamePrizeResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *GamePrizeModelToResponse(&item) - } - - return &contract.PaginatedGamePrizesResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - -// Game Play Transformers -func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.CreateGamePlayRequest { - return &models.CreateGamePlayRequest{ - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: req.CoinsUsed, - RandomSeed: req.RandomSeed, - } -} - -func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameRequest { - return &models.PlayGameRequest{ - GameID: req.GameID, - CustomerID: req.CustomerID, - } -} - -func ListGamePlaysRequestToModel(req *contract.ListGamePlaysRequest) *models.ListGamePlaysQuery { - return &models.ListGamePlaysQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - GameID: req.GameID, - CustomerID: req.CustomerID, - PrizeID: req.PrizeID, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayResponse { - if model == nil { - return nil - } - - var game *contract.GameResponse - if model.Game != nil { - game = GameModelToResponse(model.Game) - } - - var customer *contract.CustomerResponse - if model.Customer != nil { - customer = CustomerModelToResponse(model.Customer) - } - - var prize *contract.GamePrizeResponse - if model.Prize != nil { - prize = GamePrizeModelToResponse(model.Prize) - } - - return &contract.GamePlayResponse{ - ID: model.ID, - GameID: model.GameID, - CustomerID: model.CustomerID, - PrizeID: model.PrizeID, - CoinsUsed: model.CoinsUsed, - RandomSeed: model.RandomSeed, - CreatedAt: model.CreatedAt, - Game: game, - Customer: customer, - Prize: prize, - } -} - -func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameResponse { - if model == nil { - return nil - } - - var gamePlay *contract.GamePlayResponse - if &model.GamePlay != nil { - gamePlay = GamePlayModelToResponse(&model.GamePlay) - } - - var prizeWon *contract.GamePrizeResponse - if model.PrizeWon != nil { - prizeWon = GamePrizeModelToResponse(model.PrizeWon) - } - - var gamePlayValue contract.GamePlayResponse - if gamePlay != nil { - gamePlayValue = *gamePlay - } - - return &contract.PlayGameResponse{ - GamePlay: gamePlayValue, - PrizeWon: prizeWon, - CoinsRemaining: model.CoinsRemaining, - } -} - -func PaginatedGamePlaysResponseToContract(model *models.PaginatedResponse[models.GamePlayResponse]) *contract.PaginatedGamePlaysResponse { - responses := make([]contract.GamePlayResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *GamePlayModelToResponse(&item) - } - - return &contract.PaginatedGamePlaysResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - // Omset Tracker Transformers func CreateOmsetTrackerRequestToModel(req *contract.CreateOmsetTrackerRequest) *models.CreateOmsetTrackerRequest { return &models.CreateOmsetTrackerRequest{ @@ -366,11 +98,6 @@ func OmsetTrackerModelToResponse(model *models.OmsetTrackerResponse) *contract.O return nil } - var game *contract.GameResponse - if model.Game != nil { - game = GameModelToResponse(model.Game) - } - return &contract.OmsetTrackerResponse{ ID: model.ID, PeriodType: model.PeriodType, @@ -378,7 +105,6 @@ func OmsetTrackerModelToResponse(model *models.OmsetTrackerResponse) *contract.O PeriodEnd: model.PeriodEnd, Total: model.Total, GameID: model.GameID, - Game: game, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, } diff --git a/internal/validator/gamification_validator.go b/internal/validator/gamification_validator.go index e0fc023..b5c8493 100644 --- a/internal/validator/gamification_validator.go +++ b/internal/validator/gamification_validator.go @@ -14,21 +14,6 @@ type GamificationValidator interface { ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string) ValidateListTiersRequest(req *contract.ListTiersRequest) (error, string) - // Games - ValidateCreateGameRequest(req *contract.CreateGameRequest) (error, string) - ValidateUpdateGameRequest(req *contract.UpdateGameRequest) (error, string) - ValidateListGamesRequest(req *contract.ListGamesRequest) (error, string) - - // Game Prizes - ValidateCreateGamePrizeRequest(req *contract.CreateGamePrizeRequest) (error, string) - ValidateUpdateGamePrizeRequest(req *contract.UpdateGamePrizeRequest) (error, string) - ValidateListGamePrizesRequest(req *contract.ListGamePrizesRequest) (error, string) - - // Game Plays - ValidateCreateGamePlayRequest(req *contract.CreateGamePlayRequest) (error, string) - ValidateListGamePlaysRequest(req *contract.ListGamePlaysRequest) (error, string) - ValidatePlayGameRequest(req *contract.PlayGameRequest) (error, string) - // Omset Tracker ValidateCreateOmsetTrackerRequest(req *contract.CreateOmsetTrackerRequest) (error, string) ValidateUpdateOmsetTrackerRequest(req *contract.UpdateOmsetTrackerRequest) (error, string) @@ -109,206 +94,6 @@ func (v *GamificationValidatorImpl) ValidateListTiersRequest(req *contract.ListT return nil, "" } -// Game Validators -func (v *GamificationValidatorImpl) ValidateCreateGameRequest(req *contract.CreateGameRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != "" { - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validGameTypes, req.Type) { - return errors.New("invalid game type"), "INVALID_GAME_TYPE" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateUpdateGameRequest(req *contract.UpdateGameRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != nil && *req.Name != "" { - *req.Name = strings.TrimSpace(*req.Name) - if *req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(*req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - if req.Type != nil { - validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validGameTypes, *req.Type) { - return errors.New("invalid game type"), "INVALID_GAME_TYPE" - } - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListGamesRequest(req *contract.ListGamesRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - if req.Type != "" { - validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validGameTypes, req.Type) { - return errors.New("invalid game type"), "INVALID_GAME_TYPE" - } - } - - return nil, "" -} - -// Game Prize Validators -func (v *GamificationValidatorImpl) ValidateCreateGamePrizeRequest(req *contract.CreateGamePrizeRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != "" { - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - if req.Weight <= 0 { - return errors.New("weight must be greater than 0"), "INVALID_WEIGHT" - } - - if req.Stock < 0 { - return errors.New("stock cannot be negative"), "INVALID_STOCK" - } - - if req.MaxStock != nil && *req.MaxStock <= 0 { - return errors.New("max stock must be greater than 0"), "INVALID_MAX_STOCK" - } - - if req.Threshold != nil && *req.Threshold < 0 { - return errors.New("threshold cannot be negative"), "INVALID_THRESHOLD" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateUpdateGamePrizeRequest(req *contract.UpdateGamePrizeRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != nil && *req.Name != "" { - *req.Name = strings.TrimSpace(*req.Name) - if *req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(*req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - if req.Weight != nil && *req.Weight <= 0 { - return errors.New("weight must be greater than 0"), "INVALID_WEIGHT" - } - - if req.Stock != nil && *req.Stock < 0 { - return errors.New("stock cannot be negative"), "INVALID_STOCK" - } - - if req.MaxStock != nil && *req.MaxStock <= 0 { - return errors.New("max stock must be greater than 0"), "INVALID_MAX_STOCK" - } - - if req.Threshold != nil && *req.Threshold < 0 { - return errors.New("threshold cannot be negative"), "INVALID_THRESHOLD" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListGamePrizesRequest(req *contract.ListGamePrizesRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - return nil, "" -} - -// Game Play Validators -func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract.CreateGamePlayRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.CoinsUsed < 0 { - return errors.New("coins used cannot be negative"), "INVALID_COINS_USED" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListGamePlaysRequest(req *contract.ListGamePlaysRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGameRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - return nil, "" -} - // Omset Tracker Validators func (v *GamificationValidatorImpl) ValidateCreateOmsetTrackerRequest(req *contract.CreateOmsetTrackerRequest) (error, string) { if err := v.validate.Struct(req); err != nil { diff --git a/internal/validator/reward_validator.go b/internal/validator/reward_validator.go deleted file mode 100644 index cb23cdd..0000000 --- a/internal/validator/reward_validator.go +++ /dev/null @@ -1,263 +0,0 @@ -package validator - -import ( - "errors" - "regexp" - "strings" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" -) - -type RewardValidator interface { - ValidateCreateRewardRequest(req *contract.CreateRewardRequest) (error, string) - ValidateUpdateRewardRequest(req *contract.UpdateRewardRequest) (error, string) - ValidateListRewardsRequest(req *contract.ListRewardsRequest) (error, string) - ValidateGetRewardRequest(req *contract.GetRewardRequest) (error, string) - ValidateDeleteRewardRequest(req *contract.DeleteRewardRequest) (error, string) -} - -type RewardValidatorImpl struct{} - -func NewRewardValidator() RewardValidator { - return &RewardValidatorImpl{} -} - -func (v *RewardValidatorImpl) ValidateCreateRewardRequest(req *contract.CreateRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - // Validate name - if strings.TrimSpace(req.Name) == "" { - return errors.New("reward name is required"), constants.ValidationErrorCode - } - if len(req.Name) > 150 { - return errors.New("reward name cannot exceed 150 characters"), constants.ValidationErrorCode - } - - // Validate reward type - if !v.isValidRewardType(req.RewardType) { - return errors.New("invalid reward type. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode - } - - // Validate cost points - if req.CostPoints <= 0 { - return errors.New("cost points must be greater than 0"), constants.ValidationErrorCode - } - - // Validate stock - if req.Stock != nil && *req.Stock < 0 { - return errors.New("stock cannot be negative"), constants.ValidationErrorCode - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return errors.New("max per customer must be greater than 0"), constants.ValidationErrorCode - } - - // Validate TNC if provided - if req.Tnc != nil { - if err := v.validateTermsAndConditions(req.Tnc); err != nil { - return err, constants.ValidationErrorCode - } - } - - // Validate images if provided - if req.Images != nil { - if err := v.validateImages(*req.Images); err != nil { - return err, constants.ValidationErrorCode - } - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateUpdateRewardRequest(req *contract.UpdateRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - // Validate ID - if req.ID.String() == "" { - return errors.New("reward ID is required"), constants.ValidationErrorCode - } - - // Validate name - if strings.TrimSpace(req.Name) == "" { - return errors.New("reward name is required"), constants.ValidationErrorCode - } - if len(req.Name) > 150 { - return errors.New("reward name cannot exceed 150 characters"), constants.ValidationErrorCode - } - - // Validate reward type - if !v.isValidRewardType(req.RewardType) { - return errors.New("invalid reward type. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode - } - - // Validate cost points - if req.CostPoints <= 0 { - return errors.New("cost points must be greater than 0"), constants.ValidationErrorCode - } - - // Validate stock - if req.Stock != nil && *req.Stock < 0 { - return errors.New("stock cannot be negative"), constants.ValidationErrorCode - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return errors.New("max per customer must be greater than 0"), constants.ValidationErrorCode - } - - // Validate TNC if provided - if req.Tnc != nil { - if err := v.validateTermsAndConditions(req.Tnc); err != nil { - return err, constants.ValidationErrorCode - } - } - - // Validate images if provided - if req.Images != nil { - if err := v.validateImages(*req.Images); err != nil { - return err, constants.ValidationErrorCode - } - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateListRewardsRequest(req *contract.ListRewardsRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - // Validate pagination - if req.Page < 1 { - return errors.New("page must be greater than 0"), constants.ValidationErrorCode - } - if req.Limit < 1 { - return errors.New("limit must be greater than 0"), constants.ValidationErrorCode - } - if req.Limit > 100 { - return errors.New("limit cannot exceed 100"), constants.ValidationErrorCode - } - - // Validate reward type filter if provided - if req.RewardType != "" && !v.isValidRewardType(req.RewardType) { - return errors.New("invalid reward type filter. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode - } - - // Validate points range if provided - if req.MinPoints != nil && req.MaxPoints != nil { - if *req.MinPoints > *req.MaxPoints { - return errors.New("min points cannot be greater than max points"), constants.ValidationErrorCode - } - if *req.MinPoints < 0 { - return errors.New("min points cannot be negative"), constants.ValidationErrorCode - } - if *req.MaxPoints < 0 { - return errors.New("max points cannot be negative"), constants.ValidationErrorCode - } - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateGetRewardRequest(req *contract.GetRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - if req.ID.String() == "" { - return errors.New("reward ID is required"), constants.ValidationErrorCode - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateDeleteRewardRequest(req *contract.DeleteRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - if req.ID.String() == "" { - return errors.New("reward ID is required"), constants.ValidationErrorCode - } - - return nil, "" -} - -func (v *RewardValidatorImpl) isValidRewardType(rewardType string) bool { - validTypes := []string{"VOUCHER", "PHYSICAL", "DIGITAL", "BALANCE"} - return contains(validTypes, rewardType) -} - -func (v *RewardValidatorImpl) validateTermsAndConditions(tnc *contract.TermsAndConditionsStruct) error { - if tnc == nil { - return nil - } - - // Validate expiry days - if tnc.ExpiryDays < 0 { - return errors.New("expiry days cannot be negative") - } - - // Validate sections - if len(tnc.Sections) == 0 { - return errors.New("terms and conditions must have at least one section") - } - - for i, section := range tnc.Sections { - if strings.TrimSpace(section.Title) == "" { - return errors.New("section title is required") - } - if len(section.Rules) == 0 { - return errors.New("section must have at least one rule") - } - for j, rule := range section.Rules { - if strings.TrimSpace(rule) == "" { - return errors.New("rule cannot be empty") - } - if len(rule) > 500 { - return errors.New("rule cannot exceed 500 characters") - } - // Validate rule content (basic validation) - if len(rule) < 10 { - return errors.New("rule must be at least 10 characters long") - } - _ = j // Avoid unused variable warning - } - _ = i // Avoid unused variable warning - } - - return nil -} - -func (v *RewardValidatorImpl) validateImages(images []string) error { - if len(images) == 0 { - return errors.New("at least one image is required") - } - - for i, imgURL := range images { - if strings.TrimSpace(imgURL) == "" { - return errors.New("image URL is required") - } - - // Validate URL format (basic validation) - if !v.isValidURL(imgURL) { - return errors.New("invalid image URL format") - } - - _ = i // Avoid unused variable warning - } - - return nil -} - -func (v *RewardValidatorImpl) isValidURL(url string) bool { - // Basic URL validation regex - urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`) - return urlRegex.MatchString(url) -} diff --git a/migrations/000115_drop_games_is_active.down.sql b/migrations/000115_drop_games_is_active.down.sql new file mode 100644 index 0000000..7d4685c --- /dev/null +++ b/migrations/000115_drop_games_is_active.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE games ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE; +UPDATE games SET is_active = (status = 'ACTIVE'); +CREATE INDEX IF NOT EXISTS idx_games_is_active ON games(is_active); diff --git a/migrations/000115_drop_games_is_active.up.sql b/migrations/000115_drop_games_is_active.up.sql new file mode 100644 index 0000000..e85315a --- /dev/null +++ b/migrations/000115_drop_games_is_active.up.sql @@ -0,0 +1,4 @@ +-- games.status replaced is_active (docs/rfc-enakgame.md §5.1, EG-1003). Nothing reads +-- it since the old game flow was removed (EG-1002). Its index goes with it. +-- metadata stays: the old games' coin_cost was copied into entry_cost by 000103. +ALTER TABLE games DROP COLUMN IF EXISTS is_active; -- 2.54.0 From 1bb071aec62a2c67a6f05227c6ccd972e3ecf63a Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 8 Oct 2026 11:09:57 +0700 Subject: [PATCH 04/12] refactor(vouchers): move voucher routes out of /enakgame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vouchers are what EnakPoint is redeemed for, wherever it came from, so they are not part of EnakGame. Their only link to it is the budget attribution, which does not change. - Admin: /marketing/enakgame/vouchers... -> /marketing/vouchers... - Customer: /customer/enakgame/vouchers -> /customer/vouchers, /customer/enakgame/vouchers/:id/redeem -> /customer/vouchers/:id/redeem, /customer/enakgame/redemptions -> /customer/vouchers/redemptions Roles, handlers and logic stay the same. No client calls these endpoints yet. RFC §7.4 and §11 updated. Co-Authored-By: Claude Opus 5.5 --- docs/api-enakpoint.md | 377 ----------- docs/backoffice-enakpoint.md | 299 --------- docs/enakgame-spin.md | 86 --- docs/integration-enakpoint.md | 548 ---------------- docs/mobile-customer-enakpoint.md | 583 ------------------ docs/rfc-enakgame.md | 18 +- internal/contract/enakgame_contract.go | 2 +- internal/handler/enakgame_admin_handler.go | 14 +- internal/handler/enakgame_customer_handler.go | 6 +- internal/handler/enakgame_db_test.go | 28 +- internal/router/router.go | 28 +- 11 files changed, 56 insertions(+), 1933 deletions(-) delete mode 100644 docs/api-enakpoint.md delete mode 100644 docs/backoffice-enakpoint.md delete mode 100644 docs/enakgame-spin.md delete mode 100644 docs/integration-enakpoint.md delete mode 100644 docs/mobile-customer-enakpoint.md diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md deleted file mode 100644 index 516ea96..0000000 --- a/docs/api-enakpoint.md +++ /dev/null @@ -1,377 +0,0 @@ -# API EnakPoint & EnakCoin - -30 Sep 2026 - -Semua endpoint EnakPoint (`POINT`, hanya untuk ditukar ke voucher) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat. - -> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus, karena EnakPoint sekarang hanya bisa ditukar ke voucher: tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus ada di Referensi → Endpoint dan field yang dihapus. - -## Konvensi umum - -| Klien | Autentikasi | Prefix | -| --- | --- | --- | -| Customer app / self-order | `Authorization: Bearer ` | `/api/v1/customer` | -| POS | Token user (kasir/manager) | `/api/v1` | -| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` | - -**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan. - -| `code` | HTTP | Arti | -| --- | --- | --- | -| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | -| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) | -| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain | -| `429` | 429 | OTP diminta ulang terlalu cepat | -| `PIN_NOT_SET` | 403 | Customer belum membuat PIN | -| `PIN_INVALID` | 400 | PIN salah | -| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah | -| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN | -| `900` | 500 | Kesalahan server | - -**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit. - -**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. - -**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya. - -## Customer app: saldo & riwayat - -| Method | Path | Keterangan | -| --- | --- | --- | -| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir | -| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter | -| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal | -| PUT | `/customer/devices` | Daftarkan token FCM device | -| DELETE | `/customer/devices/:device_id` | Hapus device saat logout | -| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `earns_points`, `earns_coins` | -| GET | `/customer/orders` | Riwayat order customer (`page`, `limit`), dengan `points_earned` / `coins_earned` | -| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint/EnakCoin yang didapat; order customer lain → `404` | - -Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di [`mobile-customer-enakpoint.md`](./mobile-customer-enakpoint.md) §4.4–§4.5. - -### GET /customer/wallet - -```json -{ - "point_balance": 12500, - "coin_balance": 8, - "point_value": 1, - "point_discount_value": 12500, - "nearest_expiring": { - "point": { "amount": 150, "date": "2026-12-31" }, - "coin": null - }, - "recent_transactions": [ "… sama seperti item riwayat …" ] -} -``` - -- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang. -- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang. -- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa. - -### GET /customer/wallet/transactions - -| Query | Tipe | Keterangan | -| --- | --- | --- | -| `page` | int | Default 1 | -| `limit` | int | 1–100, default 20 | -| `currency` | `POINT` \| `COIN` | Opsional | -| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,TRANSFER_IN` | -| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif | - -```json -{ - "data": [ - { - "id": "…", - "currency": "POINT", - "type": "EARN", - "amount": 875, - "balance_after": 12500, - "description": "Belanja #ORD-0123 di Outlet Kemang", - "source": { "type": "ORDER", "id": "…" }, - "outlet_id": "…", - "group_id": null, - "expires_at": "2026-12-31T23:59:59+07:00", - "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], - "created_at": "2026-09-30T12:01:00Z" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } -} -``` - -`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi. - -### GET /customer/wallet/expiring - -```json -{ - "point": [ - { "amount": 150, "date": "2026-10-31" }, - { "amount": 200, "date": "2026-12-31" } - ], - "coin": [] -} -``` - -Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa. - -### PUT /customer/devices - -```json -{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } -``` - -Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`. - -## Customer app: PIN - -PIN 6 digit wajib untuk exchange dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu. - -| Method | Path | Body | Response | -| --- | --- | --- | --- | -| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` | -| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` | -| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | -| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN | -| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | - -1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer. -2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; exchange tetap bisa. -3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama. - -PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum. - -## Customer app: exchange, transfer, game - -| Method | Path | PIN | Idempotency-Key | -| --- | --- | --- | --- | -| GET | `/customer/wallet/exchange/preview?coins=` | – | – | -| POST | `/customer/wallet/exchange` | Ya | Wajib | -| GET | `/customer/wallet/transfer/recipient?phone=` | – | – | -| POST | `/customer/wallet/transfer` | Ya | Wajib | -| POST | `/customer/enakgame/sessions` | – | Wajib | - -### GET /customer/wallet/exchange/preview?coins=30 - -```json -{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } -``` - -Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`. - -### POST /customer/wallet/exchange - -Body `{ "coins": 30, "pin": "482913" }`. Response: - -```json -{ - "group_id": "…", - "coins": 30, - "points": 9, - "coin_amount": 10, - "point_amount": 3, - "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], - "coin_balance": 5, - "point_balance": 9, - "replayed": false -} -``` - -`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`). - -### GET /customer/wallet/transfer/recipient?phone=081234561234 - -```json -{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } -``` - -Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`. - -### POST /customer/wallet/transfer - -Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response: - -```json -{ - "group_id": "…", - "currency": "POINT", - "amount": 120, - "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, - "lots": [ - { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, - { "amount": 20, "expires_at": null } - ], - "balance": 30, - "replayed": false -} -``` - -`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`. - -### Game (EnakGame) - -`POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`: - -- `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda). -- `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`. -- `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar). - -Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md). - -## POS: earning, void, dan refund - -EnakPoint bukan payment method: tidak ada lagi tipe `point`, dan `POST /payments` memakai `amount` seperti pembayaran lain. Response order membawa `points_earned` dan `coins_earned` untuk struk. Saat order di-void atau direfund, EnakPoint dan EnakCoin yang didapat dari order itu ikut ditarik (`EARN_REVERSAL`); bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan. - -## Dashboard - -Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md). - -| Method | Path | Keterangan | -| --- | --- | --- | -| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning EnakPoint dan EnakCoin per outlet | -| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) | -| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) | -| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli | -| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual | -| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir | -| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer | -| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) | - -Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak. - -### /outlets/:outlet_id/loyalty-settings - -```json -{ - "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } -} -``` - -Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal. Objek `point_payment` sudah dihapus; `PUT` yang masih mengirimnya ditolak `310` (field tidak dikenal). - -### /marketing/loyalty-settings - -```json -{ - "point_value": 1, - "exchange": { "coin_amount": 1, "point_amount": 1 }, - "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, - "point_expiry": { - "enabled": false, - "mode": "FIXED_DATE", - "fixed_dates": ["12-31"], - "grace_months": 3, - "period": 12, - "unit": "MONTH", - "end_of_month": false, - "reminder_days": 7 - }, - "coin_expiry": { "…": "sama dengan point_expiry" }, - "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } -} -``` - -| Field kedaluwarsa | Dipakai mode | Nilai | -| --- | --- | --- | -| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) | -| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak | -| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya | -| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` | -| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan | -| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat | - -Response menambahkan: - -- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs. -- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). -- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal. -- `changes` dan `dry_run`. - -### POST /marketing/customers/:id/wallet/adjust - -```json -{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } -``` - -`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. - -### GET /marketing/wallet-transactions/:id/trace - -```json -{ - "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "TRANSFER_OUT", "amount": -30, "…": "…" }, - "lots": [ - { - "amount": 30, - "chain": [ - { "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } }, - { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } } - ] - } - ] -} -``` - -Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer atau exchange sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`. - -### PIN customer - -`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat. - -## Referensi - -### Tipe mutasi (`type`) - -| `type` | Arah | Arti | `source` / `destination` | -| --- | --- | --- | --- | -| `EARN` | + | Didapat dari order lunas | `ORDER` | -| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | -| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) | -| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) | -| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) | -| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) | -| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` | -| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | -| `ADJUSTMENT` | + / − | Koreksi admin | `USER` | -| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | - -### Notifikasi push (FCM) - -Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`. - -| `data.type` | Kapan | Isi `data` lainnya | -| --- | --- | --- | -| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | -| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` | -| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | -| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | - -### Endpoint dan field deprecated - -Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`. - -| Lama | Pengganti | -| --- | --- | -| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | -| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | - -### Endpoint dan field yang dihapus - -Bayar dengan EnakPoint dihapus pada 7 Okt 2026 karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya; jangan dipanggil lagi. - -| Dihapus | Catatan | -| --- | --- | -| `POST /customer/wallet/payment-code` | Kode bayar untuk kasir | -| `POST /customer/orders/:id/pay-with-points` | Bayar order dari app / self-order | -| `GET /orders/:id/point-payment/preview` | Batas pembayaran EnakPoint di POS | -| Payment method tipe `point`; field `points` dan `payment_code` di `POST /payments` | `amount` kembali wajib seperti pembayaran lain | -| `points_used`, `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id` | – | -| `accepts_point_payment` di `GET /customer/outlets` | – | -| `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di `/outlets/:outlet_id/loyalty-settings` | `PUT` yang masih mengirimnya ditolak `310` | -| `summary.point_amount`, `summary.points_used`, `summary.total_with_points`, serta `points_used` dan `counts_as_cash_in` per baris di analytics payment method | `summary.total_amount` kembali total semua method; persentase dihitung dari total itu | -| Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` | Tidak ditulis lagi | - -Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md). diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md deleted file mode 100644 index d188868..0000000 --- a/docs/backoffice-enakpoint.md +++ /dev/null @@ -1,299 +0,0 @@ -# Backoffice EnakPoint & EnakCoin - -30 Sep 2026 - -Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game. - -> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint sudah dihapus karena EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Akibatnya setting outlet tidak lagi punya `point_payment`, method "EnakPoint" (tipe `point`) tidak ada lagi di Payment Method, dan laporan per payment method tidak lagi membawa `point_amount`, `points_used`, `total_with_points`, atau `counts_as_cash_in`; `summary.total_amount` kembali total semua method. - -## Layar yang perlu dibuat - -Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`. - -| Layar | Endpoint | Tempat di menu | -| --- | --- | --- | -| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail outlet → tab Loyalitas | -| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan | -| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat | -| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet | -| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet | -| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan | -| Biaya main game | `entry_cost` di `/marketing/enakgame/games` | Marketing → EnakGame → game | - -Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada. - -**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan. - -**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error). - -## Setting loyalitas outlet - -Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order. Semua nilai default mati sampai owner menyalakannya. - -`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak (termasuk `point_payment` yang sudah dihapus). - -```json -{ - "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } -} -``` - -| Field | Label usulan | Tipe | Default | Validasi | -| --- | --- | --- | --- | --- | -| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – | -| `earn_mode` | Cara hitung: per nominal / persentase | pilihan `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya | -| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 | -| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 | -| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal | -| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 | -| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 | - -**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1). - -**Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint. - -**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak. - -Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". - -## Setting loyalitas organisasi - -Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku sama untuk semua outlet, jadi diatur sekali per organisasi. Mengubah nilai EnakPoint atau kurs langsung mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya sebelum disimpan. - -```json -{ - "point_value": 1, - "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" }, - "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } -} -``` - -| Field | Label usulan | Default | Validasi | -| --- | --- | --- | --- | -| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 | -| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 | -| `transfer.enabled` | Izinkan transfer antar customer | aktif | – | -| `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 - -1. Owner mengubah form. -2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek yang diubah. Tidak ada yang tersimpan. -3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti. -4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs berubah), dan `expiry_activations` (bila ada, lihat bagian kedaluwarsa). -5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`. - -### Dialog dampak - -`impact` berisi saldo beredar organisasi dan nilainya sebelum/sesudah: - -| Field `impact` | Tampilkan sebagai | -| --- | --- | -| `outstanding_points` | EnakPoint beredar | -| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … | -| `outstanding_coins` | EnakCoin beredar | -| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint | -| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … | - -Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: exchange yang sudah terjadi memakai kurs saat itu. - -## Pengaturan kedaluwarsa - -Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin (`coin_expiry`) dengan salah satu dari dua model; defaultnya mati, dan bila dinyalakan defaultnya hangus setiap 31 Desember. - -```json -"point_expiry": { - "enabled": true, - "mode": "FIXED_DATE", - "fixed_dates": ["12-31"], - "grace_months": 3, - "period": 12, - "unit": "MONTH", - "end_of_month": false, - "reminder_days": 7 -} -``` - -| Field | Tampil saat | Label usulan | Validasi | -| --- | --- | --- | --- | -| `enabled` | selalu | Saldo bisa kedaluwarsa | – | -| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` | -| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, format `MM-DD`, `02-29` ditolak | -| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 | -| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` | -| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – | -| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat | - -**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama, mis. 31 Desember, atau 30 Juni dan 31 Desember untuk dua kali setahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya, jadi saldo yang didapat 1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Untuk input `fixed_dates`, pakai pemilih tanggal+bulan tanpa tahun. - -**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk, mis. 12 bulan. Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027. - -**Preview.** Response `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: kapan saldo yang didapat sekarang akan kedaluwarsa (`null` = tidak). Tampilkan di bawah form: "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026." Karena dihitung dari nilai yang dikirim, dry run bisa dipakai untuk memperbarui preview saat owner mengubah pilihan. - -**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal, dengan masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode sejak hari ini (`ROLLING`). Dry run mengembalikan `expiry_activations`; tampilkan di dialog konfirmasi dengan kalimat tegas, mis. "1.250.000 EnakPoint milik customer yang ada sekarang akan kedaluwarsa pada 31 Des 2027. Tindakan ini tidak bisa dibatalkan dengan mematikan kedaluwarsa." - -| Field `expiry_activations[]` | Arti | -| --- | --- | -| `currency` | `POINT` atau `COIN` | -| `lots` | Jumlah paket saldo yang diberi tanggal | -| `amount` | Total saldo yang diberi tanggal | -| `expires_at` | Tanggal kedaluwarsanya | - -**Aturan lain yang perlu dijelaskan di layar:** - -- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya. -- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. -- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya. -- Saldo hangus tanpa kompensasi apa pun. Customer mendapat pengingat push `reminder_days` hari sebelumnya dan notifikasi saat hangus. - -## Wallet customer - -Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke order asalnya. - -### Saldo, lot, dan riwayat - -`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer) - -```json -{ - "customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" }, - "point_balance": 12650, - "coin_balance": 8, - "spendable_point_balance": 12500, - "spendable_coin_balance": 8, - "lots": [ - { "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" } - ], - "transactions": { - "data": [ - { - "id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650, - "description": "Transfer ke An*** (08**-****-5678)", - "destination": { "type": "WALLET_TX", "id": "…" }, - "counterparty": { "id": "…", "name": "Anita Rahma" }, - "created_by": null, "outlet": null, "reason": null, "metadata": {}, - "created_at": "…" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } - } -} -``` - -- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit). -- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`. -- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata` (kurs, rumus earning, shortfall). - -### Adjustment manual - -`POST /marketing/customers/:id/wallet/adjust` - -```json -{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" } -``` - -| Field | Aturan | -| --- | --- | -| `currency` | `POINT` atau `COIN` | -| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi | -| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" | -| `idempotency_key` | Opsional tapi disarankan: buat satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali | - -Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan di dialog bahwa adjustment tidak disertai pembayaran uang, sehingga alasan tidak boleh "pencairan". - -### Telusuri mutasi - -Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-transactions/:id/trace`. - -```json -{ - "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "reference_id": "…", "created_at": "…" }, - "lots": [ - { - "amount": 30, - "chain": [ - { "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } }, - { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } } - ] - } - ] -} -``` - -Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya. - -## PIN, riwayat setting, dan game - -### PIN & keamanan customer - -Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah menghapusnya, misalnya bila customer kehilangan akses, sehingga customer harus membuat PIN baru lewat OTP di aplikasi. - -- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. `reason` wajib. Tampilkan dialog konfirmasi dengan input alasan. -- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan: - -```json -{ - "data": [ - { "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 } -} -``` - -| `event` | Label usulan | -| --- | --- | -| `PIN_SET` | PIN dibuat | -| `PIN_CHANGED` | PIN diganti | -| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) | -| `PIN_FAILED` | PIN salah dimasukkan | -| `PIN_LOCKED` | PIN terkunci 30 menit | -| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) | - -### Riwayat perubahan setting - -`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; tambah `&outlet_id=…` untuk riwayat satu outlet. - -```json -{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" } -``` - -`old_value` `null` berarti sebelumnya masih nilai default. Tampilkan `key` dengan label yang sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint"), dan `changed_by` sebagai nama user. - -### Biaya main game - -Semua game (spin, raffle, minigame) memakai EnakCoin yang sama dan dikelola di `/marketing/enakgame/games`. Menu lama `/marketing/games`, `/marketing/game-prizes`, dan `/marketing/rewards` sudah dihapus. Biaya per main adalah `entry_cost` game (bilangan bulat ≥ 1), hadiahnya diatur di reward config game itu. Langkah membuat spin ada di [`enakgame-spin.md`](./enakgame-spin.md). Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. - -## Pesan error dan checklist - -| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan | -| --- | --- | --- | --- | -| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer | -| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong | `cause` di dekat field atau di toast | -| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" | -| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" | - -Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalty.point.earn_per_amount must be at least 1`. Untuk validasi form, lebih baik cek batasnya di sisi klien (tabel di tiap bagian) dan tampilkan `cause` hanya sebagai cadangan. - -### Checklist rilis - -- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning. -- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi sebelum disimpan. -- [ ] Dialog konfirmasi menampilkan `impact` saat nilai EnakPoint atau kurs berubah. -- [ ] Dialog konfirmasi menampilkan `expiry_activations` saat kedaluwarsa dinyalakan pertama kali. -- [ ] Preview "yang didapat hari ini kedaluwarsa pada …" tampil di bawah pengaturan kedaluwarsa. -- [ ] Wallet customer menampilkan saldo yang bisa dipakai, lot, dan riwayat dengan nama asli. -- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`. -- [ ] Tombol Telusuri ada di setiap baris riwayat. -- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. -- [ ] Form game EnakGame punya input `entry_cost`. -- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". - -Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu. diff --git a/docs/enakgame-spin.md b/docs/enakgame-spin.md deleted file mode 100644 index 75a9d01..0000000 --- a/docs/enakgame-spin.md +++ /dev/null @@ -1,86 +0,0 @@ -# Spin sebagai Game EnakGame - -**Sumber:** [RFC EnakGame](rfc-enakgame.md) §14, task EG-1001 -**Pembaca:** admin organisasi dan tim backoffice / aplikasi customer - -Spin lama (`POST /customer/spin`) diganti dengan game EnakGame biasa: tipe `SPIN`, -reward `PROBABILITY`, hadiah berupa EnakCoin. Spin dimainkan lewat endpoint session -yang sama dengan game lain, sehingga ikut mendapat idempotency, refund otomatis, budget, -event, dan Economy Guard. - -Tidak ada seeder: setiap organisasi membuat spin-nya sendiri lewat API admin di bawah. -Semua langkah memakai token admin organisasi; langkah 2–4 butuh loyalty manager. - -## Langkah Admin - -### 1. Buat game - -`POST /api/v1/marketing/enakgame/games` - -```json -{ - "name": "Spin Harian", - "slug": "spin", - "type": "SPIN", - "entry_cost": 5, - "status": "ACTIVE", - "thumbnail_url": "https://…/spin.png", - "game_url": "https://…/spin/index.html" -} -``` - -`entry_cost` adalah EnakCoin yang dipotong setiap kali spin (minimal 1). - -### 2. Buat reward config `PROBABILITY` - -`POST /api/v1/marketing/enakgame/games/:id/reward-configs` - -```json -{ - "reward_type": "PROBABILITY", - "max_reward": 50, - "rules": { - "table": [ - { "weight": 50, "amount": 0, "label": "Zonk" }, - { "weight": 30, "amount": 3, "label": "3 Coin" }, - { "weight": 15, "amount": 10, "label": "10 Coin" }, - { "weight": 5, "amount": 50, "label": "Jackpot" } - ] - }, - "reason": "Spin pertama" -} -``` - -- Satu baris `table` = satu segmen roda, urut searah gambar roda. -- `weight` bilangan bulat ≥ 1. Peluang segmen = `weight` ÷ total weight (di atas: 50%, - 30%, 15%, 5%). Weight tidak pernah dikirim ke customer. -- `amount` EnakCoin yang didapat (boleh 0). `label` opsional, maksimal 100 karakter, - ditampilkan di roda. -- `max_reward` minimal sebesar `amount` terbesar, kalau tidak hadiah besar terpotong. - -### 3. Aktifkan config - -`POST /api/v1/marketing/enakgame/reward-configs/:id/activate` - -Mengganti hadiah nanti berarti membuat versi config baru lalu mengaktifkannya; session -yang sedang berjalan tetap memakai versi saat dimulai. - -### 4. Pastikan ada budget global bulan berjalan - -`POST /api/v1/marketing/enakgame/budgets` dengan `scope: "GLOBAL"`, bila belum ada. Tanpa -budget global, customer tidak bisa memulai game apa pun. - -## Alur di Aplikasi Customer - -1. `GET /api/v1/customer/enakgame/games`: game spin punya `prizes`, yaitu segmen roda - (`entry`, `label`, `amount`) dalam urutan config. Gambar roda dari sini. -2. `POST /api/v1/customer/enakgame/sessions` dengan `{"game_id": "…"}` dan header - `Idempotency-Key`. Entry cost dipotong di sini. -3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan body `{}`. Server yang - mengundi. Response berisi `prize` (`entry`, `label`, `amount`): putar roda sampai - berhenti di segmen `entry` itu. `reward_total` adalah Coin yang benar-benar masuk, - bisa lebih besar dari `prize.amount` karena event, atau lebih kecil karena limit - harian (`limited_by`). - -Aplikasi tidak boleh mengundi sendiri atau mengirim hadiah: apa pun yang dikirim selain -data hasil diabaikan. diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md deleted file mode 100644 index 80b21ca..0000000 --- a/docs/integration-enakpoint.md +++ /dev/null @@ -1,548 +0,0 @@ -# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard - -**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint -lama tetap jalan sebagai alias (lihat §8) - -Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan -ada di [`prd-point-coin.md`](./prd-point-coin.md). - -> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi -> `000102`). EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai -> sebagai alat bayar dan tidak bisa dicairkan -> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus -> ada di §8. - ---- - -## 1. Konsep inti - -| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | -|---|---|---| -| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin | -| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | -| Bisa ditransfer | Ya | Ya | -| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner | - -Aturan yang berlaku di seluruh dokumen ini: - -1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint". -2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, dan EnakPoint tidak bisa - dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan - Rp …"**, bukan "saldo Rp …". -3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange - dan transfer. Main game tidak butuh PIN. -4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet - organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan - kedaluwarsa diatur per organisasi; earning per outlet. -5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah - dihapus. Koreksi muncul sebagai baris baru. - -### Format response - -Semua endpoint memakai amplop yang sama: - -```json -{ "success": true, "data": { … }, "errors": null } -``` - -```json -{ - "success": false, - "data": null, - "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] -} -``` - -| `code` | HTTP | Arti | -|---|---|---| -| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | -| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya | -| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) | -| `429` | 429 | Terlalu cepat meminta ulang (OTP) | -| `PIN_NOT_SET` | 403 | Customer belum membuat PIN | -| `PIN_INVALID` | 400 | PIN salah | -| `PIN_LOCKED` | 423 | PIN terkunci | -| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | -| `900` | 500 | Kesalahan server | - ---- - -## 2. Customer app — saldo & riwayat - -Semua endpoint customer memakai header `Authorization: Bearer `. - -### 2.1 Saldo - -`GET /api/v1/customer/wallet` - -```json -{ - "point_balance": 12500, - "coin_balance": 8, - "point_value": 1, - "point_discount_value": 12500, - "nearest_expiring": { - "point": { "amount": 150, "date": "2026-12-31" }, - "coin": null - }, - "recent_transactions": [ … ] -} -``` - -- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**. -- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai - "setara potongan Rp 12.500". -- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa. -- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2. - -### 2.2 Riwayat - -`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,TRANSFER_IN&from=2026-09-01&to=2026-09-30` - -Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma. -`from` / `to` tanggal WIB, inklusif. - -```json -{ - "data": [ - { - "id": "…", - "currency": "POINT", - "type": "EARN", - "amount": 875, - "balance_after": 12500, - "description": "Belanja #ORD-0123 di Outlet Kemang", - "source": { "type": "ORDER", "id": "…" }, - "outlet_id": "…", - "expires_at": "2026-12-31T23:59:59+07:00", - "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], - "created_at": "2026-09-30T12:01:00Z" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } -} -``` - -- `amount` bertanda: positif menambah saldo, negatif mengurangi. -- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk - `{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, game play, - dst.). -- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer - berubah belakangan. Nama lawan transfer sudah disamarkan. -- Dua baris exchange atau transfer berbagi `group_id` yang sama. - -| `type` | Arah | Arti | `source` / `destination` | -|---|---|---|---| -| `EARN` | + | Didapat dari order lunas | `ORDER` | -| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | -| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | -| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) | -| `GAME_SPEND` | − | Main game | `GAME_PLAY` | -| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | -| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` | -| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | - -### 2.3 Yang akan kedaluwarsa - -`GET /api/v1/customer/wallet/expiring` - -```json -{ - "point": [ - { "amount": 150, "date": "2026-10-31" }, - { "amount": 200, "date": "2026-12-31" } - ], - "coin": [] -} -``` - -Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai -akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa. - -### 2.4 Notifikasi push (FCM) - -Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token -baru**: - -`PUT /api/v1/customer/devices` - -```json -{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } -``` - -`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil -`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima -notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain -login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis -tidak menerima notifikasi di HP itu lagi. - -Push yang dikirim, dibedakan lewat `data.type`: - -| `data.type` | Kapan | Isi `data` lainnya | -|---|---|---| -| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | -| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, sekali per tanggal | `currency`, `amount`, `expiry_date` | -| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | -| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | - -Semua nilai di `data` berupa string, sesuai aturan FCM. - ---- - -## 3. Customer app — PIN - -PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol -di depan tidak hilang. PIN tidak pernah dikembalikan di response. - -### 3.1 Cek status - -`GET /api/v1/customer/pin/status` - -```json -{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null } -``` - -Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN -(`has_pin: false`), bukan saat registrasi. - -### 3.2 Membuat PIN pertama kali - -1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke - nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`. -2. `POST /api/v1/customer/pin` dengan - `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. - -PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama -(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir -(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat -menghasilkan `429`. - -### 3.3 Mengganti dan mereset PIN - -- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`. -- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu - `POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga - membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; - exchange tetap bisa. - -### 3.4 Menangani error PIN - -Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data` -**tidak** `null`: - -```json -{ - "success": false, - "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, - "errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }] -} -``` - -| `data.code` | Field tambahan | Yang ditampilkan aplikasi | -|---|---|---| -| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) | -| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" | -| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN | -| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" | - -Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang -benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan -reinstall atau ganti HP. - ---- - -## 4. Earning, void, dan refund - -EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan -`POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar, -bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8). - -Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak -menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan -diberikan saat order lunas. - -EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila -saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah -diblokir karena ini. - ---- - -## 5. Exchange EnakCoin → EnakPoint - -Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). - -1. **Preview** sebelum minta PIN: - - `GET /api/v1/customer/wallet/exchange/preview?coins=30` - - ```json - { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } - ``` - - Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`, - atau EnakCoin tidak cukup). - -2. **Tukar:** - - `POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib, - maks. 50 karakter, satu key per percobaan tukar) - - ```json - { "coins": 30, "pin": "482913" } - ``` - - ```json - { - "group_id": "…", - "coins": 30, - "points": 9, - "coin_amount": 10, - "point_amount": 3, - "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], - "coin_balance": 5, - "point_balance": 9, - "replayed": false - } - ``` - -- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum** - PIN dicek, jadi tidak memakan jatah percobaan PIN. -- Exchange tidak bisa dibatalkan; tampilkan konfirmasi. -- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama - dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu. - `Idempotency-Key` yang sama untuk jumlah berbeda ditolak. -- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots` - menunjukkan tanggalnya). - ---- - -## 6. Transfer ke customer lain - -1. **Cek penerima** sebelum konfirmasi: - - `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` - - ```json - { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } - ``` - - Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri, - customer walk-in, atau customer nonaktif dijawab `304`. - -2. **Kirim:** - - `POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib) - - ```json - { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } - ``` - - ```json - { - "group_id": "…", - "currency": "POINT", - "amount": 120, - "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, - "lots": [ - { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, - { "amount": 20, "expires_at": null } - ], - "balance": 30, - "replayed": false - } - ``` - -- `currency`: `POINT` atau `COIN`, satu jenis per transfer. -- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per - transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas - ditolak `304` sebelum PIN dicek. -- Transfer final dan tidak bisa dibatalkan customer. -- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`). - Tampilkan ini ke pengirim. -- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4). -- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama - (`replayed: true`) dan tidak dihitung dua kali terhadap batas harian. - ---- - -## 7. Game - -Game lama (`POST /api/v1/customer/spin`, `GET /customer/games`, -`GET /customer/ferris-wheel`, dan admin `/marketing/games`, `/marketing/game-prizes`, -`/marketing/rewards`) sudah dihapus. Semua game, termasuk spin, sekarang game EnakGame: - -- Customer: `GET /api/v1/customer/enakgame/games`, lalu - `POST /api/v1/customer/enakgame/sessions` (wajib `Idempotency-Key`, memotong - `entry_cost` EnakCoin), lalu `POST /api/v1/customer/enakgame/sessions/:id/complete` - (server menghitung hadiah EnakCoin). Tanpa PIN. -- Dashboard: game dan biaya per main (`entry_cost`, bilangan bulat ≥ 1) diatur di - `/marketing/enakgame/games`, hadiahnya di reward config. - -Langkah admin dan alur aplikasi untuk spin ada di [`enakgame-spin.md`](enakgame-spin.md). - ---- - -## 8. Endpoint lama (deprecated) - -Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi -pindah. Aplikasi baru jangan memakainya. - -| Lama | Ganti dengan | -|---|---| -| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) | -| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | - -Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, -supaya alias ini bisa dihapus. - -Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`, -`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai -`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`. - -Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena -EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). -Tidak ada penggantinya: - -- Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`, - dan `GET /orders/:id/point-payment/preview`. -- Payment method tipe `point`, serta field `points` dan `payment_code` di - `POST /payments`; `amount` kembali wajib seperti pembayaran lain. -- `points_used` dan `point_value` di response pembayaran dan di `payments` pada - `GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`. -- Objek `point_payment` (`accept_payment`, `min_payment_points`, - `max_payment_percent`) di setting outlet (§9.1). -- Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di - `summary`, serta `points_used` dan `counts_as_cash_in` per baris. - `summary.total_amount` kembali total semua method, dan persentase dihitung dari total - itu. -- Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi. - ---- - -## 9. Dashboard - -Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager. - -### 9.1 Pengaturan per outlet - -`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings` - -```json -{ - "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, - "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } -} -``` - -Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih -mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan -`point_value` organisasi dan `point_cashback_percent` -(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value` -pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di -samping setting** supaya owner tidak salah membaca skala: default di atas setara -cashback 1%. - -### 9.2 Pengaturan organisasi - -`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview -tanpa menyimpan) - -```json -{ - "point_value": 1, - "exchange": { "coin_amount": 1, "point_amount": 1 }, - "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, - "point_expiry": { - "enabled": false, - "mode": "FIXED_DATE", - "fixed_dates": ["12-31"], - "grace_months": 3, - "period": 12, - "unit": "MONTH", - "end_of_month": false, - "reminder_days": 7 - }, - "coin_expiry": { … sama … }, - "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } -} -``` - -Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan: - -- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan - `point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan. -- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini - akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang - didapat hari ini kedaluwarsa pada 31 Des 2026". -- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama - kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya - (`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu. -- `changes`: key yang berubah. - -**Kedaluwarsa** diatur per currency dengan salah satu model: - -| `mode` | Cara kerja | Field yang dipakai | -|---|---|---| -| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) | -| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` | - -- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum - hangus (0 = tanpa pengingat). -- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya. -- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal - hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`). -- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. - -Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20` -(tambah `outlet_id=` untuk setting outlet). - -### 9.3 Wallet customer - -- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa - dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer, - admin, kasir, outlet). Query riwayat sama seperti §2.2. -- `POST /api/v1/marketing/customers/:id/wallet/adjust` - - ```json - { "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } - ``` - - `amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak. - Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan". - -- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per - butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer atau - exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar - B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A yang - mentransfer ke B. - -### 9.4 PIN customer - -- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN - bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin - **tidak bisa** membuat, mengganti, atau melihat PIN. -- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan: - `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, - `PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat. - ---- - -## 10. Checklist integrasi - -**Customer app** -- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout. -- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN. -- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang - key yang sama saat retry. -- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang. -- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama. - -**POS** -- [ ] Cetak `points_earned` dan `coins_earned` di struk. -- [ ] Jangan menampilkan EnakPoint sebagai payment method (§4). - -**Dashboard** -- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan - `expiry_activations` sebelum owner menyimpan setting. -- [ ] Buat ulang game (termasuk spin) di `/marketing/enakgame/games` dengan `entry_cost` - dan reward config ([`enakgame-spin.md`](enakgame-spin.md)). diff --git a/docs/mobile-customer-enakpoint.md b/docs/mobile-customer-enakpoint.md deleted file mode 100644 index 3045441..0000000 --- a/docs/mobile-customer-enakpoint.md +++ /dev/null @@ -1,583 +0,0 @@ -# Prompt: fitur EnakPoint & EnakCoin di Mobile App Customer - -Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice). -Tugasmu: membangun fitur loyalitas EnakPoint & EnakCoin di aplikasi, memakai API backend -yang sudah jadi dan dijelaskan di dokumen ini. Jangan mengarang endpoint, field, atau -aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan dulu. - ---- - -## 1. Konteks bisnis - -| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | -|---|---|---| -| Didapat dari | Belanja (order lunas), koreksi admin, tukar EnakCoin | Belanja, koreksi admin | -| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | -| Bisa dikirim ke customer lain | Ya | Ya | -| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan | - -Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin, dan endpoint serta -field bernama token sudah dihapus dari API. - -> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint (kode bayar di kasir, bayar order -> dari app) sudah dihapus dari backend. EnakPoint sekarang hanya bisa ditukar ke -> voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan -> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Jangan membangun layar bayar atau kode -> bayar; endpoint dan field yang dihapus ada di §9. - -### Aturan yang wajib dipatuhi di UI - -1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin. -2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan - Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai, dan - EnakPoint tidak bisa dipakai membayar. -3. **PIN 6 digit wajib** untuk: tukar EnakCoin dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat - tidak butuh PIN. -4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya - nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau - analytics. -5. **Satu akun customer = satu organisasi.** Saldo berlaku di semua outlet organisasi itu. -6. **Waktu memakai WIB.** Tanggal kedaluwarsa berarti saldo masih bisa dipakai sampai - 23:59:59 WIB di tanggal itu. - ---- - -## 2. Koneksi ke API - -- Base URL: `/api/v1` -- Semua endpoint customer: header `Authorization: Bearer ` -- Semua jumlah di request dan response berupa integer. - -### Registrasi customer - -`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional): - -```json -{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" } -``` - -- Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu. -- Bila `organization_id` tidak dikirim dan backend hanya punya satu organisasi, customer - otomatis masuk ke organisasi itu. Bila ada lebih dari satu, registrasi ditolak - ("organization_id is required"), jadi sebaiknya app selalu mengirimnya dari config per - environment/brand. -- `organization_id` yang dikirim harus ada; bila tidak, registrasi ditolak sebelum OTP dikirim. -- Wallet customer baru belum punya baris sampai saldo pertama kali bergerak; - `GET /customer/wallet` tetap menjawab saldo 0. - -### Format response - -Sukses: - -```json -{ "success": true, "data": { … }, "errors": null } -``` - -Gagal: - -```json -{ "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] } -``` - -| `errors[0].code` | HTTP | Arti | Yang dilakukan app | -|---|---|---|---| -| `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum | -| `304` | 400 | Ditolak aturan bisnis | Tampilkan pesan yang ramah (lihat tiap fitur); `cause` berbahasa Inggris, jangan tampilkan mentah | -| `404` | 404 | Tidak ditemukan | Tampilkan "tidak ditemukan" | -| `429` | 429 | Minta OTP terlalu cepat | Tampilkan hitung mundur sebelum boleh minta lagi | -| `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) | -| `PIN_INVALID` | 400 | PIN salah | §6.5 | -| `PIN_LOCKED` | 423 | PIN terkunci | §6.5 | -| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | §6.5 | -| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" | - -### Idempotency-Key - -Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik, maks. -50 karakter, mis. UUID v4). - -- Buat **satu key baru saat customer menekan tombol konfirmasi**. -- Bila request gagal karena jaringan/timeout, **kirim ulang dengan key yang sama**. - Server mengembalikan hasil pertama dengan `"replayed": true` dan tidak memotong saldo - dua kali. -- Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`). - ---- - -## 3. Layar yang perlu dibuat - -| Layar | Endpoint utama | Butuh PIN | -|---|---|---| -| Beranda wallet | `GET /customer/wallet` | – | -| Riwayat mutasi | `GET /customer/wallet/transactions` | – | -| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – | -| Daftar outlet | `GET /customer/outlets` | – | -| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – | -| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya | -| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya | -| PIN (buat, ganti, lupa) | `/customer/pin/*` | – | -| Game | `GET /customer/enakgame/games`, `POST /customer/enakgame/sessions`, `POST …/sessions/:id/complete` | – | -| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – | - ---- - -## 4. Beranda wallet, riwayat, kedaluwarsa - -### 4.1 Beranda — `GET /customer/wallet` - -```json -{ - "point_balance": 12500, - "coin_balance": 8, - "point_value": 1, - "point_discount_value": 12500, - "nearest_expiring": { - "point": { "amount": 150, "date": "2026-12-31" }, - "coin": null - }, - "recent_transactions": [ /* sama dengan item riwayat §4.2, maksimal 5 */ ] -} -``` - -Tampilkan: -- Saldo EnakPoint (`point_balance`) dengan keterangan "setara potongan Rp - {point_discount_value}" (format ribuan Indonesia: `Rp 12.500`). -- Saldo EnakCoin (`coin_balance`). -- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan - kedaluwarsa pada {date}" yang membuka layar §4.3. -- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2. -- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8). - -Muat ulang beranda setelah setiap transaksi dan saat menerima push (§5). - -Field `total_points`, `points_history`, `last_updated` di response ini **deprecated**; -jangan dipakai. - -### 4.2 Riwayat — `GET /customer/wallet/transactions` - -Query (semua opsional): - -| Query | Contoh | Keterangan | -|---|---|---| -| `page` | `1` | Mulai dari 1 | -| `limit` | `20` | 1–100, default 20 | -| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin | -| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter | -| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif | - -```json -{ - "data": [ - { - "id": "…", - "currency": "POINT", - "type": "EARN", - "amount": 875, - "balance_after": 12500, - "description": "Belanja #ORD-0123 di Outlet Kemang", - "source": { "type": "ORDER", "id": "…" }, - "outlet_id": "…", - "group_id": null, - "expires_at": "2026-12-31T23:59:59+07:00", - "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], - "created_at": "2026-09-30T12:01:00Z" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } -} -``` - -Aturan tampilan: -- `amount` bertanda: positif tampil hijau dengan `+`, negatif merah dengan `−`. -- `description` sudah siap tampil (nama lawan transfer sudah disamarkan). Tampilkan apa - adanya. -- Mutasi masuk yang punya `expires_at` menampilkan "Berlaku sampai {tanggal}". -- Infinite scroll memakai `pagination.total_pages`. -- Riwayat tidak pernah berubah atau hilang; koreksi muncul sebagai baris baru. - -Label tipe: - -| `type` | Label | Arah | -|---|---|---| -| `EARN` | Dari belanja | + | -| `EARN_REVERSAL` | Dibatalkan (order di-void/refund) | − | -| `EXCHANGE_OUT` | Ditukar ke EnakPoint | − | -| `EXCHANGE_IN` | Hasil tukar EnakCoin | + | -| `TRANSFER_OUT` | Transfer keluar | − | -| `TRANSFER_IN` | Transfer masuk | + | -| `GAME_SPEND` | Main game | − | -| `EXPIRE` | Kedaluwarsa | − | -| `ADJUSTMENT` | Koreksi | + / − | -| `MIGRATION` | Saldo awal | + | - -### 4.3 Akan kedaluwarsa — `GET /customer/wallet/expiring` - -```json -{ - "point": [ - { "amount": 150, "date": "2026-10-31" }, - { "amount": 200, "date": "2026-12-31" } - ], - "coin": [] -} -``` - -Daftar per tanggal, paling dekat di atas. Daftar kosong: tampilkan "Tidak ada saldo -yang akan kedaluwarsa". Saldo yang kedaluwarsa hangus tanpa kompensasi. - ---- - -### 4.4 Daftar outlet — `GET /customer/outlets` - -Outlet aktif di organisasi customer, tempat saldo EnakPoint & EnakCoin berlaku. Urut -berdasarkan nama. - -```json -[ - { - "id": "…", - "name": "Gokuna Kemang", - "address": "Jl. Kemang Raya 10", - "earns_points": true, - "earns_coins": false - } -] -``` - -- `address` bisa `null`. -- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin. -- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend. - - -### 4.5 Riwayat order — `GET /customer/orders` dan `GET /customer/orders/:id` - -Order milik customer yang login di semua outlet organisasinya, terbaru di atas. Order -hanya masuk ke sini bila kasir mengaitkannya ke customer. - -`GET /api/v1/customer/orders?page=1&limit=20` (`limit` 1–100, default 20): - -```json -{ - "data": [ - { - "id": "…", - "order_number": "ORD-0123", - "outlet_id": "…", - "outlet_name": "Gokuna 1", - "order_type": "dine_in", - "status": "completed", - "payment_status": "completed", - "total_amount": 99000, - "item_count": 2, - "is_void": false, - "is_refund": false, - "points_earned": 865, - "coins_earned": 3, - "created_at": "2026-09-30T12:01:00Z" - } - ], - "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } -} -``` - -`GET /api/v1/customer/orders/{id}` mengembalikan field yang sama, ditambah: - -```json -{ - "table_number": "A3", - "subtotal": 90000, - "discount_amount": 0, - "tax_amount": 9000, - "refund_amount": 0, - "items": [ - { - "id": "…", - "product_id": "…", - "product_name": "Kopi Susu", - "variant_name": "Large", - "quantity": 2, - "unit_price": 25000, - "total_price": 50000, - "refund_quantity": 0, - "modifiers": [], - "status": "completed" - }, - { - "id": "…", - "product_id": "…", - "product_name": "Ikan Tude", - "variant_name": null, - "quantity": 1, - "weight": 4.2, - "unit_name": "ons", - "unit_price": 4500, - "total_price": 18900, - "refund_quantity": 0, - "modifiers": [], - "status": "completed" - } - ], - "payments": [ - { "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" } - ] -} -``` - -- Order customer lain atau yang tidak ada → `404`. -- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada. -- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons". -- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" / - "Direfund". - - -## 5. Notifikasi push (FCM) - -### 5.1 Registrasi device - -Setelah login berhasil **dan** setiap kali FCM memberi token baru (`onTokenRefresh`): - -`PUT /api/v1/customer/devices` - -```json -{ "device_id": "", "fcm_token": "", "platform": "android", "app_version": "2.4.0" } -``` - -- `device_id` wajib, stabil untuk satu instalasi (simpan di secure storage). -- `platform`: `android`, `ios`, atau `web`. -- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum - menghapus token login, supaya HP itu tidak lagi menerima notifikasi akun ini. - -Tanpa registrasi ini, customer tidak menerima push apa pun. - -### 5.2 Tipe push - -Semua nilai di `data` berupa string. - -| `data.type` | Kapan | Isi `data` lain | Aksi saat di-tap | -|---|---|---|---| -| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | Buka riwayat, sorot transaksi itu | -| `WALLET_EXPIRING` | Beberapa hari sebelum saldo hangus | `currency`, `amount`, `expiry_date` | Buka layar kedaluwarsa (§4.3) | -| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | Buka riwayat | -| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339 UTC) | Buka layar lupa PIN (§6.4) | - -Saat app terbuka dan menerima push wallet, muat ulang beranda. - ---- - -## 6. PIN - -### 6.1 Kapan diminta - -Jangan minta PIN saat registrasi. Minta saat customer **pertama kali** melakukan aksi -yang butuh PIN. Cek dengan: - -`GET /api/v1/customer/pin/status` → `{ "has_pin": false, "locked_until": null, "transfer_blocked_until": null }` - -Bila `has_pin: false`, arahkan ke alur buat PIN, lalu kembali ke aksi semula. - -### 6.2 Buat PIN - -1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. - Response: `{ "purpose": "pin_setup", "otp_token": "…", "expires_at": "…" }`. - OTP dikirim ke WhatsApp customer. -2. Customer memasukkan kode OTP, lalu PIN dua kali. -3. `POST /api/v1/customer/pin` dengan - `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. - Response: status PIN. - -Validasi di app sebelum kirim (server juga memeriksa, jawab `304`): -- Tepat 6 digit angka, dan konfirmasi sama. -- Bukan satu digit berulang (`111111`). -- Bukan berurutan naik/turun (`123456`, `654321`). -- Bukan tanggal lahir customer (`DDMMYY` atau `YYMMDD`). - -Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur. - -### 6.3 Ganti PIN - -`PUT /api/v1/customer/pin` dengan `{ "old_pin": "…", "pin": "…", "confirm_pin": "…" }`. - -### 6.4 Lupa PIN - -1. `POST /customer/pin/otp` dengan `{ "purpose": "pin_reset" }`. -2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`. - -Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; -tukar tetap bisa. Beri tahu customer hal ini di layar sukses. - -### 6.5 Menangani error PIN - -Semua endpoint yang menerima `pin` bisa menjawab error PIN. Pada error ini **`data` -tidak `null`**: - -```json -{ "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [ … ] } -``` - -| `data.code` | Field tambahan | Tampilan | -|---|---|---| -| `PIN_NOT_SET` | – | Buka alur buat PIN (§6.2) | -| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa {n} percobaan." Kosongkan input PIN | -| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai {jam}." Tombol "Lupa PIN" | -| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada {waktu}." | - -5 kali salah berturut-turut mengunci PIN 30 menit; selama terkunci PIN yang benar pun -ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app. - ---- - -## 7. Tukar dan transfer - -### 7.1 Tukar EnakCoin → EnakPoint - -1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik): - - `GET /api/v1/customer/wallet/exchange/preview?coins=30` - - ```json - { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } - ``` - - - Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint. Tampilkan "10 EnakCoin = - 3 EnakPoint". - - Bila `valid: false`, tampilkan `reason` sebagai alasan dan nonaktifkan tombol. Jumlah - harus kelipatan `coin_amount`. - - Tampilkan "Kamu akan mendapat {points} EnakPoint". - -2. Konfirmasi (tukar tidak bisa dibatalkan) → minta PIN → - - `POST /api/v1/customer/wallet/exchange` + header `Idempotency-Key` - - ```json - { "coins": 30, "pin": "482913" } - ``` - - ```json - { - "group_id": "…", - "coins": 30, - "points": 9, - "coin_amount": 10, - "point_amount": 3, - "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], - "coin_balance": 5, - "point_balance": 9, - "replayed": false - } - ``` - -3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku - sampai {tanggal}". - -### 7.2 Transfer - -1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah. -2. Cek penerima: - - `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` - - ```json - { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } - ``` - - | Hasil | Tampilan | - |---|---| - | Sukses | "Kirim ke Bu*** Sa*** (08**-****-1234)?" | - | `404` | "Nomor ini tidak terdaftar" | - | `304` | "Tidak bisa mengirim ke nomor ini" (diri sendiri, akun nonaktif) | - -3. Konfirmasi (transfer final, tidak bisa dibatalkan) → minta PIN → - - `POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key` - - ```json - { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } - ``` - - ```json - { - "group_id": "…", - "currency": "POINT", - "amount": 120, - "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, - "lots": [ - { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, - { "amount": 20, "expires_at": null } - ], - "balance": 30, - "replayed": false - } - ``` - -4. Layar sukses: saldo tersisa (`balance`). Bila ada `lots[].expires_at`, tampilkan - "Saldo yang dikirim berlaku sampai {tanggal}" (tanggal kedaluwarsa ikut terbawa ke - penerima). - -Penolakan `304` yang mungkin: transfer dimatikan owner, di bawah minimal, di atas -maksimal per transaksi, melewati batas harian (reset tengah malam WIB), saldo tidak -cukup. Tampilkan pesan umum "Transfer tidak bisa diproses" plus alasan yang sesuai -bila bisa dikenali. Bila kena `TRANSFER_BLOCKED`, ikuti §6.5. - -Penerima mendapat push `WALLET_TRANSFER_IN`. - ---- - -## 8. Game (memakai EnakCoin) - -`POST /api/v1/customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` -sudah dihapus. Semua game, termasuk spin, dimainkan lewat EnakGame. Tanpa PIN. - -1. `GET /api/v1/customer/enakgame/games`: daftar game dengan `entry_cost` (EnakCoin per - main). Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` - kurang. Spin punya `prizes` untuk menggambar roda. -2. `POST /api/v1/customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan header - `Idempotency-Key` (satu key per tap; retry memakai key yang sama). Response berisi - `session_id` dan `coin_balance` setelah dipotong. -3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan hasil main (`score` - atau `outcome`; spin cukup `{}`). Server yang menentukan hadiah: perbarui saldo dari - `coin_balance`, tampilkan `reward_total`, dan untuk spin hentikan roda di `prize.entry`. - -Rincian spin ada di [`enakgame-spin.md`](./enakgame-spin.md). - ---- - -## 9. Yang sudah dihapus / deprecated - -Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada): - -| Lama | Pengganti | -|---|---| -| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | -| `total_tokens`, `tokens_history` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` | -| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` | -| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar | -| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar | -| `GET /orders/:id/point-payment/preview` (POS) | Tidak ada; EnakPoint tidak bisa untuk bayar | -| `accepts_point_payment` di `GET /customer/outlets` | – | -| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – | -| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi | - -Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru): - -| Lama | Pengganti | -|---|---| -| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | -| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | - ---- - -## 10. Checklist selesai - -- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat. -- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, label tipe sesuai §4.2. -- [ ] Layar saldo akan kedaluwarsa. -- [ ] Registrasi device FCM setelah login dan saat token berganti; unregister saat logout. -- [ ] Penanganan tap untuk keempat tipe push. -- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP. -- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN. -- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama. -- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama. -- [ ] Game memakai `coins_used` / `coins_remaining` dan menampilkan biaya per game. -- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat). -- [ ] Tidak ada pemakaian endpoint atau field di §9. -- [ ] PIN tidak pernah disimpan, di-log, atau dikirim ke analytics. diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index 70ae45f..ecebe2a 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -636,7 +636,7 @@ menanganinya, dan complete di §7.2 langkah 4 menangani yang lebih cepat. ### 7.4 Redemption voucher internal (`STATIC`, `CODE_POOL`) ``` -POST /customer/enakgame/vouchers/:id/redeem { pin } Idempotency-Key: <≤50 char> +POST /customer/vouchers/:id/redeem { pin } Idempotency-Key: <≤50 char> ``` Satu transaksi, sehingga state `RESERVED` tidak diperlukan: @@ -843,12 +843,20 @@ tambahkan snapshot harian, bukan cache yang di-invalidate. | `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header | | `GET` | `/sessions/:id` | Status dan hasil | | `GET` | `/sessions` | Riwayat main | -| `GET` | `/vouchers` | Katalog `ACTIVE` + stok tersedia | -| `POST` | `/vouchers/:id/redeem` | §7.4 / §7.5. Wajib `Idempotency-Key` + PIN | -| `GET` | `/redemptions` | Voucher milik customer, termasuk kode | Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama. +### Voucher customer (`/api/v1/customer/vouchers`, `ValidateCustomerToken`) + +Voucher adalah tempat memakai EnakPoint dari mana pun asalnya, bukan bagian dari game, +jadi tidak di bawah `/enakgame` (diubah 2026-10-08). + +| Method | Path | Catatan | +|---|---|---| +| `GET` | `/vouchers` | Katalog `ACTIVE` + stok tersedia | +| `POST` | `/vouchers/:id/redeem` | §7.4 / §7.5. Wajib `Idempotency-Key` + PIN | +| `GET` | `/vouchers/redemptions` | Voucher milik customer, termasuk kode | + ### Admin (`/api/v1/marketing/enakgame`, `RequireAdminOrManager`) | Resource | Endpoint | @@ -858,7 +866,7 @@ Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama | Events | CRUD, `PUT /:id/status` | | Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation`, `POST /:id/recommendation/accept` | | Analytics | `GET /analytics/games?from=&to=&game_id=`, `GET /analytics/economy?from=&to=` (tanggal Asia/Jakarta, maks 366 hari) | -| Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` | +| Vouchers | Di `/api/v1/marketing/vouchers`, bukan di bawah `/enakgame`: CRUD, `PUT /:id/status`, `POST /:id/codes` (impor CSV), `GET /:id/codes` | | Redemptions | `GET` list, `GET /:id` dengan atribusi cost | | Sessions | `GET` list + filter `flagged` | | Settings | Lewat endpoint loyalty settings yang ada, dengan key baru (§5.10) | diff --git a/internal/contract/enakgame_contract.go b/internal/contract/enakgame_contract.go index fd55fc7..9bfc5aa 100644 --- a/internal/contract/enakgame_contract.go +++ b/internal/contract/enakgame_contract.go @@ -8,7 +8,7 @@ type StartGameSessionRequest struct { GameID uuid.UUID `json:"game_id" binding:"required"` } -// RedeemVoucherRequest is POST /customer/enakgame/vouchers/:id/redeem (§7.4). The +// RedeemVoucherRequest is POST /customer/vouchers/:id/redeem (§7.4). The // Idempotency-Key header is required. type RedeemVoucherRequest struct { Pin string `json:"pin" binding:"required"` diff --git a/internal/handler/enakgame_admin_handler.go b/internal/handler/enakgame_admin_handler.go index e228eaa..34578f8 100644 --- a/internal/handler/enakgame_admin_handler.go +++ b/internal/handler/enakgame_admin_handler.go @@ -285,7 +285,7 @@ func (h *EnakGameAdminHandler) EconomyAnalytics(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.EconomyAnalytics(ctx, appcontext.FromGinContext(ctx), q), method) } -// CreateVoucher is POST /marketing/enakgame/vouchers. +// CreateVoucher is POST /marketing/vouchers. func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) { const method = "EnakGameAdminHandler::CreateVoucher" body, ok := rawBody(c, method) @@ -296,7 +296,7 @@ func (h *EnakGameAdminHandler) CreateVoucher(c *gin.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=. +// ListVouchers is GET /marketing/vouchers?status=&search=&page=&limit=. func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) { const method = "EnakGameAdminHandler::ListVouchers" var q models.VoucherListQuery @@ -307,7 +307,7 @@ func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(ctx, appcontext.FromGinContext(ctx), q), method) } -// GetVoucher is GET /marketing/enakgame/vouchers/:id. +// GetVoucher is GET /marketing/vouchers/:id. func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) { const method = "EnakGameAdminHandler::GetVoucher" id, ok := pathID(c, "id", method) @@ -318,7 +318,7 @@ func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.GetVoucher(ctx, appcontext.FromGinContext(ctx), id), method) } -// UpdateVoucher is PUT /marketing/enakgame/vouchers/:id. +// UpdateVoucher is PUT /marketing/vouchers/:id. func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.Context) { const method = "EnakGameAdminHandler::UpdateVoucher" id, ok := pathID(c, "id", method) @@ -333,7 +333,7 @@ func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.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. +// SetVoucherStatus is PUT /marketing/vouchers/:id/status. func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.Context) { const method = "EnakGameAdminHandler::SetVoucherStatus" id, ok := pathID(c, "id", method) @@ -348,7 +348,7 @@ func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.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 +// ImportVoucherCodes is POST /marketing/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" @@ -379,7 +379,7 @@ func (h *EnakGameAdminHandler) ImportVoucherCodes(c *gin.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=. +// ListVoucherCodes is GET /marketing/vouchers/:id/codes?status=&page=&limit=. func (h *EnakGameAdminHandler) ListVoucherCodes(c *gin.Context) { const method = "EnakGameAdminHandler::ListVoucherCodes" id, ok := pathID(c, "id", method) diff --git a/internal/handler/enakgame_customer_handler.go b/internal/handler/enakgame_customer_handler.go index 6890d07..1c40b12 100644 --- a/internal/handler/enakgame_customer_handler.go +++ b/internal/handler/enakgame_customer_handler.go @@ -106,7 +106,7 @@ func (h *EnakGameCustomerHandler) CompleteSession(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.CompleteSession(c.Request.Context(), customerID, id, in), method) } -// ListVouchers is GET /customer/enakgame/vouchers. +// ListVouchers is GET /customer/vouchers. func (h *EnakGameCustomerHandler) ListVouchers(c *gin.Context) { const method = "EnakGameCustomerHandler::ListVouchers" customerID, ok := customerIDFromGin(c, method) @@ -116,7 +116,7 @@ func (h *EnakGameCustomerHandler) ListVouchers(c *gin.Context) { 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 +// RedeemVoucher is POST /customer/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" @@ -135,7 +135,7 @@ func (h *EnakGameCustomerHandler) RedeemVoucher(c *gin.Context) { 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=. +// ListRedemptions is GET /customer/vouchers/redemptions?page=&limit=. func (h *EnakGameCustomerHandler) ListRedemptions(c *gin.Context) { const method = "EnakGameCustomerHandler::ListRedemptions" customerID, ok := customerIDFromGin(c, method) diff --git a/internal/handler/enakgame_db_test.go b/internal/handler/enakgame_db_test.go index 044a19a..535ee5f 100644 --- a/internal/handler/enakgame_db_test.go +++ b/internal/handler/enakgame_db_test.go @@ -285,7 +285,7 @@ func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) { 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) { + g := router.Group(prefix, 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) @@ -296,10 +296,10 @@ func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) { 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 := router.Group("/customer", 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) + c.GET("/vouchers/redemptions", customerHandler.ListRedemptions) send := func(req *http.Request) (int, map[string]any) { t.Helper() @@ -320,12 +320,12 @@ func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) { 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) + status, _ := call(http.MethodPost, "/purchasing/vouchers", voucher) assert.Equal(t, http.StatusForbidden, status) - status, body := call(http.MethodPost, "/manager/enakgame/vouchers", voucher) + status, body := call(http.MethodPost, "/manager/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}`) + status, _ = call(http.MethodPut, "/manager/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. @@ -335,25 +335,25 @@ func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) { 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 := httptest.NewRequest(http.MethodPost, "/manager/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") + status, body = call(http.MethodPost, "/manager/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", "") + status, body = call(http.MethodGet, "/manager/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", "") + status, body = call(http.MethodGet, "/customer/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" + redeem := "/customer/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") @@ -377,10 +377,10 @@ func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) { 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") + status, _ = call(http.MethodPost, "/customer/vouchers/"+uuid.NewString()+"/redeem", `{"pin": "482913"}`, "Idempotency-Key", "r4") assert.Equal(t, http.StatusNotFound, status) - status, body = call(http.MethodGet, "/customer/enakgame/redemptions", "") + status, body = call(http.MethodGet, "/customer/vouchers/redemptions", "") require.Equal(t, http.StatusOK, status, body) assert.EqualValues(t, 3, data(body)["pagination"].(map[string]any)["total_count"]) } @@ -477,7 +477,7 @@ func TestEnakGameEventEndpoints_AgainstPostgres(t *testing.T) { assert.NotContains(t, events[0], "budget_id", "the budget stays internal") } -// EG-1001: the admin steps of docs/enakgame-spin.md make a spin wheel, and a customer +// EG-1001: the admin steps of docs/integration-backoffice.md §8.4 make a spin wheel, and a customer // plays it through /customer/enakgame/sessions, over HTTP down to Postgres. func TestEnakGameSpin_AgainstPostgres(t *testing.T) { dsn := os.Getenv("TEST_DATABASE_URL") diff --git a/internal/router/router.go b/internal/router/router.go index c4778f3..eaa345e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -197,10 +197,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { 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) } + + // Vouchers: what EnakPoint is spent on, whatever it came from (docs/rfc-enakgame.md + // §5.7, §7.4), so not under /enakgame. + customer.GET("/vouchers", r.enakGameCustomerHandler.ListVouchers) + customer.GET("/vouchers/redemptions", r.enakGameCustomerHandler.ListRedemptions) + customer.POST("/vouchers/:id/redeem", r.enakGameCustomerHandler.RedeemVoucher) } selfOrder := v1.Group("/self-order") @@ -634,14 +637,19 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { 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) + // Vouchers EnakPoint is redeemed for, whatever it came from; not part of + // EnakGame. Writing them takes a loyalty manager. + vouchers := gamification.Group("/vouchers") + { + vouchers.GET("", r.enakGameAdminHandler.ListVouchers) + vouchers.GET("/:id", r.enakGameAdminHandler.GetVoucher) + vouchers.GET("/:id/codes", r.enakGameAdminHandler.ListVoucherCodes) + vouchers.POST("", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.CreateVoucher) + vouchers.PUT("/:id", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.UpdateVoucher) + vouchers.PUT("/:id/status", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.SetVoucherStatus) + vouchers.POST("/:id/codes", r.authMiddleware.RequireLoyaltyManager(), r.enakGameAdminHandler.ImportVoucherCodes) } campaignRules := gamification.Group("/campaign-rules") -- 2.54.0 From b5d2cd491a5e5cf3b510ecb8e084e3f63140c11b Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 8 Oct 2026 11:10:05 +0700 Subject: [PATCH 05/12] docs: integration guides for mobile customer, POS, EnakGame and backoffice One guide per team, covering EnakPoint, EnakCoin, EnakGame and vouchers: - integration-mobile-customer.md: wallet, history (with the game and voucher ledger types), push, PIN, exchange, transfer, game list and webview, play history, voucher catalog, redeem and my vouchers. - integration-pos.md: linking customers to orders, earning, receipts, void/refund, and vouchers as a known gap (no POS endpoint to mark one used). - integration-enakgame.md: the Phaser client's side of a play: start with Idempotency-Key, complete, rewards, spin, expiry and refunds, retries. - integration-backoffice.md: loyalty settings and customer wallets, plus games, reward configs, spin setup, budgets, metrics and recommendations, events, vouchers and code import, analytics. The JS bridge between the app and the game is a proposal both teams still have to agree on. Replaces api-enakpoint.md, integration-enakpoint.md, mobile-customer-enakpoint.md, backoffice-enakpoint.md and enakgame-spin.md. Co-Authored-By: Claude Opus 5.5 --- docs/integration-backoffice.md | 929 ++++++++++++++++++++++++++++ docs/integration-enakgame.md | 298 +++++++++ docs/integration-mobile-customer.md | 790 +++++++++++++++++++++++ docs/integration-pos.md | 142 +++++ docs/prd-point-coin.md | 3 +- 5 files changed, 2161 insertions(+), 1 deletion(-) create mode 100644 docs/integration-backoffice.md create mode 100644 docs/integration-enakgame.md create mode 100644 docs/integration-mobile-customer.md create mode 100644 docs/integration-pos.md diff --git a/docs/integration-backoffice.md b/docs/integration-backoffice.md new file mode 100644 index 0000000..608ca03 --- /dev/null +++ b/docs/integration-backoffice.md @@ -0,0 +1,929 @@ +# Integrasi Backoffice: Loyalitas & EnakGame + +**Untuk:** tim backoffice (dashboard owner/admin) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan **backoffice** yang dipakai owner, admin, dan manager organisasi untuk +mengelola program loyalitas: pengaturan EnakPoint & EnakCoin, wallet customer, voucher, +dan EnakGame (game, hadiah, budget, event, analytics). Jangan mengarang endpoint, +field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke +tim backend. + +Dokumen ini menggantikan `backoffice-enakpoint.md`, bagian dashboard di +`integration-enakpoint.md` dan `api-enakpoint.md`, serta langkah admin di +`enakgame-spin.md`. Alasan di balik aturannya ada di +[`prd-point-coin.md`](./prd-point-coin.md), [`enakgame-prd.md`](./enakgame-prd.md), dan +[`rfc-enakgame.md`](./rfc-enakgame.md). + +--- + +## 1. Konvensi + +**Akses.** Semua endpoint butuh login user dan otomatis dibatasi ke organisasi user itu; +data organisasi lain dijawab `404`. + +| Aksi | Role | +|---|---| +| Membaca semua data di dokumen ini, serta membuat/mengubah game | superadmin, admin, manager, owner, purchasing | +| Mengubah reward config, budget, event, voucher, dan menerima rekomendasi | superadmin, admin, manager, owner (**loyalty manager**) | + +Sembunyikan tombol ubah untuk role yang tidak boleh; server tetap menolaknya (`403`). + +**Format response.** Sukses `{ "success": true, "data": … }`; gagal +`{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Daftar berhalaman +memakai `{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }` +dengan `limit` maks. 100 (default 20). + +**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke +voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke +EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah +"saldo Rp …", karena saldo tidak bisa dicairkan. + +**Body ketat.** Endpoint EnakGame dan voucher (`/marketing/enakgame/*`, `/marketing/vouchers/*`) serta `PUT` setting menolak +field yang tidak dikenal (`310`), supaya salah ketik tidak diam-diam diabaikan. Pada +`PUT`, field yang tidak dikirim tetap memakai nilai sekarang. + +--- + +## 2. Layar yang perlu dibuat + +| Layar | Endpoint | Tempat di menu (usulan) | +|---|---|---| +| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail → tab Loyalitas | +| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan | +| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat | +| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail → tab Wallet | +| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dari baris riwayat wallet | +| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail → tab Keamanan | +| Game | `/marketing/enakgame/games` | EnakGame → Game | +| Hadiah game (reward config) | `/marketing/enakgame/games/:id/reward-configs`, `/reward-configs/:id/activate` | EnakGame → Game → tab Hadiah | +| Budget + metrik + rekomendasi | `/marketing/enakgame/budgets` | EnakGame → Budget | +| Event | `/marketing/enakgame/events` | EnakGame → Event | +| Voucher + kode | `/marketing/vouchers` | Marketing → Voucher | +| Analytics | `/marketing/enakgame/analytics/games`, `/analytics/economy` | EnakGame → Analytics | + +--- + +## 3. Setting loyalitas outlet + +Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order. Semua +nilai default mati sampai owner menyalakannya. + +`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan +objek yang sama untuk menyimpan. + +```json +{ + "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, + "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } +} +``` + +| Field | Label usulan | Tipe | Default | Validasi | +| --- | --- | --- | --- | --- | +| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – | +| `earn_mode` | Cara hitung: per nominal / persentase | `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya | +| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 | +| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 | +| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal | +| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 | +| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 | + +**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. +Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan +hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ +earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. + +**Mode earning.** Tampilkan hanya field mode yang dipilih. Field mode lain tetap +tersimpan di server. Pada mode `PERCENTAGE` jumlahnya `floor(basis × earn_percent ÷ +100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint. + +**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." +Earning dihitung dari subtotal setelah diskon, sebelum pajak. + +Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat. + +--- + +## 4. Setting loyalitas organisasi + +Nilai rupiah EnakPoint, kurs exchange, batas transfer, kedaluwarsa, dan batas hadiah +EnakGame berlaku sama untuk semua outlet. Mengubah nilai EnakPoint atau kurs langsung +mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya +sebelum disimpan. + +```json +{ + "point_value": 1, + "exchange": { "coin_amount": 1, "point_amount": 1 }, + "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, + "point_expiry": { "…": "lihat §5" }, + "coin_expiry": { "…": "lihat §5" }, + "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } +} +``` + +| Field | Label usulan | Default | Validasi | +| --- | --- | --- | --- | +| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 | +| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 | +| `transfer.enabled` | Izinkan transfer antar customer | aktif | – | +| `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, per currency, reset tengah malam WIB | +| `enakgame.user_daily_limit` | Maks. EnakCoin dari EnakGame per customer per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB | +| `enakgame.global_daily_limit` | Maks. EnakCoin dari EnakGame seluruh organisasi per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB | + +Hadiah yang melewati batas harian **dipotong ke sisa batas**, tidak dibatalkan; bila +sisanya 0, hadiahnya 0. Batas per game ada di `result_rules.daily_reward_limit` (§8.3). + +### 4.1 Alur simpan + +1. Owner mengubah form. +2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek + yang diubah. Tidak ada yang tersimpan. +3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti. +4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs + berubah), dan `expiry_activations` (bila ada, §5). +5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`. + +### 4.2 Dialog dampak + +| Field `impact` | Tampilkan sebagai | +| --- | --- | +| `outstanding_points` | EnakPoint beredar | +| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … | +| `outstanding_coins` | EnakCoin beredar | +| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint | +| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … | + +Contoh: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang +beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya +berlaku ke depan: exchange yang sudah terjadi memakai kurs saat itu. + +--- + +## 5. Pengaturan kedaluwarsa + +Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin +(`coin_expiry`). Defaultnya mati; bila dinyalakan, defaultnya hangus setiap 31 Desember. + +```json +"point_expiry": { + "enabled": true, + "mode": "FIXED_DATE", + "fixed_dates": ["12-31"], + "grace_months": 3, + "period": 12, + "unit": "MONTH", + "end_of_month": false, + "reminder_days": 7 +} +``` + +| Field | Tampil saat | Label usulan | Validasi | +| --- | --- | --- | --- | +| `enabled` | selalu | Saldo bisa kedaluwarsa | – | +| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` | +| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, `MM-DD`, `02-29` ditolak | +| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 | +| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` | +| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – | +| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat | + +**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama. Saldo yang +didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya: saldo +1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Pakai pemilih +tanggal+bulan tanpa tahun. + +**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk. +Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027. + +**Preview.** `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: +kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). Tampilkan "EnakPoint +yang didapat hari ini kedaluwarsa pada 31 Des 2026." Dry run bisa dipakai untuk +memperbarui preview saat owner mengubah pilihan. + +**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal +dengan masa berlaku penuh. Dry run mengembalikan `expiry_activations` (`currency`, +`lots`, `amount`, `expires_at`); tampilkan di dialog konfirmasi dengan kalimat tegas, +mis. "1.250.000 EnakPoint milik customer akan kedaluwarsa pada 31 Des 2027. Tindakan ini +tidak bisa dibatalkan dengan mematikan kedaluwarsa." + +Aturan lain yang perlu dijelaskan di layar: + +- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya. +- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. +- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya. +- Saldo hangus tanpa kompensasi. Customer mendapat push `reminder_days` hari sebelumnya + dan saat hangus. + +--- + +## 6. Wallet customer + +Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan +asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke asalnya. + +### 6.1 Saldo, lot, dan riwayat + +`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` +(semua query opsional; tanggal WIB, inklusif) + +```json +{ + "customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" }, + "point_balance": 12650, + "coin_balance": 8, + "spendable_point_balance": 12500, + "spendable_coin_balance": 8, + "lots": [ + { "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" } + ], + "transactions": { + "data": [ + { + "id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650, + "description": "Transfer ke An*** (08**-****-5678)", + "destination": { "type": "WALLET_TX", "id": "…" }, + "counterparty": { "id": "…", "name": "Anita Rahma" }, + "created_by": null, "outlet": null, "reason": null, "metadata": {}, + "created_at": "…" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } + } +} +``` + +- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / + `coin_balance` bisa sedikit lebih besar selama ada lot yang lewat tanggal tapi belum + diproses job kedaluwarsa (paling lama sekitar 15 menit). +- **Lot:** paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Tandai + `expired: true`. +- **Riwayat:** ditambah nama asli yang disamarkan untuk customer: `counterparty`, + `created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata`. + +| `type` | Mata uang | Label | `source` / `destination` | +|---|---|---|---| +| `EARN` / `EARN_REVERSAL` | keduanya | Dari belanja / Ditarik (void/refund) | `ORDER` | +| `EXCHANGE_OUT` / `EXCHANGE_IN` | COIN / POINT | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | +| `TRANSFER_OUT` / `TRANSFER_IN` | keduanya | Transfer antar customer | `WALLET_TX` (baris pasangannya) | +| `GAME_SPEND` | COIN | Biaya main game | `GAME_SESSION` (data lama: `GAME_PLAY`) | +| `GAME_SPEND_REFUND` | COIN | Biaya main dikembalikan | `GAME_SESSION` | +| `GAME_REWARD` | COIN | Hadiah game (`metadata.budget_id`: budget yang membayar) | `GAME_SESSION` | +| `REWARD_REDEEM` | POINT | Ditukar ke voucher | `REWARD_REDEMPTION` | +| `REWARD_REDEEM_REFUND` | POINT | Penukaran voucher gagal, dikembalikan | `REWARD_REDEMPTION` | +| `EXPIRE` | keduanya | Kedaluwarsa | `LOT` | +| `ADJUSTMENT` | keduanya | Koreksi admin | `USER` | +| `MIGRATION` | keduanya | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | + +### 6.2 Adjustment manual + +`POST /marketing/customers/:id/wallet/adjust` + +```json +{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" } +``` + +| Field | Aturan | +| --- | --- | +| `currency` | `POINT` atau `COIN` | +| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi | +| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" | +| `idempotency_key` | Opsional tapi disarankan: satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali | + +Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah +mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", +"spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan bahwa +adjustment tidak disertai pembayaran uang, jadi alasan tidak boleh "pencairan". + +### 6.3 Telusuri mutasi + +Tombol Telusuri di setiap baris riwayat memanggil +`GET /marketing/wallet-transactions/:id/trace`. + +```json +{ + "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "reference_id": "…", "created_at": "…" }, + "lots": [ + { + "amount": 30, + "chain": [ + { "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } }, + { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } } + ] + } + ] +} +``` + +Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, +lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir +adalah asal pertama saldo, mis. `EARN`, `GAME_REWARD`, `ADJUSTMENT`, atau `MIGRATION`; +bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar +menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya. + +--- + +## 7. PIN dan riwayat setting + +### 7.1 PIN & keamanan customer + +Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah +menghapusnya (mis. customer ganti nomor HP), sehingga customer membuat PIN baru lewat OTP. + +- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. + `reason` wajib; tampilkan dialog konfirmasi dengan input alasan. +- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan: + +```json +{ + "data": [ + { "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 } +} +``` + +| `event` | Label usulan | +| --- | --- | +| `PIN_SET` | PIN dibuat | +| `PIN_CHANGED` | PIN diganti | +| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) | +| `PIN_FAILED` | PIN salah dimasukkan | +| `PIN_LOCKED` | PIN terkunci 30 menit | +| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) | + +### 7.2 Riwayat perubahan setting + +`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; +tambah `&outlet_id=…` untuk satu outlet. + +```json +{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" } +``` + +`old_value` `null` berarti sebelumnya masih default. Tampilkan `key` dengan label yang +sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint", +`enakgame.limit.user_daily` → "Maks. EnakCoin per customer per hari"), dan `changed_by` +sebagai nama user. + +--- + +## 8. EnakGame: game dan hadiah + +Semua game (spin, raffle, minigame) adalah game EnakGame: customer membayar `entry_cost` +EnakCoin per main, dan hadiahnya EnakCoin yang dihitung server dari **reward config** +game itu. Game client (Phaser) dibuat tim EnakGame dan di-host di `game_url`. + +### 8.1 Game + +| Method | Path | Body / query | +|---|---|---| +| `POST` | `/marketing/enakgame/games` | Objek game | +| `GET` | `/marketing/enakgame/games` | `?status=&search=&page=&limit=` (game `ARCHIVED` hanya tampil bila diminta lewat `status`) | +| `GET` | `/marketing/enakgame/games/:id` | – | +| `PUT` | `/marketing/enakgame/games/:id` | Field yang diubah saja; status tidak lewat sini | +| `PUT` | `/marketing/enakgame/games/:id/status` | `{ "status": "INACTIVE", "reason": "…" }` | + +```json +{ + "name": "Spin Harian", + "type": "SPIN", + "slug": "spin", + "description": "Putar roda setiap hari", + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html", + "version": "1.2.0", + "status": "DRAFT", + "entry_cost": 5, + "session_ttl_seconds": 600, + "result_rules": { "max_score": 5000, "min_duration_seconds": 10, "daily_reward_limit": 10000 } +} +``` + +| Field | Label usulan | Validasi | +|---|---|---| +| `name` | Nama game | wajib, maks. 255 | +| `type` | Jenis | `SPIN`, `RAFFLE`, `MINIGAME` (default `MINIGAME`) | +| `slug` | Kode unik | huruf kecil, angka, tanda `-` tunggal, maks. 100, unik per organisasi | +| `thumbnail_url`, `game_url` | Gambar, URL game | maks. 500; `game_url` dari tim EnakGame | +| `version` | Versi game | maks. 50 | +| `status` | Status awal | hanya saat buat: `DRAFT` (default), `ACTIVE`, `INACTIVE` | +| `entry_cost` | Biaya main (EnakCoin) | ≥ 1; tidak ada game gratis | +| `session_ttl_seconds` | Batas waktu satu main | 1–86.400 detik, default 600 | +| `result_rules` | Validasi hasil (§8.3) | opsional | + +**Status game:** + +| `status` | Arti | +|---|---| +| `DRAFT` | Disiapkan, belum tampil di aplikasi | +| `ACTIVE` | Tampil dan bisa dimainkan (butuh reward config aktif dan budget global, §9) | +| `INACTIVE` | Disembunyikan. Session yang sedang berjalan direfund otomatis | +| `ARCHIVED` | Pensiun permanen; tidak bisa diubah lagi. Game lama sebelum EnakGame berstatus ini | + +Mengubah `entry_cost` tidak memengaruhi session yang sudah berjalan. + +### 8.2 Reward config (hadiah) + +Hadiah diatur sebagai **versi**: versi tidak pernah diedit; perubahan = versi baru, lalu +diaktifkan. Satu game hanya punya satu versi `ACTIVE`; session memakai versi yang aktif +saat dimulai. + +| Method | Path | Body | +|---|---|---| +| `GET` | `/marketing/enakgame/games/:id/reward-configs` | Semua versi, terbaru di atas | +| `POST` | `/marketing/enakgame/games/:id/reward-configs` | `{ "reward_type", "rules", "max_reward", "effective_at", "reason" }` → versi baru `DRAFT` | +| `POST` | `/marketing/enakgame/reward-configs/:id/activate` | `{ "reason": "…" }` → versi ini `ACTIVE`, versi aktif sebelumnya `RETIRED` | + +```json +{ + "id": "…", "game_id": "…", "version": 3, "reward_type": "FIXED", "rules": { "amount": 9 }, + "max_reward": 9, "status": "ACTIVE", "effective_at": "…", "created_by": "…", "reason": "…", + "base_config_id": "…", "multiplier": 0.9, "budget_id": "…", "created_at": "…" +} +``` + +`base_config_id`, `multiplier`, dan `budget_id` hanya terisi pada versi yang dibuat +Budget Controller (§9.3). Tampilkan badge "Disesuaikan Budget Controller × 0,90". +Versi `RETIRED` tidak bisa diaktifkan lagi; untuk kembali, buat versi baru dengan aturan +lama. + +**Empat jenis `reward_type`:** + +| `reward_type` | `rules` | Hasil yang dikirim game | +|---|---|---| +| `FIXED` | `{ "amount": 5 }` | Apa saja; selalu 5 | +| `SCORE_BASED` | `{ "bands": [{ "min": 0, "max": 100, "amount": 1 }, { "min": 101, "amount": 20 }] }` | `score` | +| `OUTCOME_BASED` | `{ "outcomes": { "PERFECT": 20, "GOOD": 10, "FAIL": 0 } }` | `outcome` | +| `PROBABILITY` | `{ "table": [{ "weight": 1, "amount": 1000, "label": "Jackpot" }, { "weight": 999, "amount": 0, "label": "Zonk" }] }` | – (server mengundi) | + +- `SCORE_BASED`: band urut mulai dari 0, tanpa celah dan tanpa tumpang tindih; hanya band + terakhir boleh tanpa `max`. Skor di luar semua band → hadiah 0. +- `OUTCOME_BASED`: `outcome` yang tidak terdaftar → hadiah 0. +- `PROBABILITY`: `weight` bilangan bulat ≥ 1; peluang = `weight` ÷ total weight. + `label` opsional (maks. 100) dan tampil di roda spin. Customer melihat segmen dan + hadiahnya, tidak pernah weight-nya. +- Semua `amount` ≥ 0, EnakCoin bulat. +- `max_reward`: batas atas hadiah **total** per main, termasuk tambahan event (§10). + 0 = tanpa batas. Bila lewat, yang dipotong lebih dulu adalah tambahan event dengan + prioritas terendah. + +Tampilkan editor sesuai jenis (bukan textarea JSON) dan preview, mis. tabel peluang +"Jackpot 0,1% · Zonk 99,9%" untuk `PROBABILITY`. + +### 8.3 Validasi hasil (`result_rules`) + +Hasil dari game diperiksa server. Hasil yang tidak lolos tetap dicatat, tapi hadiahnya +0 dan session ditandai mencurigakan. + +| Field | Arti | +|---|---| +| `max_score` | Skor maksimal yang masuk akal | +| `min_duration_seconds` | Main lebih cepat dari ini dianggap curang | +| `max_score_per_second` | Laju skor maksimal | +| `outcomes` | Daftar `outcome` yang diterima; kosong = semua | +| `daily_reward_limit` | Maks. EnakCoin yang boleh diberikan game ini per hari (seluruh customer) | + +Semua opsional. Isi bersama tim EnakGame, karena mereka tahu skor dan durasi wajar +game-nya. + +### 8.4 Membuat spin + +1. **Game:** `POST /marketing/enakgame/games` dengan + `{ "name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "DRAFT", "thumbnail_url": "…", "game_url": "…" }`. +2. **Hadiah:** `POST /marketing/enakgame/games/:id/reward-configs`: + + ```json + { + "reward_type": "PROBABILITY", + "max_reward": 50, + "rules": { "table": [ + { "weight": 50, "amount": 0, "label": "Zonk" }, + { "weight": 30, "amount": 3, "label": "3 Coin" }, + { "weight": 15, "amount": 10, "label": "10 Coin" }, + { "weight": 5, "amount": 50, "label": "Jackpot" } + ] }, + "reason": "Spin pertama" + } + ``` + + Satu baris `table` = satu segmen roda, urut searah gambar roda. `max_reward` minimal + sebesar `amount` terbesar; beri ruang lebih bila ingin event bisa menambah hadiah. +3. **Aktifkan:** `POST /marketing/enakgame/reward-configs/:id/activate`. +4. **Budget:** pastikan ada budget global bulan berjalan (§9.1). +5. **Tayangkan:** `PUT /marketing/enakgame/games/:id/status` dengan `{ "status": "ACTIVE" }`. + +--- + +## 9. Budget + +Budget adalah rupiah yang boleh dihabiskan organisasi untuk hadiah EnakGame. Biaya +dihitung dari **voucher yang benar-benar ditukar**: saat customer menukar EnakPoint yang +asalnya dari hadiah game (EnakCoin hadiah → ditukar ke EnakPoint → voucher), nilai +voucher (`face_value`) dicatat sebagai biaya budget yang membayar hadiah itu. EnakPoint +dari belanja tidak dihitung. Entry cost yang dibayar customer tidak menambah budget. + +### 9.1 Budget global dan event + +| `scope` | Membayar | Aturan | +|---|---|---| +| `GLOBAL` | Hadiah dasar semua game | Satu per periode; periode tidak boleh tumpang tindih. **Tanpa budget global yang mencakup hari ini, customer tidak bisa mulai main.** Setiap hari sistem membuat budget bulan berikutnya dari budget yang sedang berjalan (jumlah dan threshold sama) bila belum ada | +| `EVENT` | Tambahan hadiah dari satu event | Dipasang di event (§10) | + +| Method | Path | Body / query | +|---|---|---| +| `GET` | `/marketing/enakgame/budgets` | `?scope=GLOBAL&page=&limit=` | +| `GET` | `/marketing/enakgame/budgets/:id` | – | +| `POST` | `/marketing/enakgame/budgets` | Objek budget | +| `PUT` | `/marketing/enakgame/budgets/:id` | Field yang diubah; `scope` tidak bisa berubah | +| `DELETE` | `/marketing/enakgame/budgets/:id` | Hanya budget yang belum dipakai hadiah, event, atau reward config | + +```json +{ + "scope": "GLOBAL", + "name": "Oktober 2026", + "period_start": "2026-10-01", + "period_end": "2026-10-31", + "amount": 100000000, + "thresholds": { + "warning": 70, "critical": 90, + "max_step_percent": 10, "min_multiplier_percent": 50, "max_multiplier_percent": 150, "cooldown_days": 7 + } +} +``` + +| Field | Label usulan | Validasi | +|---|---|---| +| `name` | Nama | wajib, maks. 255 | +| `period_start`, `period_end` | Periode | `YYYY-MM-DD`, inklusif, akhir ≥ awal | +| `amount` | Budget (Rp) | > 0 | +| `thresholds.warning` / `.critical` | Ambang peringatan / kritis (%) | 0–100, warning ≤ critical; default 70 / 90 | +| `thresholds.max_step_percent` | Maks. perubahan hadiah per rekomendasi (%) | 1–50; default 10 | +| `thresholds.min_multiplier_percent` | Hadiah terendah (% dari yang ditulis admin) | 1–100; default 50 | +| `thresholds.max_multiplier_percent` | Hadiah tertinggi (% dari yang ditulis admin) | 100–1000; default 150 | +| `thresholds.cooldown_days` | Jeda antar rekomendasi diterima (hari) | 0–90; default 7 | + +Nilai default threshold dan guardrail masih **sementara**, menunggu keputusan bisnis +(RFC §19.2 #4). Tampilkan default sebagai placeholder, bukan nilai yang tersimpan. + +### 9.2 Metrik — `GET /marketing/enakgame/budgets/:id/metrics` + +```json +{ + "budget_id": "…", "scope": "GLOBAL", "period_start": "2026-10-01", "period_end": "2026-10-31", + "as_of": "2026-10-21", "amount": 100000000, + "realized_cost": 60000000, "remaining": 40000000, "utilization_percent": 60, + "daily_burn": 5500000, "window_days": 7, "remaining_days": 10, + "forecast_cost": 115000000, "forecast_remaining": -15000000, "forecast_utilization_percent": 115, + "coin_issued": 1250000, + "exposure": { "coins": 400000, "points": 90000 }, + "thresholds": { "warning": 70, "critical": 90 }, + "status": "CRITICAL" +} +``` + +| Field | Arti | Tampilkan sebagai | +|---|---|---| +| `realized_cost` | Biaya voucher yang sudah ditukar (Rp). Global: dalam periode; event: tanpa batas waktu | Terpakai | +| `remaining`, `utilization_percent` | Sisa dan persen terpakai | Progress bar | +| `daily_burn`, `window_days` | Rata-rata biaya per hari dalam `window_days` hari terakhir | Burn rate | +| `forecast_cost`, `forecast_remaining` | Perkiraan biaya di akhir periode = realized + burn × `remaining_days` | Perkiraan; merah bila `forecast_remaining` negatif | +| `coin_issued` | EnakCoin hadiah yang dibayar budget ini | – | +| `exposure` | EnakCoin dan EnakPoint dari budget ini yang masih beredar: batas atas biaya yang masih bisa datang | "Masih bisa menjadi biaya" | +| `status` | `HEALTHY`, `WARNING`, `CRITICAL`, `EXHAUSTED` | Badge hijau / kuning / oranye / merah | + +Status: `EXHAUSTED` bila realized ≥ budget; `CRITICAL` bila forecast melewati budget atau +utilization/forecast ≥ critical; `WARNING` bila ≥ warning. Budget habis **belum +menghentikan hadiah** (kebijakannya belum diputuskan), jadi tampilkan peringatan yang +jelas. + +### 9.3 Rekomendasi Budget Controller + +Untuk budget `GLOBAL`, sistem menghitung pengali hadiah supaya perkiraan biaya pas dengan +budget. Tidak ada yang berubah sampai admin menerimanya. + +`GET /marketing/enakgame/budgets/:id/recommendation` + +```json +{ + "budget_id": "…", + "state": "RECOMMENDED", + "message": "forecast Rp115000000 against a budget of Rp100000000: multiply rewards by 0.90", + "metrics": { "…": "sama seperti §9.2" }, + "guardrails": { "max_step_percent": 10, "min_multiplier_percent": 50, "max_multiplier_percent": 150, "cooldown_days": 7 }, + "target_multiplier": 0.7272, + "multiplier": 0.9, + "games": [ + { + "game_id": "…", "game_name": "Tap Tap", "reward_config_id": "…", "version": 1, "base_config_id": "…", + "reward_type": "FIXED", "current_multiplier": 1, "new_multiplier": 0.9, + "current_rules": { "amount": 10 }, "new_rules": { "amount": 9 }, + "current_max_reward": 10, "new_max_reward": 9 + } + ] +} +``` + +| `state` | Arti | Tampilan | +|---|---|---| +| `RECOMMENDED` | Ada rekomendasi yang bisa diterima | Tombol Terima aktif | +| `NO_CHANGE` | Perkiraan sudah pas | "Hadiah tidak perlu diubah" | +| `COOLDOWN` | Rekomendasi diterima kurang dari `cooldown_days` lalu | "Bisa diterima lagi pada {cooldown_until}"; tampilkan `games` sebagai gambaran | +| `AT_LIMIT` | Semua game sudah di batas min/max | "Hadiah sudah di batas terendah/tertinggi" | +| `INSUFFICIENT_DATA` | Belum ada biaya dalam `window_days` hari terakhir | "Belum cukup data" | +| `OUT_OF_PERIOD` | Periode belum mulai atau tidak ada hari tersisa | "Periode tidak berjalan" | + +- `target_multiplier`: pengali yang membuat perkiraan pas dengan budget; `multiplier`: + yang direkomendasikan, dibatasi `max_step_percent` dari 1 dan dibulatkan ke bawah ke + dua desimal. Bisa di bawah 1 (hadiah turun) atau di atas 1 (hadiah naik). +- `games`: perubahan per game. Pengali tiap game dihitung dari versi yang ditulis admin + (`base_config_id`), dan dibatasi `min_multiplier_percent`–`max_multiplier_percent`. + Semua jumlah dibulatkan ke bawah, jadi hadiah kecil bisa menjadi 0 (1 × 0,9 = 0). + Tampilkan `current_rules` → `new_rules` berdampingan supaya admin melihatnya. +- Budget `EVENT` ditolak (`304`): tambahan event diatur di event. + +**Terima:** `POST /marketing/enakgame/budgets/:id/recommendation/accept` + +```json +{ "multiplier": 0.9, "reason": "Burn rate terlalu tinggi" } +``` + +- Kirim `multiplier` yang ditampilkan. Bila rekomendasi sudah berubah sejak layar dibuka, + server menolak (`304`) dan tidak mengubah apa pun: muat ulang rekomendasi. +- Berhasil: setiap game di `games` mendapat versi reward config baru yang langsung + `ACTIVE`, versi lama `RETIRED`. Response + `{ "budget_id", "multiplier", "reward_configs": [ … ] }`. +- Tercatat di audit dengan sumber Budget Controller. Mulai saat itu cooldown berlaku untuk + seluruh organisasi. +- Admin yang menulis versi baru sendiri untuk sebuah game memulai pengalinya dari 1 lagi. + +--- + +## 10. Event + +Event (= campaign) membuat game tertentu memberi hadiah lebih selama periode tertentu. +Tambahannya dibayar budget `EVENT` milik event itu. + +| Method | Path | Body / query | +|---|---|---| +| `GET` | `/marketing/enakgame/events` | `?status=&page=&limit=` | +| `GET` | `/marketing/enakgame/events/:id` | – | +| `POST` | `/marketing/enakgame/events` | Objek event | +| `PUT` | `/marketing/enakgame/events/:id` | Field yang diubah | +| `PUT` | `/marketing/enakgame/events/:id/status` | `{ "status": "ENDED", "reason": "…" }` | + +```json +{ + "name": "Ramadan 2x", + "slug": "ramadan-2x", + "description": "Hadiah dobel selama Ramadan", + "banner_url": "https://…/ramadan.png", + "start_at": "2027-02-17T00:00:00+07:00", + "end_at": "2027-03-18T23:59:59+07:00", + "timezone": "Asia/Jakarta", + "priority": 10, + "multiplier": 2, + "bonus": null, + "budget_id": "", + "reward_limit": 5000000, + "user_daily_limit": 200, + "game_ids": ["…", "…"], + "status": "DRAFT" +} +``` + +| Field | Arti | Validasi | +|---|---|---| +| `start_at`, `end_at` | Periode berlaku | wajib, akhir setelah awal | +| `timezone` | Zona waktu untuk tampilan | default `Asia/Jakarta` | +| `multiplier` | Pengali hadiah dasar: 2 = hadiah dasar ditambah sekali lagi | ≥ 1, maks. 2 desimal | +| `bonus` | Tambahan EnakCoin per main | ≥ 1 | +| | | Minimal salah satu: `multiplier` > 1 atau `bonus` | +| `priority` | Urutan bila beberapa event berlaku | angka lebih besar didahulukan | +| `budget_id` | Budget `EVENT` organisasi ini | wajib | +| `reward_limit` | Maks. tambahan EnakCoin selama event | ≥ 1, kosong = tanpa batas | +| `user_daily_limit` | Maks. tambahan per customer per hari | ≥ 1, kosong = tanpa batas | +| `game_ids` | Game yang ikut | minimal satu game organisasi ini | +| `status` | Status awal | hanya saat buat: `DRAFT` (default) atau `ACTIVE` | + +**Status:** `DRAFT` → `ACTIVE` atau `CANCELLED`; `ACTIVE` → `ENDED` atau `CANCELLED`. +Event `ACTIVE` hanya berlaku di antara `start_at` dan `end_at`. + +**Beberapa event sekaligus.** Tambahan setiap event dihitung dari hadiah dasar (tidak +saling mengalikan), lalu dijumlahkan. Bila total melewati `max_reward` game, tambahan +event berprioritas terendah dipotong lebih dulu. Main yang hadiah dasarnya 0 tidak +mendapat tambahan event. Contoh: hadiah dasar 10, event 2x → 10 dari budget global + +10 dari budget event. + +--- + +## 11. Voucher + +Voucher adalah satu-satunya cara memakai EnakPoint: customer menukar EnakPoint +(`point_cost`) dengan voucher di aplikasi, memakai PIN. Voucher berdiri sendiri, tidak +di bawah EnakGame: EnakPoint dari belanja pun ditukar di sini. Hubungannya dengan EnakGame +hanya di budget: bila EnakPoint yang ditukar berasal dari hadiah game, nilai vouchernya +dicatat sebagai biaya budget (§9). + +### 11.1 Voucher + +| Method | Path | Body / query | +|---|---|---| +| `GET` | `/marketing/vouchers` | `?status=&search=&page=&limit=` | +| `GET` | `/marketing/vouchers/:id` | – | +| `POST` | `/marketing/vouchers` | Objek voucher | +| `PUT` | `/marketing/vouchers/:id` | Field yang diubah; `stock_mode` tidak bisa berubah | +| `PUT` | `/marketing/vouchers/:id/status` | `{ "status": "ACTIVE", "reason": "…" }` | + +```json +{ + "name": "Kopi Susu Gratis", + "description": "Berlaku untuk ukuran regular", + "image_url": "https://…/kopi.png", + "voucher_type": "FREE_ITEM", + "face_value": 20000, + "point_cost": 15000, + "business_cost": 8000, + "stock_mode": "CODE_POOL", + "max_per_customer": 2, + "valid_from": "2026-10-01T00:00:00+07:00", + "valid_until": "2026-12-31T23:59:59+07:00", + "terms": { "outlets": "Semua outlet", "notes": "Tidak bisa digabung promo lain" }, + "status": "DRAFT" +} +``` + +| Field | Arti | Validasi | +|---|---|---| +| `voucher_type` | Jenis | `FIXED_VALUE`, `PERCENTAGE`, `FREE_ITEM`, `MERCHANT_BENEFIT` | +| `face_value` | Nilai voucher (Rp); **dihitung sebagai biaya budget** | > 0 | +| `point_cost` | EnakPoint yang dibayar customer | > 0 | +| `business_cost` | Biaya sebenarnya untuk laporan Finance; tidak dipakai budget | ≥ 0, opsional | +| `stock_mode` | Asal voucher (lihat bawah) | `STATIC`, `CODE_POOL`, `EXTERNAL` | +| `stock` | Stok, hanya `STATIC` | wajib untuk `STATIC`, ≥ 0 | +| `provider`, `provider_ref` | Hanya `EXTERNAL` | `provider` wajib untuk `EXTERNAL` | +| `max_per_customer` | Batas tukar per customer | ≥ 1, kosong = tanpa batas | +| `valid_from`, `valid_until` | Masa bisa ditukar | akhir setelah awal | +| `terms` | Syarat & ketentuan | objek JSON; sepakati bentuknya dengan tim aplikasi | +| `status` | Status awal | hanya saat buat: `DRAFT` (default), `ACTIVE`, `INACTIVE` | + +| `stock_mode` | Cara kerja | +|---|---| +| `STATIC` | Stok berupa angka; customer mendapat voucher tanpa kode | +| `CODE_POOL` | Setiap penukaran mengambil satu kode yang diimpor (§11.2); stok = kode `AVAILABLE` | +| `EXTERNAL` | Kode dari penyedia luar. **Belum bisa dipakai**: belum ada penyedia yang tersambung, jadi voucher ini tidak tampil di katalog customer | + +**Status:** `DRAFT`, `ACTIVE` (tampil di katalog selama dalam masa berlaku dan ada stok), +`INACTIVE`, `ARCHIVED` (permanen, tidak bisa diubah lagi). + +### 11.2 Kode voucher (`CODE_POOL`) + +**Impor:** `POST /marketing/vouchers/:id/codes` dengan file CSV sebagai +multipart field `file` (atau CSV sebagai body). + +```csv +code,expires_at +KOPI-7F3C-2291,2026-12-31 +KOPI-8A1D-5530, +``` + +- Kolom 1: kode (wajib, maks. 255). Kolom 2: kedaluwarsa, opsional, `YYYY-MM-DD` + (berlaku sampai akhir hari WIB) atau RFC3339. Baris header `code` boleh ada. +- Maks. 50.000 baris dan 16 MB per file. Kode yang sudah ada di pool atau berulang di file + dilewati. + +```json +{ "imported": 1998, "duplicate_count": 1, "duplicates": ["KOPI-7F3C-2291"], "invalid": [{ "line": 17, "reason": "…" }] } +``` + +Tampilkan ringkasan: berapa masuk, berapa duplikat, dan baris yang gagal dengan nomor +barisnya. + +**Daftar:** `GET /marketing/vouchers/:id/codes?status=AVAILABLE&page=1&limit=20` + +```json +{ + "counts": { "AVAILABLE": 1500, "REDEEMED": 480, "EXPIRED": 20 }, + "codes": { "data": [ { "id": "…", "code": "KOPI-…", "status": "AVAILABLE", "redemption_id": null, "expires_at": "…", "created_at": "…" } ], "pagination": { "…": "…" } } +} +``` + +Status kode: `AVAILABLE`, `RESERVED`, `REDEEMED`, `EXPIRED` (lewat `expires_at`, +diproses tiap jam), `CANCELLED`. Tampilkan `counts` sebagai ringkasan stok di atas tabel, +dan peringatan bila `AVAILABLE` hampir habis. + +--- + +## 12. Analytics + +Rentang tanggal WIB, kedua ujung termasuk, maks. 366 hari. `from` dan `to` wajib. + +### 12.1 Game — `GET /marketing/enakgame/analytics/games?from=2026-10-01&to=2026-10-31&game_id=` + +`game_id` opsional untuk satu game. Dihitung dari session yang **dimulai** dalam +rentang. + +```json +{ + "from": "2026-10-01", "to": "2026-10-31", + "totals": { + "plays": 4200, "completed": 3900, "refunded": 12, "expired": 288, "flagged": 35, "players": 820, + "average_score": 742.5, "average_reward": 6.2, "reward_per_play": 5.76, + "coin_issued": 24180, "entry_cost_paid": 21000, "coin_refunded": 60 + }, + "games": [ { "game_id": "…", "game_name": "Spin Harian", "plays": 3000, "…": "field sama dengan totals" } ] +} +``` + +| Field | Label usulan | +|---|---| +| `plays` | Total main | +| `completed`, `refunded`, `expired` | Selesai / dikembalikan / tidak selesai | +| `flagged` | Hasil mencurigakan (gagal validasi, hadiah 0) | +| `players` | Customer unik | +| `average_score` | Rata-rata skor (`null` bila game tidak memakai skor) | +| `average_reward`, `reward_per_play` | Rata-rata hadiah per main selesai / per main | +| `coin_issued` | EnakCoin hadiah | +| `entry_cost_paid`, `coin_refunded` | EnakCoin dibayar untuk main / dikembalikan | + +`games` urut dari yang paling banyak dimainkan. + +### 12.2 Ekonomi — `GET /marketing/enakgame/analytics/economy?from=2026-10-01&to=2026-10-31` + +Dihitung dari semua mutasi wallet organisasi dalam rentang. + +```json +{ + "from": "2026-10-01", "to": "2026-10-31", + "coin": { "generated": 52000, "game_rewards": 24180, "spent_on_games": 20940, "exchanged": 9000, "spent": 29940, "expired": 300, "outstanding": 61000 }, + "point": { "earned": 1800000, "exchanged": 2700, "redeemed": 900000, "expired": 15000, "balance": 4200000 }, + "by_type": [ { "currency": "COIN", "type": "GAME_REWARD", "credit": 24180, "debit": 0, "transactions": 3900 } ] +} +``` + +| Field | Arti | +|---|---| +| `coin.generated` | EnakCoin baru: hadiah game, belanja (dikurangi pembatalan), migrasi, adjustment tambah | +| `coin.spent_on_games` | Entry cost dikurangi yang dikembalikan | +| `coin.exchanged` | Ditukar ke EnakPoint | +| `coin.outstanding` / `point.balance` | Dipegang customer di akhir rentang | +| `point.earned` | Dari belanja, dikurangi pembatalan | +| `point.exchanged` | Hasil tukar EnakCoin | +| `point.redeemed` | Ditukar ke voucher, dikurangi penukaran yang gagal | +| `by_type` | Rincian per tipe mutasi, termasuk transfer (yang tidak dihitung di angka utama) | + +--- + +## 13. Belum tersedia + +Fitur berikut belum ada di backend; jangan dibuat layarnya dulu: + +- Daftar session main dan filter hasil mencurigakan (`flagged`) untuk admin. +- Daftar penukaran voucher untuk admin. +- Layar audit log (perubahan tetap tercatat di backend). +- Kebijakan saat budget habis, dan mode otomatis Budget Controller. +- Menandai voucher sudah dipakai di POS ([`integration-pos.md`](./integration-pos.md) §5). +- Voucher `EXTERNAL` (belum ada penyedia). + +--- + +## 14. Pesan error dan checklist + +| `code` | HTTP | Kapan terjadi | Yang ditampilkan | +| --- | --- | --- | --- | +| `303`, `310` | 400 | Body tidak valid, field tak dikenal, UUID salah | "Data tidak valid" + `cause` untuk developer | +| `304` | 400 | Nilai di luar batas, aturan bisnis (slug terpakai, periode budget tumpang tindih, versi sudah pensiun, rekomendasi berubah, dst.) | `cause` di dekat field atau di toast | +| – | 403 | Role tidak boleh mengubah (§1) | "Kamu tidak punya akses" | +| `404` | 404 | Data bukan milik organisasi ini atau tidak ada | "Data tidak ditemukan" | +| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" | + +Pesan `cause` berbahasa Inggris, mis. `thresholds.warning cannot be above +thresholds.critical`. Cek batas di sisi klien (tabel di tiap bagian) dan tampilkan +`cause` hanya sebagai cadangan. + +### Checklist rilis + +**Loyalitas** +- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning. +- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi (`impact`, `expiry_activations`). +- [ ] Preview kedaluwarsa tampil di bawah pengaturan kedaluwarsa. +- [ ] Wallet customer: saldo yang bisa dipakai, lot, riwayat dengan nama asli, label semua tipe mutasi termasuk game dan voucher. +- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`; Telusuri di setiap baris. +- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. +- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". + +**EnakGame** +- [ ] Game: form lengkap, `entry_cost` ≥ 1, status, `result_rules`. +- [ ] Reward config: editor per jenis, riwayat versi, aktivasi dengan alasan, badge versi Budget Controller. +- [ ] Spin bisa dibuat end-to-end mengikuti §8.4. +- [ ] Budget global per bulan, threshold dan guardrail, peringatan bila tidak ada budget global berjalan. +- [ ] Metrik budget dengan status dan perkiraan; rekomendasi dengan perbandingan aturan lama/baru dan tombol Terima. +- [ ] Event: form, status, budget `EVENT`, pilihan game. +- [ ] Voucher: form per `stock_mode`, impor kode CSV dengan ringkasan, stok kode. +- [ ] Analytics game dan ekonomi dengan pemilih rentang tanggal. +- [ ] Tombol ubah disembunyikan untuk role yang bukan loyalty manager. + +Transfer antar customer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar +backoffice boleh disiapkan lebih dulu. diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md new file mode 100644 index 0000000..21236e4 --- /dev/null +++ b/docs/integration-enakgame.md @@ -0,0 +1,298 @@ +# Integrasi EnakGame: Game Client (Phaser) + +**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di +dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main +dari awal sampai akhir: memulai session (EnakCoin dipotong), menjalankan permainan, +mengirim hasil, dan menampilkan hadiah. Jangan mengarang endpoint, field, atau aturan +yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke tim backend. + +Pembagian tugas dengan aplikasi customer: + +| Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) | +|---|---| +| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§2) | +| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah | +| Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup | + +Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan +[`enakgame-prd.md`](./enakgame-prd.md). + +--- + +## 1. Aturan yang tidak boleh dilanggar + +1. **Server yang menentukan hadiah.** Game hanya mengirim **hasil main**: `score`, + `outcome`, dan `data`. Jangan pernah mengirim jumlah hadiah. Kalaupun terkirim, + backend mengabaikannya. Untuk spin, server yang mengundi segmennya. +2. **Tampilkan hadiah dari response, bukan dari hitungan sendiri.** Angka di layar akhir + selalu `reward_total` dari backend. +3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama. +4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori, + tidak pernah ditaruh di URL, `localStorage`, cookie, log, atau analytics. +5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. +6. **Main game tidak butuh PIN.** + +--- + +## 2. Bridge dengan aplikasi customer + +> **Usulan.** Bentuk bridge di bawah belum diimplementasikan di sisi mana pun. Sepakati +> dengan tim aplikasi customer sebelum mulai; aplikasi memakai kontrak yang sama +> ([`integration-mobile-customer.md`](./integration-mobile-customer.md) §8.3). + +Semua pesan berupa JSON string dengan field `type`. + +- **Game → aplikasi:** `window.EnakGameHost.postMessage(JSON.stringify(pesan))` + (JavaScript channel webview bernama `EnakGameHost`). +- **Aplikasi → game:** aplikasi memanggil `window.enakGame.receive(jsonString)`. Game + wajib mendefinisikan fungsi ini sebelum mengirim `ready`. + +| Arah | `type` | Isi | Kapan | +|---|---|---|---| +| game → app | `ready` | – | Halaman game selesai dimuat | +| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready` | +| game → app | `token_expired` | – | Backend menolak token (§3) | +| app → game | `token` | `token` | Token baru setelah `token_expired` | +| game → app | `balance_changed` | `coin_balance` | Setelah start dan complete berhasil | +| game → app | `close` | – | Customer keluar dari game | + +Contoh `init`: + +```json +{ "type": "init", "api_base_url": "https://api.example.com/api/v1", "token": "eyJ…", "game_id": "8a1f…" } +``` + +Jangan memanggil API apa pun sebelum `init` diterima. Untuk development di browser +tanpa aplikasi, sediakan mode dev yang mengisi `init` dari config lokal; mode itu tidak +boleh ikut di build produksi. + +--- + +## 3. Koneksi ke API + +- Header: `Authorization: Bearer ` dari `init`. +- Sukses: `{ "success": true, "data": { … }, "errors": null }`. +- Gagal: `{ "success": false, "data": null, "errors": [{ "code", "entity", "cause" }] }`. + `cause` berbahasa Inggris; jangan tampilkan mentah ke customer. + +| `errors[0].code` | HTTP | Arti | Yang dilakukan game | +|---|---|---|---| +| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum | +| `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint | +| `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi | +| `900` | 500 | Error server | Retry (§6) | + +**Token tidak berlaku** (kedaluwarsa, salah) dijawab HTTP 400 dengan code `304`, sama +seperti penolakan bisnis. Bedakan lewat `entity`: `auth_handler` untuk token, +`enakgame_service` untuk aturan EnakGame. Pada `auth_handler`, kirim `token_expired`, +tunggu `token`, lalu ulangi request yang sama. + +--- + +## 4. Alur satu kali main + +``` +init ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) + ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) + ─► permainan berjalan (batas waktu: expires_at) + ─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah) + ─► tampilkan hadiah ─► main lagi atau close +``` + +### 4.1 Data game — `GET /customer/enakgame/games` + +Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama dengan +`game_id` dari `init`. + +```json +[ + { + "id": "8a1f…", + "slug": "spin", + "name": "Spin Harian", + "description": null, + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html", + "version": "1.2.0", + "entry_cost": 5, + "session_ttl_seconds": 600, + "events": [ + { "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" } + ], + "prizes": [ + { "entry": 1, "label": "Zonk", "amount": 0 }, + { "entry": 2, "label": "3 Coin", "amount": 3 }, + { "entry": 3, "label": "10 Coin", "amount": 10 }, + { "entry": 4, "label": "Jackpot", "amount": 50 } + ] + } +] +``` + +- `entry_cost`: EnakCoin per main. Tampilkan di tombol Main ("Main · 5 EnakCoin"). +- `events`: event yang sedang berlaku, prioritas tertinggi dulu. Tampilkan sebagai + label, mis. "2x hadiah sampai 31 Okt". `multiplier` 2 berarti hadiah dasar ditambah + sekali lagi; `bonus` menambah sejumlah EnakCoin. +- `prizes`: hanya ada untuk game ber-reward `PROBABILITY` (spin). Urutan = urutan segmen + roda. `label` bisa `null`. Bobot peluang tidak pernah dikirim. +- Game tidak ada di daftar → game sudah dinonaktifkan; tampilkan pesan dan `close`. + +### 4.2 Mulai — `POST /customer/enakgame/sessions` + +Header `Idempotency-Key` wajib (maks. 50 karakter, mis. UUID v4). Buat key baru saat +customer menekan Main; pakai key yang sama bila request diulang karena jaringan. + +```json +{ "game_id": "8a1f…" } +``` + +```json +{ + "session_id": "c0d3…", + "game_id": "8a1f…", + "entry_cost": 5, + "expires_at": "2026-10-08T05:10:00Z", + "coin_balance": 15, + "replayed": false +} +``` + +- EnakCoin sudah terpotong. Kirim `balance_changed` dengan `coin_balance`. +- `replayed: true`: request ini mengulang start yang sudah berhasil; pakai session yang + sama, EnakCoin tidak terpotong dua kali. +- `expires_at`: batas waktu mengirim hasil (default 10 menit sejak start, diatur per + game). Tampilkan timer bila permainan bisa lama. + +| Penolakan `304` (`cause`) | Tampilan | +|---|---| +| `not enough EnakCoin` | "EnakCoin kamu kurang." Tombol kembali ke aplikasi | +| `the game is not available` | "Game sedang tidak tersedia." | +| `the game has no active reward configuration` | "Game sedang tidak tersedia." | +| `no EnakGame budget is set for this period` | "Game sedang tidak tersedia." | +| `the customer is not active` | "Akun tidak aktif." | +| `this Idempotency-Key was already used to start another game` | Bug di game: key dipakai ulang untuk game lain | +| `the Idempotency-Key header is required` / `… at most 50 characters` | Bug di game | + +### 4.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete` + +Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja: + +| Field | Tipe | Untuk | +|---|---|---| +| `score` | integer ≥ 0, opsional | Game berbasis skor | +| `outcome` | string, opsional | Game berbasis hasil, mis. `"WIN"`, `"PERFECT"` | +| `data` | objek JSON, opsional, maks. 16 KB | Data tambahan untuk audit (durasi per level, dsb.) | + +Spin cukup mengirim `{}`. Game skor: `{ "score": 800 }`. Game hasil: +`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game; +sepakati daftarnya dengan tim backoffice. + +```json +{ + "session_id": "c0d3…", + "status": "COMPLETED", + "reward_total": 10, + "reward": { "base": 5, "event": 5 }, + "coin_balance": 25, + "limited_by": ["USER_DAILY"], + "prize": { "entry": 2, "label": "3 Coin", "amount": 3 } +} +``` + +| Field | Arti | Tampilan | +|---|---|---| +| `reward_total` | EnakCoin yang **benar-benar masuk** | Angka utama di layar hadiah | +| `reward.base` / `reward.event` | Hadiah dasar dan tambahan event | "5 + 5 bonus event" | +| `coin_balance` | Saldo EnakCoin setelah hadiah | Kirim `balance_changed` | +| `limited_by` | Batas harian yang memotong hadiah: `USER_DAILY`, `GAME_DAILY`, `GLOBAL_DAILY` | "Hadiah hari ini sudah mencapai batas" | +| `prize` | Untuk spin: segmen hasil undian. `amount` = hadiah dasar segmen, sebelum event dan batas | Hentikan roda di `prize.entry` | +| `status` | `COMPLETED`, atau `REFUNDED` bila game dinonaktifkan selama dimainkan | Lihat di bawah | + +- **`status: "REFUNDED"`** (`refund_reason: "GAME_DEACTIVATED"`): entry cost + dikembalikan dan tidak ada hadiah. Tampilkan "Game sedang dihentikan, EnakCoin kamu + dikembalikan." +- **`reward_total` 0** bisa terjadi: hadiahnya memang 0 (mis. segmen Zonk), batas harian + sudah habis, atau hasilnya tidak lolos validasi server (skor di atas batas, terlalu + cepat selesai, `outcome` tidak dikenal). Server tidak memberi tahu alasan validasi; + tampilkan hasil apa adanya. +- **Mengirim ulang aman.** Complete untuk session yang sudah selesai mengembalikan + jawaban yang sama, tanpa hadiah dua kali. Tidak perlu `Idempotency-Key`. + +| Penolakan | Arti | Tampilan | +|---|---|---| +| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §5) | +| `304` `data must be …` | `data` bukan JSON atau lebih dari 16 KB | Bug di game | +| `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game | +| `404` | Session tidak ada / milik customer lain | Pesan umum | + +### 4.4 Cek status — `GET /customer/enakgame/sessions/:id` + +Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: + +```json +{ + "id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0, + "started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null +} +``` + +`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Riwayat main customer ada +di `GET /customer/enakgame/sessions?page=1&limit=20` (dipakai aplikasi, bukan game). + +--- + +## 5. Batas waktu dan refund + +| Keadaan | Yang terjadi pada EnakCoin | +|---|---| +| Hasil dikirim sebelum `expires_at` | Entry cost terpakai, hadiah masuk | +| Customer menutup game / game crash, hasil tidak pernah dikirim | Session menjadi `EXPIRED` setelah `expires_at`. **Entry cost tidak dikembalikan** | +| Complete gagal karena error server (`5xx`) dan tidak berhasil sampai `expires_at` | Session direfund otomatis (`refund_reason: "SYSTEM_ERROR"`) dalam ±1 menit setelah `expires_at` | +| Game dinonaktifkan admin saat dimainkan | Session direfund (`GAME_DEACTIVATED`) | + +Karena itu kirim hasil **segera** setelah permainan selesai, sebelum animasi panjang. +Saat customer menekan keluar di tengah permainan, tampilkan konfirmasi "EnakCoin yang +sudah dipakai tidak kembali". + +--- + +## 6. Retry dan jaringan + +| Request | Gagal karena jaringan / `5xx` | Aturan | +|---|---|---| +| Start | Ulangi dengan **`Idempotency-Key` yang sama** | Key baru = potong EnakCoin lagi | +| Complete | Ulangi dengan body yang sama sampai berhasil atau `expires_at` lewat | Aman diulang | +| Token ditolak (`entity` `auth_handler`) | `token_expired` → tunggu `token` → ulangi | Jangan minta customer login dari dalam game | + +Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama +complete diulang. + +--- + +## 7. Spin + +1. Gambar roda dari `prizes` (§4.1): satu segmen per entri, urut, dengan `label` + (atau `amount` bila `label` `null`). +2. Tap Putar → start session (§4.2). +3. Mulai animasi berputar, lalu langsung kirim complete dengan `{}`. +4. Dari response, hentikan roda di segmen `prize.entry`, lalu tampilkan `reward_total`. + +Jangan menentukan segmen sendiri lalu "mencocokkan" dengan server. Bila `prize` tidak +ada di response, hasil tidak bisa ditampilkan sebagai roda; tampilkan `reward_total` +saja. + +--- + +## 8. Checklist + +- [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. +- [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. +- [ ] Biaya main dan label event tampil sebelum main. +- [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry. +- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. +- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani. +- [ ] Spin berhenti di `prize.entry`. +- [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani. +- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar. diff --git a/docs/integration-mobile-customer.md b/docs/integration-mobile-customer.md new file mode 100644 index 0000000..9257448 --- /dev/null +++ b/docs/integration-mobile-customer.md @@ -0,0 +1,790 @@ +# Integrasi Mobile App Customer: EnakPoint, EnakCoin, EnakGame & Voucher + +**Untuk:** tim aplikasi mobile customer · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice). +Tugasmu: membangun fitur loyalitas di aplikasi, yaitu saldo EnakPoint & EnakCoin, +PIN, tukar, transfer, voucher, dan pintu masuk ke game EnakGame. Semuanya memakai API +backend yang sudah jadi dan dijelaskan di dokumen ini. Jangan mengarang endpoint, +field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan +dulu. + +Dokumen ini menggantikan `mobile-customer-enakpoint.md`, `integration-enakpoint.md`, +`api-enakpoint.md`, dan `enakgame-spin.md` untuk sisi aplikasi customer. Game-nya +sendiri (Phaser) dikerjakan tim EnakGame dengan +[`integration-enakgame.md`](./integration-enakgame.md). + +--- + +## 1. Konteks bisnis + +| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | +|---|---|---| +| Didapat dari | Belanja (order lunas), tukar EnakCoin, koreksi admin | Belanja, **hadiah game**, koreksi admin | +| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | +| Bisa dikirim ke customer lain | Ya | Ya | +| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan | + +Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin. + +### Aturan yang wajib dipatuhi di UI + +1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin. +2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan + Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada tarik tunai, dan EnakPoint + tidak bisa dipakai membayar. Jangan membangun layar bayar atau kode bayar. +3. **PIN 6 digit wajib** untuk: tukar EnakCoin, transfer, dan **tukar EnakPoint ke + voucher**. Main game, melihat saldo, dan riwayat tidak butuh PIN. +4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya + nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau + analytics. +5. **Satu akun customer = satu organisasi.** Saldo berlaku di semua outlet organisasi itu. +6. **Waktu memakai WIB.** Tanggal kedaluwarsa berarti saldo masih bisa dipakai sampai + 23:59:59 WIB di tanggal itu. +7. **Hadiah game ditentukan server.** Aplikasi tidak menghitung atau mengirim hadiah. + +--- + +## 2. Koneksi ke API + +- Base URL: `/api/v1` +- Semua endpoint customer: header `Authorization: Bearer ` +- Semua jumlah di request dan response berupa integer. +- Belum ada endpoint refresh token: bila token ditolak (§2.2, `entity` `auth_handler`), + customer login ulang. + +### 2.1 Registrasi customer + +`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional): + +```json +{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" } +``` + +- Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu. +- Bila `organization_id` tidak dikirim dan backend hanya punya satu organisasi, customer + otomatis masuk ke organisasi itu. Bila ada lebih dari satu, registrasi ditolak + ("organization_id is required"), jadi sebaiknya app selalu mengirimnya dari config per + environment/brand. +- `organization_id` yang dikirim harus ada; bila tidak, registrasi ditolak sebelum OTP dikirim. +- Wallet customer baru belum punya baris sampai saldo pertama kali bergerak; + `GET /customer/wallet` tetap menjawab saldo 0. + +### 2.2 Format response + +Sukses: + +```json +{ "success": true, "data": { … }, "errors": null } +``` + +Gagal: + +```json +{ "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] } +``` + +| `errors[0].code` | HTTP | Arti | Yang dilakukan app | +|---|---|---|---| +| `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum | +| `304` | 400 | Ditolak aturan bisnis, **atau token tidak berlaku** bila `entity` = `auth_handler` | Pesan yang ramah per fitur; `cause` berbahasa Inggris, jangan tampilkan mentah. Token: login ulang | +| `404` | 404 | Tidak ditemukan, juga untuk data milik customer lain | Tampilkan "tidak ditemukan" | +| `429` | 429 | Minta OTP terlalu cepat | Hitung mundur sebelum boleh minta lagi | +| `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) | +| `PIN_INVALID` | 400 | PIN salah | §6.5 | +| `PIN_LOCKED` | 423 | PIN terkunci | §6.5 | +| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | §6.5 | +| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" | + +### 2.3 Idempotency-Key + +Endpoint **tukar**, **transfer**, dan **tukar voucher** wajib header `Idempotency-Key` +(string unik, maks. 50 karakter, mis. UUID v4; `X-Idempotency-Key` juga diterima). + +- Buat **satu key baru saat customer menekan tombol konfirmasi**. +- Bila request gagal karena jaringan/timeout, **kirim ulang dengan key yang sama**. + Server mengembalikan hasil pertama dengan `"replayed": true` dan tidak memotong saldo + dua kali. +- Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`). + +--- + +## 3. Layar yang perlu dibuat + +| Layar | Endpoint utama | Butuh PIN | +|---|---|---| +| Beranda wallet | `GET /customer/wallet` | – | +| Riwayat mutasi | `GET /customer/wallet/transactions` | – | +| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – | +| Daftar outlet | `GET /customer/outlets` | – | +| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – | +| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya | +| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya | +| PIN (buat, ganti, lupa) | `/customer/pin/*` | – | +| Daftar game + webview game | `GET /customer/enakgame/games` | – | +| Riwayat main | `GET /customer/enakgame/sessions` | – | +| Katalog voucher | `GET /customer/vouchers` | – | +| Tukar voucher | `POST /customer/vouchers/:id/redeem` | Ya | +| Voucher saya | `GET /customer/vouchers/redemptions` | – | +| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – | + +--- + +## 4. Beranda wallet, riwayat, kedaluwarsa + +### 4.1 Beranda — `GET /customer/wallet` + +```json +{ + "point_balance": 12500, + "coin_balance": 8, + "point_value": 1, + "point_discount_value": 12500, + "nearest_expiring": { + "point": { "amount": 150, "date": "2026-12-31" }, + "coin": null + }, + "recent_transactions": [ /* sama dengan item riwayat §4.2, maksimal 5 */ ] +} +``` + +Tampilkan: +- Saldo EnakPoint (`point_balance`) dengan keterangan "setara potongan Rp + {point_discount_value}" (format ribuan Indonesia: `Rp 12.500`). +- Saldo EnakCoin (`coin_balance`). +- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan + kedaluwarsa pada {date}" yang membuka layar §4.3. +- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2. +- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8), Voucher (§9). + +Muat ulang beranda setelah setiap transaksi, saat webview game ditutup, dan saat +menerima push (§5). + +Field `total_points`, `points_history`, `last_updated` di response ini **deprecated**; +jangan dipakai. + +### 4.2 Riwayat — `GET /customer/wallet/transactions` + +Query (semua opsional): + +| Query | Contoh | Keterangan | +|---|---|---| +| `page` | `1` | Mulai dari 1 | +| `limit` | `20` | 1–100, default 20 | +| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin | +| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter | +| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif | + +```json +{ + "data": [ + { + "id": "…", + "currency": "POINT", + "type": "EARN", + "amount": 875, + "balance_after": 12500, + "description": "Belanja #ORD-0123 di Outlet Kemang", + "source": { "type": "ORDER", "id": "…" }, + "outlet_id": "…", + "group_id": null, + "expires_at": "2026-12-31T23:59:59+07:00", + "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], + "created_at": "2026-09-30T12:01:00Z" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } +} +``` + +Aturan tampilan: +- `amount` bertanda: positif tampil hijau dengan `+`, negatif merah dengan `−`. +- Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. +- `description` sudah siap tampil (nama lawan transfer sudah disamarkan, nama game dan + voucher sudah tertulis). Tampilkan apa adanya. +- Mutasi masuk yang punya `expires_at` menampilkan "Berlaku sampai {tanggal}". +- Infinite scroll memakai `pagination.total_pages`. +- Riwayat tidak pernah berubah atau hilang; koreksi muncul sebagai baris baru. + +Label tipe: + +| `type` | Mata uang | Label | Arah | +|---|---|---|---| +| `EARN` | keduanya | Dari belanja | + | +| `EARN_REVERSAL` | keduanya | Dibatalkan (order di-void/refund) | − | +| `EXCHANGE_OUT` | EnakCoin | Ditukar ke EnakPoint | − | +| `EXCHANGE_IN` | EnakPoint | Hasil tukar EnakCoin | + | +| `TRANSFER_OUT` | keduanya | Transfer keluar | − | +| `TRANSFER_IN` | keduanya | Transfer masuk | + | +| `GAME_SPEND` | EnakCoin | Main game | − | +| `GAME_SPEND_REFUND` | EnakCoin | Biaya main dikembalikan | + | +| `GAME_REWARD` | EnakCoin | Hadiah game | + | +| `REWARD_REDEEM` | EnakPoint | Ditukar ke voucher | − | +| `REWARD_REDEEM_REFUND` | EnakPoint | Penukaran voucher dibatalkan | + | +| `EXPIRE` | keduanya | Kedaluwarsa | − | +| `ADJUSTMENT` | keduanya | Koreksi | + / − | +| `MIGRATION` | keduanya | Saldo awal | + | + +Tipe yang tidak dikenal (bila backend menambah tipe baru): tampilkan `description` dan +arah dari tanda `amount`, tanpa label. + +### 4.3 Akan kedaluwarsa — `GET /customer/wallet/expiring` + +```json +{ + "point": [ + { "amount": 150, "date": "2026-10-31" }, + { "amount": 200, "date": "2026-12-31" } + ], + "coin": [] +} +``` + +Daftar per tanggal, paling dekat di atas. Daftar kosong: tampilkan "Tidak ada saldo +yang akan kedaluwarsa". Saldo yang kedaluwarsa hangus tanpa kompensasi. + +### 4.4 Daftar outlet — `GET /customer/outlets` + +Outlet aktif di organisasi customer, tempat saldo EnakPoint & EnakCoin berlaku. Urut +berdasarkan nama. + +```json +[ + { + "id": "…", + "name": "Gokuna Kemang", + "address": "Jl. Kemang Raya 10", + "earns_points": true, + "earns_coins": false + } +] +``` + +- `address` bisa `null`. +- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin. +- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend. + +### 4.5 Riwayat order — `GET /customer/orders` dan `GET /customer/orders/:id` + +Order milik customer yang login di semua outlet organisasinya, terbaru di atas. Order +hanya masuk ke sini bila kasir mengaitkannya ke customer. + +`GET /api/v1/customer/orders?page=1&limit=20` (`limit` 1–100, default 20): + +```json +{ + "data": [ + { + "id": "…", + "order_number": "ORD-0123", + "outlet_id": "…", + "outlet_name": "Gokuna 1", + "order_type": "dine_in", + "status": "completed", + "payment_status": "completed", + "total_amount": 99000, + "item_count": 2, + "is_void": false, + "is_refund": false, + "points_earned": 865, + "coins_earned": 3, + "created_at": "2026-09-30T12:01:00Z" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } +} +``` + +`GET /api/v1/customer/orders/{id}` mengembalikan field yang sama, ditambah: + +```json +{ + "table_number": "A3", + "subtotal": 90000, + "discount_amount": 0, + "tax_amount": 9000, + "refund_amount": 0, + "items": [ + { + "id": "…", + "product_id": "…", + "product_name": "Kopi Susu", + "variant_name": "Large", + "quantity": 2, + "unit_price": 25000, + "total_price": 50000, + "refund_quantity": 0, + "modifiers": [], + "status": "completed" + }, + { + "id": "…", + "product_id": "…", + "product_name": "Ikan Tude", + "variant_name": null, + "quantity": 1, + "weight": 4.2, + "unit_name": "ons", + "unit_price": 4500, + "total_price": 18900, + "refund_quantity": 0, + "modifiers": [], + "status": "completed" + } + ], + "payments": [ + { "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" } + ] +} +``` + +- Order customer lain atau yang tidak ada → `404`. +- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada. +- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons". +- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" / + "Direfund". + +--- + +## 5. Notifikasi push (FCM) + +### 5.1 Registrasi device + +Setelah login berhasil **dan** setiap kali FCM memberi token baru (`onTokenRefresh`): + +`PUT /api/v1/customer/devices` + +```json +{ "device_id": "", "fcm_token": "", "platform": "android", "app_version": "2.4.0" } +``` + +- `device_id` wajib, stabil untuk satu instalasi (simpan di secure storage). +- `platform`: `android`, `ios`, atau `web`. +- Satu token FCM hanya milik satu customer: bila customer lain login di HP yang sama, + customer sebelumnya tidak lagi menerima notifikasi di HP itu. +- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum + menghapus token login. + +Tanpa registrasi ini, customer tidak menerima push apa pun. + +### 5.2 Tipe push + +Semua nilai di `data` berupa string. + +| `data.type` | Kapan | Isi `data` lain | Aksi saat di-tap | +|---|---|---|---| +| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | Buka riwayat, sorot transaksi itu | +| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo hangus | `currency`, `amount`, `expiry_date` | Buka layar kedaluwarsa (§4.3) | +| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | Buka riwayat | +| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339 UTC) | Buka layar lupa PIN (§6.4) | + +Saat app terbuka dan menerima push wallet, muat ulang beranda. + +--- + +## 6. PIN + +### 6.1 Kapan diminta + +Jangan minta PIN saat registrasi. Minta saat customer **pertama kali** melakukan aksi +yang butuh PIN (tukar, transfer, tukar voucher). Cek dengan: + +`GET /api/v1/customer/pin/status` → `{ "has_pin": false, "locked_until": null, "transfer_blocked_until": null }` + +Bila `has_pin: false`, arahkan ke alur buat PIN, lalu kembali ke aksi semula. + +### 6.2 Buat PIN + +1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. + Response: `{ "purpose": "pin_setup", "otp_token": "…", "expires_at": "…" }`. + OTP dikirim ke WhatsApp customer. +2. Customer memasukkan kode OTP, lalu PIN dua kali. +3. `POST /api/v1/customer/pin` dengan + `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. + Response: status PIN. + +Validasi di app sebelum kirim (server juga memeriksa, jawab `304`): +- Tepat 6 digit angka, dan konfirmasi sama. +- Bukan satu digit berulang (`111111`). +- Bukan berurutan naik/turun (`123456`, `654321`). +- Bukan tanggal lahir customer (`DDMMYY` atau `YYMMDD`). + +Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur. + +### 6.3 Ganti PIN + +`PUT /api/v1/customer/pin` dengan `{ "old_pin": "…", "pin": "…", "confirm_pin": "…" }`. + +### 6.4 Lupa PIN + +1. `POST /customer/pin/otp` dengan `{ "purpose": "pin_reset" }`. +2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`. + +Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; +tukar EnakCoin dan tukar voucher tetap bisa. Beri tahu customer hal ini di layar sukses. + +### 6.5 Menangani error PIN + +Semua endpoint yang menerima `pin` bisa menjawab error PIN. Pada error ini **`data` +tidak `null`**: + +```json +{ "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [ … ] } +``` + +| `data.code` | Field tambahan | Tampilan | +|---|---|---| +| `PIN_NOT_SET` | – | Buka alur buat PIN (§6.2) | +| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa {n} percobaan." Kosongkan input PIN | +| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai {jam}." Tombol "Lupa PIN" | +| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada {waktu}." | + +5 kali salah berturut-turut mengunci PIN 30 menit; selama terkunci PIN yang benar pun +ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app. + +--- + +## 7. Tukar dan transfer + +### 7.1 Tukar EnakCoin → EnakPoint + +1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik): + + `GET /api/v1/customer/wallet/exchange/preview?coins=30` + + ```json + { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } + ``` + + - Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint. Tampilkan "10 EnakCoin = + 3 EnakPoint". + - Bila `valid: false`, tampilkan `reason` sebagai alasan dan nonaktifkan tombol. Jumlah + harus kelipatan `coin_amount`. + - Tampilkan "Kamu akan mendapat {points} EnakPoint". + +2. Konfirmasi (tukar tidak bisa dibatalkan) → minta PIN → + + `POST /api/v1/customer/wallet/exchange` + header `Idempotency-Key` + + ```json + { "coins": 30, "pin": "482913" } + ``` + + ```json + { + "group_id": "…", + "coins": 30, + "points": 9, + "coin_amount": 10, + "point_amount": 3, + "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], + "coin_balance": 5, + "point_balance": 9, + "replayed": false + } + ``` + +3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku + sampai {tanggal}". EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin + asalnya. + +Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan PIN. + +### 7.2 Transfer + +1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah. +2. Cek penerima: + + `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` + + ```json + { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } + ``` + + | Hasil | Tampilan | + |---|---| + | Sukses | "Kirim ke Bu*** Sa*** (08**-****-1234)?" | + | `404` | "Nomor ini tidak terdaftar" | + | `304` | "Tidak bisa mengirim ke nomor ini" (diri sendiri, akun nonaktif) | + +3. Konfirmasi (transfer final, tidak bisa dibatalkan) → minta PIN → + + `POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key` + + ```json + { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } + ``` + + ```json + { + "group_id": "…", + "currency": "POINT", + "amount": 120, + "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, + "lots": [ + { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, + { "amount": 20, "expires_at": null } + ], + "balance": 30, + "replayed": false + } + ``` + +4. Layar sukses: saldo tersisa (`balance`). Bila ada `lots[].expires_at`, tampilkan + "Saldo yang dikirim berlaku sampai {tanggal}" (tanggal kedaluwarsa ikut terbawa ke + penerima). + +Penolakan `304` yang mungkin: transfer dimatikan owner, di bawah minimal, di atas +maksimal per transaksi, melewati batas harian (reset tengah malam WIB), saldo tidak +cukup. Tampilkan pesan umum "Transfer tidak bisa diproses" plus alasan yang sesuai +bila bisa dikenali. Bila kena `TRANSFER_BLOCKED`, ikuti §6.5. + +Penerima mendapat push `WALLET_TRANSFER_IN`. + +--- + +## 8. Game (EnakGame) + +Game dimainkan di **webview** yang memuat `game_url` tiap game. Pembagian tugasnya: +aplikasi menampilkan daftar game, membuka webview, dan memberi token lewat bridge; game +EnakGame sendiri yang memulai session, memotong EnakCoin, mengirim hasil, dan +menampilkan hadiah ([`integration-enakgame.md`](./integration-enakgame.md)). Aplikasi +**tidak** memanggil `POST /customer/enakgame/sessions` atau `…/complete`. + +### 8.1 Daftar game — `GET /customer/enakgame/games` + +```json +[ + { + "id": "8a1f…", + "slug": "spin", + "name": "Spin Harian", + "description": null, + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html", + "version": "1.2.0", + "entry_cost": 5, + "session_ttl_seconds": 600, + "events": [ + { "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" } + ], + "prizes": [ { "entry": 1, "label": "Zonk", "amount": 0 } ] + } +] +``` + +Tampilkan: +- Kartu per game: `thumbnail_url`, `name`, biaya "{entry_cost} EnakCoin". +- Badge event bila `events` tidak kosong: `name` atau `banner_url`, dan "berakhir + {end_at}" (tampilkan dalam WIB). +- Tombol Main nonaktif dengan teks "EnakCoin kurang" bila `coin_balance` (§4.1) lebih + kecil dari `entry_cost`. +- `prizes` hanya dipakai game spin di dalam webview; aplikasi boleh mengabaikannya. + +Game yang dinonaktifkan admin hilang dari daftar ini. Muat ulang daftar setiap kali +layar dibuka. + +### 8.2 Membuka game + +1. Customer menekan Main → buka webview layar penuh dengan `game_url`. +2. Pasang bridge (§8.3) **sebelum** halaman dimuat. +3. Saat game mengirim `ready`, jawab dengan `init`. +4. Saat game mengirim `close`, tutup webview, lalu muat ulang beranda wallet (§4.1). + +Jangan menaruh token di URL `game_url` (query string atau fragment): URL bisa tercatat di +log server game dan riwayat webview. + +### 8.3 Bridge (sisi aplikasi) + +> **Usulan.** Kontrak ini sama dengan [`integration-enakgame.md`](./integration-enakgame.md) +> §2 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai. + +- Game → aplikasi: JavaScript channel webview bernama **`EnakGameHost`**; setiap pesan + berupa JSON string. +- Aplikasi → game: jalankan `window.enakGame.receive('')` di webview. + +| Pesan masuk dari game | Yang dilakukan aplikasi | +|---|---| +| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "/api/v1", "token": "", "game_id": "" }` | +| `{ "type": "token_expired" }` | Login ulang customer (tidak ada refresh token), lalu kirim `{ "type": "token", "token": "" }` | +| `{ "type": "balance_changed", "coin_balance": 15 }` | Perbarui saldo EnakCoin yang ditampilkan aplikasi | +| `{ "type": "close" }` | Tutup webview, muat ulang beranda | + +Abaikan pesan dengan `type` lain. Tombol back Android jangan langsung menutup webview: +tampilkan konfirmasi "Keluar dari game? EnakCoin yang sudah dipakai untuk main tidak +kembali", lalu tutup. Tidak perlu mengirim pesan ke game. + +### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20` + +```json +{ + "data": [ + { + "id": "…", "game_id": "8a1f…", "status": "COMPLETED", "entry_cost": 5, "reward_total": 10, + "started_at": "…", "expires_at": "…", "ended_at": "…", "refund_reason": null + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 3, "total_pages": 1 } +} +``` + +| `status` | Label | Keterangan | +|---|---|---| +| `STARTED` | Sedang dimainkan | | +| `COMPLETED` | Selesai | "Dapat {reward_total} EnakCoin" | +| `REFUNDED` | Dikembalikan | Entry cost kembali; `refund_reason` `SYSTEM_ERROR` atau `GAME_DEACTIVATED` | +| `EXPIRED` | Tidak selesai | Hasil tidak dikirim sebelum batas waktu; entry cost tidak kembali | + +Nama game diambil dari daftar game (§8.1) lewat `game_id`. Detail satu session: +`GET /customer/enakgame/sessions/:id`. + +--- + +## 9. Voucher (tukar EnakPoint) + +### 9.1 Katalog — `GET /customer/vouchers` + +Voucher yang bisa ditukar sekarang: aktif, dalam masa berlaku, dan masih ada stoknya. + +```json +[ + { + "id": "…", + "name": "Kopi Susu Gratis", + "description": "Berlaku untuk ukuran regular", + "image_url": "https://…/kopi.png", + "voucher_type": "FREE_ITEM", + "face_value": 20000, + "point_cost": 15000, + "max_per_customer": 2, + "valid_until": "2026-12-31T16:59:59Z", + "terms": { "…": "syarat & ketentuan, objek JSON bebas" }, + "available": 120 + } +] +``` + +- `point_cost`: EnakPoint yang dipotong. Tombol Tukar nonaktif bila `point_balance` + kurang. +- `face_value`: nilai voucher dalam rupiah, tampilkan sebagai "senilai Rp 20.000". +- `available`: sisa stok; `null` berarti stok tidak dihitung. Bila 0, tampilkan "Habis". +- `max_per_customer`: batas tukar per customer; `null` = tanpa batas. +- `terms`: objek JSON yang isinya diatur admin. Sepakati bentuknya dengan tim + backoffice; sebelum itu tampilkan `description` saja. + +| `voucher_type` | Label usulan | +|---|---| +| `FIXED_VALUE` | Potongan Rp {face_value} | +| `PERCENTAGE` | Potongan persen | +| `FREE_ITEM` | Gratis item | +| `MERCHANT_BENEFIT` | Benefit merchant | + +### 9.2 Tukar — `POST /customer/vouchers/:id/redeem` + +Konfirmasi ("Tukar {point_cost} EnakPoint dengan {name}? Tidak bisa dibatalkan.") → +minta PIN → kirim dengan header `Idempotency-Key`: + +```json +{ "pin": "482913" } +``` + +```json +{ + "id": "…", + "voucher_id": "…", + "voucher_name": "Kopi Susu Gratis", + "voucher_image_url": "https://…/kopi.png", + "voucher_type": "FREE_ITEM", + "status": "COMPLETED", + "face_value": 20000, + "point_cost": 15000, + "code": "KOPI-7F3C-2291", + "code_expires_at": "2026-12-31T16:59:59Z", + "completed_at": "…", + "created_at": "…", + "point_balance": 2500, + "replayed": false +} +``` + +- Layar sukses: voucher, `code` bila ada (bisa disalin), masa berlaku, dan saldo + EnakPoint baru (`point_balance`). +- `code` bisa `null`: voucher ini tidak memakai kode; tunjukkan layar voucher ke kasir. +- `status: "PENDING"`: voucher sedang diproses penyedia luar (belum ada voucher seperti + ini di katalog, tapi tangani dari sekarang). EnakPoint sudah terpotong; + tampilkan "Voucher sedang diproses" dan cek lagi di Voucher saya (§9.3). Bila akhirnya + `FAILED`, EnakPoint dikembalikan otomatis (mutasi `REWARD_REDEEM_REFUND`). +- Error PIN ditangani sesuai §6.5. + +| Penolakan `304` (`cause`) | Tampilan | +|---|---| +| `not enough EnakPoint` | "EnakPoint kamu kurang." | +| `the voucher is out of stock` | "Voucher sudah habis." Muat ulang katalog | +| `this voucher can be redeemed at most … times per customer` | "Kamu sudah mencapai batas penukaran voucher ini." | +| `the voucher is not available`, `… cannot be redeemed yet`, `… has ended`, `… not available yet` | "Voucher tidak tersedia." Muat ulang katalog | +| `the customer is not active` | "Akun tidak aktif." | +| `this Idempotency-Key was already used to redeem another voucher` | Bug di app: key dipakai ulang | + +### 9.3 Voucher saya — `GET /customer/vouchers/redemptions?page=1&limit=20` + +Daftar penukaran customer, terbaru di atas, dengan bentuk item sama seperti response +§9.2 (tanpa `point_balance` dan `replayed`), dibungkus `data` + `pagination`. + +| `status` | Tampilan | +|---|---| +| `COMPLETED` | Voucher siap dipakai: nama, `code` (bila ada), berlaku sampai `code_expires_at` | +| `PENDING` | "Sedang diproses" | +| `FAILED` | "Gagal, EnakPoint sudah dikembalikan" | + +**Memakai voucher di outlet:** customer menunjukkan layar voucher ke kasir. POS belum +bisa menandai voucher terpakai, jadi aplikasi belum bisa menampilkan status "sudah +dipakai" ([`integration-pos.md`](./integration-pos.md) §5). + +--- + +## 10. Yang sudah dihapus / deprecated + +Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada): + +| Lama | Pengganti | +|---|---| +| `POST /customer/spin` | Game EnakGame di webview (§8) | +| `GET /customer/games`, `GET /customer/ferris-wheel` | `GET /customer/enakgame/games` | +| `coins_used`, `coins_remaining`, `prize_won`, `game_play` di response spin | Tidak ada; hasil game ditampilkan di dalam game | +| `metadata.coin_cost` pada data game | `entry_cost` | +| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | +| `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` | +| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `accepts_point_payment` di `GET /customer/outlets` | – | +| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – | +| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi | + +Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru): + +| Lama | Pengganti | +|---|---| +| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | +| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | + +--- + +## 11. Checklist selesai + +- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat. +- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, dan label semua tipe di §4.2, termasuk tipe game dan voucher. +- [ ] Layar saldo akan kedaluwarsa. +- [ ] Registrasi device FCM setelah login dan saat token berganti; unregister saat logout. +- [ ] Penanganan tap untuk keempat tipe push. +- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP. +- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN (tukar, transfer, voucher). +- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama. +- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama. +- [ ] Daftar game dengan biaya, badge event, dan tombol nonaktif bila EnakCoin kurang. +- [ ] Webview game dengan bridge §8.3; token tidak pernah di URL; beranda dimuat ulang saat game ditutup. +- [ ] Riwayat main dengan label status. +- [ ] Katalog voucher, tukar dengan PIN dan `Idempotency-Key`, status `PENDING` ditangani. +- [ ] Voucher saya dengan kode yang bisa disalin. +- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat). +- [ ] Tidak ada pemakaian endpoint atau field di §10. +- [ ] PIN dan token tidak pernah disimpan sembarangan, di-log, atau dikirim ke analytics. diff --git a/docs/integration-pos.md b/docs/integration-pos.md new file mode 100644 index 0000000..1ba3105 --- /dev/null +++ b/docs/integration-pos.md @@ -0,0 +1,142 @@ +# Integrasi POS: EnakPoint, EnakCoin & Voucher + +**Untuk:** tim aplikasi POS (kasir) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan aplikasi **POS** yang dipakai kasir di outlet. Dokumen ini menjelaskan +bagian program loyalitas yang menyentuh POS: mengaitkan customer ke order, menampilkan +EnakPoint dan EnakCoin yang didapat, void/refund, dan voucher. Jangan mengarang +endpoint, field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, +tanyakan ke tim backend. + +Dokumen ini menggantikan bagian POS di `integration-enakpoint.md` dan `api-enakpoint.md`. + +--- + +## 1. Yang perlu diketahui kasir + +| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | +|---|---|---| +| Didapat dari | Belanja (order lunas), hasil tukar EnakCoin, koreksi admin | Belanja, hadiah game, koreksi admin | +| Dipakai untuk | **Ditukar ke voucher** di aplikasi customer | Main game, ditukar ke EnakPoint | +| Bisa membayar order | **Tidak** | **Tidak** | + +- **EnakPoint bukan alat bayar.** Tidak ada payment method EnakPoint di POS, dan saldo + tidak bisa dicairkan. Customer menukar EnakPoint ke voucher di aplikasinya sendiri. +- Saldo berlaku di **semua outlet** organisasi. Berapa yang didapat per order diatur + **per outlet** oleh owner di backoffice. +- Semua jumlah bilangan bulat. + +--- + +## 2. Mengaitkan customer ke order + +Earning hanya terjadi bila order dikaitkan ke customer terdaftar. Order tanpa customer, +dengan **customer default (walk-in)**, atau dengan customer nonaktif tidak mendapat +apa-apa. + +1. **Cari customer:** `GET /api/v1/customers?search=0812…&page=1&limit=20` + (cocok dengan nama, email, atau nomor HP). Abaikan customer dengan `is_default: true`. +2. **Kaitkan** dengan salah satu cara: + - saat membuat order: `POST /api/v1/orders` dengan `"customer_id": "…"`, atau + - setelah order dibuat: `PUT /api/v1/orders/:id/customer` dengan + `{ "customer_id": "…" }`. + +**Kaitkan sebelum order lunas.** Earning dihitung saat order menjadi lunas penuh. +Customer yang dikaitkan setelah lunas tetap mendapat earning lewat job susulan yang +berjalan tiap 30 menit untuk order lunas 72 jam terakhir, tapi tidak langsung, sehingga +struk akan menulis 0. + +--- + +## 3. Earning: yang didapat dari order + +Earning berjalan otomatis di backend saat order lunas lewat jalur pembayaran mana pun +(`POST /payments`, update order, split bill). POS tidak memanggil apa-apa. + +- **Basis** = `subtotal − discount_amount`, **sebelum pajak** dan biaya lain. +- Rumus per outlet (diatur owner): mode `PER_AMOUNT` + `floor(basis ÷ earn_per_amount) × earn_value`, atau mode `PERCENTAGE` + `floor(basis × earn_percent ÷ 100)`, dengan minimal belanja dan batas per order. +- Contoh: basis Rp 87.500, outlet memberi 1 EnakPoint per Rp 100 dan 1 EnakCoin per + Rp 25.000 → **875 EnakPoint** dan **3 EnakCoin**. + +Response order (`GET /api/v1/orders/:id` dan response order lainnya) membawa: + +```json +{ "points_earned": 875, "coins_earned": 3 } +``` + +Keduanya 0 bila order tidak mendapat apa-apa. **Cetak di struk**, mis. "Kamu mendapat +875 EnakPoint & 3 EnakCoin". Ambil nilainya setelah pembayaran terakhir berhasil; bila +masih 0 padahal customer sudah dikaitkan, earning akan menyusul (§2). + +--- + +## 4. Void dan refund + +Tidak ada langkah tambahan di POS. Saat order di-void atau direfund, backend menarik +kembali yang didapat dari order itu (mutasi `EARN_REVERSAL` di riwayat customer): + +| Kejadian | Yang ditarik | +|---|---| +| Void | Semua EnakPoint dan EnakCoin dari order itu | +| Refund (sebagian atau penuh) | `floor(earned × total_refund ÷ basis)`, tidak pernah lebih dari yang didapat; refund berikutnya hanya menarik sisanya | + +Bila saldo customer sudah terpakai, yang ditarik sebanyak yang ada. **Refund tidak +pernah diblokir** karena ini. + +--- + +## 5. Voucher dari EnakPoint + +Customer menukar EnakPoint ke voucher di aplikasi customer. Voucher yang didapat tampil +di menu "Voucher saya" di aplikasi itu, dengan nama, nilai (`face_value`), jenis, dan +bila ada, **kode** serta tanggal berlakunya. + +> **Belum tersedia:** POS belum punya endpoint untuk **mengecek** atau **menandai +> voucher sudah dipakai**. Ini pekerjaan lanjutan di backend. + +Sampai endpoint itu ada: + +1. Kasir melihat voucher di layar aplikasi customer (nama, nilai, kode, masa berlaku). +2. Kasir memasukkan potongannya sebagai **diskon biasa** di order, sesuai jenisnya: + + | `voucher_type` | Cara memasukkan | + |---|---| + | `FIXED_VALUE` | Diskon nominal sebesar `face_value` | + | `PERCENTAGE` | Diskon persen sesuai syarat voucher | + | `FREE_ITEM` | Item gratis sesuai syarat voucher | + | `MERCHANT_BENEFIT` | Sesuai syarat voucher | + +3. Karena backend belum mencatat voucher terpakai, outlet perlu mencatat kode yang + sudah dipakai secara manual supaya voucher yang sama tidak dipakai dua kali. + +Diskon dari voucher mengurangi basis earning seperti diskon lain (§3). + +--- + +## 6. Yang sudah dihapus + +Bayar dengan EnakPoint dihapus pada 7 Okt 2026. Jangan dipanggil atau ditampilkan lagi; +tidak ada penggantinya. + +| Dihapus | Catatan | +|---|---| +| Payment method tipe `point` ("EnakPoint") | Tidak ada di daftar payment method | +| Field `points` dan `payment_code` di `POST /payments` | `amount` wajib seperti pembayaran lain | +| `GET /orders/:id/point-payment/preview` | – | +| Kode bayar dari aplikasi customer | – | +| `points_used`, `point_value` di response pembayaran | – | +| `point_amount`, `points_used`, `total_with_points`, `counts_as_cash_in` di laporan payment method | `summary.total_amount` adalah total semua method | + +--- + +## 7. Checklist + +- [ ] Kasir bisa mencari dan mengaitkan customer ke order sebelum pembayaran. +- [ ] Customer default (walk-in) tidak ditawarkan sebagai pemilik earning. +- [ ] Struk mencetak `points_earned` dan `coins_earned`. +- [ ] Tidak ada payment method EnakPoint dan tidak ada field pembayaran EnakPoint di + request. +- [ ] Void/refund tidak menampilkan langkah tambahan untuk EnakPoint/EnakCoin. +- [ ] SOP outlet untuk voucher manual (§5) sudah disepakati sampai endpoint POS tersedia. diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md index c6e374f..5bf9355 100644 --- a/docs/prd-point-coin.md +++ b/docs/prd-point-coin.md @@ -388,7 +388,8 @@ beredar. Karena itu: > **Diganti EnakGame (2026-10-07).** Alur game di bawah (`POST /customer/spin`, > `metadata.coin_cost`, `game_plays`) sudah dihapus. Game sekarang dimainkan lewat > `/customer/enakgame/sessions` dengan `games.entry_cost`; lihat -> [RFC EnakGame](rfc-enakgame.md) §14 dan [enakgame-spin.md](enakgame-spin.md). +> [RFC EnakGame](rfc-enakgame.md) §14, [integration-backoffice.md](integration-backoffice.md) §8.4, +> dan [integration-enakgame.md](integration-enakgame.md). > EnakCoin tetap mata uang untuk bermain game. - **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin -- 2.54.0 From 52e8fe11c6dd7e9a7e15f8c4c44c50a3c83c5be5 Mon Sep 17 00:00:00 2001 From: efrilm Date: Thu, 8 Oct 2026 12:51:39 +0700 Subject: [PATCH 06/12] feat(enakgame): filter play history by game and status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /customer/enakgame/sessions takes optional game_id and status, so a game reloaded mid-play finds the session it was running (status=STARTED) instead of starting a new one and charging EnakCoin again. An invalid game_id or status is refused. integration-enakgame.md §4.4 now describes recovery after a reload: keep the session_id in sessionStorage, continue a STARTED session before expires_at, and call complete again for a COMPLETED one to get the full answer, prize included. The mobile guide and RFC §11 mention the filters. Co-Authored-By: Claude Opus 5.5 --- docs/integration-enakgame.md | 46 ++++++++++++++++--- docs/integration-mobile-customer.md | 3 ++ docs/rfc-enakgame.md | 2 +- internal/handler/enakgame_customer_handler.go | 10 ++-- internal/models/enakgame.go | 10 ++++ internal/processor/enakgame_db_test.go | 26 ++++++++++- internal/processor/game_session_processor.go | 24 ++++++++-- .../repository/enakgame_repository_test.go | 2 +- .../repository/game_session_repository.go | 24 ++++++++-- internal/service/enakgame_service.go | 6 +-- 10 files changed, 129 insertions(+), 24 deletions(-) diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md index 21236e4..5e611fb 100644 --- a/docs/integration-enakgame.md +++ b/docs/integration-enakgame.md @@ -30,7 +30,8 @@ Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan selalu `reward_total` dari backend. 3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama. 4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori, - tidak pernah ditaruh di URL, `localStorage`, cookie, log, atau analytics. + tidak pernah ditaruh di URL, `localStorage`, `sessionStorage`, cookie, log, atau + analytics. (`session_id` boleh disimpan di `sessionStorage`, §4.4.) 5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. 6. **Main game tidak butuh PIN.** @@ -94,7 +95,8 @@ tunggu `token`, lalu ulangi request yang sama. ## 4. Alur satu kali main ``` -init ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) +init ─► cek session yang masih berjalan (§4.4) + ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) ─► permainan berjalan (batas waktu: expires_at) ─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah) @@ -227,9 +229,13 @@ sepakati daftarnya dengan tim backoffice. | `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game | | `404` | Session tidak ada / milik customer lain | Pesan umum | -### 4.4 Cek status — `GET /customer/enakgame/sessions/:id` +### 4.4 Pemulihan setelah reload -Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: +Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash) +saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi +session-nya. Dua endpoint dipakai: + +**Satu session** — `GET /customer/enakgame/sessions/:id` ```json { @@ -238,8 +244,35 @@ Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: } ``` -`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Riwayat main customer ada -di `GET /customer/enakgame/sessions?page=1&limit=20` (dipakai aplikasi, bukan game). +`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Response ini tidak memuat +`prize` atau rincian hadiah; untuk itu kirim ulang complete (lihat di bawah). + +**Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1` + +Bentuk item sama dengan di atas, dibungkus `data` + `pagination`, terbaru di atas. +Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, +`EXPIRED`), `page`, `limit`. `status` atau `game_id` yang tidak valid ditolak `304`. + +**Alurnya, setiap kali menerima `init`:** + +1. Simpan `session_id` di **`sessionStorage`** setiap kali start berhasil, dan hapus + setelah hasilnya ditampilkan. `session_id` bukan rahasia; token tetap hanya di + memori (§1). +2. Bila ada `session_id` tersimpan, panggil `GET /sessions/:id`. Bila tidak ada (mis. + webview dibuka ulang dari awal), panggil + `GET /sessions?game_id=&status=STARTED&limit=1`. +3. Tindak lanjuti sesuai status: + +| Keadaan | Yang dilakukan game | +|---|---| +| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete | +| `STARTED`, `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru | +| `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}`): server mengembalikan jawaban yang sama persis, termasuk `prize`, tanpa hadiah dobel. Tampilkan hasilnya | +| `REFUNDED` | "EnakCoin kamu dikembalikan." | +| `EXPIRED` | "Waktu bermain habis." | +| Tidak ada session | Tampilkan layar awal seperti biasa | + +Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game. --- @@ -289,6 +322,7 @@ saja. - [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. - [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. +- [ ] Pemulihan setelah reload (§4.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang. - [ ] Biaya main dan label event tampil sebelum main. - [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry. - [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. diff --git a/docs/integration-mobile-customer.md b/docs/integration-mobile-customer.md index 9257448..e1ee629 100644 --- a/docs/integration-mobile-customer.md +++ b/docs/integration-mobile-customer.md @@ -616,6 +616,9 @@ kembali", lalu tutup. Tidak perlu mengirim pesan ke game. ### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20` +Query opsional `game_id` (riwayat satu game) dan `status` (`STARTED`, `COMPLETED`, +`REFUNDED`, `EXPIRED`) untuk filter atau tab. + ```json { "data": [ diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index ecebe2a..b2f8c8b 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -842,7 +842,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate. | `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` | | `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header | | `GET` | `/sessions/:id` | Status dan hasil | -| `GET` | `/sessions` | Riwayat main | +| `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) | Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama. diff --git a/internal/handler/enakgame_customer_handler.go b/internal/handler/enakgame_customer_handler.go index 1c40b12..9eafccb 100644 --- a/internal/handler/enakgame_customer_handler.go +++ b/internal/handler/enakgame_customer_handler.go @@ -50,16 +50,18 @@ func (h *EnakGameCustomerHandler) StartSession(c *gin.Context) { 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=. +// ListSessions is GET /customer/enakgame/sessions?game_id=&status=&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) + var q models.GameSessionListQuery + if !bindQuery(c, &q, method) { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, q), method) } // GetSession is GET /customer/enakgame/sessions/:id. diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index e342aef..aaa1207 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -189,6 +189,16 @@ type CustomerGameSession struct { RefundReason *string `json:"refund_reason"` } +// GameSessionListQuery filters a customer's play history. The game client uses +// game_id with status=STARTED to find the play it was running before a reload. +type GameSessionListQuery struct { + GameID string `form:"game_id"` + // STARTED, COMPLETED, REFUNDED or EXPIRED; empty for all. + Status string `form:"status"` + Page int `form:"page"` + Limit int `form:"limit"` +} + // 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 { diff --git a/internal/processor/enakgame_db_test.go b/internal/processor/enakgame_db_test.go index dab2f87..f66d514 100644 --- a/internal/processor/enakgame_db_test.go +++ b/internal/processor/enakgame_db_test.go @@ -675,12 +675,34 @@ func TestGameSession_CustomerReads(t *testing.T) { _, 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) + page, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{Page: 1, Limit: 10}) require.NoError(t, err) assert.EqualValues(t, 1, page.Pagination.Total) - page, err = e.sessions.ListSessions(ctx, e.bob, 1, 10) + page, err = e.sessions.ListSessions(ctx, e.bob, models.GameSessionListQuery{Page: 1, Limit: 10}) require.NoError(t, err) assert.Empty(t, page.Data) + + // After a reload, the game finds the play it was running by game and status. + other := e.playableGame(e.orgA, "other", 1) + otherStarted, err := e.sessions.Start(ctx, e.alice, other.ID, "k2") + require.NoError(t, err) + _, err = e.sessions.Complete(ctx, e.alice, otherStarted.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + running, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: game.ID.String(), Status: "started"}) + require.NoError(t, err) + require.Len(t, running.Data, 1) + assert.Equal(t, started.SessionID, running.Data[0].ID) + running, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: other.ID.String(), Status: constants.GameSessionStatusStarted}) + require.NoError(t, err) + assert.Empty(t, running.Data, "the other game's play is completed") + all, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{}) + require.NoError(t, err) + assert.EqualValues(t, 2, all.Pagination.Total) + + _, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{Status: "PLAYING"}) + assert.ErrorIs(t, err, ErrGameSessionRejected) + _, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: "runner"}) + assert.ErrorIs(t, err, ErrGameSessionRejected) } // gameWith makes an ACTIVE game of org A with the given result rules and an active diff --git a/internal/processor/game_session_processor.go b/internal/processor/game_session_processor.go index 387d6df..76f653c 100644 --- a/internal/processor/game_session_processor.go +++ b/internal/processor/game_session_processor.go @@ -419,10 +419,26 @@ func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UU 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) +// ListSessions returns a page of the customer's sessions, newest first, of one game +// or status when asked. +func (p *GameSessionProcessor) ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) (*models.PaginatedResponse[models.CustomerGameSession], error) { + page, limit := enakGamePage(q.Page, q.Limit) + filter := repository.CustomerSessionFilter{CustomerID: customerID, Offset: (page - 1) * limit, Limit: limit} + if s := strings.TrimSpace(q.GameID); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return nil, gameSessionRejected("game_id must be a UUID") + } + filter.GameID = &id + } + switch status := strings.ToUpper(strings.TrimSpace(q.Status)); status { + case "", constants.GameSessionStatusStarted, constants.GameSessionStatusCompleted, + constants.GameSessionStatusRefunded, constants.GameSessionStatusExpired: + filter.Status = status + default: + return nil, gameSessionRejected("status must be STARTED, COMPLETED, REFUNDED or EXPIRED") + } + sessions, total, err := p.sessions.ListCustomerSessions(ctx, filter) if err != nil { return nil, err } diff --git a/internal/repository/enakgame_repository_test.go b/internal/repository/enakgame_repository_test.go index 8205dd9..8cddc89 100644 --- a/internal/repository/enakgame_repository_test.go +++ b/internal/repository/enakgame_repository_test.go @@ -287,7 +287,7 @@ func TestGameSessionRepository_ReadsAndMoves(t *testing.T) { assert.True(t, got.Flagged) assert.NotNil(t, got.CompletionFailedAt) - sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1) + sessions, total, err := f.sessions.ListCustomerSessions(ctx, CustomerSessionFilter{CustomerID: f.customer, Limit: 1}) require.NoError(t, err) assert.EqualValues(t, 2, total) assert.Len(t, sessions, 1) diff --git a/internal/repository/game_session_repository.go b/internal/repository/game_session_repository.go index 5af53d7..e5bb062 100644 --- a/internal/repository/game_session_repository.go +++ b/internal/repository/game_session_repository.go @@ -50,7 +50,7 @@ type GameSessionRepository interface { 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) + ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]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) @@ -116,8 +116,26 @@ func (r *gameSessionRepository) GetSessionBySpendTransaction(ctx context.Context 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) +// CustomerSessionFilter selects a customer's sessions. +type CustomerSessionFilter struct { + CustomerID uuid.UUID + // Nil for every game. + GameID *uuid.UUID + // Empty for every status. + Status string + Offset int + Limit int +} + +func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error) { + q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", filter.CustomerID) + if filter.GameID != nil { + q = q.Where("game_id = ?", *filter.GameID) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + offset, limit := filter.Offset, filter.Limit var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count game sessions: %w", err) diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go index b240d78..dbc2767 100644 --- a/internal/service/enakgame_service.go +++ b/internal/service/enakgame_service.go @@ -72,7 +72,7 @@ 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 + ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response @@ -371,8 +371,8 @@ func (s *EnakGameCustomerServiceImpl) CompleteSession(ctx context.Context, custo 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) +func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response { + sessions, err := s.sessions.ListSessions(ctx, customerID, q) return respond(ctx, sessions, err) } -- 2.54.0 From 4b7eddbd3e0b2e7176b4edc00ee58c0dbf019f3e Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 9 Oct 2026 22:58:02 +0700 Subject: [PATCH 07/12] fix(cors): allow the Idempotency-Key header The EnakGame client runs at its game_url, another origin than the API, so the browser asks before POST /customer/enakgame/sessions. Idempotency-Key was not in Access-Control-Allow-Headers, so the preflight refused it and a play could not start. X-Idempotency-Key, the older name the backend also reads, is allowed too. Co-Authored-By: Claude Opus 5.5 --- internal/middleware/cors.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go index e9b4196..9420bb0 100644 --- a/internal/middleware/cors.go +++ b/internal/middleware/cors.go @@ -12,7 +12,7 @@ func CORS() gin.HandlerFunc { } c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") - c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, Idempotency-Key, X-Idempotency-Key") c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") if c.Request.Method == "OPTIONS" { -- 2.54.0 From 19afa50b9c3ea50dc27ff9be40fb840ecf704dea Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 9 Oct 2026 22:58:24 +0700 Subject: [PATCH 08/12] =?UTF-8?q?feat(customers):=20store=20customer=20pho?= =?UTF-8?q?ne=20numbers=20as=2062=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A customer's phone number (customers.phone_number, the one they log in with) has one stored form, 62 followed by the number without its 0: 6281234561234. util.NormalizePhoneNumber rewrites 0812…, +62 812…, 62812…, 812… and +62 0812…, with spaces, dashes, dots or parentheses, to it, and refuses anything that is not an Indonesian mobile number (628 and 7 to 11 more digits). It is applied wherever a customer types their number: check-phone, register, login and resend-OTP (the validator rewrites the request), and the transfer recipient. Before, the number was matched as typed and a leading 0 was refused, so the same customer written another way was not found. The masked recipient stays 08**-****-1234 as customers write numbers. Migration 000116 rewrites the numbers already stored in customers and otp_sessions. When several become the same number, the customer that already has it keeps it, else a registered one, else the oldest; the others, and numbers that are not Indonesian mobile numbers, are left as they are and cannot log in until fixed by hand (the query to find them is in the migration). Down does nothing. The migration was run, twice, against Postgres with a reduced customers and otp_sessions schema and sample numbers; not against the real schema. The Postgres tests were not run. customers.phone (the POS contact number) is not touched. Co-Authored-By: Claude Opus 5.5 --- .../wallet_exchange_processor_test.go | 20 +++--- .../processor/wallet_expiry_processor_test.go | 10 +-- internal/processor/wallet_move_db_test.go | 10 ++- .../processor/wallet_trace_processor_test.go | 10 +-- .../processor/wallet_transfer_processor.go | 18 ++++-- .../wallet_transfer_processor_test.go | 47 +++++++------- internal/util/phone.go | 41 ++++++++++++ internal/util/phone_test.go | 49 +++++++++++++++ internal/validator/customer_auth_validator.go | 22 ++++--- ..._normalize_customer_phone_numbers.down.sql | 2 + ...16_normalize_customer_phone_numbers.up.sql | 62 +++++++++++++++++++ 11 files changed, 234 insertions(+), 57 deletions(-) create mode 100644 internal/util/phone.go create mode 100644 internal/util/phone_test.go create mode 100644 migrations/000116_normalize_customer_phone_numbers.down.sql create mode 100644 migrations/000116_normalize_customer_phone_numbers.up.sql diff --git a/internal/processor/wallet_exchange_processor_test.go b/internal/processor/wallet_exchange_processor_test.go index 030f021..a722d0d 100644 --- a/internal/processor/wallet_exchange_processor_test.go +++ b/internal/processor/wallet_exchange_processor_test.go @@ -152,7 +152,7 @@ func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, acti func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) { e := newWalletMoveEnv(t) - c := e.member("Budi Santoso", "081234561234") + c := e.member("Budi Santoso", "6281234561234") e.earnCoins(t, c, 50, nil) res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{}) @@ -187,7 +187,7 @@ func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) { func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) { e := newWalletMoveEnv(t) e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 35, nil) preview, err := e.exchanges().Preview(e.ctx, c, 30) @@ -206,7 +206,7 @@ func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) { func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) { e := newWalletMoveEnv(t) e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 50, nil) preview, err := e.exchanges().Preview(e.ctx, c, 25) @@ -227,7 +227,7 @@ func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) { func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) { e := newWalletMoveEnv(t) e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3} - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") soon, later := e.at(24*time.Hour), e.at(48*time.Hour) first := e.earnCoins(t, c, 15, soon) second := e.earnCoins(t, c, 15, later) @@ -268,7 +268,7 @@ func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) { func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) { e := newWalletMoveEnv(t) e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1} - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 5, e.at(time.Hour)) e.earnCoins(t, c, 5, nil) @@ -281,7 +281,7 @@ func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) { func TestWalletExchange_NotEnoughCoins(t *testing.T) { e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 5, nil) preview, err := e.exchanges().Preview(e.ctx, c, 6) @@ -295,7 +295,7 @@ func TestWalletExchange_NotEnoughCoins(t *testing.T) { func TestWalletExchange_WrongPinMovesNothing(t *testing.T) { e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 5, nil) _, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{}) @@ -307,7 +307,7 @@ func TestWalletExchange_WrongPinMovesNothing(t *testing.T) { func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) { e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 100, nil) first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{}) @@ -330,7 +330,7 @@ func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) { func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) { e := newWalletMoveEnv(t) - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") e.earnCoins(t, c, 5, nil) _, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{}) @@ -344,7 +344,7 @@ func TestWalletExchange_CappedByThePointExpiry(t *testing.T) { e := newWalletMoveEnv(t) e.now = wib(2026, 6, 1, 10, 0) e.settings.PointExpiry = rolling(30, "DAY", false) - c := e.member("Budi", "081234561234") + c := e.member("Budi", "6281234561234") soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour) e.earnCoins(t, c, 10, soon) e.earnCoins(t, c, 10, later) diff --git a/internal/processor/wallet_expiry_processor_test.go b/internal/processor/wallet_expiry_processor_test.go index 0345313..90a8f93 100644 --- a/internal/processor/wallet_expiry_processor_test.go +++ b/internal/processor/wallet_expiry_processor_test.go @@ -54,7 +54,7 @@ func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcesso func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") ord := earn(a, 150, e.at(-time.Hour)) ord.Description = "Belanja #ORD-0098" due := e.credit(t, ord) @@ -102,7 +102,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) { func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 150, e.at(-time.Hour))) notifier := ¬ifierFake{} @@ -128,7 +128,7 @@ func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) { func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 150, e.at(-time.Hour))) p, repo := e.expiry(nil) // A lot listed that ExpireLot cannot find. @@ -142,7 +142,7 @@ func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) { func TestWalletExpiry_NothingDue(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 150, e.at(time.Hour))) notifier := ¬ifierFake{} p, _ := e.expiry(notifier) @@ -206,7 +206,7 @@ func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) { e.now = wib(2026, 10, 25, 9, 0) e.settings.PointExpiry.ReminderDays = 7 e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") oct31 := wib(2026, 10, 31, 23, 59) nov30 := wib(2026, 11, 30, 23, 59) e.credit(t, earn(a, 100, &oct31)) diff --git a/internal/processor/wallet_move_db_test.go b/internal/processor/wallet_move_db_test.go index 524c943..33e725e 100644 --- a/internal/processor/wallet_move_db_test.go +++ b/internal/processor/wallet_move_db_test.go @@ -2,6 +2,7 @@ package processor import ( "context" + "encoding/binary" "fmt" "os" "sync" @@ -42,7 +43,7 @@ func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) { require.NoError(t, err) org, a, b = uuid.New(), uuid.New(), uuid.New() - phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10] + phoneA, phoneB := walletTestPhone(a), walletTestPhone(b) require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error) require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`, a, org, phoneA, b, org, phoneB).Error) @@ -117,7 +118,7 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) { _, err := wallet.Credit(ctx, earn(b, 100, nil)) return err })) - phone := func(id uuid.UUID) string { return "08" + id.String()[:10] } + phone := walletTestPhone const rounds = 10 errs := make(chan error, 2*rounds) @@ -235,3 +236,8 @@ func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) { assert.Equal(t, int64(10), expires) assert.Zero(t, left) } + +// walletTestPhone is a phone number in its stored form, 628…, unique to the customer. +func walletTestPhone(id uuid.UUID) string { + return fmt.Sprintf("628%09d", binary.BigEndian.Uint64(id[:8])%1_000_000_000) +} diff --git a/internal/processor/wallet_trace_processor_test.go b/internal/processor/wallet_trace_processor_test.go index 47f174e..fdda0b2 100644 --- a/internal/processor/wallet_trace_processor_test.go +++ b/internal/processor/wallet_trace_processor_test.go @@ -85,8 +85,8 @@ func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string // redeems 30. Tracing B's redemption leads to A's order #ORD-1. func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi Santoso", "081234561234") + a := e.member("Anita", "6281200005678") + b := e.member("Budi Santoso", "6281234561234") ord1 := earn(a, 100, e.at(30*24*time.Hour)) ord1.Description = "Belanja #ORD-1" ord2 := earn(a, 50, e.at(60*24*time.Hour)) @@ -121,8 +121,8 @@ func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) { func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") + a := e.member("Anita", "6281200005678") + b := e.member("Budi", "6281234561234") e.credit(t, earn(a, 100, e.at(time.Hour))) e.credit(t, earn(a, 50, nil)) _, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{}) @@ -153,7 +153,7 @@ func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) { func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") res := e.credit(t, earn(a, 10, nil)) _, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID) diff --git a/internal/processor/wallet_transfer_processor.go b/internal/processor/wallet_transfer_processor.go index 6c2d7c1..83c9307 100644 --- a/internal/processor/wallet_transfer_processor.go +++ b/internal/processor/wallet_transfer_processor.go @@ -15,6 +15,7 @@ import ( "apskel-pos-be/internal/logger" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" + "apskel-pos-be/internal/util" ) // ErrWalletRecipientNotFound means no customer of the sender's organization has the @@ -213,10 +214,13 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU // them: an active customer of the same organization, not the walk-in customer, and // not the sender. func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) { - phoneNumber = strings.TrimSpace(phoneNumber) - if phoneNumber == "" { + if strings.TrimSpace(phoneNumber) == "" { return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected) } + phoneNumber, err := util.NormalizePhoneNumber(phoneNumber) + if err != nil { + return nil, fmt.Errorf("%w: the recipient's phone number is not valid", ErrWalletMoveRejected) + } recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber) if errors.Is(err, repository.ErrWalletNotFound) { return nil, ErrWalletRecipientNotFound @@ -282,10 +286,14 @@ func maskName(name string) string { return strings.Join(words, " ") } -// maskPhoneNumber keeps the first two and the last four digits: -// "081234561234" → "08**-****-1234". +// maskPhoneNumber keeps the first two and the last four digits of the number as +// customers write it, with 0 for 62: "6281234561234" → "08**-****-1234". func maskPhoneNumber(phone string) string { - runes := []rune(strings.TrimSpace(phone)) + phone = strings.TrimSpace(phone) + if strings.HasPrefix(phone, "62") { + phone = "0" + phone[2:] + } + runes := []rune(phone) if len(runes) < 8 { return "****" } diff --git a/internal/processor/wallet_transfer_processor_test.go b/internal/processor/wallet_transfer_processor_test.go index 090ca88..b942c68 100644 --- a/internal/processor/wallet_transfer_processor_test.go +++ b/internal/processor/wallet_transfer_processor_test.go @@ -37,8 +37,8 @@ func sendPoints(amount int64, phone string) models.WalletTransfer { func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi Santoso", "081234561234") + a := e.member("Anita", "6281200005678") + b := e.member("Budi Santoso", "6281234561234") dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour) first := e.credit(t, earn(a, 100, dec)) second := e.credit(t, earn(a, 50, jan)) @@ -97,8 +97,8 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) { func TestWalletTransfer_Coins(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") + a := e.member("Anita", "6281200005678") + b := e.member("Budi", "6281234561234") e.earnCoins(t, a, 10, nil) _, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{}) @@ -109,14 +109,14 @@ func TestWalletTransfer_Coins(t *testing.T) { func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") + a := e.member("Anita", "6281200005678") e.credit(t, earn(a, 100, nil)) - walkIn := e.member("Walk-in", "081100000000") + walkIn := e.member("Walk-in", "6281100000000") e.customers.byID[walkIn].IsDefault = true - inactive := e.member("Old", "081100000001") + inactive := e.member("Old", "6281100000001") e.customers.byID[inactive].IsActive = false - elsewhere := e.member("Other Org", "081100000002") + elsewhere := e.member("Other Org", "6281100000002") e.customers.byID[elsewhere].OrganizationID = uuid.New() for phone, want := range map[string]error{ @@ -126,6 +126,7 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { "081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody "081999999999": ErrWalletRecipientNotFound, "": ErrWalletMoveRejected, + "021-1234567": ErrWalletMoveRejected, // not a mobile number } { _, err := e.transfers(nil).Recipient(e.ctx, a, phone) assert.ErrorIs(t, err, want, phone) @@ -138,18 +139,20 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) { func TestWalletTransfer_RecipientIsMasked(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.member("Budi Santoso", "081234561234") + a := e.member("Anita", "6281200005678") + e.member("Budi Santoso", "6281234561234") - got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ") - require.NoError(t, err) - assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got) + for _, phone := range []string{" 081234561234 ", "6281234561234", "+62 812-3456-1234"} { + got, err := e.transfers(nil).Recipient(e.ctx, a, phone) + require.NoError(t, err, phone) + assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got, phone) + } } func TestWalletTransfer_OrganizationLimits(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.member("Budi", "081234561234") + a := e.member("Anita", "6281200005678") + e.member("Budi", "6281234561234") e.credit(t, earn(a, 1000, nil)) e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))} send := func(amount int64, key string) error { @@ -177,8 +180,8 @@ func TestWalletTransfer_OrganizationLimits(t *testing.T) { func TestWalletTransfer_HeldAfterPinReset(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - e.member("Budi", "081234561234") + a := e.member("Anita", "6281200005678") + e.member("Budi", "6281234561234") e.credit(t, earn(a, 100, nil)) until := e.now.Add(time.Hour) e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until} @@ -192,8 +195,8 @@ func TestWalletTransfer_HeldAfterPinReset(t *testing.T) { func TestWalletTransfer_NotEnoughBalance(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") + a := e.member("Anita", "6281200005678") + b := e.member("Budi", "6281234561234") e.credit(t, earn(a, 100, nil)) // An expired lot cannot be sent even before the expiry job takes it. e.credit(t, earn(a, 50, e.at(-time.Hour))) @@ -205,9 +208,9 @@ func TestWalletTransfer_NotEnoughBalance(t *testing.T) { func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) { e := newWalletMoveEnv(t) - a := e.member("Anita", "081200005678") - b := e.member("Budi", "081234561234") - e.member("Citra", "081255550000") + a := e.member("Anita", "6281200005678") + b := e.member("Budi", "6281234561234") + e.member("Citra", "6281255550000") e.credit(t, earn(a, 100, nil)) notifier := ¬ifierFake{} diff --git a/internal/util/phone.go b/internal/util/phone.go new file mode 100644 index 0000000..dbc9719 --- /dev/null +++ b/internal/util/phone.go @@ -0,0 +1,41 @@ +package util + +import ( + "errors" + "regexp" + "strings" +) + +// ErrInvalidPhoneNumber means a phone number is not an Indonesian mobile number. +var ErrInvalidPhoneNumber = errors.New("invalid phone number format") + +// customerPhonePattern is an Indonesian mobile number in its stored form: 62, then the +// number without its leading 0 (628…, 10 to 14 digits in all). +var customerPhonePattern = regexp.MustCompile(`^628\d{7,11}$`) + +// NormalizePhoneNumber turns a customer's phone number into the one form it is stored +// and looked up in, 62…: "0812-3456-1234", "+62 812 3456 1234", "62812…" and "812…" +// all become "6281234561234". Spaces, dashes, dots and parentheses are dropped. +func NormalizePhoneNumber(raw string) (string, error) { + digits := strings.Map(func(r rune) rune { + switch r { + case ' ', '-', '.', '(', ')': + return -1 + } + return r + }, strings.TrimSpace(raw)) + digits = strings.TrimPrefix(digits, "+") + switch { + case strings.HasPrefix(digits, "620"): + // +62 typed in front of a number that kept its 0. + digits = "62" + digits[3:] + case strings.HasPrefix(digits, "0"): + digits = "62" + digits[1:] + case strings.HasPrefix(digits, "8"): + digits = "62" + digits + } + if !customerPhonePattern.MatchString(digits) { + return "", ErrInvalidPhoneNumber + } + return digits, nil +} diff --git a/internal/util/phone_test.go b/internal/util/phone_test.go new file mode 100644 index 0000000..fef9c97 --- /dev/null +++ b/internal/util/phone_test.go @@ -0,0 +1,49 @@ +package util + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizePhoneNumber(t *testing.T) { + for _, raw := range []string{ + "6281234561234", + "+6281234561234", + "081234561234", + "81234561234", + "0812-3456-1234", + "+62 812 3456 1234", + "(+62) 812.3456.1234", + "+62081234561234", + " 081234561234 ", + } { + got, err := NormalizePhoneNumber(raw) + require.NoError(t, err, raw) + assert.Equal(t, "6281234561234", got, raw) + } + + shortest, err := NormalizePhoneNumber("0811234567") + require.NoError(t, err) + assert.Equal(t, "62811234567", shortest) + longest, err := NormalizePhoneNumber("0812345678901") + require.NoError(t, err) + assert.Equal(t, "62812345678901", longest) + _, err = NormalizePhoneNumber("08123456789012") + assert.ErrorIs(t, err, ErrInvalidPhoneNumber, "too long") + + for _, raw := range []string{ + "", + "0", + "021-1234567", // a landline, cannot get the WhatsApp OTP + "+6521234567", // not Indonesian + "+1 415 555 0100", // not Indonesian + "62812", // too short + "0812abc61234", + "0812/3456/1234", + } { + _, err := NormalizePhoneNumber(raw) + assert.ErrorIs(t, err, ErrInvalidPhoneNumber, raw) + } +} diff --git a/internal/validator/customer_auth_validator.go b/internal/validator/customer_auth_validator.go index a62f459..136be21 100644 --- a/internal/validator/customer_auth_validator.go +++ b/internal/validator/customer_auth_validator.go @@ -9,6 +9,7 @@ import ( "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/util" ) type CustomerAuthValidator interface { @@ -36,7 +37,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.Chec return errors.New("phone number is required"), constants.ValidationErrorCode } - if !v.isValidPhoneNumber(req.PhoneNumber) { + if !v.normalizePhoneNumber(&req.PhoneNumber) { return errors.New("invalid phone number format"), constants.ValidationErrorCode } @@ -53,7 +54,7 @@ func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.R return errors.New("phone number is required"), constants.ValidationErrorCode } - if !v.isValidPhoneNumber(req.PhoneNumber) { + if !v.normalizePhoneNumber(&req.PhoneNumber) { return errors.New("invalid phone number format"), constants.ValidationErrorCode } @@ -161,7 +162,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C return errors.New("phone number is required"), constants.ValidationErrorCode } - if !v.isValidPhoneNumber(req.PhoneNumber) { + if !v.normalizePhoneNumber(&req.PhoneNumber) { return errors.New("invalid phone number format"), constants.ValidationErrorCode } @@ -174,10 +175,15 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C } // Helper validation functions -func (v *CustomerAuthValidatorImpl) isValidPhoneNumber(phoneNumber string) bool { - // Basic phone number validation - adjust regex based on your requirements - phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`) - return phoneRegex.MatchString(phoneNumber) +// normalizePhoneNumber rewrites the phone number in the form it is stored in, 62… +// (util.NormalizePhoneNumber), and reports false when it is not a valid one. +func (v *CustomerAuthValidatorImpl) normalizePhoneNumber(phoneNumber *string) bool { + normalized, err := util.NormalizePhoneNumber(*phoneNumber) + if err != nil { + return false + } + *phoneNumber = normalized + return true } func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool { @@ -208,7 +214,7 @@ func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.Resen } // Validate phone number format - if !v.isValidPhoneNumber(req.PhoneNumber) { + if !v.normalizePhoneNumber(&req.PhoneNumber) { return errors.New("invalid phone number format"), constants.CustomerEntity } diff --git a/migrations/000116_normalize_customer_phone_numbers.down.sql b/migrations/000116_normalize_customer_phone_numbers.down.sql new file mode 100644 index 0000000..6779adc --- /dev/null +++ b/migrations/000116_normalize_customer_phone_numbers.down.sql @@ -0,0 +1,2 @@ +-- Nothing to undo: the forms the numbers were stored in before are not kept, and 62… is +-- what the code before this migration accepted too. diff --git a/migrations/000116_normalize_customer_phone_numbers.up.sql b/migrations/000116_normalize_customer_phone_numbers.up.sql new file mode 100644 index 0000000..d0e62fc --- /dev/null +++ b/migrations/000116_normalize_customer_phone_numbers.up.sql @@ -0,0 +1,62 @@ +-- Customer phone numbers are stored in one form, 62… without + (util.NormalizePhoneNumber), +-- and every number a customer types is rewritten to it before it is looked up. This +-- rewrites the numbers stored before, so 0812…, +62 812… and 812… become 62812…. +-- +-- Only one customer may have a number. When several stored numbers become the same one, +-- the customer that already has it keeps it, else a registered one (with a password), +-- else the oldest; the others are left as they are. A number that is not an Indonesian +-- mobile number is left as it is too. Neither can log in until it is fixed by hand: +-- +-- SELECT id, name, phone_number FROM customers +-- WHERE phone_number IS NOT NULL AND phone_number !~ '^628[0-9]{7,11}$'; + +WITH stripped AS ( + SELECT id, phone_number, password_hash, created_at, + regexp_replace(regexp_replace(phone_number, '[[:space:]().-]', '', 'g'), '^\+', '') AS p + FROM customers + WHERE phone_number IS NOT NULL +), +normalized AS ( + SELECT id, phone_number, password_hash, created_at, + CASE + WHEN p LIKE '620%' THEN '62' || substr(p, 4) + WHEN p LIKE '0%' THEN '62' || substr(p, 2) + WHEN p LIKE '8%' THEN '62' || p + ELSE p + END AS new_phone + FROM stripped +), +ranked AS ( + SELECT id, new_phone, + row_number() OVER ( + PARTITION BY new_phone + ORDER BY phone_number = new_phone DESC, password_hash IS NOT NULL DESC, created_at, id + ) AS rank + FROM normalized + WHERE new_phone ~ '^628[0-9]{7,11}$' +) +UPDATE customers c +SET phone_number = r.new_phone, updated_at = NOW() +FROM ranked r +WHERE c.id = r.id AND r.rank = 1 AND c.phone_number <> r.new_phone; + +-- A registration started before this migration finishes with the number in its OTP +-- session, and a resent OTP is found by it. +UPDATE otp_sessions +SET phone_number = n.new_phone, updated_at = NOW() +FROM ( + SELECT id, + CASE + WHEN p LIKE '620%' THEN '62' || substr(p, 4) + WHEN p LIKE '0%' THEN '62' || substr(p, 2) + WHEN p LIKE '8%' THEN '62' || p + ELSE p + END AS new_phone + FROM ( + SELECT id, regexp_replace(regexp_replace(phone_number, '[[:space:]().-]', '', 'g'), '^\+', '') AS p + FROM otp_sessions + ) stripped +) n +WHERE otp_sessions.id = n.id + AND n.new_phone ~ '^628[0-9]{7,11}$' + AND otp_sessions.phone_number <> n.new_phone; -- 2.54.0 From a01e6517097862a09ac5c268bb1e40c76e701b7e Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 9 Oct 2026 22:59:27 +0700 Subject: [PATCH 09/12] fix(customer-auth): refuse a wrong login with 304 and limit attempts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wrong password or an unknown phone number was answered 900 (HTTP 500), like a server error, so clients could only tell them apart by the cause text. Both are now 304 (HTTP 400) from customer_auth_service with one cause, "invalid phone number or password", so a login does not tell which numbers have an account. A customer who never set a password gets 304 "customer not properly registered". Anything else stays 900. Login is limited per phone number: 5 attempts in 15 minutes, counted in Redis before the password is checked, so attempts sent at once all count, and for numbers without a customer too. The sixth is refused with 429 and data.locked_until, even with the right password, until the window ends. A successful login starts the count again. Since the number is normalized first, 0812… and 62812… count as one. When Redis fails, logins go on unlimited and the error is logged. There is no limit per IP: the client IP comes from X-Forwarded-For, which anyone can set while no trusted proxies are configured. Tested over HTTP with fakes; the Redis commands were checked against miniredis outside the repo, not against a real Redis. Co-Authored-By: Claude Opus 5.5 --- internal/app/app.go | 4 +- internal/constants/error.go | 1 + internal/handler/customer_auth_handler.go | 28 +- .../handler/customer_auth_handler_test.go | 245 ++++++++++++++++++ internal/processor/customer_auth_processor.go | 76 +++++- .../customer_login_attempt_repository.go | 51 ++++ 6 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 internal/handler/customer_auth_handler_test.go create mode 100644 internal/repository/customer_login_attempt_repository.go diff --git a/internal/app/app.go b/internal/app/app.go index 8df41ce..af88462 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -307,6 +307,7 @@ type repositories struct { campaignRepo repository.CampaignRepository campaignRuleRepo repository.CampaignRuleRepository customerAuthRepo repository.CustomerAuthRepository + customerLoginAttemptRepo repository.CustomerLoginAttemptRepository otpRepo repository.OtpRepository sessionRepo repository.SessionRepository txManager *repository.TxManager @@ -359,6 +360,7 @@ func (a *App) initRepositories() *repositories { campaignRepo: repository.NewCampaignRepository(a.db), campaignRuleRepo: repository.NewCampaignRuleRepository(a.db), customerAuthRepo: repository.NewCustomerAuthRepository(a.db), + customerLoginAttemptRepo: repository.NewCustomerLoginAttemptRepository(a.redisClient), otpRepo: repository.NewOtpRepository(a.db), sessionRepo: repository.NewSessionRepository(a.redisClient), txManager: repository.NewTxManager(a.db), @@ -488,7 +490,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), - customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), + customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, repos.customerLoginAttemptRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager))), otpProcessor: otpProcessor, fileClient: fileClient, diff --git a/internal/constants/error.go b/internal/constants/error.go index 05193ea..b7e52ec 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -67,6 +67,7 @@ const ( EnakGameServiceEntity = "enakgame_service" LoyaltySettingsServiceEntity = "loyalty_settings_service" CustomerPinServiceEntity = "customer_pin_service" + CustomerAuthServiceEntity = "customer_auth_service" ) var HttpErrorMap = map[string]int{ diff --git a/internal/handler/customer_auth_handler.go b/internal/handler/customer_auth_handler.go index 126d4bc..e748f9d 100644 --- a/internal/handler/customer_auth_handler.go +++ b/internal/handler/customer_auth_handler.go @@ -1,9 +1,12 @@ package handler import ( + "errors" + "apskel-pos-be/internal/constants" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/processor" "apskel-pos-be/internal/service" "apskel-pos-be/internal/util" "apskel-pos-be/internal/validator" @@ -161,13 +164,36 @@ func (h *CustomerAuthHandler) Login(c *gin.Context) { response, err := h.customerAuthService.Login(ctx, &req) if err != nil { logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::Login") + util.HandleResponse(c.Writer, c.Request, loginErrorResponse(err), "CustomerAuthHandler::Login") return } util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::Login") } +// loginErrorResponse answers a phone number with too many attempts with 429 and when it +// may try again, a refused login with 304 and its reason, and anything else with 900. +func loginErrorResponse(err error) *contract.Response { + var locked *processor.CustomerLoginLockedError + if errors.As(err, &locked) { + return &contract.Response{ + Success: false, + Data: map[string]interface{}{"locked_until": locked.Until}, + Errors: []*contract.ResponseError{contract.NewResponseError(constants.TooManyRequestsErrorCode, constants.CustomerAuthServiceEntity, locked.Error())}, + } + } + for _, refused := range []error{processor.ErrCustomerLoginInvalid, processor.ErrCustomerNotRegistered} { + if errors.Is(err, refused) { + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.CustomerAuthServiceEntity, refused.Error()), + }) + } + } + return contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }) +} + func (h *CustomerAuthHandler) ResendOtp(c *gin.Context) { ctx := c.Request.Context() diff --git a/internal/handler/customer_auth_handler_test.go b/internal/handler/customer_auth_handler_test.go new file mode 100644 index 0000000..206a9c8 --- /dev/null +++ b/internal/handler/customer_auth_handler_test.go @@ -0,0 +1,245 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "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" + + "apskel-pos-be/internal/entities" + applogger "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/processor" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/validator" +) + +// customerAuthRepoFake holds customers by phone number. Only the login lookup is used. +type customerAuthRepoFake struct { + customers map[string]*entities.Customer + err error +} + +func (f *customerAuthRepoFake) GetCustomerByPhoneNumber(_ context.Context, phone string) (*entities.Customer, error) { + if f.err != nil { + return nil, f.err + } + return f.customers[phone], nil +} + +func (f *customerAuthRepoFake) GetCustomerByID(context.Context, string) (*entities.Customer, error) { + return nil, nil +} +func (f *customerAuthRepoFake) CreateCustomer(context.Context, *entities.Customer) error { return nil } +func (f *customerAuthRepoFake) UpdateCustomer(context.Context, *entities.Customer) error { return nil } +func (f *customerAuthRepoFake) CheckPhoneNumberExists(context.Context, string) (bool, error) { + return false, nil +} +func (f *customerAuthRepoFake) SetCustomerPassword(context.Context, string, string) error { + return nil +} +func (f *customerAuthRepoFake) OrganizationExists(context.Context, uuid.UUID) (bool, error) { + return false, nil +} +func (f *customerAuthRepoFake) OrganizationIDs(context.Context, int) ([]uuid.UUID, error) { + return nil, nil +} + +// loginAttemptsFake counts attempts per phone number in one window that never ends. +type loginAttemptsFake struct { + counts map[string]int64 + err error +} + +func (f *loginAttemptsFake) Hit(_ context.Context, phone string, window time.Duration) (int64, time.Duration, error) { + if f.err != nil { + return 0, 0, f.err + } + f.counts[phone]++ + return f.counts[phone], window, nil +} + +func (f *loginAttemptsFake) Reset(_ context.Context, phone string) error { + delete(f.counts, phone) + return nil +} + +const ( + loginTestPassword = "rahasia123" + loginTestRegistered = "6281234561234" + loginTestUnfinished = "6281234569999" +) + +type loginTest struct { + repo *customerAuthRepoFake + attempts *loginAttemptsFake + router *gin.Engine +} + +func newLoginTest(t *testing.T) *loginTest { + t.Helper() + applogger.Setup("fatal", "json") + hash, err := bcrypt.GenerateFromPassword([]byte(loginTestPassword), bcrypt.MinCost) + require.NoError(t, err) + hashStr := string(hash) + registered, unfinished := loginTestRegistered, loginTestUnfinished + birth := time.Date(2000, 1, 31, 0, 0, 0, 0, time.UTC) + lt := &loginTest{ + repo: &customerAuthRepoFake{customers: map[string]*entities.Customer{ + registered: {ID: uuid.New(), Name: "Budi", PhoneNumber: ®istered, BirthDate: &birth, PasswordHash: &hashStr}, + unfinished: {ID: uuid.New(), Name: "Sari", PhoneNumber: &unfinished}, + }}, + attempts: &loginAttemptsFake{counts: map[string]int64{}}, + } + h := NewCustomerAuthHandler( + service.NewCustomerAuthService(processor.NewCustomerAuthProcessor(lt.repo, lt.attempts, nil, nil, "test", 60)), + validator.NewCustomerAuthValidator(), + ) + gin.SetMode(gin.TestMode) + lt.router = gin.New() + lt.router.POST("/customer-auth/login", h.Login) + return lt +} + +func (lt *loginTest) login(t *testing.T, phone, password string) (int, map[string]any) { + t.Helper() + body, _ := json.Marshal(map[string]string{"phone_number": phone, "password": password}) + rec := httptest.NewRecorder() + lt.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/customer-auth/login", bytes.NewReader(body))) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + return rec.Code, out +} + +func firstLoginError(t *testing.T, out map[string]any) map[string]any { + t.Helper() + errs, ok := out["errors"].([]any) + require.True(t, ok, "errors: %v", out) + require.NotEmpty(t, errs) + return errs[0].(map[string]any) +} + +func TestCustomerLoginRefusalsAreValidationErrors(t *testing.T) { + lt := newLoginTest(t) + + for name, tc := range map[string]struct{ phone, password, cause string }{ + "wrong password": {loginTestRegistered, "salah", "invalid phone number or password"}, + "unknown phone": {"6280000000000", loginTestPassword, "invalid phone number or password"}, + "registration unfinished": {loginTestUnfinished, loginTestPassword, "customer not properly registered"}, + } { + t.Run(name, func(t *testing.T) { + status, out := lt.login(t, tc.phone, tc.password) + assert.Equal(t, http.StatusBadRequest, status) + e := firstLoginError(t, out) + assert.Equal(t, "304", e["code"]) + assert.Equal(t, "customer_auth_service", e["entity"]) + assert.Equal(t, tc.cause, e["cause"]) + }) + } + + t.Run("right password", func(t *testing.T) { + status, out := lt.login(t, loginTestRegistered, loginTestPassword) + require.Equal(t, http.StatusOK, status, "%v", out) + inner := out["data"].(map[string]any)["data"].(map[string]any) + assert.NotEmpty(t, inner["access_token"]) + }) + + t.Run("the number written another way finds the same customer", func(t *testing.T) { + for _, phone := range []string{"081234561234", "+62 812-3456-1234", "81234561234"} { + status, out := lt.login(t, phone, loginTestPassword) + require.Equal(t, http.StatusOK, status, "%s: %v", phone, out) + user := out["data"].(map[string]any)["data"].(map[string]any)["user"].(map[string]any) + assert.Equal(t, loginTestRegistered, user["phone_number"], phone) + } + }) + + t.Run("a number that is not an Indonesian mobile number", func(t *testing.T) { + status, out := lt.login(t, "021-1234567", loginTestPassword) + assert.Equal(t, http.StatusBadRequest, status) + e := firstLoginError(t, out) + assert.Equal(t, "304", e["code"]) + assert.Equal(t, "invalid phone number format", e["cause"]) + }) + + t.Run("a failing lookup is still a server error", func(t *testing.T) { + lt.repo.err = errors.New("connection refused") + defer func() { lt.repo.err = nil }() + status, out := lt.login(t, loginTestRegistered, loginTestPassword) + assert.Equal(t, http.StatusInternalServerError, status) + assert.Equal(t, "900", firstLoginError(t, out)["code"]) + }) +} + +func TestCustomerLoginLocksAfterTooManyAttempts(t *testing.T) { + t.Run("the sixth attempt is refused, even with the right password", func(t *testing.T) { + lt := newLoginTest(t) + for i := 0; i < 5; i++ { + status, _ := lt.login(t, loginTestRegistered, "salah") + require.Equal(t, http.StatusBadRequest, status, "attempt %d", i+1) + } + status, out := lt.login(t, loginTestRegistered, loginTestPassword) + assert.Equal(t, http.StatusTooManyRequests, status) + e := firstLoginError(t, out) + assert.Equal(t, "429", e["code"]) + assert.Equal(t, "customer_auth_service", e["entity"]) + data, ok := out["data"].(map[string]any) + require.True(t, ok, "data: %v", out) + until, err := time.Parse(time.RFC3339, data["locked_until"].(string)) + require.NoError(t, err) + assert.WithinDuration(t, time.Now().Add(15*time.Minute), until, time.Minute) + + // Another number is not affected. + status, _ = lt.login(t, loginTestUnfinished, loginTestPassword) + assert.Equal(t, http.StatusBadRequest, status) + }) + + t.Run("writing the number another way counts toward the same limit", func(t *testing.T) { + lt := newLoginTest(t) + for _, phone := range []string{"081234561234", "+6281234561234", "81234561234", "0812-3456-1234", loginTestRegistered} { + lt.login(t, phone, "salah") + } + status, _ := lt.login(t, "081234561234", loginTestPassword) + assert.Equal(t, http.StatusTooManyRequests, status) + }) + + t.Run("unknown numbers lock the same way", func(t *testing.T) { + lt := newLoginTest(t) + for i := 0; i < 5; i++ { + lt.login(t, "6280000000000", "salah") + } + status, _ := lt.login(t, "6280000000000", "salah") + assert.Equal(t, http.StatusTooManyRequests, status) + }) + + t.Run("a successful login starts the count again", func(t *testing.T) { + lt := newLoginTest(t) + for i := 0; i < 4; i++ { + lt.login(t, loginTestRegistered, "salah") + } + status, _ := lt.login(t, loginTestRegistered, loginTestPassword) + require.Equal(t, http.StatusOK, status) + for i := 0; i < 5; i++ { + status, _ := lt.login(t, loginTestRegistered, "salah") + require.Equal(t, http.StatusBadRequest, status, "attempt %d after the login", i+1) + } + }) + + t.Run("logins go on when the counter is down", func(t *testing.T) { + lt := newLoginTest(t) + lt.attempts.err = errors.New("redis: connection refused") + for i := 0; i < 6; i++ { + lt.login(t, loginTestRegistered, "salah") + } + status, _ := lt.login(t, loginTestRegistered, loginTestPassword) + assert.Equal(t, http.StatusOK, status) + }) +} diff --git a/internal/processor/customer_auth_processor.go b/internal/processor/customer_auth_processor.go index 02b59f9..2fc254b 100644 --- a/internal/processor/customer_auth_processor.go +++ b/internal/processor/customer_auth_processor.go @@ -2,12 +2,14 @@ package processor import ( "context" + "errors" "fmt" "strings" "time" "apskel-pos-be/internal/contract" "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/logger" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" "apskel-pos-be/internal/util" @@ -16,6 +18,32 @@ import ( "golang.org/x/crypto/bcrypt" ) +var ( + // ErrCustomerLoginInvalid means no customer has the phone number or the password is + // wrong. Which of the two is not told. + ErrCustomerLoginInvalid = errors.New("invalid phone number or password") + // ErrCustomerNotRegistered means the customer never set a password: registration + // stopped before its last step. + ErrCustomerNotRegistered = errors.New("customer not properly registered") +) + +// Login attempts a phone number may make before it has to wait, and the window they +// are counted in. A successful login starts the count again. +const ( + customerLoginMaxAttempts = 5 + customerLoginWindow = 15 * time.Minute +) + +// CustomerLoginLockedError means the phone number made too many login attempts and may +// try again at Until. +type CustomerLoginLockedError struct { + Until time.Time +} + +func (e *CustomerLoginLockedError) Error() string { + return fmt.Sprintf("too many login attempts, try again after %s", e.Until.Format(time.RFC3339)) +} + type CustomerAuthProcessor interface { CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error) StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error) @@ -26,20 +54,22 @@ type CustomerAuthProcessor interface { } type customerAuthProcessor struct { - customerAuthRepo repository.CustomerAuthRepository - otpProcessor OtpProcessor - otpRepo repository.OtpRepository - jwtSecret string - tokenTTLMinutes int + customerAuthRepo repository.CustomerAuthRepository + loginAttemptsRepo repository.CustomerLoginAttemptRepository + otpProcessor OtpProcessor + otpRepo repository.OtpRepository + jwtSecret string + tokenTTLMinutes int } -func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor { +func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, loginAttemptsRepo repository.CustomerLoginAttemptRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor { return &customerAuthProcessor{ - customerAuthRepo: customerAuthRepo, - otpProcessor: otpProcessor, - otpRepo: otpRepo, - jwtSecret: jwtSecret, - tokenTTLMinutes: tokenTTLMinutes, + customerAuthRepo: customerAuthRepo, + loginAttemptsRepo: loginAttemptsRepo, + otpProcessor: otpProcessor, + otpRepo: otpRepo, + jwtSecret: jwtSecret, + tokenTTLMinutes: tokenTTLMinutes, } } @@ -344,6 +374,21 @@ func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.R } func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) { + // Counted before the password is checked, so attempts sent at once all count, and + // for numbers without a customer too, so a refusal never tells which numbers have + // one. + attempts, left, err := p.loginAttemptsRepo.Hit(ctx, req.PhoneNumber, customerLoginWindow) + switch { + case err != nil: + // Without the counter, logins go on unlimited rather than stop for everyone. + logger.FromContext(ctx).WithError(err).Error("CustomerAuthProcessor::Login -> failed to count the attempt") + case attempts > customerLoginMaxAttempts: + if left <= 0 { + left = customerLoginWindow + } + return nil, &CustomerLoginLockedError{Until: time.Now().Add(left).UTC().Truncate(time.Second)} + } + // Get customer by phone number customer, err := p.customerAuthRepo.GetCustomerByPhoneNumber(ctx, req.PhoneNumber) if err != nil { @@ -351,16 +396,19 @@ func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.Custome } if customer == nil { - return nil, fmt.Errorf("customer not found") + return nil, ErrCustomerLoginInvalid } if customer.PasswordHash == nil { - return nil, fmt.Errorf("customer not properly registered") + return nil, ErrCustomerNotRegistered } // Verify password if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil { - return nil, fmt.Errorf("invalid password") + return nil, ErrCustomerLoginInvalid + } + if err := p.loginAttemptsRepo.Reset(ctx, req.PhoneNumber); err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerAuthProcessor::Login -> failed to reset the attempts") } // Generate JWT tokens using customer JWT util diff --git a/internal/repository/customer_login_attempt_repository.go b/internal/repository/customer_login_attempt_repository.go new file mode 100644 index 0000000..8ce349f --- /dev/null +++ b/internal/repository/customer_login_attempt_repository.go @@ -0,0 +1,51 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +const customerLoginAttemptKeyPrefix = "customer_login:attempts:" + +// CustomerLoginAttemptRepository counts customer login attempts per phone number, so a +// password cannot be guessed by trying many. +type CustomerLoginAttemptRepository interface { + // Hit counts one attempt for the phone number. It returns the attempts counted in + // the current window, this one included, and how long the window still runs. A + // window starts at the first attempt after the previous one ended. + Hit(ctx context.Context, phoneNumber string, window time.Duration) (int64, time.Duration, error) + // Reset forgets the attempts of the phone number. + Reset(ctx context.Context, phoneNumber string) error +} + +type customerLoginAttemptRepository struct { + client *redis.Client +} + +func NewCustomerLoginAttemptRepository(client *redis.Client) CustomerLoginAttemptRepository { + return &customerLoginAttemptRepository{client: client} +} + +func (r *customerLoginAttemptRepository) Hit(ctx context.Context, phoneNumber string, window time.Duration) (int64, time.Duration, error) { + key := customerLoginAttemptKeyPrefix + phoneNumber + // One transaction, so the key never exists without its expiry: concurrent attempts + // all count, and a crash cannot leave a number locked for good. + pipe := r.client.TxPipeline() + pipe.SetNX(ctx, key, 0, window) + count := pipe.Incr(ctx, key) + left := pipe.PTTL(ctx, key) + if _, err := pipe.Exec(ctx); err != nil { + return 0, 0, fmt.Errorf("count login attempt: %w", err) + } + return count.Val(), left.Val(), nil +} + +func (r *customerLoginAttemptRepository) Reset(ctx context.Context, phoneNumber string) error { + if err := r.client.Del(ctx, customerLoginAttemptKeyPrefix+phoneNumber).Err(); err != nil { + return fmt.Errorf("reset login attempts: %w", err) + } + return nil +} -- 2.54.0 From c43baa53e1d18ac47531af521540f03785601299 Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 9 Oct 2026 22:59:58 +0700 Subject: [PATCH 10/12] docs: EnakGame game list, API list and in-game login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit integration-enakgame.md was only the flow of a play. It now also has: - §2 the games: Spin is the only one; what each reward_type needs from the client at complete; what to agree on to register a new game. - §3 the endpoints the client calls, in one table. - §5 tokens: with no token, or one the backend refuses, the game shows a customer login (POST /customer-auth/login) and repeats the request once. Standalone mode for a browser without the app finds its game_id by slug. The login errors (304, 429 with locked_until), the attempt limit and the phone formats accepted. A JS helper for all of it. The bridge loses token_expired and token: the game logs the customer in itself. Sections are renumbered. integration-mobile-customer.md: customer phone numbers are 62… (§2), a login section with its errors and the change from 900 to 304 (§2.4), 62… examples, and init carrying the access token. integration-backoffice.md: example responses are the data field, so a list is data.data in a raw response. Co-Authored-By: Claude Opus 5.5 --- docs/integration-backoffice.md | 7 +- docs/integration-enakgame.md | 388 ++++++++++++++++++++++++---- docs/integration-mobile-customer.md | 50 +++- 3 files changed, 384 insertions(+), 61 deletions(-) diff --git a/docs/integration-backoffice.md b/docs/integration-backoffice.md index 608ca03..c5fabbf 100644 --- a/docs/integration-backoffice.md +++ b/docs/integration-backoffice.md @@ -29,9 +29,10 @@ data organisasi lain dijawab `404`. Sembunyikan tombol ubah untuk role yang tidak boleh; server tetap menolaknya (`403`). **Format response.** Sukses `{ "success": true, "data": … }`; gagal -`{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Daftar berhalaman -memakai `{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }` -dengan `limit` maks. 100 (default 20). +`{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Contoh response di +dokumen ini adalah isi `data`. Daftar berhalaman isinya +`{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }`, +jadi pada response mentah array-nya ada di `data.data`; `limit` maks. 100 (default 20). **Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md index 5e611fb..3a64c40 100644 --- a/docs/integration-enakgame.md +++ b/docs/integration-enakgame.md @@ -1,6 +1,6 @@ # Integrasi EnakGame: Game Client (Phaser) -**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 +**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 9 Okt 2026 Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main @@ -12,7 +12,7 @@ Pembagian tugas dengan aplikasi customer: | Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) | |---|---| -| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§2) | +| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) | | Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah | | Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup | @@ -29,15 +29,90 @@ Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan 2. **Tampilkan hadiah dari response, bukan dari hitungan sendiri.** Angka di layar akhir selalu `reward_total` dari backend. 3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama. -4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori, - tidak pernah ditaruh di URL, `localStorage`, `sessionStorage`, cookie, log, atau - analytics. (`session_id` boleh disimpan di `sessionStorage`, §4.4.) -5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. -6. **Main game tidak butuh PIN.** +4. **Token customer adalah rahasia.** Hanya didapat dari bridge atau dari login di + game, disimpan di memori, tidak pernah ditaruh di URL, `localStorage`, + `sessionStorage`, cookie, log, atau analytics. Begitu juga password customer. + (`session_id` boleh disimpan di `sessionStorage`, §6.4.) +5. **Tanpa token, jangan panggil API customer.** Tampilkan layar login akun customer + (§5.2). +6. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. +7. **Main game tidak butuh PIN.** --- -## 2. Bridge dengan aplikasi customer +## 2. Daftar game + +Game bukan daftar tetap di kode. Setiap game adalah data yang dibuat admin di +backoffice per organisasi (nama, `slug`, `game_url`, biaya main, aturan hadiah) dan +dibaca game lewat `GET /customer/enakgame/games` (§6.1). Satu `game_url` = satu game. + +### 2.1 Game yang ada sekarang + +| Game | `slug` | Jenis hadiah (`reward_type`) | Body complete | Status | +|---|---|---|---|---| +| Spin Harian | `spin` | `PROBABILITY`: server mengundi segmen roda | `{}` | Backend siap. Panduan: §9 | + +Runner, Memory, dan Puzzle hanya disebut sebagai contoh di PRD; belum ada spesifikasi, +`slug`, atau aturan hadiahnya. Game baru harus didaftarkan dulu (§2.3) sebelum bisa +dimainkan. + +### 2.2 Jenis hadiah menentukan apa yang dikirim game + +API customer tidak memberi tahu `reward_type`. Jenisnya disepakati saat game +didaftarkan (§2.3), dan game dibangun untuk jenis itu. + +| `reward_type` | Hadiah dihitung dari | Body complete | Bila field-nya tidak dikirim | +|---|---|---|---| +| `FIXED` | Jumlah tetap per main | `{}` | – | +| `SCORE_BASED` | Rentang skor yang diatur admin | `{ "score": 800 }` | Hadiah 0 | +| `OUTCOME_BASED` | Hasil main, mis. `PERFECT`, `GOOD`, `FAIL` | `{ "outcome": "PERFECT" }` | Hadiah 0. `outcome` yang tidak terdaftar juga 0 | +| `PROBABILITY` | Undian server; peluang tidak pernah dikirim ke game | `{}` | – | + +Admin bisa mengubah besar hadiah kapan saja tanpa build game baru. Supaya game tetap +benar bila admin juga mengganti jenisnya, **kirim semua hasil yang dimiliki game**: +game berbasis skor selalu mengirim `score`, game berbasis hasil selalu mengirim +`outcome`. Field yang tidak dipakai jenis hadiah aktif diabaikan. + +### 2.3 Mendaftarkan game baru + +Sepakati dengan tim backoffice, lalu admin membuatnya +([`integration-backoffice.md`](./integration-backoffice.md) §8): + +| Yang disepakati | Contoh | Catatan | +|---|---|---| +| `slug` | `runner` | Huruf kecil, angka, `-`; unik per organisasi. Game memakainya untuk memeriksa dirinya (§6.1) | +| `game_url` | `https://…/runner/index.html` | URL build game; dibuka aplikasi di webview | +| `version` | `1.0.0` | Versi build | +| `reward_type` | `SCORE_BASED` | §2.2 | +| Daftar `outcome` | `WIN`, `LOSE` | Hanya game `OUTCOME_BASED`; harus sama persis (huruf besar/kecil) | +| `result_rules` | `max_score`, `min_duration_seconds`, `max_score_per_second` | Hasil di luar batas ini mendapat hadiah 0 tanpa pemberitahuan (§6.3). Isi dengan skor dan durasi wajar game-mu | +| `session_ttl_seconds` | `600` | Batas waktu satu main. Harus lebih lama dari durasi main terpanjang, ditambah jeda jaringan | +| `entry_cost` | `5` | Ditentukan bisnis, minimal 1 | + +--- + +## 3. Daftar API + +Semua endpoint diawali `api_base_url` (dari `init`, §4, atau dari config build pada +mode mandiri, §5.4), mis. `https://api.example.com/api/v1`. Selain login, semua wajib +memakai header `Authorization: Bearer `. Request ber-body memakai +`Content-Type: application/json`. + +| # | Endpoint | Header tambahan | Body | Dipakai saat | Detail | +|---|---|---|---|---|---| +| 0 | `POST /customer-auth/login` | Tanpa `Authorization` | `{ "phone_number", "password" }` | Tidak ada token, atau token ditolak | §5.3 | +| 1 | `GET /customer/enakgame/games` | – | – | Setelah ada token: biaya main, event, segmen roda | §6.1 | +| 2 | `POST /customer/enakgame/sessions` | `Idempotency-Key` (wajib) | `{ "game_id" }` | Customer menekan Main; EnakCoin dipotong | §6.2 | +| 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 | +| 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 | +| 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 | + +Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN +adalah tugas aplikasi customer. + +--- + +## 4. Bridge dengan aplikasi customer > **Usulan.** Bentuk bridge di bawah belum diimplementasikan di sisi mana pun. Sepakati > dengan tim aplikasi customer sebelum mulai; aplikasi memakai kontrak yang sama @@ -53,9 +128,7 @@ Semua pesan berupa JSON string dengan field `type`. | Arah | `type` | Isi | Kapan | |---|---|---|---| | game → app | `ready` | – | Halaman game selesai dimuat | -| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready` | -| game → app | `token_expired` | – | Backend menolak token (§3) | -| app → game | `token` | `token` | Token baru setelah `token_expired` | +| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready`. `token` boleh kosong bila customer belum login | | game → app | `balance_changed` | `coin_balance` | Setelah start dan complete berhasil | | game → app | `close` | – | Customer keluar dari game | @@ -65,37 +138,216 @@ Contoh `init`: { "type": "init", "api_base_url": "https://api.example.com/api/v1", "token": "eyJ…", "game_id": "8a1f…" } ``` -Jangan memanggil API apa pun sebelum `init` diterima. Untuk development di browser -tanpa aplikasi, sediakan mode dev yang mengisi `init` dari config lokal; mode itu tidak -boleh ikut di build produksi. +Di dalam aplikasi, jangan memanggil API apa pun sebelum `init` diterima. Token yang +kosong atau ditolak tidak dikembalikan ke aplikasi: game sendiri yang meminta customer +login (§5.2). Tanpa aplikasi (browser biasa), game berjalan dalam mode mandiri (§5.4). --- -## 3. Koneksi ke API +## 5. Koneksi ke API dan token + +### 5.1 Bentuk response dan error -- Header: `Authorization: Bearer ` dari `init`. - Sukses: `{ "success": true, "data": { … }, "errors": null }`. - Gagal: `{ "success": false, "data": null, "errors": [{ "code", "entity", "cause" }] }`. `cause` berbahasa Inggris; jangan tampilkan mentah ke customer. +- Contoh response di dokumen ini adalah isi `data`, kecuali yang menampilkan amplop + lengkap (§6.4). | `errors[0].code` | HTTP | Arti | Yang dilakukan game | |---|---|---|---| -| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum | +| `304` dengan `entity` `auth_handler` | 400 | **Token tidak ada atau tidak berlaku** | Layar login (§5.2) | | `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint | +| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum | | `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi | -| `900` | 500 | Error server | Retry (§6) | +| `900` | 500 | Error server | Retry (§8) | -**Token tidak berlaku** (kedaluwarsa, salah) dijawab HTTP 400 dengan code `304`, sama -seperti penolakan bisnis. Bedakan lewat `entity`: `auth_handler` untuk token, -`enakgame_service` untuk aturan EnakGame. Pada `auth_handler`, kirim `token_expired`, -tunggu `token`, lalu ulangi request yang sama. +Backend **tidak** memakai HTTP 401. Token yang ditolak dijawab HTTP 400 dengan code +`304`, sama seperti penolakan bisnis; bedakan lewat `entity`: `auth_handler` untuk token, +`enakgame_service` untuk aturan EnakGame. + +### 5.2 Token tidak ada atau ditolak: customer login di game + +Token didapat dari `init` (game dibuka dari aplikasi) atau dari login di game. Setiap +kali tidak ada token yang berlaku, game menampilkan **layar login akun customer** +(§5.3). + +| Keadaan | Cara mengenali | Yang dilakukan game | +|---|---|---| +| `init` datang dengan token | `token` terisi | Pakai token itu, tanpa layar login | +| `init` datang tanpa token | `token` kosong, `null`, atau tidak ada | Layar login | +| Dibuka tanpa aplikasi (browser biasa) | `window.EnakGameHost` tidak ada | Mode mandiri (§5.4), dimulai dari layar login | +| `init` tidak datang | Tidak ada `init` 5 detik setelah `ready` | Kirim `ready` sekali lagi. Masih tidak datang dalam 5 detik: mode mandiri (§5.4) | +| Token ditolak backend | HTTP 400, `code` `304`, `entity` `auth_handler` | Layar login dengan pesan "Sesi kamu berakhir, silakan login lagi". Setelah login berhasil, ulangi request yang sama **satu kali** (body dan `Idempotency-Key` sama) | +| Token hasil login langsung ditolak lagi | Penolakan `auth_handler` kedua untuk request yang sama | Berhenti: "Login bermasalah, coba buka ulang game" dengan tombol Keluar. Jangan menampilkan login berulang-ulang | +| Customer login dengan akun lain | `GET /sessions/:id` untuk session tersimpan menjawab `404` | Hapus `session_id` dari `sessionStorage`, tampilkan layar awal (§6.4) | + +Token ditolak sebelum apa pun diproses: start yang ditolak tidak memotong EnakCoin, dan +complete yang ditolak tidak menyelesaikan session. Jadi aman mengulang request yang +sama setelah login. Timer `expires_at` tetap berjalan selama customer login; complete +setelah `expires_at` ditolak dan entry cost **tidak** dikembalikan (§7). + +Token hasil login di game tidak dikirim ke aplikasi; aplikasi mengurus tokennya +sendiri. + +`cause` dari `auth_handler` hanya untuk debugging, jangan dicocokkan di kode: + +| `cause` | Penyebab | +|---|---| +| `Authorization header is required` | Header tidak dikirim. Bug di game: memanggil API sebelum ada token | +| `Invalid authorization header format` | Header bukan `Bearer ` | +| `Invalid token: …` | Token kedaluwarsa, rusak, atau dari environment lain (staging vs. produksi) | +| `Invalid token type` | Yang dipakai refresh token, bukan access token | +| `Token is not valid`, `Customer ID not found in token`, `Phone number not found in token` | Token bukan token customer yang sah | + +### 5.3 Layar login — `POST /customer-auth/login` + +Form berisi nomor HP dan password akun customer, sama dengan akun di aplikasi. Endpoint +ini tidak memakai header `Authorization`. + +```json +{ "phone_number": "6281234561234", "password": "rahasia123" } +``` + +Response lengkap, termasuk amplopnya. Token ada di **`data.data.access_token`**: + +```json +{ + "success": true, + "data": { + "status": "SUCCESS", + "message": "Login successful.", + "data": { + "access_token": "eyJ…", + "refresh_token": "eyJ…", + "user": { "id": "…", "name": "Budi", "phone_number": "6281234561234", "birth_date": "2000-01-31" } + } + }, + "errors": null +} +``` + +- Pakai `access_token` saja. `refresh_token` ditolak endpoint customer dan belum ada + endpoint refresh; abaikan dan jangan disimpan. +- Token hanya di memori (§1). Halaman dimuat ulang berarti login lagi, kecuali di dalam + aplikasi yang mengirim token lewat `init`. +- Password hanya dipegang selama request login: jangan disimpan, di-log, atau dikirim + ke analytics. +- `user.name` boleh ditampilkan, mis. "Main sebagai Budi", dengan tombol "Ganti akun" + yang menghapus token dari memori lalu menampilkan login lagi. +- Nomor HP boleh ditulis `0812…`, `+62 812…`, `62812…`, atau `812…` (spasi dan `-` + boleh). Backend mengubahnya menjadi format baku **`62812…`**, dan format itu juga yang + dikembalikan di `user.phone_number`. Hanya nomor HP Indonesia (`628…`) yang diterima; + selain itu ditolak `invalid phone number format`. +- Registrasi tidak ada di game. Tampilkan "Belum punya akun atau pendaftaran belum + selesai? Lanjutkan di aplikasi." + +| `code` | `entity` | HTTP | Arti (`cause`) | Tampilan | +|---|---|---|---|---| +| `304` | `customer_auth_service` | 400 | Nomor HP tidak terdaftar atau password salah (`invalid phone number or password`), atau pendaftaran belum selesai (`customer not properly registered`) | **"Nomor HP atau password salah."** | +| `304` | `request` | 400 | Format salah: `invalid phone number format`, `phone number is required`, `password is required` | "Periksa nomor HP dan password." Cek juga di game sebelum mengirim | +| `303` | `request` | 400 | Body tidak lengkap | Bug di game | +| `429` | `customer_auth_service` | 429 | Terlalu banyak percobaan login untuk nomor ini; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." Nonaktifkan tombol Masuk sampai `locked_until` | +| `900` | – | 500 | Error server | "Gagal login, coba lagi." | + +Login yang gagal jangan diulang otomatis. Setiap nomor HP hanya boleh mencoba login +5 kali dalam 15 menit, berhasil maupun gagal; login yang berhasil memulai hitungan +dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga bila +password-nya benar. Batas ini berlaku untuk nomor HP, bukan perangkat, dan sama untuk +aplikasi customer. + +### 5.4 Mode mandiri (tanpa aplikasi) + +Dipakai saat game dibuka di browser biasa, atau saat aplikasi tidak mengirim `init` +(§5.2). Juga dipakai untuk development dengan config staging. + +1. `api_base_url` diambil dari config build per environment (staging, produksi), bukan + dari URL atau input customer. +2. Tampilkan layar login (§5.3). +3. Panggil `GET /customer/enakgame/games` dan ambil game dengan `slug` milik build ini; + `id`-nya menjadi `game_id`. Tidak ada → "Game tidak tersedia untuk akun ini." +4. Lanjutkan seperti biasa: pemulihan session (§6.4), lalu layar awal. + +Tanpa aplikasi tidak ada bridge: `balance_changed` dan `close` tidak dikirim, dan tombol +Keluar kembali ke layar awal game. + +### 5.5 Contoh helper API + +Helper ini menangani token dari `init`, layar login, dan token yang ditolak: + +```js +let apiBaseUrl = CONFIG.apiBaseUrl; // config build per environment; init menimpanya +let gameId = null; // dari init; mode mandiri: dicari lewat slug (§5.4) +let token = null; // hanya di memori +let loginWaiters = []; // request yang menunggu customer login + +const hasHost = () => typeof window.EnakGameHost !== 'undefined'; +const toHost = (msg) => hasHost() && window.EnakGameHost.postMessage(JSON.stringify(msg)); + +window.enakGame = { + receive(raw) { + const msg = JSON.parse(raw); + if (msg.type !== 'init') return; + apiBaseUrl = msg.api_base_url; + gameId = msg.game_id; + token = msg.token || null; // kosong → layar login saat request pertama + onInit(); // pemulihan session (§6.4), lalu layar awal + }, +}; + +// Menampilkan layar login; selesai setelah customer berhasil login. +function waitForLogin(message) { + return new Promise((resolve) => { + if (loginWaiters.length === 0) showLoginScreen(message); + loginWaiters.push(resolve); + }); +} + +// Dipanggil tombol Masuk. Error dilempar ke layar login dan ditampilkan sesuai §5.3. +async function login(phoneNumber, password) { + const res = await fetch(apiBaseUrl + '/customer-auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone_number: phoneNumber, password }), + }); + const json = await res.json().catch(() => null); + if (!json?.success) throw json?.errors?.[0] ?? { code: String(res.status) }; + token = json.data.data.access_token; + hideLoginScreen(); + loginWaiters.splice(0).forEach((resolve) => resolve()); +} + +// Mengembalikan isi `data`, atau melempar errors[0]. Error jaringan ikut dilempar, +// lalu ditangani pemanggil sesuai §8. +async function api(method, path, { body, idempotencyKey } = {}) { + for (let attempt = 0; ; attempt++) { + if (!token) await waitForLogin(attempt === 0 ? null : 'Sesi kamu berakhir, silakan login lagi.'); + const headers = { Authorization: `Bearer ${token}` }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; + const res = await fetch(apiBaseUrl + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = await res.json().catch(() => null); + if (json?.success) return json.data; + const err = json?.errors?.[0] ?? { code: String(res.status) }; + if (err.entity === 'auth_handler' && attempt === 0) { + token = null; // login lagi, lalu ulangi request yang sama sekali + continue; + } + throw err; + } +} +``` --- -## 4. Alur satu kali main +## 6. Alur satu kali main ``` -init ─► cek session yang masih berjalan (§4.4) +init ─► token ada? tidak: login (§5.2) ─► cek session yang masih berjalan (§6.4) ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) ─► permainan berjalan (batas waktu: expires_at) @@ -103,10 +355,10 @@ init ─► cek session yang masih berjalan (§4.4) ─► tampilkan hadiah ─► main lagi atau close ``` -### 4.1 Data game — `GET /customer/enakgame/games` +### 6.1 Data game — `GET /customer/enakgame/games` Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama dengan -`game_id` dari `init`. +`game_id` dari `init`; pada mode mandiri, yang `slug`-nya milik build ini (§5.4). ```json [ @@ -140,11 +392,15 @@ Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama den - `prizes`: hanya ada untuk game ber-reward `PROBABILITY` (spin). Urutan = urutan segmen roda. `label` bisa `null`. Bobot peluang tidak pernah dikirim. - Game tidak ada di daftar → game sudah dinonaktifkan; tampilkan pesan dan `close`. +- `slug` bukan yang dibangun untuk build ini (mis. build spin menerima game `runner`) + → `game_url` salah dipasang admin. Tampilkan "Game sedang tidak tersedia", `close`, + dan laporkan ke tim backoffice. -### 4.2 Mulai — `POST /customer/enakgame/sessions` +### 6.2 Mulai — `POST /customer/enakgame/sessions` Header `Idempotency-Key` wajib (maks. 50 karakter, mis. UUID v4). Buat key baru saat -customer menekan Main; pakai key yang sama bila request diulang karena jaringan. +customer menekan Main; pakai key yang sama bila request diulang karena jaringan atau +karena customer login ulang (§5.2). ```json { "game_id": "8a1f…" } @@ -177,9 +433,10 @@ customer menekan Main; pakai key yang sama bila request diulang karena jaringan. | `this Idempotency-Key was already used to start another game` | Bug di game: key dipakai ulang untuk game lain | | `the Idempotency-Key header is required` / `… at most 50 characters` | Bug di game | -### 4.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete` +### 6.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete` -Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja: +Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja, +sesuai jenis hadiah game (§2.2): | Field | Tipe | Untuk | |---|---|---| @@ -188,8 +445,8 @@ Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saj | `data` | objek JSON, opsional, maks. 16 KB | Data tambahan untuk audit (durasi per level, dsb.) | Spin cukup mengirim `{}`. Game skor: `{ "score": 800 }`. Game hasil: -`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game; -sepakati daftarnya dengan tim backoffice. +`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game +(§2.3). ```json { @@ -216,20 +473,20 @@ sepakati daftarnya dengan tim backoffice. dikembalikan dan tidak ada hadiah. Tampilkan "Game sedang dihentikan, EnakCoin kamu dikembalikan." - **`reward_total` 0** bisa terjadi: hadiahnya memang 0 (mis. segmen Zonk), batas harian - sudah habis, atau hasilnya tidak lolos validasi server (skor di atas batas, terlalu - cepat selesai, `outcome` tidak dikenal). Server tidak memberi tahu alasan validasi; - tampilkan hasil apa adanya. + sudah habis, field yang dibutuhkan jenis hadiahnya tidak dikirim (§2.2), atau hasilnya + tidak lolos validasi server (skor di atas batas, terlalu cepat selesai, `outcome` + tidak dikenal). Server tidak memberi tahu alasan validasi; tampilkan hasil apa adanya. - **Mengirim ulang aman.** Complete untuk session yang sudah selesai mengembalikan jawaban yang sama, tanpa hadiah dua kali. Tidak perlu `Idempotency-Key`. | Penolakan | Arti | Tampilan | |---|---|---| -| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §5) | +| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §7) | | `304` `data must be …` | `data` bukan JSON atau lebih dari 16 KB | Bug di game | | `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game | | `404` | Session tidak ada / milik customer lain | Pesan umum | -### 4.4 Pemulihan setelah reload +### 6.4 Pemulihan setelah reload Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash) saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi @@ -249,9 +506,32 @@ session-nya. Dua endpoint dipakai: **Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1` -Bentuk item sama dengan di atas, dibungkus `data` + `pagination`, terbaru di atas. -Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, -`EXPIRED`), `page`, `limit`. `status` atau `game_id` yang tidak valid ditolak `304`. +Response lengkap, termasuk amplopnya. Daftar session ada di **`data.data`**, terbaru di +atas: + +```json +{ + "success": true, + "data": { + "data": [ + { + "id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0, + "started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null + } + ], + "pagination": { "page": 1, "limit": 1, "total_count": 1, "total_pages": 1 } + }, + "errors": null +} +``` + +Tidak ada session yang cocok: `data.data` berupa array kosong `[]` (tidak pernah +`null`). Bentuk lain berarti error, bukan "tidak ada session". Semua query opsional: +`game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, `EXPIRED`), `page`, `limit`. +`status` atau `game_id` yang tidak valid ditolak `304`. + +Bila pengecekan ini gagal (jaringan, `5xx`), **jangan** menganggap tidak ada session: +customer bisa terpotong EnakCoin dua kali. Tampilkan "Coba lagi" sampai berhasil. **Alurnya, setiap kali menerima `init`:** @@ -265,23 +545,25 @@ Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, | Keadaan | Yang dilakukan game | |---|---| -| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete | +| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete. Complete setelah `expires_at` ditolak, jadi **akhiri ronde otomatis dan kirim skor saat itu** beberapa detik sebelum `expires_at` (mis. 5 detik, untuk jeda jaringan dan selisih jam perangkat) | | `STARTED`, `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru | | `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}`): server mengembalikan jawaban yang sama persis, termasuk `prize`, tanpa hadiah dobel. Tampilkan hasilnya | | `REFUNDED` | "EnakCoin kamu dikembalikan." | | `EXPIRED` | "Waktu bermain habis." | +| `404` untuk `session_id` tersimpan | Session milik akun lain (customer berganti akun). Hapus `session_id`, lanjut seperti tidak ada session | | Tidak ada session | Tampilkan layar awal seperti biasa | Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game. --- -## 5. Batas waktu dan refund +## 7. Batas waktu dan refund | Keadaan | Yang terjadi pada EnakCoin | |---|---| | Hasil dikirim sebelum `expires_at` | Entry cost terpakai, hadiah masuk | | Customer menutup game / game crash, hasil tidak pernah dikirim | Session menjadi `EXPIRED` setelah `expires_at`. **Entry cost tidak dikembalikan** | +| Complete tertahan karena customer harus login ulang sampai `expires_at` lewat | Sama dengan di atas: `EXPIRED`, **entry cost tidak dikembalikan** | | Complete gagal karena error server (`5xx`) dan tidak berhasil sampai `expires_at` | Session direfund otomatis (`refund_reason: "SYSTEM_ERROR"`) dalam ±1 menit setelah `expires_at` | | Game dinonaktifkan admin saat dimainkan | Session direfund (`GAME_DEACTIVATED`) | @@ -291,24 +573,25 @@ sudah dipakai tidak kembali". --- -## 6. Retry dan jaringan +## 8. Retry dan jaringan | Request | Gagal karena jaringan / `5xx` | Aturan | |---|---|---| | Start | Ulangi dengan **`Idempotency-Key` yang sama** | Key baru = potong EnakCoin lagi | | Complete | Ulangi dengan body yang sama sampai berhasil atau `expires_at` lewat | Aman diulang | -| Token ditolak (`entity` `auth_handler`) | `token_expired` → tunggu `token` → ulangi | Jangan minta customer login dari dalam game | +| Token ditolak (`entity` `auth_handler`) | Bukan error jaringan; layar login (§5.2) | Setelah login, ulangi sekali | +| Login gagal | Tampilkan pesan (§5.3) | Jangan diulang otomatis | Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama complete diulang. --- -## 7. Spin +## 9. Spin -1. Gambar roda dari `prizes` (§4.1): satu segmen per entri, urut, dengan `label` +1. Gambar roda dari `prizes` (§6.1): satu segmen per entri, urut, dengan `label` (atau `amount` bila `label` `null`). -2. Tap Putar → start session (§4.2). +2. Tap Putar → start session (§6.2). 3. Mulai animasi berputar, lalu langsung kirim complete dengan `{}`. 4. Dari response, hentikan roda di segmen `prize.entry`, lalu tampilkan `reward_total`. @@ -318,11 +601,16 @@ saja. --- -## 8. Checklist +## 10. Checklist -- [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. +- [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1). +- [ ] Body complete sesuai jenis hadiah game (§2.2). +- [ ] Bridge sesuai kontrak §4 yang sudah disepakati dengan tim aplikasi. - [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. -- [ ] Pemulihan setelah reload (§4.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang. +- [ ] Tanpa token tidak ada request; token kosong atau ditolak → layar login → ulangi request sekali (§5.2). +- [ ] Login memakai `access_token`; password tidak disimpan; nomor HP/password salah tampil sebagai pesan yang jelas (§5.3). +- [ ] Mode mandiri (§5.4): dibuka di browser atau `init` tidak datang → login, `game_id` dicari lewat `slug`. +- [ ] Pemulihan setelah reload (§6.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang. - [ ] Biaya main dan label event tampil sebelum main. - [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry. - [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. diff --git a/docs/integration-mobile-customer.md b/docs/integration-mobile-customer.md index e1ee629..46c9c3f 100644 --- a/docs/integration-mobile-customer.md +++ b/docs/integration-mobile-customer.md @@ -52,13 +52,19 @@ Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin. - Semua jumlah di request dan response berupa integer. - Belum ada endpoint refresh token: bila token ditolak (§2.2, `entity` `auth_handler`), customer login ulang. +- **Nomor HP customer selalu disimpan dan dikembalikan dalam format `62…`** (mis. + `6281234561234`). Di request (registrasi, login, kirim ulang OTP, cek nomor, penerima + transfer) nomor boleh ditulis `0812…`, `+62 812…`, `62812…`, atau `812…`; backend + mengubahnya ke `62…`. Hanya nomor HP Indonesia (`628…`) yang diterima; selain itu + ditolak `304` `invalid phone number format`. Pengecualian: nomor penerima transfer + yang disamarkan tetap ditampilkan dalam format lokal, `08**-****-1234` (§7.2). ### 2.1 Registrasi customer `POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional): ```json -{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" } +{ "phone_number": "6281234561234", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" } ``` - Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu. @@ -78,6 +84,10 @@ Sukses: { "success": true, "data": { … }, "errors": null } ``` +Semua contoh response di dokumen ini adalah isi `data`. Untuk daftar berhalaman, isi +itu sendiri berbentuk `{ "data": [ … ], "pagination": { … } }`, jadi array-nya ada di +`data.data` pada response mentah. + Gagal: ```json @@ -89,7 +99,7 @@ Gagal: | `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum | | `304` | 400 | Ditolak aturan bisnis, **atau token tidak berlaku** bila `entity` = `auth_handler` | Pesan yang ramah per fitur; `cause` berbahasa Inggris, jangan tampilkan mentah. Token: login ulang | | `404` | 404 | Tidak ditemukan, juga untuk data milik customer lain | Tampilkan "tidak ditemukan" | -| `429` | 429 | Minta OTP terlalu cepat | Hitung mundur sebelum boleh minta lagi | +| `429` | 429 | Minta OTP terlalu cepat, atau terlalu banyak percobaan login (§2.4) | Hitung mundur sebelum boleh minta lagi | | `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) | | `PIN_INVALID` | 400 | PIN salah | §6.5 | | `PIN_LOCKED` | 423 | PIN terkunci | §6.5 | @@ -107,6 +117,25 @@ Endpoint **tukar**, **transfer**, dan **tukar voucher** wajib header `Idempotenc dua kali. - Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`). +### 2.4 Login — `POST /api/v1/customer-auth/login` + +`{ "phone_number": "…", "password": "…" }` → token di `data.data.access_token`. + +| `errors[0].code` | HTTP | Arti | Tampilan | +|---|---|---|---| +| `304`, `entity` `customer_auth_service` | 400 | Nomor HP tidak terdaftar, password salah, atau pendaftaran belum selesai | "Nomor HP atau password salah." | +| `304`, `entity` `request` | 400 | Format nomor HP salah, field kosong | "Periksa nomor HP dan password." | +| `429` | 429 | Terlalu banyak percobaan; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." | +| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" | + +> **Berubah per 9 Okt 2026.** Sebelumnya nomor HP atau password salah dijawab `900` +> (HTTP 500) dengan `cause` `invalid password` / `customer not found`. Bila app +> menangani login gagal dari HTTP 500 atau teks `cause` itu, ganti ke tabel di atas. + +Setiap nomor HP hanya boleh mencoba login 5 kali dalam 15 menit; login yang berhasil +memulai hitungan dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga +bila password-nya benar. + --- ## 3. Layar yang perlu dibuat @@ -495,7 +524,7 @@ Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan 1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah. 2. Cek penerima: - `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` + `GET /api/v1/customer/wallet/transfer/recipient?phone=6281234561234` ```json { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } @@ -512,7 +541,7 @@ Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan `POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key` ```json - { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } + { "currency": "POINT", "amount": 120, "recipient_phone": "6281234561234", "pin": "482913" } ``` ```json @@ -597,7 +626,7 @@ log server game dan riwayat webview. ### 8.3 Bridge (sisi aplikasi) > **Usulan.** Kontrak ini sama dengan [`integration-enakgame.md`](./integration-enakgame.md) -> §2 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai. +> §4 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai. - Game → aplikasi: JavaScript channel webview bernama **`EnakGameHost`**; setiap pesan berupa JSON string. @@ -605,11 +634,14 @@ log server game dan riwayat webview. | Pesan masuk dari game | Yang dilakukan aplikasi | |---|---| -| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "/api/v1", "token": "", "game_id": "" }` | -| `{ "type": "token_expired" }` | Login ulang customer (tidak ada refresh token), lalu kirim `{ "type": "token", "token": "" }` | +| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "/api/v1", "token": "", "game_id": "" }`. Jawab setiap `ready`, termasuk setelah halaman game dimuat ulang. Access token, bukan refresh token (ditolak backend) | | `{ "type": "balance_changed", "coin_balance": 15 }` | Perbarui saldo EnakCoin yang ditampilkan aplikasi | | `{ "type": "close" }` | Tutup webview, muat ulang beranda | +Bila token kosong atau ditolak backend, game menampilkan layar login akun customer +sendiri; aplikasi tidak perlu menangani apa pun, dan token hasil login di game tidak +dikirim balik ke aplikasi. + Abaikan pesan dengan `type` lain. Tombol back Android jangan langsung menutup webview: tampilkan konfirmasi "Keluar dari game? EnakCoin yang sudah dipakai untuk main tidak kembali", lalu tutup. Tidak perlu mengirim pesan ke game. @@ -731,7 +763,9 @@ minta PIN → kirim dengan header `Idempotency-Key`: ### 9.3 Voucher saya — `GET /customer/vouchers/redemptions?page=1&limit=20` Daftar penukaran customer, terbaru di atas, dengan bentuk item sama seperti response -§9.2 (tanpa `point_balance` dan `replayed`), dibungkus `data` + `pagination`. +§9.2 (tanpa `point_balance` dan `replayed`). Seperti semua daftar berhalaman di dokumen +ini, array-nya ada di `data.data` dan pagination di `data.pagination` di dalam amplop +response (§2.2); daftar kosong berupa `[]`. | `status` | Tampilan | |---|---| -- 2.54.0 From 3af16a1fbc32679cb3493762f96019611e9f503c Mon Sep 17 00:00:00 2001 From: efrilm Date: Mon, 12 Oct 2026 16:15:02 +0700 Subject: [PATCH 11/12] feat(enakgame): weekly leaderboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /customer/enakgame/leaderboard ranks the customers of an organization by the EnakCoin they won from games in one week, Monday to Sunday in Asia/Jakarta. Only completed, unflagged sessions count, by the week they started; entry cost is not subtracted. Equal totals rank by who reached theirs first, so no two customers share a rank. Optional date (any day of the week, this week by default), game_id and limit (10, at most 100). The response holds the top ranks and, under me, the customer asking wherever they rank. Other customers are shown by a masked name: the first word whole, the others by their first letter; a word with a digit keeps only its first character. The EnakGame web game shows it: integration-enakgame.md §10 (checklist now §11) and RFC §11. The DB test needs TEST_DATABASE_URL. Co-Authored-By: Claude Opus 5.5 --- docs/integration-enakgame.md | 50 +++++++++- docs/rfc-enakgame.md | 4 +- internal/handler/enakgame_customer_handler.go | 14 +++ internal/models/enakgame.go | 28 ++++++ internal/processor/game_leaderboard.go | 98 +++++++++++++++++++ .../processor/game_leaderboard_db_test.go | 95 ++++++++++++++++++ internal/processor/game_leaderboard_test.go | 38 +++++++ .../repository/game_session_repository.go | 68 +++++++++++++ internal/router/router.go | 1 + internal/service/enakgame_service.go | 6 ++ 10 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 internal/processor/game_leaderboard.go create mode 100644 internal/processor/game_leaderboard_db_test.go create mode 100644 internal/processor/game_leaderboard_test.go diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md index 3a64c40..61a876e 100644 --- a/docs/integration-enakgame.md +++ b/docs/integration-enakgame.md @@ -1,6 +1,6 @@ # Integrasi EnakGame: Game Client (Phaser) -**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 9 Okt 2026 +**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 12 Okt 2026 Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main @@ -13,7 +13,7 @@ Pembagian tugas dengan aplikasi customer: | Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) | |---|---| | Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) | -| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah | +| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah, leaderboard mingguan (§10) | | Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup | Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan @@ -106,6 +106,7 @@ memakai header `Authorization: Bearer `. Request ber-body memakai | 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 | | 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 | | 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 | +| 6 | `GET /customer/enakgame/leaderboard?date=&game_id=&limit=` | – | – | Layar leaderboard; muat ulang setelah complete | §10 | Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN adalah tugas aplikasi customer. @@ -601,7 +602,49 @@ saja. --- -## 10. Checklist +## 10. Leaderboard mingguan — `GET /customer/enakgame/leaderboard` + +Peringkat customer satu organisasi menurut EnakCoin yang dimenangkan dari game dalam +satu minggu, Senin 00:00 sampai Minggu 23:59 WIB. Yang dihitung hanya session +`COMPLETED`, menurut minggu saat session dimulai; entry cost tidak mengurangi. Total +yang sama diurutkan menurut siapa yang mencapainya lebih dulu, jadi tidak ada peringkat +kembar. + +| Query | Default | Keterangan | +|---|---|---| +| `date` | Minggu ini | Hari mana pun dalam minggu yang diminta, `YYYY-MM-DD`. Untuk minggu lalu, kirim tanggal 7 hari sebelum `week_start` minggu ini | +| `game_id` | Semua game | Leaderboard satu game, mis. `game_id` dari `init` untuk "peringkat game ini" | +| `limit` | 10 | Jumlah peringkat teratas, maks 100 | + +```json +{ + "week_start": "2026-10-12", + "week_end": "2026-10-18", + "game_id": null, + "entries": [ + { "rank": 1, "name": "Budi S******", "coins": 120, "is_me": false }, + { "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true } + ], + "me": { "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true } +} +``` + +- Nama customer lain sudah disamarkan server: kata pertama utuh, kata berikutnya hanya + huruf pertama. Tampilkan apa adanya; untuk baris `is_me: true` tampilkan "Kamu". +- `me` adalah posisi customer yang login, juga bila di luar `limit`: tampilkan sebagai + baris tersendiri di bawah daftar. `null` bila belum menang EnakCoin minggu itu + ("Main untuk masuk leaderboard"). +- `entries` kosong (`[]`, tidak pernah `null`) bila belum ada yang menang minggu itu. +- Tampilkan rentang minggu dari `week_start`–`week_end`; jangan menghitung minggu + sendiri dari jam perangkat. +- Leaderboard tidak berubah sendiri. Muat ulang setelah complete berhasil, atau saat + layar leaderboard dibuka. +- `date` yang bukan `YYYY-MM-DD` atau `game_id` yang bukan UUID ditolak `304` (bug di + game; pesan umum). + +--- + +## 11. Checklist - [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1). - [ ] Body complete sesuai jenis hadiah game (§2.2). @@ -616,5 +659,6 @@ saja. - [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. - [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani. - [ ] Spin berhenti di `prize.entry`. +- [ ] Leaderboard (§10): rentang minggu dari `week_start`/`week_end`, baris `is_me` sebagai "Kamu", `me` di luar `limit` tampil di bawah daftar, dimuat ulang setelah complete. - [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani. - [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar. diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index b2f8c8b..af68df6 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -843,6 +843,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate. | `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header | | `GET` | `/sessions/:id` | Status dan hasil | | `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) | +| `GET` | `/leaderboard` | Peringkat mingguan (Senin–Minggu, Asia/Jakarta) org customer menurut EnakCoin yang dimenangkan dari game. Query opsional `date` (hari mana pun dalam minggu itu), `game_id`, `limit` (default 10, maks 100) | Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama. @@ -940,7 +941,8 @@ Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak ## 16. Di Luar Scope - **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya. -- **Leaderboard** (PRD §15). +- **Leaderboard event** (PRD §15). Leaderboard mingguan umum sudah ada (§11, ditambahkan + 2026-10-12); leaderboard per event dengan aturannya sendiri belum. - **Reward `TIERED`** (§8). - **Budget Controller automatic mode** (D9). - **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game. diff --git a/internal/handler/enakgame_customer_handler.go b/internal/handler/enakgame_customer_handler.go index 9eafccb..b48f480 100644 --- a/internal/handler/enakgame_customer_handler.go +++ b/internal/handler/enakgame_customer_handler.go @@ -78,6 +78,20 @@ func (h *EnakGameCustomerHandler) GetSession(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, h.service.GetSession(c.Request.Context(), customerID, id), method) } +// Leaderboard is GET /customer/enakgame/leaderboard?date=&game_id=&limit=. +func (h *EnakGameCustomerHandler) Leaderboard(c *gin.Context) { + const method = "EnakGameCustomerHandler::Leaderboard" + customerID, ok := customerIDFromGin(c, method) + if !ok { + return + } + var q models.EnakGameLeaderboardQuery + if !bindQuery(c, &q, method) { + return + } + util.HandleResponse(c.Writer, c.Request, h.service.Leaderboard(c.Request.Context(), customerID, q), 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 diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index aaa1207..7f441a5 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -199,6 +199,34 @@ type GameSessionListQuery struct { Limit int `form:"limit"` } +// EnakGameLeaderboardQuery picks a week of the leaderboard, and optionally one game. +type EnakGameLeaderboardQuery struct { + // Any day of the week, YYYY-MM-DD in Asia/Jakarta; this week when empty. + Date string `form:"date"` + GameID string `form:"game_id"` + Limit int `form:"limit"` +} + +// EnakGameLeaderboard ranks the customers of an organization by the EnakCoin they won +// from games in one week, Monday to Sunday in Asia/Jakarta. +type EnakGameLeaderboard struct { + WeekStart string `json:"week_start"` + WeekEnd string `json:"week_end"` + GameID *uuid.UUID `json:"game_id"` + Entries []EnakGameLeaderboardEntry `json:"entries"` + // The customer asking, wherever they rank; null when they won nothing that week. + Me *EnakGameLeaderboardEntry `json:"me"` +} + +// EnakGameLeaderboardEntry is one customer on the leaderboard. Other customers are +// shown by a masked name only. +type EnakGameLeaderboardEntry struct { + Rank int `json:"rank"` + Name string `json:"name"` + Coins int64 `json:"coins"` + IsMe bool `json:"is_me"` +} + // 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 { diff --git a/internal/processor/game_leaderboard.go b/internal/processor/game_leaderboard.go new file mode 100644 index 0000000..8e7acf8 --- /dev/null +++ b/internal/processor/game_leaderboard.go @@ -0,0 +1,98 @@ +package processor + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +const ( + // leaderboardLimit is how many ranks a leaderboard shows unless asked, and + // leaderboardMaxLimit the most it shows. + leaderboardLimit = 10 + leaderboardMaxLimit = 100 +) + +// Leaderboard ranks the customers of the customer's organization by the EnakCoin they +// won from games in one week, Monday to Sunday in Asia/Jakarta: this week, or the one +// holding q.Date. A session counts in the week it started. Refunded, expired and +// flagged sessions count for nothing. +func (p *GameSessionProcessor) Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) (*models.EnakGameLeaderboard, error) { + day := walletDay(p.now()) + if s := strings.TrimSpace(q.Date); s != "" { + d, err := time.ParseInLocation("2006-01-02", s, walletDisplayLocation) + if err != nil { + return nil, gameSessionRejected("date must be YYYY-MM-DD") + } + day = d + } + weekStart := leaderboardWeekStart(day) + filter := repository.LeaderboardFilter{ + CustomerID: customerID, From: weekStart, To: weekStart.AddDate(0, 0, 7), Limit: q.Limit, + } + if filter.Limit < 1 || filter.Limit > leaderboardMaxLimit { + filter.Limit = leaderboardLimit + } + if s := strings.TrimSpace(q.GameID); s != "" { + id, err := uuid.Parse(s) + if err != nil { + return nil, gameSessionRejected("game_id must be a UUID") + } + filter.GameID = &id + } + customer, err := p.customers.GetCustomer(ctx, customerID) + if err != nil { + return nil, err + } + filter.OrganizationID = customer.OrganizationID + rows, err := p.sessions.Leaderboard(ctx, filter) + if err != nil { + return nil, err + } + board := &models.EnakGameLeaderboard{ + WeekStart: weekStart.Format("2006-01-02"), WeekEnd: weekStart.AddDate(0, 0, 6).Format("2006-01-02"), + GameID: filter.GameID, Entries: []models.EnakGameLeaderboardEntry{}, + } + for _, row := range rows { + entry := models.EnakGameLeaderboardEntry{ + Rank: row.Rank, Name: maskCustomerName(row.Name), Coins: row.Coins, IsMe: row.CustomerID == customerID, + } + if row.Rank <= filter.Limit { + board.Entries = append(board.Entries, entry) + } + if entry.IsMe { + me := entry + board.Me = &me + } + } + return board, nil +} + +// leaderboardWeekStart is the Monday, at midnight in Asia/Jakarta, of the week of day. +func leaderboardWeekStart(day time.Time) time.Time { + day = walletDay(day) + return day.AddDate(0, 0, -((int(day.Weekday()) + 6) % 7)) +} + +// maskCustomerName keeps the first word of a name and the first letter of every other +// word: "Budi Santoso" is shown as "Budi S******". A word with a digit, as when a phone +// number was used for the name, keeps only its first character, the first word too. +func maskCustomerName(name string) string { + words := strings.Fields(name) + if len(words) == 0 { + return "Pemain" + } + for i, w := range words { + if i == 0 && !strings.ContainsAny(w, "0123456789") { + continue + } + r := []rune(w) + words[i] = string(r[0]) + strings.Repeat("*", len(r)-1) + } + return strings.Join(words, " ") +} diff --git a/internal/processor/game_leaderboard_db_test.go b/internal/processor/game_leaderboard_db_test.go new file mode 100644 index 0000000..6a11d93 --- /dev/null +++ b/internal/processor/game_leaderboard_db_test.go @@ -0,0 +1,95 @@ +package processor + +import ( + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "apskel-pos-be/internal/models" +) + +// The weekly leaderboard against Postgres. Needs TEST_DATABASE_URL; see +// internal/repository/wallet_repository_test.go. +func TestGameSession_Leaderboard(t *testing.T) { + e := newEnakGameEnv(t) + ctx := e.ctx() + dave := uuid.New() + require.NoError(t, e.db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Dave Lee')`, dave, e.orgA).Error) + runner := e.playableGame(e.orgA, "runner", 1) + other := e.playableGame(e.orgA, "other", 1) + theirs := e.playableGame(e.orgB, "theirs", 1) + for _, c := range []uuid.UUID{e.alice, e.bob, e.carol, dave} { + e.coins(c, 100, nil) + } + n := 0 + // play completes a session of game for customer and returns its id; every + // playableGame pays 10. + play := func(customer, game uuid.UUID) uuid.UUID { + t.Helper() + n++ + s, err := e.sessions.Start(ctx, customer, game, fmt.Sprintf("k%d", n)) + require.NoError(t, err) + _, err = e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{}) + require.NoError(t, err) + return s.SessionID + } + play(e.alice, runner.ID) + play(e.alice, other.ID) + bobs := play(e.bob, runner.ID) + play(dave, runner.ID) + play(e.carol, theirs.ID) + // Bob reached 10 before Dave did. + require.NoError(t, e.db.Exec(`UPDATE game_sessions SET ended_at = ended_at - INTERVAL '1 minute' WHERE id = ?`, bobs).Error) + flagged := play(e.bob, runner.ID) + require.NoError(t, e.db.Exec(`UPDATE game_sessions SET flagged = TRUE WHERE id = ?`, flagged).Error) + lastWeek := play(dave, runner.ID) + require.NoError(t, e.db.Exec(`UPDATE game_sessions SET started_at = started_at - INTERVAL '7 days' WHERE id = ?`, lastWeek).Error) + _, err := e.sessions.Start(ctx, e.alice, runner.ID, "open") + require.NoError(t, err, "a STARTED session counts for nothing") + + board, err := e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{}) + require.NoError(t, err) + week := leaderboardWeekStart(e.now) + assert.Equal(t, week.Format("2006-01-02"), board.WeekStart) + assert.Equal(t, week.AddDate(0, 0, 6).Format("2006-01-02"), board.WeekEnd) + assert.Equal(t, []models.EnakGameLeaderboardEntry{ + {Rank: 1, Name: "Alice", Coins: 20}, + {Rank: 2, Name: "Bob", Coins: 10, IsMe: true}, + {Rank: 3, Name: "Dave L**", Coins: 10}, + }, board.Entries, "the flagged play, last week's play and another organization's do not count") + require.NotNil(t, board.Me) + assert.Equal(t, 2, board.Me.Rank) + + board, err = e.sessions.Leaderboard(ctx, dave, models.EnakGameLeaderboardQuery{Limit: 1}) + require.NoError(t, err) + require.Len(t, board.Entries, 1) + assert.Equal(t, "Alice", board.Entries[0].Name) + require.NotNil(t, board.Me, "the customer asking is returned below the limit") + assert.Equal(t, models.EnakGameLeaderboardEntry{Rank: 3, Name: "Dave L**", Coins: 10, IsMe: true}, *board.Me) + + board, err = e.sessions.Leaderboard(ctx, e.alice, models.EnakGameLeaderboardQuery{GameID: other.ID.String()}) + require.NoError(t, err) + assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Alice", Coins: 10, IsMe: true}}, board.Entries) + assert.Equal(t, &other.ID, board.GameID) + + board, err = e.sessions.Leaderboard(ctx, dave, models.EnakGameLeaderboardQuery{Date: week.AddDate(0, 0, -3).Format("2006-01-02")}) + require.NoError(t, err) + assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Dave L**", Coins: 10, IsMe: true}}, board.Entries, "last week") + + board, err = e.sessions.Leaderboard(ctx, e.carol, models.EnakGameLeaderboardQuery{}) + require.NoError(t, err) + assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Carol", Coins: 10, IsMe: true}}, board.Entries, "only the customer's organization") + + board, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{GameID: uuid.NewString(), Date: "2020-01-01"}) + require.NoError(t, err) + assert.Empty(t, board.Entries) + assert.Nil(t, board.Me) + + _, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{Date: "12-10-2026"}) + assert.ErrorIs(t, err, ErrGameSessionRejected) + _, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{GameID: "runner"}) + assert.ErrorIs(t, err, ErrGameSessionRejected) +} diff --git a/internal/processor/game_leaderboard_test.go b/internal/processor/game_leaderboard_test.go new file mode 100644 index 0000000..ca40b36 --- /dev/null +++ b/internal/processor/game_leaderboard_test.go @@ -0,0 +1,38 @@ +package processor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestMaskCustomerName(t *testing.T) { + for name, want := range map[string]string{ + "Budi Santoso": "Budi S******", + "budi": "budi", + " Siti Nur Aisyah": "Siti N** A*****", + "Ádám Ő": "Ádám Ő", + "081234567890": "0***********", + "Budi2 Santoso": "B**** S******", + "": "Pemain", + " ": "Pemain", + } { + assert.Equal(t, want, maskCustomerName(name), "%q", name) + } +} + +func TestLeaderboardWeekStart(t *testing.T) { + monday := time.Date(2026, 10, 12, 0, 0, 0, 0, walletDisplayLocation) + for _, day := range []time.Time{ + monday, + time.Date(2026, 10, 14, 12, 0, 0, 0, walletDisplayLocation), + time.Date(2026, 10, 18, 23, 59, 59, 0, walletDisplayLocation), + // Sunday 17:00 UTC is already Monday 00:00 in Jakarta. + time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC), + } { + assert.True(t, monday.Equal(leaderboardWeekStart(day)), "%s", day) + } + // Sunday 16:59 UTC is still Sunday in Jakarta, the week before. + assert.True(t, monday.AddDate(0, 0, -7).Equal(leaderboardWeekStart(time.Date(2026, 10, 11, 16, 59, 0, 0, time.UTC)))) +} diff --git a/internal/repository/game_session_repository.go b/internal/repository/game_session_repository.go index e5bb062..d604c50 100644 --- a/internal/repository/game_session_repository.go +++ b/internal/repository/game_session_repository.go @@ -70,6 +70,32 @@ type GameSessionRepository interface { CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error) + + // Leaderboard ranks the active customers of an organization by the EnakCoin their + // completed, unflagged sessions started in [from, to) won, and returns the first + // limit ranks plus the row of filter.CustomerID wherever it ranks. + Leaderboard(ctx context.Context, filter LeaderboardFilter) ([]LeaderboardRow, error) +} + +// LeaderboardFilter selects the sessions a leaderboard counts. +type LeaderboardFilter struct { + OrganizationID uuid.UUID + // Nil for every game. + GameID *uuid.UUID + From time.Time + To time.Time + Limit int + // The customer asking, returned even below Limit. + CustomerID uuid.UUID +} + +// LeaderboardRow is one customer's total. Equal totals rank by who reached theirs +// first, so no two rows share a rank. +type LeaderboardRow struct { + Rank int + CustomerID uuid.UUID + Name string + Coins int64 } type gameSessionRepository struct { @@ -255,3 +281,45 @@ func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Ti } return due, nil } + +func (r *gameSessionRepository) Leaderboard(ctx context.Context, filter LeaderboardFilter) ([]LeaderboardRow, error) { + gameFilter, args := "", []any{filter.OrganizationID, filter.From, filter.To, constants.GameSessionStatusCompleted} + if filter.GameID != nil { + gameFilter = "AND s.game_id = ?" + args = append(args, *filter.GameID) + } + args = append(args, filter.Limit, filter.CustomerID) + var rows []struct { + Rank int + CustomerID string + Name string + Coins int64 + } + err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` + WITH totals AS ( + SELECT s.customer_id, SUM(s.reward_total) AS coins, MAX(s.ended_at) AS reached_at + FROM game_sessions s + WHERE s.organization_id = ? AND s.started_at >= ? AND s.started_at < ? + AND s.status = ? AND NOT s.flagged AND s.reward_total > 0 `+gameFilter+` + GROUP BY s.customer_id + ), ranked AS ( + SELECT t.customer_id, c.name, t.coins, + ROW_NUMBER() OVER (ORDER BY t.coins DESC, t.reached_at, t.customer_id) AS rank + FROM totals t + JOIN customers c ON c.id = t.customer_id AND c.is_active + ) + SELECT rank, customer_id::text AS customer_id, name, coins + FROM ranked + WHERE rank <= ? OR customer_id = ? + ORDER BY rank`, args...).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("failed to read game leaderboard: %w", err) + } + out := make([]LeaderboardRow, 0, len(rows)) + for _, row := range rows { + l := LeaderboardRow{Rank: row.Rank, Name: row.Name, Coins: row.Coins} + l.CustomerID, _ = uuid.Parse(row.CustomerID) + out = append(out, l) + } + return out, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index eaa345e..5f9fd0d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -197,6 +197,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { enakGame.GET("/sessions", r.enakGameCustomerHandler.ListSessions) enakGame.GET("/sessions/:id", r.enakGameCustomerHandler.GetSession) enakGame.POST("/sessions/:id/complete", r.enakGameCustomerHandler.CompleteSession) + enakGame.GET("/leaderboard", r.enakGameCustomerHandler.Leaderboard) } // Vouchers: what EnakPoint is spent on, whatever it came from (docs/rfc-enakgame.md diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go index dbc2767..f6feb3c 100644 --- a/internal/service/enakgame_service.go +++ b/internal/service/enakgame_service.go @@ -74,6 +74,7 @@ type EnakGameCustomerService interface { CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response + Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) *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 @@ -381,6 +382,11 @@ func (s *EnakGameCustomerServiceImpl) GetSession(ctx context.Context, customerID return respond(ctx, session, err) } +func (s *EnakGameCustomerServiceImpl) Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) *contract.Response { + board, err := s.sessions.Leaderboard(ctx, customerID, q) + return respond(ctx, board, 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 { -- 2.54.0 From 0f60aadbfd65cbde13d55121f4168017db41b66c Mon Sep 17 00:00:00 2001 From: efrilm Date: Mon, 12 Oct 2026 22:07:22 +0700 Subject: [PATCH 12/12] fix: migration --- migrations/000108_create_voucher_tables.up.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/migrations/000108_create_voucher_tables.up.sql b/migrations/000108_create_voucher_tables.up.sql index 4590178..27c23e4 100644 --- a/migrations/000108_create_voucher_tables.up.sql +++ b/migrations/000108_create_voucher_tables.up.sql @@ -2,6 +2,21 @@ -- which budget (docs/rfc-enakgame.md §5.7, D5). Replaces the unused rewards table for -- EnakGame; rewards itself stays. +-- Some databases still have the vouchers table of the reverted Voucher Undian feature +-- (commit cfe690a). Keep its data under another name; its primary key index is renamed +-- too, or vouchers_pkey below would clash. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() AND table_name = 'vouchers' AND column_name = 'winner_number' + ) THEN + ALTER TABLE vouchers RENAME TO vouchers_undian_legacy; + ALTER INDEX IF EXISTS vouchers_pkey RENAME TO vouchers_undian_legacy_pkey; + ALTER INDEX IF EXISTS vouchers_voucher_code_key RENAME TO vouchers_undian_legacy_voucher_code_key; + END IF; +END $$; + CREATE TABLE vouchers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL, -- 2.54.0