# RFC: EnakGame — Game Session, Reward, Voucher & Budget **Status:** Draft **Tanggal:** 2026-10-07 **PRD:** [enakgame-prd.md](enakgame-prd.md) **Scope:** Game catalog, game session + entry cost + refund, Reward Engine, Economy Guard, voucher & redemption, budget & Budget Controller (recommendation mode), audit **Out of scope:** Mission, leaderboard, reward `TIERED`, Budget Controller automatic mode (lihat §16) --- ## 1. Ringkasan EnakGame dibangun **di atas wallet EnakPoint/EnakCoin yang sudah ada** ([prd-point-coin.md](prd-point-coin.md)), bukan sebagai sistem saldo baru. Ledger, lot, kedaluwarsa, idempotency, exchange Coin → Point, dan PIN sudah tersedia dan sudah teruji. Yang dibangun baru: | Komponen PRD | Kondisi sekarang | Rencana | |---|---|---| | Game Management (§9) | `games` ada, tanpa `organization_id`, tanpa status/slug/URL | **Extend** `games` | | Game Session (§10) | Tidak ada. `game_plays` adalah main-instan tanpa session | **Baru**: `game_sessions` | | Entry cost (§10.1) | Ada (`GAME_SPEND`, `metadata.coin_cost`) tapi tanpa idempotency | **Reuse** `GAME_SPEND`, ref baru `GAME_SESSION` | | Refund entry cost (§10.2) | Tidak ada | **Baru**: tipe ledger `GAME_SPEND_REFUND` + job | | Reward Engine (§11–13) | Tidak ada. Hadiah spin tidak memberi apa pun | **Baru**: `game_reward_configs` (versioned) | | Event / campaign (§14–16) | Tidak ada. `campaigns` ada tapi tidak pernah dieksekusi | **Baru**: `game_events`, masing-masing dengan budget sendiri | | Economy Guard & Limits (§17, §35) | Tidak ada | **Baru**: counter harian + settings organisasi | | Coin Wallet & Ledger (§18–20) | **Ada lengkap** | **Reuse**, tambah 3 tipe ledger | | Coin → Point, expiry | **Ada** (F4, F12) | **Reuse** tanpa perubahan | | Voucher & Redemption (§21–28) | `rewards` ada tanpa org, tanpa redemption, tanpa kode | **Baru**: `vouchers`, `voucher_codes`, `voucher_redemptions` | | Budget & Controller (§5–8, §29–34) | Tidak ada | **Baru**: `game_budgets` + atribusi cost per lot | | Audit Log (§37) | Tidak ada yang generik | **Baru**: `audit_logs` | Keputusan paling penting ada di §3, terutama **D5**: realized cost voucher diatribusikan ke budget dengan menelusuri lot Point yang dipakai sampai ke asalnya. --- ## 2. Kondisi Sekarang Temuan yang memengaruhi desain: 1. **Tabel game, reward, campaign, dan tier tidak punya `organization_id`.** Semua query membaca semua tenant. Tabel wallet (`000090`) sudah punya. 2. **Main game sekarang tidak punya session.** `GamePlayProcessor.PlayGame` (`processor/game_play_processor.go:141`) memotong Coin, memilih hadiah, dan mencatat `game_plays` dalam satu request. 3. **Hadiah game tidak memberi apa pun.** Prize hanya tercatat sebagai `game_plays.prize_id` dan teks deskripsi ledger. Tidak ada kredit Coin/Point, tidak ada voucher. 4. **`GAME_SPEND` tanpa idempotency key** (`game_play_processor.go:186`). Tombol main yang ditekan dua kali memotong Coin dua kali. 5. **Tidak ada alur penukaran.** Tipe ledger `REWARD_REDEEM` dan ref `REWARD_REDEMPTION` sudah ada di `walletTypeRules` dan CHECK database, tapi belum pernah dipakai. 6. **Wallet sudah mendukung semua kebutuhan dasar:** `Credit` / `Debit` dengan lock per customer, lot FIFO berdasarkan kedaluwarsa, `idempotency_key` UNIQUE dengan replay, `origin_lot_id` untuk menelusuri asal saldo, `RefundExpiry` untuk refund. 7. **Tidak ada scheduler library.** Semua job adalah goroutine `time.NewTicker` di `app/app.go`, aman multi-instance lewat lock wallet dan idempotency key. 8. **`TxManager.WithTransaction` tidak me-reuse transaksi di context.** Pemanggilan bersarang membuka transaksi baru yang independen. --- ## 3. Keputusan Inti **D1 — EnakGame memakai wallet yang sudah ada.** Entry cost, reward, refund, dan redemption semuanya lewat `WalletProcessor.Credit` / `Debit`. Tidak ada tabel saldo baru. Konsekuensinya, aturan K5 (setiap mutasi punya asal dan tujuan), K6 (bilangan bulat), dan K9 (lot FIFO) otomatis berlaku untuk EnakGame. **D2 — `games` di-extend, game lama diarsipkan, `game_plays` tidak dipakai EnakGame.** `games` sudah dibaca customer app. Kolom yang kurang ditambahkan (§5.1). Session baru masuk ke `game_sessions`. Game lama (spin, ferris wheel) **dihapus dari sisi produk** dan spin dibangun ulang sebagai game EnakGame. Secara data, baris lama diarsipkan, bukan di-`DELETE` (§14). **D3 — Coin dipotong saat session dibuat, dalam satu transaksi.** Sesuai PRD §10.1. Idempotency key dari header `Idempotency-Key`, mengikuti pola exchange. **D4 — Session punya state machine yang ditegakkan dengan UPDATE bersyarat.** `STARTED → COMPLETED | REFUNDED | EXPIRED`. Setiap transisi adalah `UPDATE ... WHERE id = ? AND status = 'STARTED'`. Complete dan refund tidak mungkin sama-sama berhasil untuk satu session, karena hanya satu yang mendapat baris ter-update. **D5 — Realized cost diatribusikan ke budget lewat lot.** Point yang dipakai menukar voucher ditelusuri lewat `wallet_lot_allocations` → `wallet_lots.origin_lot_id` sampai ke lot pertama. Lot pertama menunjuk mutasi asalnya: `GAME_REWARD` (EnakGame, dengan budget yang tercatat), atau `EARN` / `ADJUSTMENT` / `MIGRATION` (bukan dari game). Hasilnya dibekukan per redemption di `voucher_redemption_costs`. **Hanya bagian yang berasal dari `GAME_REWARD` yang dihitung ke budget.** Point dari belanja (`EARN`) dan sumber lain tetap dicatat atribusinya (dengan `budget_id` kosong) untuk reporting, tetapi tidak mengurangi budget mana pun. Alasannya: Point bersifat fungible. Customer bisa memegang Point dari belanja, dari exchange Coin hasil game, dan dari transfer sekaligus. Tanpa penelusuran lot, sistem tidak bisa tahu berapa bagian voucher yang benar-benar dibiayai budget EnakGame atau budget event tertentu. Lot sudah menyimpan jejak ini sejak PRD point-coin (Q9), jadi tidak ada perubahan struktur wallet. **D6 — Reward per budget dicatat sebagai baris ledger terpisah.** Satu session bisa menghasilkan reward dari budget global (reward normal) dan dari budget event (tambahan dari multiplier/bonus event). Masing-masing menjadi satu baris `GAME_REWARD` dengan lot sendiri, dan `game_session_rewards` mencatat budget tiap baris. Ini yang membuat D5 bisa membedakan budget global dan event tanpa menambah kolom di `wallet_lots`. Dalam RFC ini **event = campaign**: istilah yang sama untuk hal yang sama. **D7 — Reward configuration immutable.** Baris `game_reward_configs` tidak pernah di-UPDATE kecuali kolom `status`. Perubahan reward = baris baru dengan `version + 1`. Session menyimpan `reward_config_id` saat **Start Game**, sehingga perubahan config tidak memengaruhi session yang sedang berjalan. **D8 — Semua tabel baru punya `organization_id`.** Satu organisasi = satu ekonomi EnakGame (budget, limit, voucher, game). Org customer dibaca dari tabel `customers` seperti flow wallet sekarang, karena JWT customer tidak membawa org. Game, voucher, dan event milik org lain ditolak. **D9 — Budget Controller v1 hanya recommendation mode.** Sesuai default PRD §33. Automatic mode di luar scope RFC ini. --- ## 4. Prinsip **P1 — Backend satu-satunya penentu reward.** Client hanya mengirim `score`, `outcome`, dan data hasil. Request yang membawa angka reward diabaikan. **P2 — Setiap mutasi uang punya idempotency key deterministik.** Diturunkan dari id session/redemption, bukan dari waktu. Retry selalu menghasilkan key yang sama. **P3 — Snapshot, bukan join.** Entry cost, reward config, face value voucher, dan point cost dibekukan di baris transaksi saat terjadi, sama seperti `unit_price` di `order_items`. **P4 — Lock wallet customer selalu diambil lebih dulu.** Semua alur (start, complete, refund, redeem) mengunci `customer_wallets` sebelum menyentuh tabel lain, supaya urutan lock konsisten dan tidak deadlock. --- ## 5. Model Data Semua migrasi mengikuti golang-migrate di `migrations/`, nomor lanjut dari `000101`. ### 5.1 `games` (extend) ```sql 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' CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')), ADD COLUMN entry_cost BIGINT, ADD COLUMN session_ttl_seconds INT NOT NULL DEFAULT 600 CHECK (session_ttl_seconds > 0), -- Batas validasi hasil: max_score, min_duration_seconds, max_score_per_second, outcome -- yang valid. Dibaca Result Validator (§7.2). ADD COLUMN result_rules JSONB NOT NULL DEFAULT '{}'; -- Game lama dihapus dari produk: diarsipkan, tidak di-DELETE (§14). UPDATE games SET status = 'ARCHIVED', is_active = FALSE, entry_cost = COALESCE((metadata->>'coin_cost')::bigint, 1); ALTER TABLE games ALTER COLUMN entry_cost SET NOT NULL, ADD CONSTRAINT chk_games_entry_cost CHECK (entry_cost >= 1), -- PRD §10.1: tidak ada game gratis -- Semua game EnakGame wajib punya org dan slug. Hanya arsip lama yang boleh kosong. 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); ``` **Catatan:** - Baris lama tidak punya org, sehingga tidak bisa dijadikan game EnakGame. Mereka diarsipkan dan tidak pernah tampil di endpoint EnakGame. Game baru (termasuk spin yang dibangun ulang) dibuat sebagai baris baru dengan org. - `is_active` tidak dipakai EnakGame dan dihapus bersama alur lama (§14). EnakGame hanya membaca `status`. ### 5.2 `game_reward_configs` ```sql 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 CHECK (reward_type IN ('FIXED', 'SCORE_BASED', 'OUTCOME_BASED', 'PROBABILITY')), rules JSONB NOT NULL, -- bentuk per tipe di §8 max_reward BIGINT NOT NULL CHECK (max_reward >= 0), status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')), effective_at TIMESTAMPTZ, created_by UUID NOT NULL, reason VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (game_id, version) ); -- Satu config aktif per game. CREATE UNIQUE INDEX uq_game_reward_configs_active ON game_reward_configs(game_id) WHERE status = 'ACTIVE'; ``` `MULTIPLIER` tidak menjadi `reward_type` karena di PRD ia adalah modifier di atas base reward, bukan cara menghitung base. Multiplier dan bonus hidup di `game_events` (§5.5). ### 5.3 `game_sessions` ```sql 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, reward_config_id UUID NOT NULL REFERENCES game_reward_configs(id), -- snapshot (D7) entry_cost BIGINT NOT NULL CHECK (entry_cost >= 1), -- snapshot (P3) status VARCHAR(20) NOT NULL DEFAULT 'STARTED' CHECK (status IN ('STARTED', 'COMPLETED', 'REFUNDED', 'EXPIRED')), started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ NOT NULL, ended_at TIMESTAMPTZ, -- Hasil dari client (P1: hanya data, tanpa angka reward). result JSONB, -- Validasi & perhitungan: base, modifier event, cap guard, alasan penolakan, roll RNG. reward_breakdown JSONB, reward_total BIGINT NOT NULL DEFAULT 0 CHECK (reward_total >= 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) CHECK (refund_reason IN ('SYSTEM_ERROR', 'GAME_DEACTIVATED')), -- Diisi saat complete gagal karena error sistem (5xx), di transaksi terpisah (§7.3). completion_failed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 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'; ``` `balance_before` yang diminta PRD §19 tidak perlu kolom: `balance_after - amount` di `wallet_transactions` sudah memberikannya. ### 5.4 `game_session_rewards` ```sql -- Satu baris per budget yang membiayai reward session (D6). 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 CHECK (amount > 0), wallet_transaction_id UUID NOT NULL UNIQUE REFERENCES wallet_transactions(id), PRIMARY KEY (session_id, budget_id) ); ``` ### 5.5 `game_events` dan `game_event_games` ```sql 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 TIMESTAMPTZ NOT NULL, end_at TIMESTAMPTZ NOT NULL, timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Jakarta', status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ACTIVE', 'ENDED', 'CANCELLED')), priority INT NOT NULL DEFAULT 0, multiplier NUMERIC(5,2) CHECK (multiplier IS NULL OR multiplier > 0), bonus BIGINT CHECK (bonus IS NULL OR bonus > 0), -- Budget event sendiri, terpisah dari global (§5.6). Membiayai tambahan reward -- dari multiplier dan bonus event ini. budget_id UUID NOT NULL REFERENCES game_budgets(id), reward_limit BIGINT, -- PRD §35 Event Limit user_daily_limit BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (organization_id, slug), CHECK (end_at > start_at) ); 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) ); ``` Event adalah campaign dalam arti PRD §7: setiap event punya budget sendiri. Base reward tetap dibiayai budget global. Hanya selisih yang ditambahkan event (multiplier + bonus) yang dibiayai budget event. Budget event harus ber-`scope = 'EVENT'`, ditegakkan di processor. ### 5.6 `game_budgets` ```sql CREATE TABLE game_budgets ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL, scope VARCHAR(20) NOT NULL CHECK (scope IN ('GLOBAL', 'EVENT')), name VARCHAR(255) NOT NULL, period_start DATE NOT NULL, period_end DATE NOT NULL, amount BIGINT NOT NULL CHECK (amount > 0), -- rupiah -- {"warning": 70, "critical": 90} dalam persen utilisasi/forecast (PRD §8, §32). thresholds JSONB NOT NULL DEFAULT '{}', exhaustion_policy VARCHAR(30), -- PRD §34, menunggu keputusan created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK (period_end >= period_start) ); -- Budget global tidak boleh tumpang tindih di satu org: satu baris per periode. CREATE UNIQUE INDEX uq_game_budgets_global_period ON game_budgets(organization_id, period_start) WHERE scope = 'GLOBAL'; ``` - **Global** default bulanan (PRD §5.2): `period_start` tanggal 1, `period_end` akhir bulan. Periode lain bisa di-configure dengan mengisi rentang sendiri. - **Event** periodenya rentang event. Realized cost dihitung ke budget event **kapan pun Point-nya ditukar**, termasuk setelah event berakhir, karena biaya itu lahir dari reward event tersebut. ### 5.7 Voucher ```sql 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 CHECK (voucher_type IN ('FIXED_VALUE', 'PERCENTAGE', 'FREE_ITEM', 'MERCHANT_BENEFIT')), face_value BIGINT NOT NULL CHECK (face_value > 0), -- rupiah, dasar realized cost point_cost BIGINT NOT NULL CHECK (point_cost > 0), -- boleh beda dari face_value (PRD §22) business_cost BIGINT, -- reporting saja, bukan budget stock_mode VARCHAR(20) NOT NULL CHECK (stock_mode IN ('STATIC', 'CODE_POOL', 'EXTERNAL')), stock BIGINT CHECK (stock IS NULL OR stock >= 0), -- hanya STATIC provider VARCHAR(50), -- hanya EXTERNAL provider_ref VARCHAR(255), max_per_customer INT, valid_from TIMESTAMPTZ, valid_until TIMESTAMPTZ, terms JSONB NOT NULL DEFAULT '{}', status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK ((stock_mode = 'STATIC') = (stock IS NOT NULL)), CHECK ((stock_mode = 'EXTERNAL') = (provider IS NOT NULL)) ); 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' CHECK (status IN ('AVAILABLE', 'RESERVED', 'REDEEMED', 'EXPIRED', 'CANCELLED')), redemption_id UUID, expires_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (voucher_id, code), 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 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 CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')), -- Snapshot (P3). 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 TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (customer_id, idempotency_key), CHECK ((status = 'FAILED') = (refund_transaction_id IS NOT NULL)) ); CREATE INDEX idx_voucher_redemptions_pending ON voucher_redemptions(updated_at) WHERE status = 'PENDING'; -- Atribusi realized cost per budget, dibekukan saat redemption COMPLETED (D5). CREATE TABLE voucher_redemption_costs ( redemption_id UUID NOT NULL REFERENCES voucher_redemptions(id), -- NULL = Point yang bukan berasal dari EnakGame (EARN, ADJUSTMENT, MIGRATION). budget_id UUID REFERENCES game_budgets(id), source_type VARCHAR(30) NOT NULL, -- tipe ledger lot asal points BIGINT NOT NULL CHECK (points > 0), cost BIGINT NOT NULL CHECK (cost >= 0), -- rupiah, bagian dari face_value recognized_at TIMESTAMPTZ NOT NULL, -- = completed_at, dasar periode budget UNIQUE (redemption_id, budget_id, source_type) ); CREATE INDEX idx_voucher_redemption_costs_budget ON voucher_redemption_costs(budget_id, recognized_at); ``` ### 5.8 Counter Economy Guard ```sql -- Jumlah reward yang sudah diterbitkan per cakupan per hari (Asia/Jakarta). CREATE TABLE game_reward_counters ( organization_id UUID NOT NULL, scope_type VARCHAR(20) NOT NULL CHECK (scope_type IN ('USER', 'GAME', 'EVENT', 'GLOBAL')), scope_id UUID NOT NULL, -- customer / game / event / organization day DATE NOT NULL, amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0), PRIMARY KEY (organization_id, scope_type, scope_id, day) ); ``` `EVENT` memakai `day = '0001-01-01'` untuk limit seumur event (PRD §35 Event Limit) dan tanggal sebenarnya untuk `game_events.user_daily_limit`. ### 5.9 `audit_logs` ```sql CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL, actor_type VARCHAR(20) NOT NULL CHECK (actor_type IN ('USER', '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), source VARCHAR(50) NOT NULL, -- 'admin_api', 'budget_controller', 'session_job', ... created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id, created_at DESC); ``` ### 5.10 Pengaturan organisasi Limit global dan per user disimpan di `organization_settings` (key-value, `000091`), dikelola lewat pola `LoyaltySettingsProcessor`: field descriptor dengan default dan min/max, advisory lock, dan riwayat otomatis di `loyalty_setting_changes`. Audit perubahan setting gratis didapat dari situ. | Key | Tipe | Default | Arti | |---|---|---|---| | `enakgame.limit.user_daily` | int ≥ 0 | 0 (tanpa batas) | Coin maksimal yang didapat satu customer per hari | | `enakgame.limit.global_daily` | int ≥ 0 | 0 | Coin maksimal yang diterbitkan seluruh org per hari | Perilaku saat limit terlampaui tidak di-configure: reward selalu **dipotong ke sisa limit** (§9). Limit per game (`Game Daily Limit`) disimpan di `games.result_rules` agar ikut di-configure per game. --- ## 6. Ledger ### 6.1 Tipe baru dan perubahan | Tipe | Currency | Arah | Ref | Wajib tambahan | Idempotency key | Status | |---|---|---|---|---|---|---| | `GAME_SPEND` | COIN | keluar | `GAME_PLAY` (lama) **atau `GAME_SESSION`** | – | `game-entry:{customer}:{key}` | ref baru | | `GAME_SPEND_REFUND` | COIN | masuk | `GAME_SESSION` | `reverses_transaction_id` | `game-refund:{session}` | **baru** | | `GAME_REWARD` | COIN | masuk | `GAME_SESSION` | – | `game-reward:{session}:{budget}` | **baru** | | `REWARD_REDEEM` | POINT | keluar | `REWARD_REDEMPTION` → `voucher_redemptions.id` | – | `redeem:{redemption}` | sudah ada, mulai dipakai | | `REWARD_REDEEM_REFUND` | POINT | masuk | `REWARD_REDEMPTION` | `reverses_transaction_id` | `redeem-refund:{redemption}` | **baru** | Pemetaan ke konsep PRD §19: `EARNED` = `GAME_REWARD`, `SPENT` = `GAME_SPEND` / `REWARD_REDEEM`, `REFUND` = `GAME_SPEND_REFUND` / `REWARD_REDEEM_REFUND`, `EXPIRED` = `EXPIRE`, `CONVERSION` = `EXCHANGE_OUT` + `EXCHANGE_IN`. `BONUS` dari event tetap `GAME_REWARD`, dibedakan lewat budget dan `reward_breakdown`. ### 6.2 Perubahan yang harus dilakukan bersamaan Menambah tipe berarti mengubah **dua tempat** yang harus sinkron: 1. `walletTypeRules` di `processor/wallet_processor.go:487`, plus konstanta di `constants/wallet.go` (`WalletRefTypeGameSession`). 2. CHECK di `wallet_transactions` (drop + create ulang): - `chk_wallet_transactions_point_only_types` + `REWARD_REDEEM_REFUND` - `chk_wallet_transactions_coin_only_types` + `GAME_SPEND_REFUND`, `GAME_REWARD` - `chk_wallet_transactions_reversal_source` + `GAME_SPEND_REFUND`, `REWARD_REDEEM_REFUND` Reconciliation job (`service/wallet_reconciliation_job.go`) harus diperiksa: invariant yang menghitung per tipe perlu mengenali tipe baru. ### 6.3 Lot | Mutasi | Lot yang dibuat | |---|---| | `GAME_REWARD` | Satu lot, `expires_at = ComputeExpiry(CoinExpiry, now)`, tanpa `origin_lot_id` (lot akar) | | `GAME_SPEND_REFUND` | Satu lot per alokasi `GAME_SPEND` asal: `expires_at = RefundExpiry(lot.expires_at, now)`, `origin_lot_id = lot asal`. Sama persis dengan pola `PAYMENT_REFUND` (`point_payment_refund.go`) | | `REWARD_REDEEM_REFUND` | Sama dengan `GAME_SPEND_REFUND`, untuk Point | --- ## 7. Alur ### 7.1 Start Game ``` POST /customer/enakgame/sessions { game_id } Idempotency-Key: <≤50 char> ``` Satu transaksi: 1. Baca customer → `organization_id`. Tolak bila game bukan milik org tersebut atau `status <> 'ACTIVE'`. 2. Baca reward config `ACTIVE` untuk game. Tolak bila tidak ada. 3. Pastikan ada budget global untuk periode berjalan. Tolak bila belum diatur, karena reward yang nanti diterbitkan wajib menunjuk budget (D6). 4. `LockWallet(customer)`. 5. `FindTransaction("game-entry:{customer}:{key}")`. Bila ada → kembalikan session yang menunjuknya (replay, tidak memotong lagi). 6. Buat `session_id` baru. `Debit` COIN `GAME_SPEND`, ref `GAME_SESSION → session_id`, amount `games.entry_cost`. Saldo kurang → `ErrWalletInsufficientBalance` → tolak tanpa apa pun tercatat. 7. Insert `game_sessions` dengan `entry_cost`, `reward_config_id`, `expires_at = now + session_ttl_seconds`, `spend_transaction_id`. Response: `session_id`, `expires_at`, `entry_cost`, `coin_balance`. ### 7.2 Complete Game ``` POST /customer/enakgame/sessions/:id/complete { score?, outcome?, data? } ``` Satu transaksi: 1. `LockWallet(customer)`. 2. Baca session. Bila `customer_id` beda → 404. Bila status sudah `COMPLETED` → kembalikan hasil yang tersimpan (idempotent, PRD §20). Bila `REFUNDED` / `EXPIRED` → tolak. 3. Bila `now > expires_at` → tolak (session dibiarkan untuk job, §7.3). 4. Bila game sudah tidak `ACTIVE` → **refund** (`GAME_DEACTIVATED`) di transaksi ini, lalu kembalikan response "game dinonaktifkan, Coin dikembalikan". 5. **Result Validator** (`games.result_rules`): durasi minimal sejak `started_at`, skor maksimum, skor per detik, outcome yang dikenal. Gagal → reward 0, `flagged = true`, alasan di `reward_breakdown`. Session tetap `COMPLETED`, tidak ada refund. 6. **Reward Engine** (§8): hitung base dari config snapshot. 7. **Event modifier**: event `ACTIVE` yang mencakup game, `start_at ≤ now < end_at`. Hasilnya daftar komponen `{budget_id, amount}`. 8. **Economy Guard** (§9): naikkan counter dengan UPDATE bersyarat, potong komponen yang melewati limit. 9. `UPDATE game_sessions SET status='COMPLETED' ... WHERE id=? AND status='STARTED'`. 0 baris → session baru saja di-refund job → rollback, kembalikan status terbaru. 10. Untuk tiap komponen > 0: `Credit` COIN `GAME_REWARD` dengan key `game-reward:{session}:{budget}`, lalu insert `game_session_rewards`. Response: `reward_total`, rincian yang aman ditampilkan (base, bonus event), `coin_balance`. ### 7.3 Refund otomatis & kedaluwarsa session PRD §10.2: refund otomatis untuk **system error** dan **game dinonaktifkan**; session yang ditinggal user tidak di-refund. **Mendefinisikan "system error".** Backend hanya bisa membedakan dua hal ini bila ada jejak. Aturannya: - Bila complete (§7.2) gagal dengan error non-bisnis (DB error, panic, timeout → HTTP 5xx), handler menulis `completion_failed_at = now()` di **transaksi terpisah** (`DetachTransaction`, karena transaksi utama sudah rollback). - Error validasi (4xx) dan error yang terjadi di client (Phaser crash, koneksi putus sebelum request sampai) **tidak** meninggalkan jejak, sehingga diperlakukan sebagai ditinggal user dan tidak di-refund (diputuskan). **`GameSessionJob`** (ticker, pola `WalletExpiryJob`, per batch, satu transaksi per session): | Kondisi session `STARTED` | Aksi | |---|---| | Game tidak lagi `ACTIVE` | Refund `GAME_DEACTIVATED` segera, tanpa menunggu kedaluwarsa | | Lewat `expires_at` dan `completion_failed_at IS NOT NULL` | Refund `SYSTEM_ERROR` | | Lewat `expires_at`, tanpa jejak gagal | `EXPIRED`, tanpa refund | Langkah refund: `LockWallet` → `UPDATE ... SET status='REFUNDED' WHERE status='STARTED'` (0 baris = sudah diselesaikan pihak lain, lewati) → `Credit` COIN `GAME_SPEND_REFUND` dengan `reverses_transaction_id = spend_transaction_id`, lot sesuai §6.3, key `game-refund:{session}` → isi `refund_transaction_id`, `refund_reason` → `audit_logs` dengan `actor_type = SYSTEM`. Saat admin mengubah game ke `INACTIVE`, handler tidak perlu menyapu session. Job berikutnya 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> ``` Satu transaksi, sehingga state `RESERVED` tidak diperlukan: 1. Validasi voucher `ACTIVE`, dalam masa berlaku, org sama. 2. `VerifyPin(..., PinActionRedeem)` — aksi PIN baru, konsisten dengan K8 (PIN untuk setiap pemakaian saldo). Batas salah 5 kali / kunci 30 menit yang sudah ada ikut berlaku. 3. `LockWallet(customer)`. 4. Cari `voucher_redemptions (customer_id, idempotency_key)`. Ada → kembalikan (replay). 5. Cek `max_per_customer` dari `COUNT(*)` redemption `COMPLETED` / `PENDING`. 6. Ambil stok: - `STATIC`: `UPDATE vouchers SET stock = stock - 1 WHERE id = ? AND stock > 0`. - `CODE_POOL`: `SELECT ... FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE' ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, lalu set `REDEEMED`. - Habis → tolak, tidak ada yang tercatat. 7. `Debit` POINT `REWARD_REDEEM` sebesar `point_cost`, key `redeem:{redemption}`. 8. Insert `voucher_redemptions` `COMPLETED` dengan snapshot `face_value`, `point_cost`. 9. **Atribusi cost** (§7.6) → insert `voucher_redemption_costs`. ### 7.5 Redemption voucher eksternal (`EXTERNAL`) Panggilan ke provider tidak boleh berada di dalam transaksi database, jadi alurnya dua tahap dengan pemulihan: 1. **Transaksi 1:** langkah 1–5 dan 7 di §7.4, lalu insert redemption `PENDING`. Point sudah terpotong. 2. **Panggil provider** dengan `redemption_id` sebagai idempotency key provider. 3. **Transaksi 2**, tergantung hasil: - Sukses → simpan `external_code` / `external_ref`, `COMPLETED`, atribusi cost (§7.6). - Gagal pasti (provider menolak) → `Credit` POINT `REWARD_REDEEM_REFUND`, `FAILED`. - Timeout / tidak jelas → biarkan `PENDING`, response "sedang diproses". 4. **`VoucherRedemptionRecoveryJob`** mengambil `PENDING` yang sudah lewat N menit, bertanya ke provider (atau mengulang dengan key yang sama), lalu menjalankan transaksi 2. Setelah batas percobaan, refund dan `FAILED`. Dengan ini syarat PRD §26 terpenuhi: tidak ada keadaan "Point terpotong, voucher tidak datang" yang tidak dipulihkan. ### 7.6 Atribusi realized cost Dijalankan di dalam transaksi redemption, setelah debit `REWARD_REDEEM`: ```sql 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 = :redeem_tx 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 c.points, t.type AS source_type, gsr.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; -- lot akar ``` Rantai lot akar Point bisa melewati `EXCHANGE_IN` → lot Coin → `TRANSFER_IN` → ... sampai `GAME_REWARD` (EnakGame) atau `EARN` / `ADJUSTMENT` / `MIGRATION` (bukan EnakGame). `exchangeLots` dan transfer sudah mengisi `origin_lot_id`, jadi query ini bekerja dengan data yang sudah ada. Hasil dikelompokkan per `(budget_id, source_type)`, lalu `face_value` dibagi proporsional terhadap Point: ``` cost_i = face_value × points_i / point_cost (dibulatkan; selisih pembulatan diberikan ke bagian terbesar, sehingga Σ cost_i = face_value persis) ``` Contoh: voucher face value Rp10.000, point cost 8.000. Point yang dipakai: 6.000 dari `GAME_REWARD` (budget global Oktober), 2.000 dari `EARN`. | budget_id | source_type | points | cost | |---|---|---|---| | global-2026-10 | `GAME_REWARD` | 6.000 | 7.500 | | NULL | `EARN` | 2.000 | 2.500 | Hanya Rp7.500 yang mengurangi budget global Oktober. Rp2.500 dari Point belanja tetap tercatat untuk reporting Finance, tetapi tidak dihitung ke budget mana pun (D5). --- ## 8. Reward Engine Satu interface, satu implementasi per tipe: ```go type RewardCalculator interface { Validate(rules json.RawMessage) error // saat config dibuat Calculate(rules json.RawMessage, result SessionResult, rng RNG) (base int64, detail map[string]any, err error) } ``` | Tipe | `rules` | Catatan | |---|---|---| | `FIXED` | `{"amount": 5}` | | | `SCORE_BASED` | `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}` | Band tidak boleh tumpang tindih atau berlubang; band terakhir boleh tanpa `max` | | `OUTCOME_BASED` | `{"outcomes": {"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0}}` | Outcome di luar daftar → ditolak Result Validator | | `PROBABILITY` | `{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 889, "amount": 0}]}` | Bobot bilangan bulat, bukan persen desimal, supaya validasi "total = 100%" tidak bergantung float | **PROBABILITY memakai `crypto/rand`**, bukan `math/rand` yang di-seed ulang dengan waktu seperti `selectPrizeByWeight` sekarang. Angka acak yang ditarik disimpan di `reward_breakdown` untuk audit. Hasil diundi saat **complete**, bukan saat start, supaya client tidak bisa mengetahui hasil lalu meninggalkan session. **Modifier event** (urutan tetap): `base × multiplier` → `+ bonus` → cap `max_reward` config. Aturan tumpuk antar event mengikuti default PRD §16 (multiplier terkontrol, bonus dihitung terpisah, cap selalu berlaku) sampai diputuskan (§19.2 #2). **Pembulatan:** semua hasil perkalian **dibulatkan ke bawah** ke Coin utuh, mengikuti K6 di PRD point-coin. Ini usulan untuk menutup Open Decision PRD §43 #1. `TIERED` di luar scope: tabel `tiers` belum terhubung ke customer, dan aturan tier dibahas di PRD terpisah (point-coin Q8). --- ## 9. Economy Guard & Limits Pemeriksaan di §7.2 langkah 8, untuk setiap komponen reward: 1. Session valid, belum rewarded, user & game cocok — sudah dijamin §7.2 langkah 2–4 dan 9. 2. Untuk tiap limit yang berlaku (`USER` harian, `GAME` harian, `EVENT` total & per user, `GLOBAL` harian): ```sql INSERT INTO game_reward_counters (...) VALUES (..., 0) ON CONFLICT DO NOTHING; UPDATE game_reward_counters SET amount = amount + :x WHERE ... AND amount + :x <= :limit RETURNING amount; ``` 0 baris → limit terlampaui → `x` diturunkan ke sisa limit dan diulang. Sisa 0 berarti komponen menjadi 0. Batas yang memotong dicatat di `reward_breakdown`, supaya customer bisa diberi tahu kenapa reward-nya lebih kecil. 3. Status budget (§10) `EXHAUSTED` → terapkan `exhaustion_policy` (§19.2 #3). Counter `USER` aman karena wallet customer sudah dikunci. Counter `GAME` / `GLOBAL` adalah baris panas yang dikunci singkat oleh setiap complete di org tersebut. Untuk volume awal ini dapat diterima. Lihat risiko di §17. --- ## 10. Budget & Budget Controller v1 **Metrik per budget** (PRD §8), dihitung on-read dari tabel yang ada, tanpa tabel agregat: | Metrik | Sumber | |---|---| | Realized cost | `SUM(voucher_redemption_costs.cost)` untuk `budget_id`, `recognized_at` dalam periode (global) / tanpa batas waktu (event) | | Coin issued | `SUM(game_session_rewards.amount)` untuk `budget_id` | | Remaining | `amount − realized` | | Utilization | `realized / amount` | | Forecast | `realized + rata-rata realized harian 7 hari terakhir × sisa hari` | | Exposure | Sisa Coin/Point beredar yang berasal dari budget ini (via lot), sebagai batas atas biaya yang masih bisa datang | | Status | Bandingkan utilization dan forecast dengan `thresholds` → `HEALTHY` / `WARNING` / `CRITICAL` / `EXHAUSTED` | **Rekomendasi** (recommendation mode, D9): bila forecast > budget, hitung multiplier yang membuat forecast = budget, lalu batasi dengan step maksimum dan min/max multiplier (PRD §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. `GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat, tambahkan snapshot harian, bukan cache yang di-invalidate. --- ## 11. API ### Customer (`/api/v1/customer/enakgame`, `ValidateCustomerToken`) | Method | Path | Catatan | |---|---|---| | `GET` | `/games` | Game `ACTIVE` milik org customer, dengan `entry_cost` dan event aktif | | `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` | `/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. ### Admin (`/api/v1/marketing/enakgame`, `RequireAdminOrManager`) | Resource | Endpoint | |---|---| | 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` | | Vouchers | CRUD, `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) | Perubahan budget, voucher, dan reward config memerlukan `RequireLoyaltyManager`, sama seperti adjustment saldo. --- ## 12. Background Jobs Semua mengikuti pola goroutine + `time.NewTicker` di `app/app.go`, satu transaksi per item, aman dijalankan di beberapa instance karena setiap item dikunci lewat UPDATE bersyarat. | Job | Interval | Tugas | |---|---|---| | `GameSessionJob` | 1 menit | §7.3: refund / expire session `STARTED` | | `VoucherRedemptionRecoveryJob` | 1 menit | §7.5: selesaikan `PENDING` eksternal | | `VoucherCodeExpiryJob` | 1 jam | `AVAILABLE → EXPIRED` untuk kode lewat `expires_at` | | `GameBudgetPeriodJob` | 1 hari | Membuat baris budget global bulan berikutnya dari bulan berjalan, bila belum ada | --- ## 13. Audit `audit_logs` diisi untuk semua perubahan yang disebut PRD §37: reward config (buat, aktifkan, pensiunkan), event, budget, voucher (termasuk impor kode), status game, refund otomatis, dan penerimaan rekomendasi Budget Controller. Ditulis di transaksi yang sama dengan perubahannya, sehingga tidak ada perubahan tanpa jejak. Pengaturan limit sudah ter-audit lewat `loyalty_setting_changes` (§5.10). Adjustment saldo sudah ter-audit lewat ledger `ADJUSTMENT`. --- ## 14. Legacy & Migrasi | Bagian lama | Nasib | |---|---| | Baris `games` lama | Diarsipkan (`status = 'ARCHIVED'`), **tidak di-`DELETE`**. FK `game_plays.game_id` adalah `ON DELETE CASCADE`: menghapus game ikut menghapus `game_plays`, padahal ledger `GAME_SPEND` lama menunjuk ke sana lewat `reference_id`. Jejak asal-tujuan saldo (K5) akan putus | | `POST /customer/spin`, `GET /customer/games`, `GET /customer/ferris-wheel` | Dihapus. Spin dibangun ulang sebagai game EnakGame dengan config `PROBABILITY` dan reward Coin | | `game_prizes`, `game_plays` | Dibekukan (read-only). Data tetap disimpan untuk riwayat ledger `GAME_PLAY`. Kode processor/handler/route-nya dihapus | | `rewards` | Diganti `vouchers`. Tidak punya org, tidak punya redemption, tidak dipakai flow mana pun, dan tidak punya data produksi (dikonfirmasi), sehingga tidak ada migrasi data | | `campaigns`, `campaign_rules` | Tidak dipakai EnakGame. Campaign EnakGame adalah `game_events` | | Bayar order dengan EnakPoint | **Dimatikan sebelum EnakGame rilis** (PRD §3.2). Belum ada order yang dibayar dengan Point, jadi tidak ada data yang dimigrasi. Yang dilepas: route point payment, `payment_methods` tipe `point` beserta trigger pembuatnya (`000094`), dan setting `loyalty.point.accept_payment`. Dikerjakan sebagai task terpisah | --- ## 15. Temuan Sampingan Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak ke uang: 1. **Double charge di `/customer/spin`.** `GAME_SPEND` di `PlayGame` tanpa idempotency key. 2. **`/customer/spin` menerima game id mana pun**, tanpa cek tipe dan tanpa cek org (`spin_game_service.go:27`). Customer org A bisa memainkan game org B. Nomor 1 dan 2 hilang sendiri saat alur lama dihapus (§14). Bila penghapusannya tidak segera, endpoint lama sebaiknya dimatikan lebih dulu daripada ditambal. 3. **RNG hadiah** di-seed ulang dengan `UnixNano` setiap panggilan (`game_play_processor.go:287`). 4. **`threshold` dan `fallback_prize_id`** di `game_prizes` tidak pernah dipakai. 5. **`GET /customer/ferris-wheel`** mengembalikan `First()` dari game SPIN aktif tanpa urutan, sehingga game yang dikembalikan tidak pasti. 6. **`rewards`**: create menolak tipe `BALANCE`, update menerimanya, database tidak punya CHECK. 7. **`campaigns`**: `GetActiveCampaigns` mengikat string `"now()"` sebagai parameter tanggal (`campaign_repository.go:114`). Belum diverifikasi apakah Postgres menerimanya. 8. **`tiers.name` UNIQUE global**, bukan per org. --- ## 16. Di Luar Scope - **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya. - **Leaderboard** (PRD §15). - **Reward `TIERED`** (§8). - **Budget Controller automatic mode** (D9). - **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game. --- ## 17. Urutan Implementasi 1. **Matikan bayar dengan EnakPoint** (§14). Independen, bisa paralel. 2. **Migrasi skema**, dengan urutan mengikuti foreign key: extend `games`, `game_budgets`, `game_reward_configs`, `game_sessions`, `game_session_rewards`, `audit_logs`; tipe ledger baru + CHECK (§6.2). 3. **Session + entry cost** (§7.1) dan **refund + `GameSessionJob`** (§7.3). 4. **Reward Engine** `FIXED`, `SCORE_BASED`, `OUTCOME_BASED`, `PROBABILITY` + Result Validator + complete (§7.2, §8). 5. **Economy Guard + limits** (§9, §5.10). 6. **Voucher internal + redemption + atribusi cost** (§7.4, §7.6). 7. **Budget metrics + status** (§10). 8. **Events / campaign** dengan budget sendiri (§5.5). 9. **Voucher eksternal + recovery job** (§7.5). Butuh provider pertama yang konkret. 10. **Rekomendasi Budget Controller + analytics** (§10, PRD §36). 11. **Spin dibangun ulang sebagai game EnakGame**, lalu hapus kode alur lama dan arsipkan datanya (§14). Endpoint lama bisa dimatikan lebih awal, kapan pun. Langkah 2–4 sudah membuat game bisa dimainkan end-to-end dengan Coin. Langkah 6 membuat Point bisa ditukar. Langkah 7 membuat Finance bisa melihat biaya. --- ## 18. Risiko | Risiko | Dampak | Mitigasi | |---|---|---| | Satu dari dua tempat aturan ledger (§6.2) terlewat | Mutasi ditolak di produksi, atau lolos tanpa validasi | Test per tipe di `wallet_processor_test.go` + test DB yang benar-benar insert ke `wallet_transactions` | | Farming lewat banyak akun + transfer Coin | Limit harian per user dilewati, karena Coin hasil game boleh ditransfer (diputuskan) | Setting transfer yang ada (`Transfer.DailyLimit`, `MaxPerTransaction`); pantau `GAME_REWARD` yang langsung diikuti `TRANSFER_OUT` di analytics | | Counter `GLOBAL` / `GAME` jadi bottleneck | Complete melambat saat ramai | Diukur dulu. Bila perlu, pecah counter per shard dan jumlahkan saat cek | | Rantai `origin_lot_id` panjang | Query atribusi lambat | Rantai praktis pendek (reward → exchange → transfer). Bila perlu, tambah kolom `root_lot_id` di `wallet_lots` | | Provider eksternal tidak idempotent | Voucher terbit dua kali saat recovery | Syarat integrasi: provider wajib menerima idempotency key; bila tidak, recovery hanya boleh *query*, tidak boleh mengulang | | Client mengirim skor palsu | Coin terbit tanpa main | Result Validator (§7.2) + `flagged`; reward tidak pernah dari client (P1) | | Game lama di-`DELETE` alih-alih diarsipkan | `game_plays` ikut terhapus (CASCADE), ledger `GAME_SPEND` lama kehilangan tujuan | Migrasi §5.1 mengarsipkan; `chk_games_enakgame_identity` mencegah arsip lama tampil sebagai game EnakGame | --- ## 19. Keputusan & Pertanyaan Terbuka ### 19.1 Sudah Diputuskan (2026-10-07) | # | Pertanyaan | Keputusan | Tercermin di | |---|---|---|---| | Q1 | Point dari belanja (`EARN`) yang ditukar voucher masuk budget EnakGame? | **Tidak.** Hanya Point yang berasal dari `GAME_REWARD` | D5, §7.6 | | Q2 | Campaign itu apa? | **Campaign = event.** Setiap event punya budget sendiri | D6, §5.5, §5.6 | | Q3 | Spin dan game lama? | **Dibangun ulang** sebagai game EnakGame | §14, §17 | | Q4 | Baris `games` lama? | **Dihapus dari produk** (diarsipkan secara data, lihat §14) | §5.1, §14 | | Q5 | Reward melewati limit? | **Dipotong ke sisa limit** | §9 | | Q6 | Coin hasil game boleh ditransfer? | **Boleh** | §18 | | Q7 | PIN untuk redemption voucher? | **Ya** | §7.4 | | Q8 | Definisi "system error" untuk refund? | **Hanya error yang tercatat di server** (5xx saat complete). Crash di client = ditinggal user | §7.3 | ### 19.2 Masih Terbuka 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.** 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 7 (budget status) dirilis.