| 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.
| `GET` | `/leaderboard` | Peringkat mingguan (Senin–Minggu, Asia/Jakarta) org customer menurut EnakCoin yang dimenangkan dari game. Query opsional `date` (hari mana pun dalam minggu itu), `game_id`, `limit` (default 10, maks 100) |
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.
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 |