From 3ebc09f818ee8a315df957aac9aaf698a8ca8479 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 7 Oct 2026 13:48:17 +0700 Subject: [PATCH] docs(enakgame): PRD, RFC, and task breakdown EnakGame: pay EnakCoin to play, earn EnakCoin from the result, exchange into EnakPoint, and redeem EnakPoint for vouchers only. - enakgame-prd.md: economy and business rules, including entry cost and automatic refund, monthly global budget with a separate budget per event (event = campaign), and EnakPoint being voucher-only. - rfc-enakgame.md: built on the existing wallet, ledger and lots. Game sessions with a state machine, versioned reward configs, Economy Guard counters, vouchers with internal codes and external providers, and realized cost attributed to budgets by tracing the lots spent. - tasks-enakgame.md: EG-001 to EG-1003 in eleven phases. Co-Authored-By: Claude Opus 5.5 --- docs/enakgame-prd.md | 1622 ++++++++++++++++++++++++++++++++++++++++ docs/rfc-enakgame.md | 981 ++++++++++++++++++++++++ docs/tasks-enakgame.md | 503 +++++++++++++ 3 files changed, 3106 insertions(+) create mode 100644 docs/enakgame-prd.md create mode 100644 docs/rfc-enakgame.md create mode 100644 docs/tasks-enakgame.md diff --git a/docs/enakgame-prd.md b/docs/enakgame-prd.md new file mode 100644 index 0000000..a995e45 --- /dev/null +++ b/docs/enakgame-prd.md @@ -0,0 +1,1622 @@ +# EnakGame — Economy & Business Rules v1 + +| Item | Value | +| --------------------------- | ----------------------------------------------------------------------------------- | +| **Status** | Draft v1 | +| **Purpose** | Source of truth untuk perencanaan dan implementasi EnakGame menggunakan Claude Code | +| **Backend** | Go | +| **Database** | PostgreSQL | +| **Game Client** | Phaser | +| **Frontend/Backoffice** | Next.js | +| **Cache/Temporary Session** | Redis (opsional, sesuai kebutuhan implementasi) | + +--- + +## 1. Product Overview + +EnakGame adalah game portal dalam ekosistem Enaklo/F&B. + +- User membayar **Coin** untuk memainkan mini-game (entry cost), dan dapat memperoleh **Coin** sebagai virtual reward dari hasil permainan. +- Coin dapat dikonversi/digunakan sebagai **Point**, kemudian Point **hanya** dapat ditukar ke voucher yang disediakan Enaklo. +- Point **tidak** dapat digunakan sebagai alat pembayaran dan **tidak** dapat diuangkan. +- EnakGame **bukan** platform gambling dan **tidak** menggunakan uang tunai sebagai hadiah dari game. + +### Core Flow + +```text +User + ↓ +Game Lobby + ↓ +Start Game + ↓ +Pay Entry Cost (Coin) + Create Game Session + ↓ +Play Game + ↓ +Submit Result + ↓ +Result Validation + ↓ +Reward Engine + ↓ +Economy Guard + ↓ +Coin Wallet + Ledger + ↓ +Coin → Point + ↓ +Voucher Redemption + ↓ +Realized Voucher Cost + ↓ +Budget Controller +``` + +--- + +## 2. Core Business Principles + +### 2.1 Server Is Authoritative + +Client/game (Phaser) **tidak boleh** menentukan reward final. + +Client hanya mengirim hasil permainan yang diperlukan, misalnya: + +- `session_id` +- `score` +- `outcome` +- game-specific result data + +Backend menghitung reward berdasarkan configuration yang aktif. + +#### ❌ Tidak boleh + +```text +Phaser: + score = 800 + reward = 1000 Coin +→ backend menerima 1000 Coin +``` + +#### ✅ Yang benar + +```text +Phaser: + score = 800 + +Backend: + score 800 + → load active reward configuration + → calculate reward + → validate limits + → issue approved Coin +``` + +--- + +## 3. Currency Model + +EnakGame memiliki dua konsep utama: **Coin** dan **Point**. + +### 3.1 Coin + +Coin adalah **virtual game/reward currency**. + +Sumber utama: + +- Game reward +- Event reward +- Mission reward +- Bonus/promo yang diizinkan + +Penggunaan: + +- Membayar entry cost untuk memainkan game (lihat [Section 10.1](#101-game-entry-cost)) +- Dikonversi ke Point + +Coin dapat memiliki expiration (lihat [Section 4](#4-coin-expiration)). + +### 3.2 Point + +Point adalah **redemption currency**. + +Current business rule: + +```text +1 Coin = 1 Point = Rp1 +``` + +Penggunaan Point dibatasi: + +- Point **hanya** dapat ditukar ke voucher (lihat [Section 26](#26-redemption-flow)). +- Point **tidak** dapat digunakan sebagai alat pembayaran (misalnya membayar order). +- Point **tidak** dapat diuangkan (cash out). + +Nilai `Rp1` di atas adalah nilai acuan untuk perhitungan voucher dan budget, **bukan** nilai tukar ke uang tunai. + +Konversi dan expiration mengikuti sistem existing ([prd-point-coin.md](prd-point-coin.md)), tidak didesain ulang: + +- **Coin → Point** menggunakan fitur exchange existing (K3, F4): dilakukan manual oleh customer, satu arah, kurs di-configure di level organisasi (default 1 : 1). +- **Point expiration** menggunakan sistem expiration existing per lot (F12). + +> **Catatan perubahan dari sistem existing:** Berdasarkan keputusan sebelumnya ([prd-point-coin.md](prd-point-coin.md)), backend saat ini masih mengizinkan EnakPoint dipakai untuk **membayar order** (point payment, ledger `PAYMENT` / `PAYMENT_REFUND`). Aturan EnakGame di atas menggantikan keputusan tersebut, sehingga fitur point payment perlu di-update/dinonaktifkan sebelum EnakGame berjalan. Belum ada order yang dibayar dengan Point, jadi tidak ada data lama yang perlu dimigrasi. Saldo Point user tetap sebagai Point; yang berubah hanya penggunaannya (redeem voucher saja). + +Namun Coin dan Point tetap diperlakukan sebagai **konsep/domain yang berbeda** agar sistem tidak terlalu tightly coupled. + +Tujuannya agar business rule di masa depan dapat berubah tanpa membongkar Game/Reward Engine. + +--- + +## 4. Coin Expiration + +Sistem Coin sudah memiliki konsep expiration. Expiration harus tetap menjadi bagian dari economy. + +Coin yang expired: + +```text +Wallet usable balance + ↓ +berkurang + +Ledger + ↓ +mencatat expiration transaction +``` + +> **Penting:** Expiration **tidak boleh** dianggap sebagai voucher redemption. + +Kategori transaksi harus dapat dibedakan: + +```text +Wallet usable balance + ↓ +berkurang + +Ledger + ↓ +mencatat expiration transaction +``` + +Implementasi detail expiration mengikuti sistem existing dan tidak perlu didesain ulang kecuali diperlukan. + +--- + +## 5. Budget Model + +### 5.1 Budget Is Shared + +Semua game menggunakan **budget pool yang sama**. Tidak ada kewajiban setiap game memiliki budget terpisah. + +Contoh: + +```text +EnakGame Global Budget +Rp100.000.000 +│ +├── Runner +├── Spin +├── Memory +└── Puzzle +``` + +Semua reward normal game berasal dari economy/budget pool yang sama. + +Pengecualian: **Event/Campaign** memiliki budget sendiri (lihat [Section 7](#7-budget-hierarchy)). + +### 5.2 Budget Period + +- Global budget menggunakan periode **bulanan** secara default. +- Periode budget dapat di-**configure**. +- Setiap Event/Campaign memiliki budget sendiri, terpisah dari global budget bulanan. + +--- + +## 6. Budget vs Realized Cost + +### 6.1 Budget Is Based on Actual Voucher Redemption + +Budget Controller menggunakan **voucher yang benar-benar diredeem** sebagai dasar actual cost. + +Coin yang baru diterbitkan **bukan** otomatis dianggap sebagai biaya voucher. + +Contoh: + +| Metric | Jumlah | +| ------------------- | ---------- | +| Coin Generated | 20.000.000 | +| Coin Outstanding | 12.000.000 | +| Coin Expired | 3.000.000 | +| Coin Used/Converted | 5.000.000 | + +```text +Actual Voucher Cost = Rp5.000.000 +``` + +Karena `1 Point = Rp1` dan Point digunakan untuk redemption. + +### 6.1.1 Hanya Point dari EnakGame yang Dihitung ke Budget + +Satu voucher dapat dibayar dengan Point dari berbagai asal: reward game, belanja (earning order), transfer, atau adjustment. + +Realized cost yang dihitung ke budget EnakGame (global maupun event) **hanya** bagian voucher yang dibayar dengan Point yang berasal dari **reward EnakGame**. Bagian yang dibayar dengan Point dari belanja tetap dicatat untuk reporting Finance, tetapi **tidak** mengurangi budget mana pun. + +```text +Voucher face value = Rp10.000, point cost = 8.000 +Point dipakai = 6.000 (dari reward game) + 2.000 (dari belanja) + +Dihitung ke budget = Rp7.500 +Tidak ke budget = Rp2.500 +``` + +### 6.2 Important Distinction + +Sistem harus membedakan: + +| Konsep | Definisi | +| ----------------- | ------------------------------------------------------------ | +| **Issuance** | Berapa Coin yang dikeluarkan oleh Reward Engine | +| **Outstanding** | Berapa Coin masih berada di user/economy | +| **Redemption** | Berapa Point benar-benar digunakan untuk mendapatkan voucher | +| **Realized Cost** | Nilai voucher yang benar-benar menjadi cost bisnis | + +Budget Controller terutama menggunakan **realized redemption/cost**, bukan sekadar total Coin issuance. + +--- + +## 7. Budget Hierarchy + +Secara konsep: + +```text +GLOBAL BUDGET (bulanan, configurable) +│ +└── Regular Game Activity (reward normal semua game) + +EVENT/CAMPAIGN BUDGET (per event, terpisah) +│ +└── Tambahan reward dari event (multiplier + bonus) +``` + +**Event dan Campaign adalah hal yang sama.** Setiap event memiliki **budget sendiri**, terpisah dari global budget. + +Pembagian biaya saat event berjalan: + +| Komponen reward | Dibiayai oleh | +| ------------------------------------ | --------------- | +| Reward normal game (base reward) | Global budget | +| Tambahan dari event (multiplier/bonus) | Budget event | + +Contoh: + +```text +Normal Reward 10 Coin → Global budget +Ramadan 2x +10 Coin → Budget event Ramadan +────────────────────────── +Final Reward 20 Coin +``` + +Realized cost dari Point yang berasal dari tambahan event dihitung ke budget event tersebut, **kapan pun** Point itu ditukar ke voucher, termasuk setelah event berakhir. + +--- + +## 8. Budget Metrics + +Minimum metric yang harus tersedia: + +- Total Budget +- Allocated Budget +- Realized Cost +- Remaining Budget +- Budget Utilization +- Forecasted Cost +- Forecasted Remaining Budget +- Budget Status + +Contoh status: + +- `HEALTHY` +- `WARNING` +- `CRITICAL` +- `EXHAUSTED` + +Threshold harus **configurable**. + +--- + +## 9. Game Management + +Game Management mengelola semua game yang tersedia di EnakGame. + +Minimum information: + +- `id` +- `name` +- `slug` +- `description` +- `thumbnail` +- game URL/path +- `version` +- `status` +- `configuration` +- `created_at` +- `updated_at` + +Game status minimal: + +- `DRAFT` +- `ACTIVE` +- `INACTIVE` +- `ARCHIVED` + +Game harus dapat memiliki reward configuration. + +--- + +## 10. Game Session + +Setiap permainan yang dapat menghasilkan reward **harus** memiliki session. + +Flow: + +```text +User + ↓ +Start Game + ↓ +Debit Entry Cost (Coin) + Create Game Session + ↓ +Phaser Game + ↓ +Submit Result + ↓ +Validate Session + ↓ +Calculate Reward + ↓ +Issue Reward +``` + +Session harus dapat mencegah: + +- duplicate completion +- duplicate reward +- expired session +- invalid session +- user mismatch +- game mismatch + +Recommended concept: `game_session_id` menjadi **idempotency key** untuk reward completion. + +> **Penting:** Satu session tidak boleh menghasilkan reward berkali-kali. + +### 10.1 Game Entry Cost + +Semua game **wajib berbayar** menggunakan **Coin**. Tidak ada game gratis. + +- Entry cost di-configure per game sebagai bagian dari game configuration, dengan nilai minimal **1 Coin**. +- Entry cost dibayar dengan **Coin**, bukan Point. +- Coin dipotong saat **Start Game**, yaitu saat session dibuat — bukan saat submit result. +- Debit Coin dan pembuatan session terjadi dalam **satu database transaction**. Jika salah satu gagal, tidak ada Coin yang terpotong dan tidak ada session yang terbuat. +- Jika usable balance Coin tidak cukup, session **tidak** dibuat dan user tidak dapat bermain. +- Entry cost yang berlaku dicatat di session (snapshot), sehingga perubahan configuration tidak mengubah session yang sudah berjalan. +- Debit dicatat di ledger sebagai `SPENT` dengan reference `game_session_id` dan `game_id`. +- Satu session hanya boleh didebit **satu kali** (`game_session_id` menjadi idempotency key untuk debit). + +```text +Start Game + → load game configuration (entry cost) + → check Coin usable balance + → [1 transaction] debit Coin (SPENT) + create session + → return session_id ke Phaser +``` + +#### Entry Cost & Budget + +Entry cost **tidak** mengurangi atau meng-offset perhitungan budget. + +Budget Controller dan Budget Metrics hanya memperhitungkan **reward** (Coin yang diterbitkan) dan realized voucher cost. Coin yang dibayar untuk bermain tidak dianggap sebagai pemasukan yang menambah budget. + +```text +Reward issued = 1.000.000 Coin +Entry cost paid = 400.000 Coin + +Budget memakai → 1.000.000 Coin (reward), bukan 600.000 (net) +``` + +### 10.2 Entry Cost Refund + +Entry cost dapat dikembalikan (refund) ke user untuk session yang tidak dapat diselesaikan. + +- Refund mengembalikan **jumlah Coin yang sama** dengan entry cost yang tercatat di session. +- Refund dicatat di ledger sebagai `REFUND` dengan reference `game_session_id` dan reference ke transaksi `SPENT` asalnya. +- Refund **wajib idempotent**: satu session maksimal satu kali refund. +- Session yang sudah di-refund **tidak** boleh menghasilkan reward, dan session yang sudah rewarded **tidak** boleh di-refund. +- Refund bukan reward: tidak melewati Reward Engine, tidak dihitung sebagai Coin issued, dan tidak dihitung di budget. +- Refund harus auditable (siapa/sistem apa yang memicu dan alasannya). + +Kondisi refund: + +| Kondisi | Refund | +| -------------------------------------------------------- | -------------------------- | +| System error (session gagal diselesaikan karena sistem) | **Ya, otomatis** oleh sistem | +| Game di-nonaktifkan saat session sedang berjalan | **Ya, otomatis** oleh sistem | +| Session expired/abandoned oleh user | **Tidak** | + +- Refund dijalankan **otomatis** oleh sistem, tanpa perlu request user atau approval admin. +- Session yang ditinggalkan user tidak di-refund, agar user tidak dapat memulai game lalu meninggalkannya saat hasilnya tidak menguntungkan. + +--- + +## 11. Reward Engine + +Reward Engine adalah komponen yang menentukan berapa reward yang **secara teoritis** berhak diterima user berdasarkan result dan configuration. + +Reward Engine **tidak** bertanggung jawab sendirian untuk memutuskan apakah reward boleh diterbitkan. + +Flow: + +```text +Game Result + ↓ +Reward Engine + ↓ +Base Reward + ↓ +Event Modifier / Bonus + ↓ +Final Calculated Reward + ↓ +Economy Guard +``` + +--- + +## 12. Supported Reward Types + +Reward Engine dirancang agar **extensible**. + +Jenis reward minimum yang dapat didukung: + +### `FIXED` + +```text +Play completed → 5 Coin +``` + +### `SCORE_BASED` + +| Score | Reward | +| -------- | ------- | +| 0–100 | 1 Coin | +| 101–500 | 5 Coin | +| 501–1000 | 10 Coin | +| 1001+ | 20 Coin | + +### `OUTCOME_BASED` + +| Outcome | Reward | +| --------- | ------- | +| `PERFECT` | 20 Coin | +| `GOOD` | 10 Coin | +| `NORMAL` | 5 Coin | +| `FAIL` | 0 Coin | + +### `PROBABILITY` + +Reward berdasarkan probability yang **dihitung server**. + +Contoh: + +| Probability | Reward | +| ----------- | --------- | +| 0.1% | 1000 Coin | +| 1% | 100 Coin | +| 10% | 10 Coin | +| 88.9% | 0 Coin | + +Probability harus tervalidasi. + +### `MULTIPLIER` + +Base reward dikalikan modifier. + +```text +Base = 10 Coin +Multiplier = 2x + +Final = 20 Coin +``` + +### `TIERED` + +Reward berdasarkan tier/user/game progression. + +--- + +Jenis reward dapat ditambah di masa depan tanpa mengubah seluruh engine. + +--- + +## 13. Reward Configuration + +Reward configuration harus memiliki **lifecycle/version**. + +Contoh: + +| Config | Reward | +| ----------------------- | ------- | +| Runner Reward Config v1 | 10 Coin | +| Runner Reward Config v2 | 8 Coin | + +> **Penting:** Jangan overwrite configuration lama jika configuration tersebut sudah pernah digunakan dalam transaksi. + +Tujuan: + +- audit +- debugging +- historical accuracy +- mengetahui rule yang berlaku saat user bermain + +Setiap reward transaction idealnya dapat dilacak ke configuration/version yang digunakan. + +--- + +## 14. Normal Reward + Event Reward + +Normal reward dan event modifier **boleh aktif bersamaan**. + +Contoh: + +```text +Normal Reward 10 Coin +Ramadan Event 2x multiplier +Mission Bonus +5 Coin +───────────────────────── +Final Reward 25 Coin +``` + +Event **tidak boleh** mengubah permanent/base configuration game. Event bekerja sebagai **layer/override/modifier**. + +Setelah event selesai: + +```text +Normal Reward 10 Coin +``` + +--- + +## 15. Event Management + +Event digunakan untuk seasonal/campaign activity. + +Contoh: + +- Ramadan +- Christmas +- Independence Day +- Anniversary +- F&B campaign + +Minimum event data: + +- `id` +- `name` +- `slug` +- `description` +- `banner` +- `start_at` +- `end_at` +- `timezone` +- `status` +- `priority` +- reward configuration/modifier +- budget sendiri (lihat [Section 7](#7-budget-hierarchy)) +- rules + +Event dapat menentukan: + +- participating games +- reward multiplier +- bonus reward +- mission +- daily limit +- user limit +- leaderboard +- event-specific campaign rules + +--- + +## 16. Multiple Event Handling + +Karena event dapat overlap, sistem harus memiliki **priority/stacking rule**. + +Contoh: + +```text +Normal Reward ++ Event A ++ Event B +``` + +Sistem harus memiliki aturan jelas apakah: + +- hanya event priority tertinggi yang berlaku +- modifier dapat ditumpuk +- bonus dapat ditumpuk +- ada maximum multiplier + +Default recommendation: + +| Komponen | Rule | +| ------------------ | --------------------- | +| Reward Modifier | Controlled stacking | +| Bonus | Independently tracked | +| Maximum Reward Cap | Always enforced | + +> **Penting:** Final stacking rules harus ditentukan sebelum production. + +--- + +## 17. Economy Guard + +Economy Guard menentukan apakah calculated reward **benar-benar boleh diterbitkan**. + +Minimum checks: + +- session valid +- session belum rewarded +- user valid +- game valid +- reward configuration active +- user daily limit +- game daily limit +- event limit +- global limit +- maximum reward +- budget/economy status +- fraud/risk checks +- idempotency + +Contoh: + +```text +Reward Engine + → 20 Coin + +Economy Guard + → daily limit OK + → event limit OK + → duplicate NO + → budget status OK + +Approved + → issue 20 Coin +``` + +--- + +## 18. Coin Wallet + +Wallet menyimpan **current usable balance**. + +Namun wallet **bukan** satu-satunya source of truth. Source of truth untuk audit adalah **ledger**. + +| Komponen | Digunakan untuk | +| ---------- | ---------------------------------------------------------------- | +| **Wallet** | fast balance lookup, current balance | +| **Ledger** | transaction history, audit, reconciliation, debugging, reporting | + +--- + +## 19. Coin Ledger + +Setiap perubahan balance **harus** menghasilkan ledger transaction. + +Minimum transaction concepts: + +- `EARNED` +- `BONUS` +- `SPENT` +- `EXPIRED` +- `ADJUSTMENT` +- `REVERSAL` +- `CONVERSION` +- `REFUND` + +`SPENT` mencakup pembayaran entry cost game. `REFUND` adalah pengembalian entry cost (lihat [Section 10.2](#102-entry-cost-refund)). + +Transaction harus dapat memiliki reference: + +- `game_session_id` +- `game_id` +- `event_id` +- `voucher_redemption_id` +- `mission_id` +- admin adjustment reference + +Idealnya setiap transaction memiliki: + +- `balance_before` +- `amount` +- `balance_after` + +--- + +## 20. Idempotency + +Reward transaction **wajib idempotent**. + +Contoh: + +```text +POST /game-session/complete +session_id = ABC +``` + +Request pertama: + +```text +Reward = 10 Coin +Status = SUCCESS +``` + +Request kedua dengan session yang sama: + +```text +Tidak membuat reward baru. +Return existing reward/result. +``` + +Database harus memiliki **unique constraint/strategy** yang menjamin satu session tidak bisa menghasilkan duplicate reward. + +Hal yang sama berlaku untuk entry cost: satu session maksimal satu debit `SPENT` dan satu `REFUND`. + +--- + +## 21. Voucher Management + +Voucher Management adalah **core module tersendiri**. + +Voucher adalah benefit yang dapat ditukar menggunakan Point. + +Minimum voucher data: + +- `id` +- `name` +- `description` +- `image` +- provider/merchant +- voucher value +- point cost +- stock +- validity +- terms +- status +- redemption rules + +Sumber voucher mendukung **keduanya**: + +- **Internal voucher codes** — kode dikelola sendiri (lihat [Section 25](#25-voucher-stock)). +- **External API** — kode/voucher diterbitkan oleh provider eksternal saat redemption. + +--- + +## 22. Voucher Value vs Point Cost + +Jangan menganggap `Voucher Value = Point Cost` selamanya. + +Current conversion: + +```text +1 Coin = 1 Point = Rp1 +``` + +Tetapi sebuah voucher dapat memiliki: + +| Voucher Value | Point Cost | +| ------------- | ---------- | +| Rp10.000 | 8.000 | +| Rp10.000 | 12.000 | + +Hal ini memungkinkan Product/Finance mengatur **subsidy, promotion, atau margin**. + +--- + +## 23. Voucher Types + +Voucher Management harus **extensible**. + +Contoh: + +| Type | Contoh | +| ----------------------- | ---------------------------------------------------------- | +| **Fixed Value** | Rp5.000, Rp10.000, Rp20.000 | +| **Percentage Discount** | 10%, 20%, 50% | +| **Free Item** | Free Coffee, Free Food | +| **Merchant Benefit** | Benefit yang hanya berlaku pada merchant/location tertentu | + +Jenis voucher dapat ditambah sesuai kebutuhan. + +--- + +## 24. Voucher Inventory + +Jika voucher menggunakan unique code, sistem harus memiliki inventory. + +Status minimal: + +- `AVAILABLE` +- `RESERVED` +- `REDEEMED` +- `EXPIRED` +- `CANCELLED` + +Flow normal: + +```text +AVAILABLE → RESERVED → REDEEMED +``` + +Jika redemption gagal/timeout: + +```text +RESERVED → AVAILABLE +``` + +Jika voucher expired: + +```text +AVAILABLE → EXPIRED +``` + +--- + +## 25. Voucher Stock + +Voucher dapat menggunakan salah satu dari: + +### Static Stock + +Admin memasukkan jumlah stock. + +```text +Stock = 1.000 +``` + +### Code Pool + +Admin/provider memasukkan unique voucher codes. + +```text +CODE-001 +CODE-002 +CODE-003 +... +``` + +System harus mengetahui stock available secara **reliable**. + +--- + +## 26. Redemption Flow + +Recommended flow: + +```text +User + ↓ +Select Voucher + ↓ +Check Point Balance + ↓ +Check Voucher Availability + ↓ +Reserve Voucher + ↓ +Deduct Point + ↓ +Issue Voucher + ↓ +Mark Voucher Redeemed + ↓ +Create Redemption Record + ↓ +Record Realized Cost +``` + +Transaction harus **atomic**. + +> **Penting:** Jangan sampai Point sudah dikurangi **+** voucher gagal diberikan tanpa recovery/rollback. + +--- + +## 27. Redemption Idempotency + +Redemption juga **harus idempotent**. + +Satu redemption request tidak boleh menghasilkan: + +- dua voucher +- dua Point deduction +- dua cost records + +Gunakan idempotency/reference key. + +--- + +## 28. Realized Cost + +Setiap successful voucher redemption menghasilkan **realized cost**. + +Contoh: + +```text +Voucher Value = Rp10.000 +Point Used = 10.000 + +Realized Cost = Rp10.000 +``` + +Realized cost menggunakan **voucher face value**. + +Sistem tetap menyimpan field berikut secara terpisah: + +- `voucher_value` — face value, menjadi dasar realized cost dan budget +- `point_cost` — Point yang dibayar user +- `business_cost` — opsional, untuk reporting Finance jika berbeda dari face value; **tidak** dipakai untuk budget + +--- + +## 29. Budget Controller + +Budget Controller bertugas mengontrol reward economy berdasarkan **actual redemption dan forecast**. + +### Input + +Minimum input: + +- global budget +- realized voucher cost +- remaining budget +- remaining days +- historical redemption +- coin issuance +- active users +- plays +- average reward +- event status +- redemption rate +- target utilization + +### Output + +Budget Controller dapat menghasilkan: + +- budget status +- forecast cost +- recommended reward multiplier +- recommended reward rate +- warning +- automatic adjustment +- stop reward recommendation + +--- + +## 30. Redemption-Based Forecast + +Contoh kondisi: + +| Metric | Nilai | +| -------------- | ------ | +| Global Budget | Rp100M | +| Realized Cost | Rp60M | +| Remaining | Rp40M | +| Remaining Days | 10 | + +Jika current burn rate terlalu tinggi: + +```text +Forecast = Rp115M +``` + +System dapat menghitung adjustment. + +Contoh: + +| Item | Nilai | +| ---------------------- | -------- | +| Current Reward | 10 Coin | +| Recommended Multiplier | 0.85x | +| New Effective Reward | 8.5 Coin | + +> **Penting:** Rounding rule harus ditentukan agar reward final tidak menghasilkan pecahan Coin jika Coin integer. + +--- + +## 31. Dynamic Rate Safety + +Reward rate **tidak boleh** berubah secara liar setiap request. + +Gunakan: + +- configuration version +- `effective_at` +- cooldown +- min/max multiplier +- adjustment step +- forecast window +- audit log + +Contoh: + +```text +Current = 10 Coin +Allowed adjustment step = 10% + +Next possible: 9 Coin atau 11 Coin +``` + +❌ Jangan: + +```text +10 → 7 → 12 → 5 → 14 (dalam waktu singkat) +``` + +Tujuannya menjaga **predictability** dan **user trust**. + +--- + +## 32. Budget Status + +Recommended: + +| Status | Arti | +| ----------- | ------------------------------------- | +| `HEALTHY` | Budget aman | +| `WARNING` | Burn rate mulai tinggi | +| `CRITICAL` | Forecast berpotensi melewati budget | +| `EXHAUSTED` | Budget sudah mencapai limit | + +Behavior setiap status harus **configurable**. + +--- + +## 33. Automatic vs Approval + +Sistem sebaiknya mendukung dua mode: + +### `RECOMMENDATION MODE` + +```text +System calculates recommendation + ↓ +Admin/Product/Finance approves + ↓ +Publish +``` + +### `AUTOMATIC MODE` + +```text +System calculates + ↓ +Guardrails + ↓ +Auto publish +``` + +Automatic mode hanya boleh berjalan dengan: + +- min reward +- max reward +- min multiplier +- max multiplier +- maximum daily adjustment +- budget safety threshold +- audit log +- rollback capability + +> **Default recommendation untuk production awal:** `RECOMMENDATION MODE` +> +> Setelah economy memiliki data historis yang cukup, automatic mode dapat diaktifkan untuk rule tertentu. + +--- + +## 34. Budget Exhaustion + +Jika budget exhausted, system harus memiliki policy. + +Possible policy: + +1. Stop rewards +2. Reduce rewards to minimum +3. Allow non-budget rewards only +4. Disable affected event +5. Continue game but no reward + +Policy harus **configurable**. + +Game tetap dapat dimainkan meskipun reward sementara tidak tersedia, kecuali Product menentukan game harus ikut disabled. + +--- + +## 35. Limits + +Minimum limit concepts: + +| Limit | Definisi | +| -------------------- | ----------------------------------------------------- | +| **User Daily Limit** | Jumlah Coin maksimal yang dapat diperoleh user per hari | +| **Game Daily Limit** | Total reward game per hari | +| **Event Limit** | Reward maksimal dari event | +| **Global Limit** | Global economy protection | +| **Session Limit** | Satu session hanya dapat rewarded sekali | + +Semua limit harus dapat di-**configure**. + +Jika reward melewati limit, reward **dipotong ke sisa limit** (bukan dibatalkan seluruhnya). Jika sisa limit 0, reward menjadi 0. + +--- + +## 36. Reporting & Analytics + +Dashboard minimal: + +### Game + +- DAU +- total plays +- completed games +- average score +- total Coin issued +- average reward +- reward per play +- total Coin spent for entry cost +- total Coin refunded + +### Economy + +- Coin generated +- Coin spent +- Coin expired +- Coin outstanding +- Point balance +- Point redeemed + +### Voucher + +- redemption count +- voucher value +- point spent +- realized cost +- stock +- redemption rate + +### Budget + +- total budget +- realized cost +- remaining budget +- utilization +- forecast +- burn rate + +### Event + +- participants +- plays +- Coin issued +- redemption +- event cost +- event performance + +--- + +## 37. Audit Log + +Admin changes harus dapat **diaudit**. + +Minimal audit: + +- who +- what +- before +- after +- timestamp +- reason +- source + +Contoh: + +```text +Who : Admin A +Change : Reward 10 Coin → 8 Coin +Reason : Budget optimization +Timestamp : 2027-03-10 10:00 +``` + +Audit terutama **wajib** untuk: + +- reward configuration +- event configuration +- budget +- voucher +- Point/Coin adjustment +- automatic controller changes + +--- + +## 38. Security & Anti-Abuse + +Minimum protection: + +- server-side reward calculation +- session validation +- idempotency +- rate limiting +- duplicate detection +- suspicious score detection +- impossible score detection +- concurrent request protection +- atomic wallet update +- atomic redemption +- audit trail + +> **Penting:** Jangan mempercayai reward amount dari client. + +--- + +## 39. Recommended System Architecture + +```text + ┌──────────────────┐ + │ Next.js Web │ + │ Game Portal │ + └────────┬─────────┘ + │ + ▼ + ┌──────────────────┐ + │ Go API │ + └────────┬─────────┘ + │ + ┌────────────────────┼────────────────────┐ + │ │ │ + ▼ ▼ ▼ + Game Management Game Session User/Economy + │ │ │ + └────────────────────┼────────────────────┘ + ▼ + Result Validator + │ + ▼ + Reward Engine + │ + ▼ + Economy Guard + │ + ┌──────┴──────┐ + ▼ ▼ + Budget Controller Event + │ │ + └──────┬──────┘ + ▼ + Coin Wallet + │ + ▼ + Coin Ledger + │ + ▼ + Point Layer + │ + ▼ + Voucher Management + │ + ▼ + Redemption Engine + │ + ▼ + Realized Cost + │ + ▼ + Budget Controller +``` + +--- + +## 40. Recommended Development Order + +Jangan langsung membuat semua modul sekaligus. + +### Phase 1 — Business Rules + +Lock: + +- Coin +- Point +- Voucher +- Budget +- Redemption +- Reward +- Event +- Limits +- Expiration + +### Phase 2 — Domain & Database + +Design: + +- `games` +- `game_sessions` +- `reward_configs` +- `events` +- `event_reward_configs` +- `wallets` +- `coin_transactions` +- `budgets` +- `budget_transactions` +- `vouchers` +- `voucher_inventory` +- `redemptions` +- `audit_logs` + +Exact schema harus ditentukan setelah business rules final. + +### Phase 3 — Game Management + +Build: + +- Game CRUD +- Game status +- Game configuration +- Game version + +### Phase 4 — Game Session + +Build: + +- create session +- entry cost debit (Coin) saat create session +- entry cost refund +- validate session +- complete session +- idempotency + +### Phase 5 — Reward Engine + +Build: + +- fixed reward +- score based +- outcome based +- probability +- multiplier +- tiered +- reward configuration versioning + +### Phase 6 — Coin Economy + +Build: + +- wallet +- ledger +- earning +- expiration integration +- adjustment +- transaction history + +### Phase 7 — Voucher Management + +Build: + +- voucher CRUD +- voucher type +- voucher value +- point cost +- stock +- code pool +- expiration +- status + +### Phase 8 — Redemption + +Build: + +- voucher reservation +- Point deduction +- voucher issue +- rollback +- idempotency +- realized cost + +### Phase 9 — Event Management + +Build: + +- event CRUD +- participating games +- reward modifiers +- missions +- event limits +- event leaderboard + +### Phase 10 — Budget Controller + +Build: + +- budget pool +- realized cost tracking +- burn rate +- forecast +- recommendation +- automatic mode +- guardrails + +### Phase 11 — Analytics + +Build: + +- game analytics +- economy analytics +- voucher analytics +- budget dashboard +- event analytics + +### Phase 12 — Phaser Integration + +Integrate real games with: + +- game session +- result submission +- reward result +- leaderboard +- missions + +--- + +## 41. Important Implementation Rules for Claude Code + +Claude Code **must** follow these principles: + +1. Do not invent business rules that are not defined. +2. Do not let Phaser/client determine reward amount. +3. Do not mutate historical reward configuration that has already been used in transactions. +4. Every reward completion must be idempotent. +5. Every wallet mutation must have a ledger record. +6. Voucher redemption must be atomic/recoverable. +7. Budget actual cost is based on successful voucher redemption, according to the current business rule. +8. All games use the same global/shared budget pool. +9. Normal reward and event reward can be active simultaneously. +10. Event configuration must not permanently mutate normal game reward configuration. +11. Automatic reward adjustment must have guardrails. +12. All important admin/economy changes must be auditable. +13. Use database transactions for wallet, redemption, and other financial/economic mutations. +14. Avoid premature microservices. Start with a modular Go backend unless scale requires otherwise. +15. Prefer clear domain boundaries inside the existing backend. +16. Entry cost is debited in Coin when the session is created, in the same transaction as the session. +17. Entry cost and its refund never offset the budget; budget only counts reward and realized voucher cost. +18. Point can only be redeemed for vouchers. It must never be usable as payment or cashed out. + +--- + +## 42. Current Decisions + +These decisions are **confirmed for v1**: + +| Decision | Rule | +| ----------------------- | -------------------------------- | +| Backend | Go | +| Database | PostgreSQL | +| Game engine | Phaser | +| Temporary session | Redis optional | +| Coin | Virtual game currency | +| Point | Redemption currency | +| Coin : Point | 1 : 1 | +| Coin : Rupiah | 1 Coin = Rp1 (nilai acuan, bukan cash out) | +| Point usage | Voucher redemption only | +| Point as payment | Not allowed | +| Point cash out | Not allowed | +| Game entry cost | Paid in Coin | +| Entry cost debit | At session creation (Start Game) | +| Entry cost refund | Automatic, idempotent; only for system error / game deactivated mid-session | +| Abandoned/expired session | No refund | +| Entry cost vs budget | Not counted; budget = reward only | +| Coin expiration | Existing system | +| Point expiration | Existing system | +| Coin → Point | Existing exchange (manual, configurable rate) | +| Budget model | Shared/global pool | +| Budget period | Monthly (default), configurable | +| Event = Campaign | Same concept | +| Budget counts | Only Point originating from EnakGame rewards | +| Budget actual cost | Successful voucher redemption | +| Realized cost basis | Voucher face value | +| Voucher source | Internal codes + external API | +| Free game | Not allowed (entry cost ≥ 1 Coin) | +| Event budget | Own budget per event, separate from global; funds the event's extra reward | +| Over limit | Reward capped to remaining limit | +| Game reward Coin transfer | Allowed | +| Voucher redemption PIN | Required | +| Legacy games (spin, ferris wheel) | Removed; spin rebuilt as EnakGame game | +| Game budget | Shared pool, not isolated per game | +| Normal + Event reward | Can run together | +| Reward authority | Backend | +| Reward idempotency | Required | +| Wallet | Required | +| Ledger | Required | +| Voucher Management | Core module | +| Redemption | Atomic/idempotent | +| Budget Controller | Required | +| Dynamic reward | Supported | +| Auto adjustment | Supported with guardrails | +| Default adjustment mode | Recommendation/approval | +| Event | Layer over normal game reward | +| Historical configs | Must be versioned | +| Audit | Required | + +--- + +## 43. Open Decisions Before Database Design + +The following should remain **explicitly unresolved** until Product/Finance decides them: + +1. **Reward rounding** + - integer Coin only + - allow fractional internal calculation but round final reward +2. **Event stacking** + - priority only + - controlled stacking + - maximum multiplier +3. **Budget exhaustion policy** (global dan event) +4. **Automatic Budget Controller thresholds** +5. **Voucher reservation timeout** + +Sudah diputuskan (lihat [Section 42](#42-current-decisions)): budget period, event/campaign budget, budget attribution, redemption cost model, Coin → Point conversion, Point expiration, voucher provider, free game, entry cost refund conditions. + +These decisions should be finalized before production schema/API is considered final. + +--- + +## 44. Definition of Done for Economy v1 + +Economy v1 is considered technically ready when: + +- [ ] Game session cannot be rewarded twice. +- [ ] Entry cost is debited exactly once per session, atomically with session creation. +- [ ] Session cannot start when Coin balance is insufficient. +- [ ] Entry cost refund is idempotent, and a session cannot be both refunded and rewarded. +- [ ] Sessions failed by system error or game deactivation are refunded automatically; abandoned/expired sessions are not. +- [ ] Entry cost does not affect budget calculation. +- [ ] Point cannot be used as payment or cashed out. +- [ ] Client cannot directly choose reward amount. +- [ ] Reward configuration is versioned. +- [ ] Reward Engine calculates reward correctly. +- [ ] Economy Guard enforces limits. +- [ ] Wallet balance is consistent with ledger. +- [ ] Coin expiration is correctly represented. +- [ ] Voucher stock cannot go negative. +- [ ] Redemption is atomic. +- [ ] Redemption is idempotent. +- [ ] Successful redemption records realized cost. +- [ ] Budget Controller can calculate current utilization. +- [ ] Budget Controller can forecast future cost. +- [ ] Reward adjustment has guardrails. +- [ ] Event reward does not corrupt normal reward configuration. +- [ ] Admin changes are auditable. +- [ ] Finance can reconcile voucher cost against redemption records. + +--- + +## 45. Guiding Principle + +| Component | Responsibility | +| ---------------------- | ------------------------------------ | +| **Product/Finance** | Defines the budget | +| **Game Management** | Defines the game | +| **Reward Engine** | Calculates the reward | +| **Economy Guard** | Protects the economy | +| **Budget Controller** | Protects the budget | +| **Voucher Management** | Defines what users can redeem | +| **Redemption** | Records the actual business cost | +| **Ledger** | Provides the audit trail | + +The goal is not simply to build a collection of games. diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md new file mode 100644 index 0000000..1b85d96 --- /dev/null +++ b/docs/rfc-enakgame.md @@ -0,0 +1,981 @@ +# 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. diff --git a/docs/tasks-enakgame.md b/docs/tasks-enakgame.md new file mode 100644 index 0000000..25377bb --- /dev/null +++ b/docs/tasks-enakgame.md @@ -0,0 +1,503 @@ +# Task Breakdown: EnakGame + +**Sumber:** [RFC EnakGame](rfc-enakgame.md), [PRD EnakGame](enakgame-prd.md) +**Tanggal:** 2026-10-07 + +Setiap task menyebut bagian RFC yang dikerjakan, lapisan kode yang disentuh, task yang +harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari, +**L** 4–5 hari. + +Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000101`), +`entities` → `repository` → `processor` → `service` → `handler` / `validator` → +`router`, dan wiring di `internal/app/app.go`. Repository **selalu** memakai +`DBFromContext`. Semua mutasi saldo **hanya** lewat `WalletProcessor`. + +--- + +## Ringkasan + +| Fase | Task | Terblokir oleh | +|---|---|---| +| 0. Matikan bayar dengan EnakPoint | EG-001 – EG-002 | – | +| 1. Fondasi | EG-101 – EG-106 | – | +| 2. Admin game & session | EG-201 – EG-206 | – | +| 3. Reward Engine & complete | EG-301 – EG-303 | Pembulatan (RFC §19.2 #1) sebelum **rilis** | +| 4. Economy Guard | EG-401 – EG-402 | – | +| 5. Voucher & redemption | EG-501 – EG-507 | – | +| 6. Budget metrics | EG-601 – EG-602 | Exhaustion policy & threshold (#3, #4) sebelum **rilis** | +| 7. Event / campaign | EG-701 – EG-704 | Event stacking (#2) sebelum **rilis** | +| 8. Voucher eksternal | EG-801 – EG-803 | Provider pertama yang konkret sebelum **dikerjakan** | +| 9. Budget Controller & analytics | EG-901 – EG-903 | Threshold (#4) sebelum **dikerjakan** | +| 10. Spin & bersih-bersih | EG-1001 – EG-1003 | – | + +Fase 0 independen dan bisa jalan paralel dengan semua fase lain, tetapi **harus selesai +sebelum EnakGame rilis** (PRD §3.2). + +Fase 1–3 membuat game bisa dimainkan end-to-end dengan Coin. Fase 5 membuat Point bisa +ditukar. Fase 6 membuat Finance bisa melihat biaya. + +``` +Fase 0 EG-001 ── EG-002 + +Fondasi EG-101 ── EG-102 ── EG-105 + EG-103, EG-104, EG-106, EG-301 (independen) + +Game EG-104 + EG-105 ─────────────────── EG-201, EG-203 + EG-104 + EG-105 + EG-301 ────────── EG-202 + EG-103 + EG-201 + EG-202 + EG-203 ─ EG-204 ── EG-205 + EG-204 + EG-205 + EG-301 + EG-302 ─ EG-303 + EG-106 ── EG-401 + EG-303 + EG-401 ─────────────────── EG-402 + +Voucher EG-501 ── EG-502 + EG-103 + EG-502 + EG-503 ────────── EG-504 + EG-303 + EG-504 ─────────────────── EG-505 ── EG-601 ─┬─ EG-602 + └─ EG-901 ── EG-902 + EG-504 ── EG-801 + EG-505 + EG-801 ─────────────────── EG-802 ── EG-803 + +Event EG-102 ── EG-701 + EG-203 + EG-701 ─────────────────── EG-702 + EG-402 + EG-702 ─────────────────── EG-703 + +Akhir EG-402 ── EG-1001 ── EG-1002 ── EG-1003 +``` + +Task kecil yang tidak digambar: EG-206, EG-302 (setelah EG-105), EG-506 (EG-504), EG-507 +(EG-501), EG-704 (EG-206 + EG-702), EG-903 (EG-303 + EG-505). + +--- + +## Fase 0 — Matikan Bayar dengan EnakPoint + +PRD §3.2: Point hanya bisa ditukar ke voucher. Belum ada order yang dibayar dengan Point, +jadi tidak ada data yang dimigrasi. + +### EG-001 · Tutup semua jalur pembayaran EnakPoint · M +- **RFC:** §14 +- **Kerjakan:** + - Sudah dikonfirmasi belum ada pembayaran dengan EnakPoint di production (2026-10-07). + - Migrasi: drop trigger yang membuat payment method `point` untuk organisasi baru + (`000094`), lalu nonaktifkan / hapus baris `payment_methods` tipe `point`. + - Tolak pembayaran bertipe `point` di jalur POS (`order_handler`, `order_processor`, + `payment_method_processor`) dan customer app (`customer_order_payment_service`). + - Hapus route `GET /orders/:id/point-payment/preview` dan + `POST /customer/wallet/payment-code`. + - Hapus `PAYMENT` dan `PAYMENT_REFUND` dari `walletTypeRules`, supaya engine menolak + keduanya. CHECK database dibiarkan. + - Hapus setting `loyalty.point.accept_payment` dari descriptor + `LoyaltySettingsProcessor` dan dari response outlet customer. +- **Selesai jika:** pembayaran order dengan method `point` ditolak di POS dan customer app, + outlet baru tidak lagi mendapat payment method EnakPoint, dan test yang ada diperbarui. +- **Bergantung pada:** – + +### EG-002 · Hapus kode point payment · S +- **Kerjakan:** hapus `PointPaymentProcessor`, `point_payment_refund.go`, + `PointPaymentRepository`, `PointPaymentService`, `PointPaymentHandler`, wiring di + `app.go` / `router.go`, dan test-nya (`point_payment_db_test.go`, + `point_payment_method_db_test.go`). Pindahkan dulu helper yang ternyata dipakai di + tempat lain (mis. pola pembuatan lot refund, yang dipakai ulang di EG-205). +- **Selesai jika:** `go build ./...` dan seluruh test lulus, dan + `grep -rn "PointPayment" internal/` kosong. +- **Bergantung pada:** EG-001 + +--- + +## Fase 1 — Fondasi + +### EG-101 · Migrasi extend `games` + arsipkan game lama · S +- **RFC:** §5.1, §14 +- **Kerjakan:** migrasi up & down persis seperti §5.1. Semua baris lama menjadi + `ARCHIVED`. **Tidak ada `DELETE`** (FK `game_plays` CASCADE). +- **Selesai jika:** + - Up/down bersih di database kosong dan di salinan staging. + - Jumlah baris `game_plays` sebelum = sesudah. + - Ditolak database: game `ACTIVE` tanpa `organization_id` atau `slug`; + `entry_cost = 0`; dua game dengan slug sama di satu org. +- **Bergantung pada:** – + +### EG-102 · Migrasi budget, reward config, session · M +- **RFC:** §5.2, §5.3, §5.4, §5.6 +- **Kerjakan:** `game_budgets`, `game_reward_configs`, `game_sessions`, + `game_session_rewards`, dalam urutan foreign key. +- **Selesai jika:** up/down bersih. Ditolak database: dua config `ACTIVE` untuk satu game; + session `REFUNDED` tanpa `refund_transaction_id`; dua budget `GLOBAL` dengan + `period_start` sama di satu org; `game_session_rewards` dengan + `wallet_transaction_id` yang sama dua kali. +- **Bergantung pada:** EG-101 + +### EG-103 · Tipe ledger baru · M +- **RFC:** §6 +- **Kerjakan:** + - Konstanta `GAME_SPEND_REFUND`, `GAME_REWARD`, `REWARD_REDEEM_REFUND`, ref + `GAME_SESSION` di `constants/wallet.go`. + - `walletTypeRules`: tambah tiga tipe, dan izinkan ref `GAME_SESSION` pada + `GAME_SPEND`. + - Migrasi: drop + create ulang tiga CHECK di `wallet_transactions` (§6.2). + - Periksa invariant di `wallet_reconciliation_repository.go` terhadap tipe baru. +- **Selesai jika:** test validasi di `wallet_processor_test.go` per tipe baru (currency + salah, ref salah, reversal tanpa `reverses_transaction_id` → ditolak), dan test DB yang + benar-benar insert ke `wallet_transactions` untuk setiap tipe (lolos engine **dan** + CHECK). +- **Bergantung pada:** – + +### EG-104 · `audit_logs` + helper · S +- **RFC:** §5.9, §13 +- **Kerjakan:** migrasi, entity, dan `AuditLogger.Record(ctx, entry)` yang menulis lewat + `DBFromContext`, sehingga audit ikut commit/rollback bersama perubahannya. +- **Selesai jika:** perubahan yang di-rollback tidak meninggalkan baris audit. +- **Bergantung pada:** – + +### EG-105 · Entities & repository EnakGame · M +- **RFC:** §5.1–§5.6 +- **Kerjakan:** entities dan repository untuk kolom baru `games`, `game_reward_configs`, + `game_sessions`, `game_session_rewards`, `game_budgets`. Transisi status session + sebagai `UPDATE ... WHERE status = 'STARTED'` yang mengembalikan jumlah baris + ter-update (D4). Semua query game/budget memfilter `organization_id`. +- **Selesai jika:** test repository: dua transisi bersamaan pada session yang sama, hanya + satu yang mendapat 1 baris. +- **Bergantung pada:** EG-101, EG-102 + +### EG-106 · Setting limit EnakGame · S +- **RFC:** §5.10 +- **Kerjakan:** key `enakgame.limit.user_daily` dan `enakgame.limit.global_daily` sebagai + field descriptor di `LoyaltySettingsProcessor` (default 0 = tanpa batas, min 0). +- **Selesai jika:** setting terbaca dengan default, bisa diubah lewat endpoint loyalty + settings yang ada, dan perubahan tercatat di `loyalty_setting_changes`. +- **Bergantung pada:** – + +--- + +## Fase 2 — Admin Game & Session + +### EG-201 · Admin game CRUD · M +- **RFC:** §11 (admin) +- **Kerjakan:** `/marketing/enakgame/games` CRUD + `PUT /:id/status`, org dari user admin. + Validasi `slug`, `entry_cost ≥ 1`, `result_rules`. Game `ARCHIVED` tidak bisa diubah. + Perubahan status masuk `audit_logs`. +- **Selesai jika:** admin org A tidak bisa melihat atau mengubah game org B; game lama + (arsip) tidak muncul. +- **Bergantung pada:** EG-104, EG-105 + +### EG-202 · Admin reward config versioning · M +- **RFC:** §5.2, D7 +- **Kerjakan:** `POST /games/:id/reward-configs` (versi baru, `rules` divalidasi + `RewardCalculator.Validate`), `POST /reward-configs/:id/activate` (dalam satu transaksi: + config lama → `RETIRED`, yang baru → `ACTIVE`), `GET` daftar versi. Tidak ada endpoint + update atau delete. Wajib `RequireLoyaltyManager`. Semua aksi masuk `audit_logs`. +- **Selesai jika:** config yang sudah dipakai session tidak bisa diubah lewat jalur apa + pun; aktivasi bersamaan dua versi berakhir dengan tepat satu `ACTIVE`. +- **Bergantung pada:** EG-104, EG-105, EG-301 + +### EG-203 · Admin budget + `GameBudgetPeriodJob` · M +- **RFC:** §5.6, §12 +- **Kerjakan:** CRUD `/marketing/enakgame/budgets` (scope `GLOBAL` / `EVENT`), wajib + `RequireLoyaltyManager`, audit. Job harian yang membuat budget global bulan berikutnya + dari bulan berjalan bila belum ada. +- **Selesai jika:** job aman dijalankan berulang (unique index mencegah duplikat), dan + perubahan `amount` tercatat sebelum/sesudah di audit. +- **Bergantung pada:** EG-104, EG-105 + +### EG-204 · Start Game · M +- **RFC:** §7.1 +- **Kerjakan:** `POST /customer/enakgame/sessions` dengan `Idempotency-Key` wajib (≤ 50 + karakter, pola `customer_wallet_handler.go`). Langkah persis §7.1. +- **Selesai jika:** test untuk: + - Key yang sama dua kali → satu debit, session yang sama dikembalikan. + - Coin kurang → ditolak, tidak ada ledger maupun session. + - Game org lain, game tidak `ACTIVE`, tanpa config aktif, tanpa budget global → ditolak. + - `entry_cost` dan `reward_config_id` tersimpan sebagai snapshot: mengubah game setelah + start tidak mengubah session. +- **Bergantung pada:** EG-103, EG-105, EG-201, EG-202, EG-203 + +### EG-205 · Refund otomatis + `GameSessionJob` · M +- **RFC:** §7.3, §6.3 +- **Kerjakan:** + - `RefundSession(ctx, sessionID, reason)`: lock wallet → transisi bersyarat ke + `REFUNDED` → `Credit` `GAME_SPEND_REFUND` dengan lot per alokasi asal + (`RefundExpiry`, `origin_lot_id`) → audit `SYSTEM`. + - Job tiap 1 menit, per batch, satu transaksi per session, sesuai tabel §7.3. + - Wiring start/stop di `app.go` seperti `WalletExpiryJob`. +- **Selesai jika:** test untuk: + - Game dinonaktifkan → session `STARTED` di-refund tanpa menunggu kedaluwarsa. + - Expired dengan `completion_failed_at` → refund. Expired tanpa → `EXPIRED`, saldo tetap. + - Lot refund punya `origin_lot_id` ke lot asal dan `expires_at` minimal 7 hari. + - Complete dan refund bersamaan pada session yang sama → tepat satu yang berhasil. + - Job dijalankan dua kali → tidak ada refund ganda. +- **Bergantung pada:** EG-204 + +### EG-206 · Customer: daftar game & riwayat session · S +- **RFC:** §11 (customer) +- **Kerjakan:** `GET /customer/enakgame/games`, `GET /sessions`, `GET /sessions/:id`. Hanya + game `ACTIVE` milik org customer. Rincian internal (`reward_breakdown` lengkap, angka + RNG) tidak ditampilkan. +- **Bergantung pada:** EG-105 + +--- + +## Fase 3 — Reward Engine & Complete + +### EG-301 · `RewardCalculator` + empat tipe · M +- **RFC:** §8 +- **Kerjakan:** interface `Validate` / `Calculate`, implementasi `FIXED`, `SCORE_BASED`, + `OUTCOME_BASED`, `PROBABILITY`. RNG lewat interface yang diisi `crypto/rand` di + production dan RNG tetap di test. Pembulatan ke bawah. +- **Selesai jika:** unit test: contoh tabel PRD §12 untuk setiap tipe; band skor + tumpang tindih / berlubang ditolak `Validate`; bobot `PROBABILITY` nol atau negatif + ditolak; distribusi `PROBABILITY` pada 100.000 undian berada dalam toleransi bobotnya. +- **Bergantung pada:** – + +### EG-302 · Result Validator · S +- **RFC:** §7.2 langkah 5 +- **Kerjakan:** validasi `result` terhadap `games.result_rules`: durasi minimal, skor + maksimum, skor per detik, outcome yang dikenal. Mengembalikan alasan, bukan error. +- **Selesai jika:** unit test per aturan, termasuk game tanpa `result_rules` (semua lolos). +- **Bergantung pada:** EG-105 + +### EG-303 · Complete Game · L +- **RFC:** §7.2, §7.3 (bagian `completion_failed_at`) +- **Kerjakan:** + - `POST /customer/enakgame/sessions/:id/complete`, langkah §7.2 dengan base reward dari + budget global saja. Event menyusul di EG-703, guard di EG-402. + - Satu baris `GAME_REWARD` + `game_session_rewards` per budget, key + `game-reward:{session}:{budget}`. Lot dengan `ComputeExpiry(CoinExpiry)`. + - Game tidak `ACTIVE` saat complete → `RefundSession(GAME_DEACTIVATED)`. + - Error non-bisnis → tulis `completion_failed_at` di transaksi terpisah + (`DetachTransaction`), lalu kembalikan 5xx. +- **Selesai jika:** test untuk: + - Complete dua kali → satu reward, response kedua sama dengan yang pertama. + - Hasil tidak valid → reward 0, `flagged`, session `COMPLETED`, tanpa refund. + - Session milik customer lain → 404. Session expired → ditolak. + - Error yang disuntikkan setelah kredit → rollback total, `completion_failed_at` terisi. + - Request yang membawa field `reward` diabaikan (P1). +- **Bergantung pada:** EG-204, EG-205, EG-301, EG-302 + +--- + +## Fase 4 — Economy Guard + +### EG-401 · Counter reward + increment bersyarat · M +- **RFC:** §5.8, §9 +- **Kerjakan:** migrasi `game_reward_counters`; repository `Consume(scope, id, day, x, + limit)` yang mengembalikan jumlah yang **benar-benar** diterima (dipotong ke sisa + limit, 0 bila habis). Hari dihitung di Asia/Jakarta. +- **Selesai jika:** test DB: 20 goroutine menambah counter yang sama dengan limit 100 → + total tepat 100, tidak pernah lewat. +- **Bergantung pada:** EG-106 + +### EG-402 · Guard di complete · S +- **RFC:** §9 +- **Kerjakan:** panggil `Consume` untuk `USER` dan `GLOBAL` harian (dari setting) dan + `GAME` harian (dari `result_rules`) sebelum kredit. Batas yang memotong dicatat di + `reward_breakdown` dan dikembalikan di response. +- **Selesai jika:** reward 30 dengan sisa limit user 10 → kredit 10, breakdown menyebut + limit user. Sisa 0 → session `COMPLETED` dengan reward 0. +- **Bergantung pada:** EG-303, EG-401 + +--- + +## Fase 5 — Voucher & Redemption + +### EG-501 · Migrasi voucher · M +- **RFC:** §5.7 +- **Kerjakan:** `vouchers`, `voucher_codes`, `voucher_redemptions`, + `voucher_redemption_costs`. +- **Selesai jika:** up/down bersih. Ditolak database: voucher `STATIC` tanpa `stock`; + `EXTERNAL` tanpa `provider`; kode `REDEEMED` tanpa `redemption_id`; dua redemption dengan + `(customer_id, idempotency_key)` yang sama. +- **Bergantung pada:** – + +### EG-502 · Admin voucher + impor kode · M +- **RFC:** §11 (admin) +- **Kerjakan:** CRUD `/marketing/enakgame/vouchers`, `POST /:id/codes` (CSV, kode duplikat + dilewati dan dilaporkan), `GET /:id/codes` dengan jumlah per status. `RequireLoyaltyManager` + dan audit. +- **Selesai jika:** impor CSV yang sama dua kali tidak menggandakan kode. +- **Bergantung pada:** EG-104, EG-501 + +### EG-503 · Aksi PIN `REDEEM` · S +- **RFC:** §7.4 langkah 2 +- **Kerjakan:** `PinActionRedeem` di `customer_pin_processor.go`, ikut aturan kunci yang + ada (5 salah, 30 menit). +- **Bergantung pada:** – + +### EG-504 · Redemption internal · L +- **RFC:** §7.4 +- **Kerjakan:** `POST /customer/enakgame/vouchers/:id/redeem` untuk `STATIC` dan + `CODE_POOL`, satu transaksi, langkah persis §7.4. Debit `REWARD_REDEEM` dengan key + `redeem:{redemption}`. +- **Selesai jika:** test untuk: + - Stok tersisa 1, dua customer menukar bersamaan → tepat satu berhasil. + - `CODE_POOL`: dua redemption bersamaan mendapat kode berbeda (`SKIP LOCKED`). + - Key yang sama dua kali → satu debit, satu kode. + - Point kurang, PIN salah, `max_per_customer` tercapai, voucher di luar masa berlaku → + ditolak tanpa apa pun tercatat. +- **Bergantung pada:** EG-103, EG-501, EG-502, EG-503 + +### EG-505 · Atribusi realized cost · M +- **RFC:** §7.6, D5 +- **Kerjakan:** query rekursif §7.6 di dalam transaksi redemption, pembagian + `face_value` proporsional dengan sisa pembulatan ke bagian terbesar, insert + `voucher_redemption_costs`. +- **Selesai jika:** test DB untuk rantai: `GAME_REWARD` → exchange → redeem; + `GAME_REWARD` → transfer → exchange → redeem; campuran `GAME_REWARD` + `EARN` (contoh + §7.6: Rp7.500 ke budget, Rp2.500 tanpa budget); `Σ cost = face_value` untuk kombinasi + angka yang tidak habis dibagi. +- **Bergantung pada:** EG-303, EG-504 + +### EG-506 · Customer: katalog & voucher saya · S +- **RFC:** §11 (customer) +- **Kerjakan:** `GET /customer/enakgame/vouchers` (stok tersedia, tanpa membocorkan + jumlah kode per status) dan `GET /redemptions` (dengan kode voucher). +- **Bergantung pada:** EG-504 + +### EG-507 · `VoucherCodeExpiryJob` · S +- **RFC:** §12 +- **Kerjakan:** `AVAILABLE → EXPIRED` untuk kode lewat `expires_at`, per batch. +- **Bergantung pada:** EG-501 + +--- + +## Fase 6 — Budget Metrics + +### EG-601 · Perhitungan metrik & status budget · M +- **RFC:** §10 +- **Kerjakan:** realized cost, Coin issued, remaining, utilization, forecast, exposure, + dan status per budget. Global dibatasi periode; event tanpa batas waktu. Threshold dari + `game_budgets.thresholds`. +- **Selesai jika:** test dengan data tetap menghasilkan angka contoh PRD §30 (budget + Rp100M, realized Rp60M, sisa 10 hari); Point dari `EARN` tidak ikut dihitung. +- **Bergantung pada:** EG-505 + +### EG-602 · Endpoint metrik budget · S +- **Kerjakan:** `GET /marketing/enakgame/budgets/:id/metrics`. +- **Bergantung pada:** EG-601 + +--- + +## Fase 7 — Event / Campaign + +### EG-701 · Migrasi event · S +- **RFC:** §5.5 +- **Kerjakan:** `game_events`, `game_event_games`. +- **Selesai jika:** up/down bersih; event tanpa `budget_id` atau dengan + `end_at ≤ start_at` ditolak. +- **Bergantung pada:** EG-102 + +### EG-702 · Admin event · M +- **RFC:** §5.5, §11 +- **Kerjakan:** CRUD + status. Budget yang dipasang harus milik org yang sama dan ber-scope + `EVENT`. Audit. +- **Bergantung pada:** EG-203, EG-701 + +### EG-703 · Modifier event di complete · M +- **RFC:** §7.2 langkah 7, §8 (modifier), D6 +- **Kerjakan:** cari event aktif untuk game, hitung tambahan dari multiplier dan bonus, + pisahkan menjadi komponen per budget, terapkan `reward_limit` dan `user_daily_limit` + event lewat counter `EVENT`, cap `max_reward`. Aturan tumpuk mengikuti default PRD §16. +- **Selesai jika:** contoh PRD §7 (10 Coin + Ramadan 2x) menghasilkan dua baris + `GAME_REWARD`: 10 ke budget global, 10 ke budget event; event di luar jam aktif tidak + berpengaruh. +- **Bergantung pada:** EG-402, EG-702 + +### EG-704 · Event di daftar game customer · S +- **Kerjakan:** `GET /customer/enakgame/games` menyertakan event aktif per game (nama, + banner, multiplier/bonus, berakhir kapan). +- **Bergantung pada:** EG-206, EG-702 + +--- + +## Fase 8 — Voucher Eksternal + +Terblokir sampai provider pertama ditentukan: API-nya menentukan bentuk adapter dan +apakah provider menerima idempotency key (RFC §18). + +### EG-801 · Interface provider + adapter pertama · M +- **Kerjakan:** `VoucherProvider` (`Issue(ctx, redemptionID, voucher)`, + `Lookup(ctx, redemptionID)`), adapter provider pertama, dan klasifikasi hasil: sukses, + gagal pasti, tidak jelas. +- **Bergantung pada:** EG-504 + +### EG-802 · Redemption dua tahap · M +- **RFC:** §7.5 +- **Kerjakan:** jalur `EXTERNAL` di endpoint redeem: transaksi 1 (`PENDING` + debit), + panggil provider di luar transaksi, transaksi 2 (`COMPLETED` + atribusi, atau `FAILED` + + `REWARD_REDEEM_REFUND`). +- **Selesai jika:** test dengan provider palsu untuk ketiga hasil; timeout meninggalkan + `PENDING` dengan Point terpotong dan response "sedang diproses". +- **Bergantung pada:** EG-505, EG-801 + +### EG-803 · `VoucherRedemptionRecoveryJob` · M +- **RFC:** §7.5 langkah 4, §12 +- **Kerjakan:** ambil `PENDING` lewat N menit, `Lookup` ke provider, selesaikan transaksi 2. + Setelah batas percobaan → refund + `FAILED`. +- **Selesai jika:** tidak ada redemption yang tertinggal `PENDING` melewati batas + percobaan; job berjalan dua kali tidak merefund dua kali. +- **Bergantung pada:** EG-802 + +--- + +## Fase 9 — Budget Controller & Analytics + +### EG-901 · Rekomendasi multiplier · M +- **RFC:** §10 (rekomendasi), PRD §30–§31 +- **Kerjakan:** hitung multiplier yang membuat forecast = budget, dibatasi step maksimum dan + min/max multiplier. `GET /budgets/:id/recommendation`. +- **Bergantung pada:** EG-601 + +### EG-902 · Terima rekomendasi · S +- **Kerjakan:** admin menerima rekomendasi → reward config versi baru dibuat dari config + aktif dengan angka yang disesuaikan, tercatat di audit dengan `source = budget_controller`. +- **Bergantung pada:** EG-202, EG-901 + +### EG-903 · Analytics · M +- **RFC:** PRD §36 +- **Kerjakan:** endpoint dashboard game (plays, completed, rata-rata skor & reward, Coin + issued, entry cost dibayar, Coin di-refund) dan economy (Coin generated / spent / + expired / outstanding, Point redeemed), per org dan rentang tanggal. +- **Bergantung pada:** EG-303, EG-505 + +--- + +## Fase 10 — Spin & Bersih-bersih + +### EG-1001 · Spin sebagai game EnakGame · S +- **RFC:** §14 +- **Kerjakan:** seeder / langkah admin untuk game spin baru dengan config `PROBABILITY` dan + reward Coin. Client Phaser di luar repo ini. +- **Selesai jika:** spin bisa dimainkan lewat `/customer/enakgame/sessions` end-to-end. +- **Bergantung pada:** EG-402 + +### EG-1002 · Hapus alur game lama · M +- **RFC:** §14, §15 +- **Kerjakan:** hapus route `POST /customer/spin`, `GET /customer/games`, + `GET /customer/ferris-wheel`, admin `/marketing/games`, `/marketing/game-prizes`, dan + `/marketing/rewards`, beserta handler, service, processor, dan test-nya + (`GamePlayProcessor`, `SpinGameService`, dan seterusnya). **Tabel `games`, + `game_prizes`, `game_plays` tetap ada** untuk riwayat ledger. +- **Selesai jika:** build dan test lulus; aplikasi customer sudah tidak memanggil endpoint + lama (konfirmasi tim aplikasi). +- **Bergantung pada:** EG-1001. Tabel `rewards` tidak punya data produksi (dikonfirmasi + 2026-10-07), jadi tidak ada yang dipindah ke `vouchers`. +- **Catatan:** endpoint customer lama boleh dimatikan **lebih awal**, kapan pun, untuk + menutup temuan RFC §15 nomor 1 dan 2. + +### EG-1003 · Bersihkan kolom lama `games` · S +- **Kerjakan:** drop `games.is_active` dan berhenti membaca `metadata.coin_cost`. +- **Bergantung pada:** EG-1002 + +--- + +## Yang Bisa Dimulai Sekarang + +Bisa dikerjakan paralel tanpa menunggu apa pun: + +- **EG-001** (matikan bayar dengan EnakPoint) → **EG-002** +- **EG-101** → **EG-102** → **EG-105** (skema & repository) +- **EG-103** (tipe ledger) +- **EG-104** (audit), **EG-106** (setting limit) +- **EG-301** (Reward Engine, kode murni tanpa database) +- **EG-501**, **EG-503** (fondasi voucher) + +Jalur kritis: **EG-105 → EG-204 → EG-303 → EG-402**. Hampir semua fase setelahnya +menunggu complete game selesai.