| 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`.
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
| `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.
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`,
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 |