feat(enakgame): spin as an EnakGame game; remove the old game flow

EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003).

Spin (EG-1001)
- PROBABILITY entries take an optional label (a wheel segment). The customer game
  list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and
  completing returns the drawn prize, so the client can draw the wheel and stop it
  on the server's draw.
- docs/enakgame-spin.md: the admin steps to set up spin per organization (no
  seeder) and the customer app flow. An HTTP test plays it end to end.

Old game flow removed (EG-1002)
- Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and
  admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their
  handlers, services, processors, repositories, validators, models, contracts,
  mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also
  closes RFC §15 findings 1 and 2 (double charge, spinning another org's game).
- Tables games, game_prizes, game_plays and rewards stay for ledger history.
  entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps
  game_id but no longer embeds the old game response.

games.is_active dropped (EG-1003)
- Migration 000115; nothing reads metadata.coin_cost any more.

The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests
were not run: no test database here. Migration 000115 has not been run anywhere.
The customer app must stop calling the removed endpoints before this is deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 21:31:56 +07:00
co-authored by Claude Opus 5.5
parent 296708244e
commit 18e87398bb
68 changed files with 467 additions and 5064 deletions
+7 -11
View File
@@ -151,7 +151,7 @@ PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama
| POST | `/customer/wallet/exchange` | Ya | Wajib |
| GET | `/customer/wallet/transfer/recipient?phone=` | – | – |
| POST | `/customer/wallet/transfer` | Ya | Wajib |
| POST | `/customer/spin` | – | – |
| POST | `/customer/enakgame/sessions` | – | Wajib |
### GET /customer/wallet/exchange/preview?coins=30
@@ -210,19 +210,15 @@ Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "
`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`.
### POST /customer/spin
### Game (EnakGame)
Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
`POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`:
```json
{
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
"prize_won": { "id": "…", "name": "Voucher 10rb" },
"coins_remaining": 7
}
```
- `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda).
- `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`.
- `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar).
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong.
Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md).
## POS: earning, void, dan refund
+3 -3
View File
@@ -18,7 +18,7 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a
| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet |
| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet |
| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan |
| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game |
| Biaya main game | `entry_cost` di `/marketing/enakgame/games` | Marketing → EnakGame → game |
Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada.
@@ -269,7 +269,7 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks
### Biaya main game
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama dan dikelola di `/marketing/enakgame/games`. Menu lama `/marketing/games`, `/marketing/game-prizes`, dan `/marketing/rewards` sudah dihapus. Biaya per main adalah `entry_cost` game (bilangan bulat ≥ 1), hadiahnya diatur di reward config game itu. Langkah membuat spin ada di [`enakgame-spin.md`](./enakgame-spin.md). Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
## Pesan error dan checklist
@@ -293,7 +293,7 @@ Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalt
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`.
- [ ] Tombol Telusuri ada di setiap baris riwayat.
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
- [ ] Form game punya input `coin_cost`.
- [ ] Form game EnakGame punya input `entry_cost`.
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu.
+86
View File
@@ -0,0 +1,86 @@
# Spin sebagai Game EnakGame
**Sumber:** [RFC EnakGame](rfc-enakgame.md) §14, task EG-1001
**Pembaca:** admin organisasi dan tim backoffice / aplikasi customer
Spin lama (`POST /customer/spin`) diganti dengan game EnakGame biasa: tipe `SPIN`,
reward `PROBABILITY`, hadiah berupa EnakCoin. Spin dimainkan lewat endpoint session
yang sama dengan game lain, sehingga ikut mendapat idempotency, refund otomatis, budget,
event, dan Economy Guard.
Tidak ada seeder: setiap organisasi membuat spin-nya sendiri lewat API admin di bawah.
Semua langkah memakai token admin organisasi; langkah 2–4 butuh loyalty manager.
## Langkah Admin
### 1. Buat game
`POST /api/v1/marketing/enakgame/games`
```json
{
"name": "Spin Harian",
"slug": "spin",
"type": "SPIN",
"entry_cost": 5,
"status": "ACTIVE",
"thumbnail_url": "https://…/spin.png",
"game_url": "https://…/spin/index.html"
}
```
`entry_cost` adalah EnakCoin yang dipotong setiap kali spin (minimal 1).
### 2. Buat reward config `PROBABILITY`
`POST /api/v1/marketing/enakgame/games/:id/reward-configs`
```json
{
"reward_type": "PROBABILITY",
"max_reward": 50,
"rules": {
"table": [
{ "weight": 50, "amount": 0, "label": "Zonk" },
{ "weight": 30, "amount": 3, "label": "3 Coin" },
{ "weight": 15, "amount": 10, "label": "10 Coin" },
{ "weight": 5, "amount": 50, "label": "Jackpot" }
]
},
"reason": "Spin pertama"
}
```
- Satu baris `table` = satu segmen roda, urut searah gambar roda.
- `weight` bilangan bulat ≥ 1. Peluang segmen = `weight` ÷ total weight (di atas: 50%,
30%, 15%, 5%). Weight tidak pernah dikirim ke customer.
- `amount` EnakCoin yang didapat (boleh 0). `label` opsional, maksimal 100 karakter,
ditampilkan di roda.
- `max_reward` minimal sebesar `amount` terbesar, kalau tidak hadiah besar terpotong.
### 3. Aktifkan config
`POST /api/v1/marketing/enakgame/reward-configs/:id/activate`
Mengganti hadiah nanti berarti membuat versi config baru lalu mengaktifkannya; session
yang sedang berjalan tetap memakai versi saat dimulai.
### 4. Pastikan ada budget global bulan berjalan
`POST /api/v1/marketing/enakgame/budgets` dengan `scope: "GLOBAL"`, bila belum ada. Tanpa
budget global, customer tidak bisa memulai game apa pun.
## Alur di Aplikasi Customer
1. `GET /api/v1/customer/enakgame/games`: game spin punya `prizes`, yaitu segmen roda
(`entry`, `label`, `amount`) dalam urutan config. Gambar roda dari sini.
2. `POST /api/v1/customer/enakgame/sessions` dengan `{"game_id": "…"}` dan header
`Idempotency-Key`. Entry cost dipotong di sini.
3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan body `{}`. Server yang
mengundi. Response berisi `prize` (`entry`, `label`, `amount`): putar roda sampai
berhenti di segmen `entry` itu. `reward_total` adalah Coin yang benar-benar masuk,
bisa lebih besar dari `prize.amount` karena event, atau lebih kecil karena limit
harian (`limited_by`).
Aplikasi tidak boleh mengundi sendiri atau mengirim hadiah: apa pun yang dikirim selain
data hasil diabaikan.
+12 -16
View File
@@ -367,23 +367,18 @@ Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default
## 7. Game
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
Game lama (`POST /api/v1/customer/spin`, `GET /customer/games`,
`GET /customer/ferris-wheel`, dan admin `/marketing/games`, `/marketing/game-prizes`,
`/marketing/rewards`) sudah dihapus. Semua game, termasuk spin, sekarang game EnakGame:
Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
Response:
- Customer: `GET /api/v1/customer/enakgame/games`, lalu
`POST /api/v1/customer/enakgame/sessions` (wajib `Idempotency-Key`, memotong
`entry_cost` EnakCoin), lalu `POST /api/v1/customer/enakgame/sessions/:id/complete`
(server menghitung hadiah EnakCoin). Tanpa PIN.
- Dashboard: game dan biaya per main (`entry_cost`, bilangan bulat ≥ 1) diatur di
`/marketing/enakgame/games`, hadiahnya di reward config.
```json
{
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
"coins_remaining": 7
}
```
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
EnakCoin yang terpotong.
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
Langkah admin dan alur aplikasi untuk spin ada di [`enakgame-spin.md`](enakgame-spin.md).
---
@@ -549,4 +544,5 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=
**Dashboard**
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
`expiry_activations` sebelum owner menyimpan setting.
- [ ] Isi `metadata.coin_cost` untuk setiap game.
- [ ] Buat ulang game (termasuk spin) di `/marketing/enakgame/games` dengan `entry_cost`
dan reward config ([`enakgame-spin.md`](enakgame-spin.md)).
+13 -15
View File
@@ -116,7 +116,7 @@ Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik,
| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya |
| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya |
| PIN (buat, ganti, lupa) | `/customer/pin/*` | – |
| Game | `POST /customer/spin` | – |
| Game | `GET /customer/enakgame/games`, `POST /customer/enakgame/sessions`, `POST …/sessions/:id/complete` | – |
| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – |
---
@@ -524,22 +524,20 @@ Penerima mendapat push `WALLET_TRANSFER_IN`.
## 8. Game (memakai EnakCoin)
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
`POST /api/v1/customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel`
sudah dihapus. Semua game, termasuk spin, dimainkan lewat EnakGame. Tanpa PIN.
```json
{
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
"prize_won": { "id": "…", "name": "Voucher 10rb" },
"coins_remaining": 7
}
```
1. `GET /api/v1/customer/enakgame/games`: daftar game dengan `entry_cost` (EnakCoin per
main). Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance`
kurang. Spin punya `prizes` untuk menggambar roda.
2. `POST /api/v1/customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan header
`Idempotency-Key` (satu key per tap; retry memakai key yang sama). Response berisi
`session_id` dan `coin_balance` setelah dipotong.
3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan hasil main (`score`
atau `outcome`; spin cukup `{}`). Server yang menentukan hadiah: perbarui saldo dari
`coin_balance`, tampilkan `reward_total`, dan untuk spin hentikan roda di `prize.entry`.
- Setiap game punya biaya sendiri: `metadata.coin_cost` pada data game dari
`GET /api/v1/customer/games` (atau `GET /customer/ferris-wheel`), default 1 bila kosong.
Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` kurang.
- `304`: EnakCoin kurang, game nonaktif, atau hadiah baru saja habis. Tidak ada
EnakCoin yang terpotong; tampilkan pesan dan biarkan customer mencoba lagi.
- Setelah main, perbarui saldo EnakCoin dari `coins_remaining`.
Rincian spin ada di [`enakgame-spin.md`](./enakgame-spin.md).
---
+6
View File
@@ -385,6 +385,12 @@ beredar. Karena itu:
### F8 — Game Memakai EnakCoin
> **Diganti EnakGame (2026-10-07).** Alur game di bawah (`POST /customer/spin`,
> `metadata.coin_cost`, `game_plays`) sudah dihapus. Game sekarang dimainkan lewat
> `/customer/enakgame/sessions` dengan `games.entry_cost`; lihat
> [RFC EnakGame](rfc-enakgame.md) §14 dan [enakgame-spin.md](enakgame-spin.md).
> EnakCoin tetap mata uang untuk bermain game.
- **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin
yang sama. `POST /customer/spin` memotong EnakCoin, bukan Token `SPIN`.
- Biaya per main diatur per game di `games.metadata.coin_cost` (default 1), sehingga