diff --git a/docs/integration-backoffice.md b/docs/integration-backoffice.md new file mode 100644 index 0000000..608ca03 --- /dev/null +++ b/docs/integration-backoffice.md @@ -0,0 +1,929 @@ +# Integrasi Backoffice: Loyalitas & EnakGame + +**Untuk:** tim backoffice (dashboard owner/admin) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan **backoffice** yang dipakai owner, admin, dan manager organisasi untuk +mengelola program loyalitas: pengaturan EnakPoint & EnakCoin, wallet customer, voucher, +dan EnakGame (game, hadiah, budget, event, analytics). Jangan mengarang endpoint, +field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke +tim backend. + +Dokumen ini menggantikan `backoffice-enakpoint.md`, bagian dashboard di +`integration-enakpoint.md` dan `api-enakpoint.md`, serta langkah admin di +`enakgame-spin.md`. Alasan di balik aturannya ada di +[`prd-point-coin.md`](./prd-point-coin.md), [`enakgame-prd.md`](./enakgame-prd.md), dan +[`rfc-enakgame.md`](./rfc-enakgame.md). + +--- + +## 1. Konvensi + +**Akses.** Semua endpoint butuh login user dan otomatis dibatasi ke organisasi user itu; +data organisasi lain dijawab `404`. + +| Aksi | Role | +|---|---| +| Membaca semua data di dokumen ini, serta membuat/mengubah game | superadmin, admin, manager, owner, purchasing | +| Mengubah reward config, budget, event, voucher, dan menerima rekomendasi | superadmin, admin, manager, owner (**loyalty manager**) | + +Sembunyikan tombol ubah untuk role yang tidak boleh; server tetap menolaknya (`403`). + +**Format response.** Sukses `{ "success": true, "data": … }`; gagal +`{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Daftar berhalaman +memakai `{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }` +dengan `limit` maks. 100 (default 20). + +**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke +voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke +EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah +"saldo Rp …", karena saldo tidak bisa dicairkan. + +**Body ketat.** Endpoint EnakGame dan voucher (`/marketing/enakgame/*`, `/marketing/vouchers/*`) serta `PUT` setting menolak +field yang tidak dikenal (`310`), supaya salah ketik tidak diam-diam diabaikan. Pada +`PUT`, field yang tidak dikirim tetap memakai nilai sekarang. + +--- + +## 2. Layar yang perlu dibuat + +| Layar | Endpoint | Tempat di menu (usulan) | +|---|---|---| +| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail → tab Loyalitas | +| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan | +| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat | +| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail → tab Wallet | +| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dari baris riwayat wallet | +| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail → tab Keamanan | +| Game | `/marketing/enakgame/games` | EnakGame → Game | +| Hadiah game (reward config) | `/marketing/enakgame/games/:id/reward-configs`, `/reward-configs/:id/activate` | EnakGame → Game → tab Hadiah | +| Budget + metrik + rekomendasi | `/marketing/enakgame/budgets` | EnakGame → Budget | +| Event | `/marketing/enakgame/events` | EnakGame → Event | +| Voucher + kode | `/marketing/vouchers` | Marketing → Voucher | +| Analytics | `/marketing/enakgame/analytics/games`, `/analytics/economy` | EnakGame → Analytics | + +--- + +## 3. Setting loyalitas outlet + +Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order. Semua +nilai default mati sampai owner menyalakannya. + +`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan +objek yang sama untuk menyimpan. + +```json +{ + "point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }, + "coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null } +} +``` + +| Field | Label usulan | Tipe | Default | Validasi | +| --- | --- | --- | --- | --- | +| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – | +| `earn_mode` | Cara hitung: per nominal / persentase | `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya | +| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 | +| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 | +| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal | +| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 | +| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 | + +**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. +Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan +hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ +earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. + +**Mode earning.** Tampilkan hanya field mode yang dipilih. Field mode lain tetap +tersimpan di server. Pada mode `PERCENTAGE` jumlahnya `floor(basis × earn_percent ÷ +100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint. + +**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." +Earning dihitung dari subtotal setelah diskon, sebelum pajak. + +Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat. + +--- + +## 4. Setting loyalitas organisasi + +Nilai rupiah EnakPoint, kurs exchange, batas transfer, kedaluwarsa, dan batas hadiah +EnakGame berlaku sama untuk semua outlet. Mengubah nilai EnakPoint atau kurs langsung +mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya +sebelum disimpan. + +```json +{ + "point_value": 1, + "exchange": { "coin_amount": 1, "point_amount": 1 }, + "transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null }, + "point_expiry": { "…": "lihat §5" }, + "coin_expiry": { "…": "lihat §5" }, + "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } +} +``` + +| Field | Label usulan | Default | Validasi | +| --- | --- | --- | --- | +| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 | +| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 | +| `transfer.enabled` | Izinkan transfer antar customer | aktif | – | +| `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 | +| `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 | +| `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, per currency, reset tengah malam WIB | +| `enakgame.user_daily_limit` | Maks. EnakCoin dari EnakGame per customer per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB | +| `enakgame.global_daily_limit` | Maks. EnakCoin dari EnakGame seluruh organisasi per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB | + +Hadiah yang melewati batas harian **dipotong ke sisa batas**, tidak dibatalkan; bila +sisanya 0, hadiahnya 0. Batas per game ada di `result_rules.daily_reward_limit` (§8.3). + +### 4.1 Alur simpan + +1. Owner mengubah form. +2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek + yang diubah. Tidak ada yang tersimpan. +3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti. +4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs + berubah), dan `expiry_activations` (bila ada, §5). +5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`. + +### 4.2 Dialog dampak + +| Field `impact` | Tampilkan sebagai | +| --- | --- | +| `outstanding_points` | EnakPoint beredar | +| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … | +| `outstanding_coins` | EnakCoin beredar | +| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint | +| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … | + +Contoh: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang +beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya +berlaku ke depan: exchange yang sudah terjadi memakai kurs saat itu. + +--- + +## 5. Pengaturan kedaluwarsa + +Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin +(`coin_expiry`). Defaultnya mati; bila dinyalakan, defaultnya hangus setiap 31 Desember. + +```json +"point_expiry": { + "enabled": true, + "mode": "FIXED_DATE", + "fixed_dates": ["12-31"], + "grace_months": 3, + "period": 12, + "unit": "MONTH", + "end_of_month": false, + "reminder_days": 7 +} +``` + +| Field | Tampil saat | Label usulan | Validasi | +| --- | --- | --- | --- | +| `enabled` | selalu | Saldo bisa kedaluwarsa | – | +| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` | +| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, `MM-DD`, `02-29` ditolak | +| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 | +| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` | +| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – | +| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat | + +**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama. Saldo yang +didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya: saldo +1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Pakai pemilih +tanggal+bulan tanpa tahun. + +**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk. +Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027. + +**Preview.** `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: +kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). Tampilkan "EnakPoint +yang didapat hari ini kedaluwarsa pada 31 Des 2026." Dry run bisa dipakai untuk +memperbarui preview saat owner mengubah pilihan. + +**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal +dengan masa berlaku penuh. Dry run mengembalikan `expiry_activations` (`currency`, +`lots`, `amount`, `expires_at`); tampilkan di dialog konfirmasi dengan kalimat tegas, +mis. "1.250.000 EnakPoint milik customer akan kedaluwarsa pada 31 Des 2027. Tindakan ini +tidak bisa dibatalkan dengan mematikan kedaluwarsa." + +Aturan lain yang perlu dijelaskan di layar: + +- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya. +- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. +- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya. +- Saldo hangus tanpa kompensasi. Customer mendapat push `reminder_days` hari sebelumnya + dan saat hangus. + +--- + +## 6. Wallet customer + +Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan +asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke asalnya. + +### 6.1 Saldo, lot, dan riwayat + +`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` +(semua query opsional; tanggal WIB, inklusif) + +```json +{ + "customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" }, + "point_balance": 12650, + "coin_balance": 8, + "spendable_point_balance": 12500, + "spendable_coin_balance": 8, + "lots": [ + { "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" } + ], + "transactions": { + "data": [ + { + "id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650, + "description": "Transfer ke An*** (08**-****-5678)", + "destination": { "type": "WALLET_TX", "id": "…" }, + "counterparty": { "id": "…", "name": "Anita Rahma" }, + "created_by": null, "outlet": null, "reason": null, "metadata": {}, + "created_at": "…" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } + } +} +``` + +- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / + `coin_balance` bisa sedikit lebih besar selama ada lot yang lewat tanggal tapi belum + diproses job kedaluwarsa (paling lama sekitar 15 menit). +- **Lot:** paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Tandai + `expired: true`. +- **Riwayat:** ditambah nama asli yang disamarkan untuk customer: `counterparty`, + `created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata`. + +| `type` | Mata uang | Label | `source` / `destination` | +|---|---|---|---| +| `EARN` / `EARN_REVERSAL` | keduanya | Dari belanja / Ditarik (void/refund) | `ORDER` | +| `EXCHANGE_OUT` / `EXCHANGE_IN` | COIN / POINT | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | +| `TRANSFER_OUT` / `TRANSFER_IN` | keduanya | Transfer antar customer | `WALLET_TX` (baris pasangannya) | +| `GAME_SPEND` | COIN | Biaya main game | `GAME_SESSION` (data lama: `GAME_PLAY`) | +| `GAME_SPEND_REFUND` | COIN | Biaya main dikembalikan | `GAME_SESSION` | +| `GAME_REWARD` | COIN | Hadiah game (`metadata.budget_id`: budget yang membayar) | `GAME_SESSION` | +| `REWARD_REDEEM` | POINT | Ditukar ke voucher | `REWARD_REDEMPTION` | +| `REWARD_REDEEM_REFUND` | POINT | Penukaran voucher gagal, dikembalikan | `REWARD_REDEMPTION` | +| `EXPIRE` | keduanya | Kedaluwarsa | `LOT` | +| `ADJUSTMENT` | keduanya | Koreksi admin | `USER` | +| `MIGRATION` | keduanya | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | + +### 6.2 Adjustment manual + +`POST /marketing/customers/:id/wallet/adjust` + +```json +{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" } +``` + +| Field | Aturan | +| --- | --- | +| `currency` | `POINT` atau `COIN` | +| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi | +| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" | +| `idempotency_key` | Opsional tapi disarankan: satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali | + +Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah +mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", +"spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan bahwa +adjustment tidak disertai pembayaran uang, jadi alasan tidak boleh "pencairan". + +### 6.3 Telusuri mutasi + +Tombol Telusuri di setiap baris riwayat memanggil +`GET /marketing/wallet-transactions/:id/trace`. + +```json +{ + "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "reference_id": "…", "created_at": "…" }, + "lots": [ + { + "amount": 30, + "chain": [ + { "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } }, + { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } } + ] + } + ] +} +``` + +Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, +lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir +adalah asal pertama saldo, mis. `EARN`, `GAME_REWARD`, `ADJUSTMENT`, atau `MIGRATION`; +bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar +menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya. + +--- + +## 7. PIN dan riwayat setting + +### 7.1 PIN & keamanan customer + +Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah +menghapusnya (mis. customer ganti nomor HP), sehingga customer membuat PIN baru lewat OTP. + +- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. + `reason` wajib; tampilkan dialog konfirmasi dengan input alasan. +- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan: + +```json +{ + "data": [ + { "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 } +} +``` + +| `event` | Label usulan | +| --- | --- | +| `PIN_SET` | PIN dibuat | +| `PIN_CHANGED` | PIN diganti | +| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) | +| `PIN_FAILED` | PIN salah dimasukkan | +| `PIN_LOCKED` | PIN terkunci 30 menit | +| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) | + +### 7.2 Riwayat perubahan setting + +`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; +tambah `&outlet_id=…` untuk satu outlet. + +```json +{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" } +``` + +`old_value` `null` berarti sebelumnya masih default. Tampilkan `key` dengan label yang +sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint", +`enakgame.limit.user_daily` → "Maks. EnakCoin per customer per hari"), dan `changed_by` +sebagai nama user. + +--- + +## 8. EnakGame: game dan hadiah + +Semua game (spin, raffle, minigame) adalah game EnakGame: customer membayar `entry_cost` +EnakCoin per main, dan hadiahnya EnakCoin yang dihitung server dari **reward config** +game itu. Game client (Phaser) dibuat tim EnakGame dan di-host di `game_url`. + +### 8.1 Game + +| Method | Path | Body / query | +|---|---|---| +| `POST` | `/marketing/enakgame/games` | Objek game | +| `GET` | `/marketing/enakgame/games` | `?status=&search=&page=&limit=` (game `ARCHIVED` hanya tampil bila diminta lewat `status`) | +| `GET` | `/marketing/enakgame/games/:id` | – | +| `PUT` | `/marketing/enakgame/games/:id` | Field yang diubah saja; status tidak lewat sini | +| `PUT` | `/marketing/enakgame/games/:id/status` | `{ "status": "INACTIVE", "reason": "…" }` | + +```json +{ + "name": "Spin Harian", + "type": "SPIN", + "slug": "spin", + "description": "Putar roda setiap hari", + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html", + "version": "1.2.0", + "status": "DRAFT", + "entry_cost": 5, + "session_ttl_seconds": 600, + "result_rules": { "max_score": 5000, "min_duration_seconds": 10, "daily_reward_limit": 10000 } +} +``` + +| Field | Label usulan | Validasi | +|---|---|---| +| `name` | Nama game | wajib, maks. 255 | +| `type` | Jenis | `SPIN`, `RAFFLE`, `MINIGAME` (default `MINIGAME`) | +| `slug` | Kode unik | huruf kecil, angka, tanda `-` tunggal, maks. 100, unik per organisasi | +| `thumbnail_url`, `game_url` | Gambar, URL game | maks. 500; `game_url` dari tim EnakGame | +| `version` | Versi game | maks. 50 | +| `status` | Status awal | hanya saat buat: `DRAFT` (default), `ACTIVE`, `INACTIVE` | +| `entry_cost` | Biaya main (EnakCoin) | ≥ 1; tidak ada game gratis | +| `session_ttl_seconds` | Batas waktu satu main | 1–86.400 detik, default 600 | +| `result_rules` | Validasi hasil (§8.3) | opsional | + +**Status game:** + +| `status` | Arti | +|---|---| +| `DRAFT` | Disiapkan, belum tampil di aplikasi | +| `ACTIVE` | Tampil dan bisa dimainkan (butuh reward config aktif dan budget global, §9) | +| `INACTIVE` | Disembunyikan. Session yang sedang berjalan direfund otomatis | +| `ARCHIVED` | Pensiun permanen; tidak bisa diubah lagi. Game lama sebelum EnakGame berstatus ini | + +Mengubah `entry_cost` tidak memengaruhi session yang sudah berjalan. + +### 8.2 Reward config (hadiah) + +Hadiah diatur sebagai **versi**: versi tidak pernah diedit; perubahan = versi baru, lalu +diaktifkan. Satu game hanya punya satu versi `ACTIVE`; session memakai versi yang aktif +saat dimulai. + +| Method | Path | Body | +|---|---|---| +| `GET` | `/marketing/enakgame/games/:id/reward-configs` | Semua versi, terbaru di atas | +| `POST` | `/marketing/enakgame/games/:id/reward-configs` | `{ "reward_type", "rules", "max_reward", "effective_at", "reason" }` → versi baru `DRAFT` | +| `POST` | `/marketing/enakgame/reward-configs/:id/activate` | `{ "reason": "…" }` → versi ini `ACTIVE`, versi aktif sebelumnya `RETIRED` | + +```json +{ + "id": "…", "game_id": "…", "version": 3, "reward_type": "FIXED", "rules": { "amount": 9 }, + "max_reward": 9, "status": "ACTIVE", "effective_at": "…", "created_by": "…", "reason": "…", + "base_config_id": "…", "multiplier": 0.9, "budget_id": "…", "created_at": "…" +} +``` + +`base_config_id`, `multiplier`, dan `budget_id` hanya terisi pada versi yang dibuat +Budget Controller (§9.3). Tampilkan badge "Disesuaikan Budget Controller × 0,90". +Versi `RETIRED` tidak bisa diaktifkan lagi; untuk kembali, buat versi baru dengan aturan +lama. + +**Empat jenis `reward_type`:** + +| `reward_type` | `rules` | Hasil yang dikirim game | +|---|---|---| +| `FIXED` | `{ "amount": 5 }` | Apa saja; selalu 5 | +| `SCORE_BASED` | `{ "bands": [{ "min": 0, "max": 100, "amount": 1 }, { "min": 101, "amount": 20 }] }` | `score` | +| `OUTCOME_BASED` | `{ "outcomes": { "PERFECT": 20, "GOOD": 10, "FAIL": 0 } }` | `outcome` | +| `PROBABILITY` | `{ "table": [{ "weight": 1, "amount": 1000, "label": "Jackpot" }, { "weight": 999, "amount": 0, "label": "Zonk" }] }` | – (server mengundi) | + +- `SCORE_BASED`: band urut mulai dari 0, tanpa celah dan tanpa tumpang tindih; hanya band + terakhir boleh tanpa `max`. Skor di luar semua band → hadiah 0. +- `OUTCOME_BASED`: `outcome` yang tidak terdaftar → hadiah 0. +- `PROBABILITY`: `weight` bilangan bulat ≥ 1; peluang = `weight` ÷ total weight. + `label` opsional (maks. 100) dan tampil di roda spin. Customer melihat segmen dan + hadiahnya, tidak pernah weight-nya. +- Semua `amount` ≥ 0, EnakCoin bulat. +- `max_reward`: batas atas hadiah **total** per main, termasuk tambahan event (§10). + 0 = tanpa batas. Bila lewat, yang dipotong lebih dulu adalah tambahan event dengan + prioritas terendah. + +Tampilkan editor sesuai jenis (bukan textarea JSON) dan preview, mis. tabel peluang +"Jackpot 0,1% · Zonk 99,9%" untuk `PROBABILITY`. + +### 8.3 Validasi hasil (`result_rules`) + +Hasil dari game diperiksa server. Hasil yang tidak lolos tetap dicatat, tapi hadiahnya +0 dan session ditandai mencurigakan. + +| Field | Arti | +|---|---| +| `max_score` | Skor maksimal yang masuk akal | +| `min_duration_seconds` | Main lebih cepat dari ini dianggap curang | +| `max_score_per_second` | Laju skor maksimal | +| `outcomes` | Daftar `outcome` yang diterima; kosong = semua | +| `daily_reward_limit` | Maks. EnakCoin yang boleh diberikan game ini per hari (seluruh customer) | + +Semua opsional. Isi bersama tim EnakGame, karena mereka tahu skor dan durasi wajar +game-nya. + +### 8.4 Membuat spin + +1. **Game:** `POST /marketing/enakgame/games` dengan + `{ "name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "DRAFT", "thumbnail_url": "…", "game_url": "…" }`. +2. **Hadiah:** `POST /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. `max_reward` minimal + sebesar `amount` terbesar; beri ruang lebih bila ingin event bisa menambah hadiah. +3. **Aktifkan:** `POST /marketing/enakgame/reward-configs/:id/activate`. +4. **Budget:** pastikan ada budget global bulan berjalan (§9.1). +5. **Tayangkan:** `PUT /marketing/enakgame/games/:id/status` dengan `{ "status": "ACTIVE" }`. + +--- + +## 9. Budget + +Budget adalah rupiah yang boleh dihabiskan organisasi untuk hadiah EnakGame. Biaya +dihitung dari **voucher yang benar-benar ditukar**: saat customer menukar EnakPoint yang +asalnya dari hadiah game (EnakCoin hadiah → ditukar ke EnakPoint → voucher), nilai +voucher (`face_value`) dicatat sebagai biaya budget yang membayar hadiah itu. EnakPoint +dari belanja tidak dihitung. Entry cost yang dibayar customer tidak menambah budget. + +### 9.1 Budget global dan event + +| `scope` | Membayar | Aturan | +|---|---|---| +| `GLOBAL` | Hadiah dasar semua game | Satu per periode; periode tidak boleh tumpang tindih. **Tanpa budget global yang mencakup hari ini, customer tidak bisa mulai main.** Setiap hari sistem membuat budget bulan berikutnya dari budget yang sedang berjalan (jumlah dan threshold sama) bila belum ada | +| `EVENT` | Tambahan hadiah dari satu event | Dipasang di event (§10) | + +| Method | Path | Body / query | +|---|---|---| +| `GET` | `/marketing/enakgame/budgets` | `?scope=GLOBAL&page=&limit=` | +| `GET` | `/marketing/enakgame/budgets/:id` | – | +| `POST` | `/marketing/enakgame/budgets` | Objek budget | +| `PUT` | `/marketing/enakgame/budgets/:id` | Field yang diubah; `scope` tidak bisa berubah | +| `DELETE` | `/marketing/enakgame/budgets/:id` | Hanya budget yang belum dipakai hadiah, event, atau reward config | + +```json +{ + "scope": "GLOBAL", + "name": "Oktober 2026", + "period_start": "2026-10-01", + "period_end": "2026-10-31", + "amount": 100000000, + "thresholds": { + "warning": 70, "critical": 90, + "max_step_percent": 10, "min_multiplier_percent": 50, "max_multiplier_percent": 150, "cooldown_days": 7 + } +} +``` + +| Field | Label usulan | Validasi | +|---|---|---| +| `name` | Nama | wajib, maks. 255 | +| `period_start`, `period_end` | Periode | `YYYY-MM-DD`, inklusif, akhir ≥ awal | +| `amount` | Budget (Rp) | > 0 | +| `thresholds.warning` / `.critical` | Ambang peringatan / kritis (%) | 0–100, warning ≤ critical; default 70 / 90 | +| `thresholds.max_step_percent` | Maks. perubahan hadiah per rekomendasi (%) | 1–50; default 10 | +| `thresholds.min_multiplier_percent` | Hadiah terendah (% dari yang ditulis admin) | 1–100; default 50 | +| `thresholds.max_multiplier_percent` | Hadiah tertinggi (% dari yang ditulis admin) | 100–1000; default 150 | +| `thresholds.cooldown_days` | Jeda antar rekomendasi diterima (hari) | 0–90; default 7 | + +Nilai default threshold dan guardrail masih **sementara**, menunggu keputusan bisnis +(RFC §19.2 #4). Tampilkan default sebagai placeholder, bukan nilai yang tersimpan. + +### 9.2 Metrik — `GET /marketing/enakgame/budgets/:id/metrics` + +```json +{ + "budget_id": "…", "scope": "GLOBAL", "period_start": "2026-10-01", "period_end": "2026-10-31", + "as_of": "2026-10-21", "amount": 100000000, + "realized_cost": 60000000, "remaining": 40000000, "utilization_percent": 60, + "daily_burn": 5500000, "window_days": 7, "remaining_days": 10, + "forecast_cost": 115000000, "forecast_remaining": -15000000, "forecast_utilization_percent": 115, + "coin_issued": 1250000, + "exposure": { "coins": 400000, "points": 90000 }, + "thresholds": { "warning": 70, "critical": 90 }, + "status": "CRITICAL" +} +``` + +| Field | Arti | Tampilkan sebagai | +|---|---|---| +| `realized_cost` | Biaya voucher yang sudah ditukar (Rp). Global: dalam periode; event: tanpa batas waktu | Terpakai | +| `remaining`, `utilization_percent` | Sisa dan persen terpakai | Progress bar | +| `daily_burn`, `window_days` | Rata-rata biaya per hari dalam `window_days` hari terakhir | Burn rate | +| `forecast_cost`, `forecast_remaining` | Perkiraan biaya di akhir periode = realized + burn × `remaining_days` | Perkiraan; merah bila `forecast_remaining` negatif | +| `coin_issued` | EnakCoin hadiah yang dibayar budget ini | – | +| `exposure` | EnakCoin dan EnakPoint dari budget ini yang masih beredar: batas atas biaya yang masih bisa datang | "Masih bisa menjadi biaya" | +| `status` | `HEALTHY`, `WARNING`, `CRITICAL`, `EXHAUSTED` | Badge hijau / kuning / oranye / merah | + +Status: `EXHAUSTED` bila realized ≥ budget; `CRITICAL` bila forecast melewati budget atau +utilization/forecast ≥ critical; `WARNING` bila ≥ warning. Budget habis **belum +menghentikan hadiah** (kebijakannya belum diputuskan), jadi tampilkan peringatan yang +jelas. + +### 9.3 Rekomendasi Budget Controller + +Untuk budget `GLOBAL`, sistem menghitung pengali hadiah supaya perkiraan biaya pas dengan +budget. Tidak ada yang berubah sampai admin menerimanya. + +`GET /marketing/enakgame/budgets/:id/recommendation` + +```json +{ + "budget_id": "…", + "state": "RECOMMENDED", + "message": "forecast Rp115000000 against a budget of Rp100000000: multiply rewards by 0.90", + "metrics": { "…": "sama seperti §9.2" }, + "guardrails": { "max_step_percent": 10, "min_multiplier_percent": 50, "max_multiplier_percent": 150, "cooldown_days": 7 }, + "target_multiplier": 0.7272, + "multiplier": 0.9, + "games": [ + { + "game_id": "…", "game_name": "Tap Tap", "reward_config_id": "…", "version": 1, "base_config_id": "…", + "reward_type": "FIXED", "current_multiplier": 1, "new_multiplier": 0.9, + "current_rules": { "amount": 10 }, "new_rules": { "amount": 9 }, + "current_max_reward": 10, "new_max_reward": 9 + } + ] +} +``` + +| `state` | Arti | Tampilan | +|---|---|---| +| `RECOMMENDED` | Ada rekomendasi yang bisa diterima | Tombol Terima aktif | +| `NO_CHANGE` | Perkiraan sudah pas | "Hadiah tidak perlu diubah" | +| `COOLDOWN` | Rekomendasi diterima kurang dari `cooldown_days` lalu | "Bisa diterima lagi pada {cooldown_until}"; tampilkan `games` sebagai gambaran | +| `AT_LIMIT` | Semua game sudah di batas min/max | "Hadiah sudah di batas terendah/tertinggi" | +| `INSUFFICIENT_DATA` | Belum ada biaya dalam `window_days` hari terakhir | "Belum cukup data" | +| `OUT_OF_PERIOD` | Periode belum mulai atau tidak ada hari tersisa | "Periode tidak berjalan" | + +- `target_multiplier`: pengali yang membuat perkiraan pas dengan budget; `multiplier`: + yang direkomendasikan, dibatasi `max_step_percent` dari 1 dan dibulatkan ke bawah ke + dua desimal. Bisa di bawah 1 (hadiah turun) atau di atas 1 (hadiah naik). +- `games`: perubahan per game. Pengali tiap game dihitung dari versi yang ditulis admin + (`base_config_id`), dan dibatasi `min_multiplier_percent`–`max_multiplier_percent`. + Semua jumlah dibulatkan ke bawah, jadi hadiah kecil bisa menjadi 0 (1 × 0,9 = 0). + Tampilkan `current_rules` → `new_rules` berdampingan supaya admin melihatnya. +- Budget `EVENT` ditolak (`304`): tambahan event diatur di event. + +**Terima:** `POST /marketing/enakgame/budgets/:id/recommendation/accept` + +```json +{ "multiplier": 0.9, "reason": "Burn rate terlalu tinggi" } +``` + +- Kirim `multiplier` yang ditampilkan. Bila rekomendasi sudah berubah sejak layar dibuka, + server menolak (`304`) dan tidak mengubah apa pun: muat ulang rekomendasi. +- Berhasil: setiap game di `games` mendapat versi reward config baru yang langsung + `ACTIVE`, versi lama `RETIRED`. Response + `{ "budget_id", "multiplier", "reward_configs": [ … ] }`. +- Tercatat di audit dengan sumber Budget Controller. Mulai saat itu cooldown berlaku untuk + seluruh organisasi. +- Admin yang menulis versi baru sendiri untuk sebuah game memulai pengalinya dari 1 lagi. + +--- + +## 10. Event + +Event (= campaign) membuat game tertentu memberi hadiah lebih selama periode tertentu. +Tambahannya dibayar budget `EVENT` milik event itu. + +| Method | Path | Body / query | +|---|---|---| +| `GET` | `/marketing/enakgame/events` | `?status=&page=&limit=` | +| `GET` | `/marketing/enakgame/events/:id` | – | +| `POST` | `/marketing/enakgame/events` | Objek event | +| `PUT` | `/marketing/enakgame/events/:id` | Field yang diubah | +| `PUT` | `/marketing/enakgame/events/:id/status` | `{ "status": "ENDED", "reason": "…" }` | + +```json +{ + "name": "Ramadan 2x", + "slug": "ramadan-2x", + "description": "Hadiah dobel selama Ramadan", + "banner_url": "https://…/ramadan.png", + "start_at": "2027-02-17T00:00:00+07:00", + "end_at": "2027-03-18T23:59:59+07:00", + "timezone": "Asia/Jakarta", + "priority": 10, + "multiplier": 2, + "bonus": null, + "budget_id": "", + "reward_limit": 5000000, + "user_daily_limit": 200, + "game_ids": ["…", "…"], + "status": "DRAFT" +} +``` + +| Field | Arti | Validasi | +|---|---|---| +| `start_at`, `end_at` | Periode berlaku | wajib, akhir setelah awal | +| `timezone` | Zona waktu untuk tampilan | default `Asia/Jakarta` | +| `multiplier` | Pengali hadiah dasar: 2 = hadiah dasar ditambah sekali lagi | ≥ 1, maks. 2 desimal | +| `bonus` | Tambahan EnakCoin per main | ≥ 1 | +| | | Minimal salah satu: `multiplier` > 1 atau `bonus` | +| `priority` | Urutan bila beberapa event berlaku | angka lebih besar didahulukan | +| `budget_id` | Budget `EVENT` organisasi ini | wajib | +| `reward_limit` | Maks. tambahan EnakCoin selama event | ≥ 1, kosong = tanpa batas | +| `user_daily_limit` | Maks. tambahan per customer per hari | ≥ 1, kosong = tanpa batas | +| `game_ids` | Game yang ikut | minimal satu game organisasi ini | +| `status` | Status awal | hanya saat buat: `DRAFT` (default) atau `ACTIVE` | + +**Status:** `DRAFT` → `ACTIVE` atau `CANCELLED`; `ACTIVE` → `ENDED` atau `CANCELLED`. +Event `ACTIVE` hanya berlaku di antara `start_at` dan `end_at`. + +**Beberapa event sekaligus.** Tambahan setiap event dihitung dari hadiah dasar (tidak +saling mengalikan), lalu dijumlahkan. Bila total melewati `max_reward` game, tambahan +event berprioritas terendah dipotong lebih dulu. Main yang hadiah dasarnya 0 tidak +mendapat tambahan event. Contoh: hadiah dasar 10, event 2x → 10 dari budget global + +10 dari budget event. + +--- + +## 11. Voucher + +Voucher adalah satu-satunya cara memakai EnakPoint: customer menukar EnakPoint +(`point_cost`) dengan voucher di aplikasi, memakai PIN. Voucher berdiri sendiri, tidak +di bawah EnakGame: EnakPoint dari belanja pun ditukar di sini. Hubungannya dengan EnakGame +hanya di budget: bila EnakPoint yang ditukar berasal dari hadiah game, nilai vouchernya +dicatat sebagai biaya budget (§9). + +### 11.1 Voucher + +| Method | Path | Body / query | +|---|---|---| +| `GET` | `/marketing/vouchers` | `?status=&search=&page=&limit=` | +| `GET` | `/marketing/vouchers/:id` | – | +| `POST` | `/marketing/vouchers` | Objek voucher | +| `PUT` | `/marketing/vouchers/:id` | Field yang diubah; `stock_mode` tidak bisa berubah | +| `PUT` | `/marketing/vouchers/:id/status` | `{ "status": "ACTIVE", "reason": "…" }` | + +```json +{ + "name": "Kopi Susu Gratis", + "description": "Berlaku untuk ukuran regular", + "image_url": "https://…/kopi.png", + "voucher_type": "FREE_ITEM", + "face_value": 20000, + "point_cost": 15000, + "business_cost": 8000, + "stock_mode": "CODE_POOL", + "max_per_customer": 2, + "valid_from": "2026-10-01T00:00:00+07:00", + "valid_until": "2026-12-31T23:59:59+07:00", + "terms": { "outlets": "Semua outlet", "notes": "Tidak bisa digabung promo lain" }, + "status": "DRAFT" +} +``` + +| Field | Arti | Validasi | +|---|---|---| +| `voucher_type` | Jenis | `FIXED_VALUE`, `PERCENTAGE`, `FREE_ITEM`, `MERCHANT_BENEFIT` | +| `face_value` | Nilai voucher (Rp); **dihitung sebagai biaya budget** | > 0 | +| `point_cost` | EnakPoint yang dibayar customer | > 0 | +| `business_cost` | Biaya sebenarnya untuk laporan Finance; tidak dipakai budget | ≥ 0, opsional | +| `stock_mode` | Asal voucher (lihat bawah) | `STATIC`, `CODE_POOL`, `EXTERNAL` | +| `stock` | Stok, hanya `STATIC` | wajib untuk `STATIC`, ≥ 0 | +| `provider`, `provider_ref` | Hanya `EXTERNAL` | `provider` wajib untuk `EXTERNAL` | +| `max_per_customer` | Batas tukar per customer | ≥ 1, kosong = tanpa batas | +| `valid_from`, `valid_until` | Masa bisa ditukar | akhir setelah awal | +| `terms` | Syarat & ketentuan | objek JSON; sepakati bentuknya dengan tim aplikasi | +| `status` | Status awal | hanya saat buat: `DRAFT` (default), `ACTIVE`, `INACTIVE` | + +| `stock_mode` | Cara kerja | +|---|---| +| `STATIC` | Stok berupa angka; customer mendapat voucher tanpa kode | +| `CODE_POOL` | Setiap penukaran mengambil satu kode yang diimpor (§11.2); stok = kode `AVAILABLE` | +| `EXTERNAL` | Kode dari penyedia luar. **Belum bisa dipakai**: belum ada penyedia yang tersambung, jadi voucher ini tidak tampil di katalog customer | + +**Status:** `DRAFT`, `ACTIVE` (tampil di katalog selama dalam masa berlaku dan ada stok), +`INACTIVE`, `ARCHIVED` (permanen, tidak bisa diubah lagi). + +### 11.2 Kode voucher (`CODE_POOL`) + +**Impor:** `POST /marketing/vouchers/:id/codes` dengan file CSV sebagai +multipart field `file` (atau CSV sebagai body). + +```csv +code,expires_at +KOPI-7F3C-2291,2026-12-31 +KOPI-8A1D-5530, +``` + +- Kolom 1: kode (wajib, maks. 255). Kolom 2: kedaluwarsa, opsional, `YYYY-MM-DD` + (berlaku sampai akhir hari WIB) atau RFC3339. Baris header `code` boleh ada. +- Maks. 50.000 baris dan 16 MB per file. Kode yang sudah ada di pool atau berulang di file + dilewati. + +```json +{ "imported": 1998, "duplicate_count": 1, "duplicates": ["KOPI-7F3C-2291"], "invalid": [{ "line": 17, "reason": "…" }] } +``` + +Tampilkan ringkasan: berapa masuk, berapa duplikat, dan baris yang gagal dengan nomor +barisnya. + +**Daftar:** `GET /marketing/vouchers/:id/codes?status=AVAILABLE&page=1&limit=20` + +```json +{ + "counts": { "AVAILABLE": 1500, "REDEEMED": 480, "EXPIRED": 20 }, + "codes": { "data": [ { "id": "…", "code": "KOPI-…", "status": "AVAILABLE", "redemption_id": null, "expires_at": "…", "created_at": "…" } ], "pagination": { "…": "…" } } +} +``` + +Status kode: `AVAILABLE`, `RESERVED`, `REDEEMED`, `EXPIRED` (lewat `expires_at`, +diproses tiap jam), `CANCELLED`. Tampilkan `counts` sebagai ringkasan stok di atas tabel, +dan peringatan bila `AVAILABLE` hampir habis. + +--- + +## 12. Analytics + +Rentang tanggal WIB, kedua ujung termasuk, maks. 366 hari. `from` dan `to` wajib. + +### 12.1 Game — `GET /marketing/enakgame/analytics/games?from=2026-10-01&to=2026-10-31&game_id=` + +`game_id` opsional untuk satu game. Dihitung dari session yang **dimulai** dalam +rentang. + +```json +{ + "from": "2026-10-01", "to": "2026-10-31", + "totals": { + "plays": 4200, "completed": 3900, "refunded": 12, "expired": 288, "flagged": 35, "players": 820, + "average_score": 742.5, "average_reward": 6.2, "reward_per_play": 5.76, + "coin_issued": 24180, "entry_cost_paid": 21000, "coin_refunded": 60 + }, + "games": [ { "game_id": "…", "game_name": "Spin Harian", "plays": 3000, "…": "field sama dengan totals" } ] +} +``` + +| Field | Label usulan | +|---|---| +| `plays` | Total main | +| `completed`, `refunded`, `expired` | Selesai / dikembalikan / tidak selesai | +| `flagged` | Hasil mencurigakan (gagal validasi, hadiah 0) | +| `players` | Customer unik | +| `average_score` | Rata-rata skor (`null` bila game tidak memakai skor) | +| `average_reward`, `reward_per_play` | Rata-rata hadiah per main selesai / per main | +| `coin_issued` | EnakCoin hadiah | +| `entry_cost_paid`, `coin_refunded` | EnakCoin dibayar untuk main / dikembalikan | + +`games` urut dari yang paling banyak dimainkan. + +### 12.2 Ekonomi — `GET /marketing/enakgame/analytics/economy?from=2026-10-01&to=2026-10-31` + +Dihitung dari semua mutasi wallet organisasi dalam rentang. + +```json +{ + "from": "2026-10-01", "to": "2026-10-31", + "coin": { "generated": 52000, "game_rewards": 24180, "spent_on_games": 20940, "exchanged": 9000, "spent": 29940, "expired": 300, "outstanding": 61000 }, + "point": { "earned": 1800000, "exchanged": 2700, "redeemed": 900000, "expired": 15000, "balance": 4200000 }, + "by_type": [ { "currency": "COIN", "type": "GAME_REWARD", "credit": 24180, "debit": 0, "transactions": 3900 } ] +} +``` + +| Field | Arti | +|---|---| +| `coin.generated` | EnakCoin baru: hadiah game, belanja (dikurangi pembatalan), migrasi, adjustment tambah | +| `coin.spent_on_games` | Entry cost dikurangi yang dikembalikan | +| `coin.exchanged` | Ditukar ke EnakPoint | +| `coin.outstanding` / `point.balance` | Dipegang customer di akhir rentang | +| `point.earned` | Dari belanja, dikurangi pembatalan | +| `point.exchanged` | Hasil tukar EnakCoin | +| `point.redeemed` | Ditukar ke voucher, dikurangi penukaran yang gagal | +| `by_type` | Rincian per tipe mutasi, termasuk transfer (yang tidak dihitung di angka utama) | + +--- + +## 13. Belum tersedia + +Fitur berikut belum ada di backend; jangan dibuat layarnya dulu: + +- Daftar session main dan filter hasil mencurigakan (`flagged`) untuk admin. +- Daftar penukaran voucher untuk admin. +- Layar audit log (perubahan tetap tercatat di backend). +- Kebijakan saat budget habis, dan mode otomatis Budget Controller. +- Menandai voucher sudah dipakai di POS ([`integration-pos.md`](./integration-pos.md) §5). +- Voucher `EXTERNAL` (belum ada penyedia). + +--- + +## 14. Pesan error dan checklist + +| `code` | HTTP | Kapan terjadi | Yang ditampilkan | +| --- | --- | --- | --- | +| `303`, `310` | 400 | Body tidak valid, field tak dikenal, UUID salah | "Data tidak valid" + `cause` untuk developer | +| `304` | 400 | Nilai di luar batas, aturan bisnis (slug terpakai, periode budget tumpang tindih, versi sudah pensiun, rekomendasi berubah, dst.) | `cause` di dekat field atau di toast | +| – | 403 | Role tidak boleh mengubah (§1) | "Kamu tidak punya akses" | +| `404` | 404 | Data bukan milik organisasi ini atau tidak ada | "Data tidak ditemukan" | +| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" | + +Pesan `cause` berbahasa Inggris, mis. `thresholds.warning cannot be above +thresholds.critical`. Cek batas di sisi klien (tabel di tiap bagian) dan tampilkan +`cause` hanya sebagai cadangan. + +### Checklist rilis + +**Loyalitas** +- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning. +- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi (`impact`, `expiry_activations`). +- [ ] Preview kedaluwarsa tampil di bawah pengaturan kedaluwarsa. +- [ ] Wallet customer: saldo yang bisa dipakai, lot, riwayat dengan nama asli, label semua tipe mutasi termasuk game dan voucher. +- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`; Telusuri di setiap baris. +- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. +- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". + +**EnakGame** +- [ ] Game: form lengkap, `entry_cost` ≥ 1, status, `result_rules`. +- [ ] Reward config: editor per jenis, riwayat versi, aktivasi dengan alasan, badge versi Budget Controller. +- [ ] Spin bisa dibuat end-to-end mengikuti §8.4. +- [ ] Budget global per bulan, threshold dan guardrail, peringatan bila tidak ada budget global berjalan. +- [ ] Metrik budget dengan status dan perkiraan; rekomendasi dengan perbandingan aturan lama/baru dan tombol Terima. +- [ ] Event: form, status, budget `EVENT`, pilihan game. +- [ ] Voucher: form per `stock_mode`, impor kode CSV dengan ringkasan, stok kode. +- [ ] Analytics game dan ekonomi dengan pemilih rentang tanggal. +- [ ] Tombol ubah disembunyikan untuk role yang bukan loyalty manager. + +Transfer antar customer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar +backoffice boleh disiapkan lebih dulu. diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md new file mode 100644 index 0000000..21236e4 --- /dev/null +++ b/docs/integration-enakgame.md @@ -0,0 +1,298 @@ +# Integrasi EnakGame: Game Client (Phaser) + +**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di +dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main +dari awal sampai akhir: memulai session (EnakCoin dipotong), menjalankan permainan, +mengirim hasil, dan menampilkan hadiah. Jangan mengarang endpoint, field, atau aturan +yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke tim backend. + +Pembagian tugas dengan aplikasi customer: + +| Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) | +|---|---| +| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§2) | +| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah | +| Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup | + +Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan +[`enakgame-prd.md`](./enakgame-prd.md). + +--- + +## 1. Aturan yang tidak boleh dilanggar + +1. **Server yang menentukan hadiah.** Game hanya mengirim **hasil main**: `score`, + `outcome`, dan `data`. Jangan pernah mengirim jumlah hadiah. Kalaupun terkirim, + backend mengabaikannya. Untuk spin, server yang mengundi segmennya. +2. **Tampilkan hadiah dari response, bukan dari hitungan sendiri.** Angka di layar akhir + selalu `reward_total` dari backend. +3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama. +4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori, + tidak pernah ditaruh di URL, `localStorage`, cookie, log, atau analytics. +5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. +6. **Main game tidak butuh PIN.** + +--- + +## 2. Bridge dengan aplikasi customer + +> **Usulan.** Bentuk bridge di bawah belum diimplementasikan di sisi mana pun. Sepakati +> dengan tim aplikasi customer sebelum mulai; aplikasi memakai kontrak yang sama +> ([`integration-mobile-customer.md`](./integration-mobile-customer.md) §8.3). + +Semua pesan berupa JSON string dengan field `type`. + +- **Game → aplikasi:** `window.EnakGameHost.postMessage(JSON.stringify(pesan))` + (JavaScript channel webview bernama `EnakGameHost`). +- **Aplikasi → game:** aplikasi memanggil `window.enakGame.receive(jsonString)`. Game + wajib mendefinisikan fungsi ini sebelum mengirim `ready`. + +| Arah | `type` | Isi | Kapan | +|---|---|---|---| +| game → app | `ready` | – | Halaman game selesai dimuat | +| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready` | +| game → app | `token_expired` | – | Backend menolak token (§3) | +| app → game | `token` | `token` | Token baru setelah `token_expired` | +| game → app | `balance_changed` | `coin_balance` | Setelah start dan complete berhasil | +| game → app | `close` | – | Customer keluar dari game | + +Contoh `init`: + +```json +{ "type": "init", "api_base_url": "https://api.example.com/api/v1", "token": "eyJ…", "game_id": "8a1f…" } +``` + +Jangan memanggil API apa pun sebelum `init` diterima. Untuk development di browser +tanpa aplikasi, sediakan mode dev yang mengisi `init` dari config lokal; mode itu tidak +boleh ikut di build produksi. + +--- + +## 3. Koneksi ke API + +- Header: `Authorization: Bearer ` dari `init`. +- Sukses: `{ "success": true, "data": { … }, "errors": null }`. +- Gagal: `{ "success": false, "data": null, "errors": [{ "code", "entity", "cause" }] }`. + `cause` berbahasa Inggris; jangan tampilkan mentah ke customer. + +| `errors[0].code` | HTTP | Arti | Yang dilakukan game | +|---|---|---|---| +| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum | +| `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint | +| `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi | +| `900` | 500 | Error server | Retry (§6) | + +**Token tidak berlaku** (kedaluwarsa, salah) dijawab HTTP 400 dengan code `304`, sama +seperti penolakan bisnis. Bedakan lewat `entity`: `auth_handler` untuk token, +`enakgame_service` untuk aturan EnakGame. Pada `auth_handler`, kirim `token_expired`, +tunggu `token`, lalu ulangi request yang sama. + +--- + +## 4. Alur satu kali main + +``` +init ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) + ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) + ─► permainan berjalan (batas waktu: expires_at) + ─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah) + ─► tampilkan hadiah ─► main lagi atau close +``` + +### 4.1 Data game — `GET /customer/enakgame/games` + +Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama dengan +`game_id` dari `init`. + +```json +[ + { + "id": "8a1f…", + "slug": "spin", + "name": "Spin Harian", + "description": null, + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html", + "version": "1.2.0", + "entry_cost": 5, + "session_ttl_seconds": 600, + "events": [ + { "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" } + ], + "prizes": [ + { "entry": 1, "label": "Zonk", "amount": 0 }, + { "entry": 2, "label": "3 Coin", "amount": 3 }, + { "entry": 3, "label": "10 Coin", "amount": 10 }, + { "entry": 4, "label": "Jackpot", "amount": 50 } + ] + } +] +``` + +- `entry_cost`: EnakCoin per main. Tampilkan di tombol Main ("Main · 5 EnakCoin"). +- `events`: event yang sedang berlaku, prioritas tertinggi dulu. Tampilkan sebagai + label, mis. "2x hadiah sampai 31 Okt". `multiplier` 2 berarti hadiah dasar ditambah + sekali lagi; `bonus` menambah sejumlah EnakCoin. +- `prizes`: hanya ada untuk game ber-reward `PROBABILITY` (spin). Urutan = urutan segmen + roda. `label` bisa `null`. Bobot peluang tidak pernah dikirim. +- Game tidak ada di daftar → game sudah dinonaktifkan; tampilkan pesan dan `close`. + +### 4.2 Mulai — `POST /customer/enakgame/sessions` + +Header `Idempotency-Key` wajib (maks. 50 karakter, mis. UUID v4). Buat key baru saat +customer menekan Main; pakai key yang sama bila request diulang karena jaringan. + +```json +{ "game_id": "8a1f…" } +``` + +```json +{ + "session_id": "c0d3…", + "game_id": "8a1f…", + "entry_cost": 5, + "expires_at": "2026-10-08T05:10:00Z", + "coin_balance": 15, + "replayed": false +} +``` + +- EnakCoin sudah terpotong. Kirim `balance_changed` dengan `coin_balance`. +- `replayed: true`: request ini mengulang start yang sudah berhasil; pakai session yang + sama, EnakCoin tidak terpotong dua kali. +- `expires_at`: batas waktu mengirim hasil (default 10 menit sejak start, diatur per + game). Tampilkan timer bila permainan bisa lama. + +| Penolakan `304` (`cause`) | Tampilan | +|---|---| +| `not enough EnakCoin` | "EnakCoin kamu kurang." Tombol kembali ke aplikasi | +| `the game is not available` | "Game sedang tidak tersedia." | +| `the game has no active reward configuration` | "Game sedang tidak tersedia." | +| `no EnakGame budget is set for this period` | "Game sedang tidak tersedia." | +| `the customer is not active` | "Akun tidak aktif." | +| `this Idempotency-Key was already used to start another game` | Bug di game: key dipakai ulang untuk game lain | +| `the Idempotency-Key header is required` / `… at most 50 characters` | Bug di game | + +### 4.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete` + +Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja: + +| Field | Tipe | Untuk | +|---|---|---| +| `score` | integer ≥ 0, opsional | Game berbasis skor | +| `outcome` | string, opsional | Game berbasis hasil, mis. `"WIN"`, `"PERFECT"` | +| `data` | objek JSON, opsional, maks. 16 KB | Data tambahan untuk audit (durasi per level, dsb.) | + +Spin cukup mengirim `{}`. Game skor: `{ "score": 800 }`. Game hasil: +`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game; +sepakati daftarnya dengan tim backoffice. + +```json +{ + "session_id": "c0d3…", + "status": "COMPLETED", + "reward_total": 10, + "reward": { "base": 5, "event": 5 }, + "coin_balance": 25, + "limited_by": ["USER_DAILY"], + "prize": { "entry": 2, "label": "3 Coin", "amount": 3 } +} +``` + +| Field | Arti | Tampilan | +|---|---|---| +| `reward_total` | EnakCoin yang **benar-benar masuk** | Angka utama di layar hadiah | +| `reward.base` / `reward.event` | Hadiah dasar dan tambahan event | "5 + 5 bonus event" | +| `coin_balance` | Saldo EnakCoin setelah hadiah | Kirim `balance_changed` | +| `limited_by` | Batas harian yang memotong hadiah: `USER_DAILY`, `GAME_DAILY`, `GLOBAL_DAILY` | "Hadiah hari ini sudah mencapai batas" | +| `prize` | Untuk spin: segmen hasil undian. `amount` = hadiah dasar segmen, sebelum event dan batas | Hentikan roda di `prize.entry` | +| `status` | `COMPLETED`, atau `REFUNDED` bila game dinonaktifkan selama dimainkan | Lihat di bawah | + +- **`status: "REFUNDED"`** (`refund_reason: "GAME_DEACTIVATED"`): entry cost + dikembalikan dan tidak ada hadiah. Tampilkan "Game sedang dihentikan, EnakCoin kamu + dikembalikan." +- **`reward_total` 0** bisa terjadi: hadiahnya memang 0 (mis. segmen Zonk), batas harian + sudah habis, atau hasilnya tidak lolos validasi server (skor di atas batas, terlalu + cepat selesai, `outcome` tidak dikenal). Server tidak memberi tahu alasan validasi; + tampilkan hasil apa adanya. +- **Mengirim ulang aman.** Complete untuk session yang sudah selesai mengembalikan + jawaban yang sama, tanpa hadiah dua kali. Tidak perlu `Idempotency-Key`. + +| Penolakan | Arti | Tampilan | +|---|---|---| +| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §5) | +| `304` `data must be …` | `data` bukan JSON atau lebih dari 16 KB | Bug di game | +| `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game | +| `404` | Session tidak ada / milik customer lain | Pesan umum | + +### 4.4 Cek status — `GET /customer/enakgame/sessions/:id` + +Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: + +```json +{ + "id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0, + "started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null +} +``` + +`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Riwayat main customer ada +di `GET /customer/enakgame/sessions?page=1&limit=20` (dipakai aplikasi, bukan game). + +--- + +## 5. Batas waktu dan refund + +| Keadaan | Yang terjadi pada EnakCoin | +|---|---| +| Hasil dikirim sebelum `expires_at` | Entry cost terpakai, hadiah masuk | +| Customer menutup game / game crash, hasil tidak pernah dikirim | Session menjadi `EXPIRED` setelah `expires_at`. **Entry cost tidak dikembalikan** | +| Complete gagal karena error server (`5xx`) dan tidak berhasil sampai `expires_at` | Session direfund otomatis (`refund_reason: "SYSTEM_ERROR"`) dalam ±1 menit setelah `expires_at` | +| Game dinonaktifkan admin saat dimainkan | Session direfund (`GAME_DEACTIVATED`) | + +Karena itu kirim hasil **segera** setelah permainan selesai, sebelum animasi panjang. +Saat customer menekan keluar di tengah permainan, tampilkan konfirmasi "EnakCoin yang +sudah dipakai tidak kembali". + +--- + +## 6. Retry dan jaringan + +| Request | Gagal karena jaringan / `5xx` | Aturan | +|---|---|---| +| Start | Ulangi dengan **`Idempotency-Key` yang sama** | Key baru = potong EnakCoin lagi | +| Complete | Ulangi dengan body yang sama sampai berhasil atau `expires_at` lewat | Aman diulang | +| Token ditolak (`entity` `auth_handler`) | `token_expired` → tunggu `token` → ulangi | Jangan minta customer login dari dalam game | + +Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama +complete diulang. + +--- + +## 7. Spin + +1. Gambar roda dari `prizes` (§4.1): satu segmen per entri, urut, dengan `label` + (atau `amount` bila `label` `null`). +2. Tap Putar → start session (§4.2). +3. Mulai animasi berputar, lalu langsung kirim complete dengan `{}`. +4. Dari response, hentikan roda di segmen `prize.entry`, lalu tampilkan `reward_total`. + +Jangan menentukan segmen sendiri lalu "mencocokkan" dengan server. Bila `prize` tidak +ada di response, hasil tidak bisa ditampilkan sebagai roda; tampilkan `reward_total` +saja. + +--- + +## 8. Checklist + +- [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. +- [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. +- [ ] Biaya main dan label event tampil sebelum main. +- [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry. +- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. +- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani. +- [ ] Spin berhenti di `prize.entry`. +- [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani. +- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar. diff --git a/docs/integration-mobile-customer.md b/docs/integration-mobile-customer.md new file mode 100644 index 0000000..9257448 --- /dev/null +++ b/docs/integration-mobile-customer.md @@ -0,0 +1,790 @@ +# Integrasi Mobile App Customer: EnakPoint, EnakCoin, EnakGame & Voucher + +**Untuk:** tim aplikasi mobile customer · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice). +Tugasmu: membangun fitur loyalitas di aplikasi, yaitu saldo EnakPoint & EnakCoin, +PIN, tukar, transfer, voucher, dan pintu masuk ke game EnakGame. Semuanya memakai API +backend yang sudah jadi dan dijelaskan di dokumen ini. Jangan mengarang endpoint, +field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan +dulu. + +Dokumen ini menggantikan `mobile-customer-enakpoint.md`, `integration-enakpoint.md`, +`api-enakpoint.md`, dan `enakgame-spin.md` untuk sisi aplikasi customer. Game-nya +sendiri (Phaser) dikerjakan tim EnakGame dengan +[`integration-enakgame.md`](./integration-enakgame.md). + +--- + +## 1. Konteks bisnis + +| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | +|---|---|---| +| Didapat dari | Belanja (order lunas), tukar EnakCoin, koreksi admin | Belanja, **hadiah game**, koreksi admin | +| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | +| Bisa dikirim ke customer lain | Ya | Ya | +| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan | + +Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin. + +### Aturan yang wajib dipatuhi di UI + +1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin. +2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan + Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada tarik tunai, dan EnakPoint + tidak bisa dipakai membayar. Jangan membangun layar bayar atau kode bayar. +3. **PIN 6 digit wajib** untuk: tukar EnakCoin, transfer, dan **tukar EnakPoint ke + voucher**. Main game, melihat saldo, dan riwayat tidak butuh PIN. +4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya + nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau + analytics. +5. **Satu akun customer = satu organisasi.** Saldo berlaku di semua outlet organisasi itu. +6. **Waktu memakai WIB.** Tanggal kedaluwarsa berarti saldo masih bisa dipakai sampai + 23:59:59 WIB di tanggal itu. +7. **Hadiah game ditentukan server.** Aplikasi tidak menghitung atau mengirim hadiah. + +--- + +## 2. Koneksi ke API + +- Base URL: `/api/v1` +- Semua endpoint customer: header `Authorization: Bearer ` +- Semua jumlah di request dan response berupa integer. +- Belum ada endpoint refresh token: bila token ditolak (§2.2, `entity` `auth_handler`), + customer login ulang. + +### 2.1 Registrasi customer + +`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional): + +```json +{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" } +``` + +- Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu. +- Bila `organization_id` tidak dikirim dan backend hanya punya satu organisasi, customer + otomatis masuk ke organisasi itu. Bila ada lebih dari satu, registrasi ditolak + ("organization_id is required"), jadi sebaiknya app selalu mengirimnya dari config per + environment/brand. +- `organization_id` yang dikirim harus ada; bila tidak, registrasi ditolak sebelum OTP dikirim. +- Wallet customer baru belum punya baris sampai saldo pertama kali bergerak; + `GET /customer/wallet` tetap menjawab saldo 0. + +### 2.2 Format response + +Sukses: + +```json +{ "success": true, "data": { … }, "errors": null } +``` + +Gagal: + +```json +{ "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] } +``` + +| `errors[0].code` | HTTP | Arti | Yang dilakukan app | +|---|---|---|---| +| `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum | +| `304` | 400 | Ditolak aturan bisnis, **atau token tidak berlaku** bila `entity` = `auth_handler` | Pesan yang ramah per fitur; `cause` berbahasa Inggris, jangan tampilkan mentah. Token: login ulang | +| `404` | 404 | Tidak ditemukan, juga untuk data milik customer lain | Tampilkan "tidak ditemukan" | +| `429` | 429 | Minta OTP terlalu cepat | Hitung mundur sebelum boleh minta lagi | +| `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) | +| `PIN_INVALID` | 400 | PIN salah | §6.5 | +| `PIN_LOCKED` | 423 | PIN terkunci | §6.5 | +| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | §6.5 | +| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" | + +### 2.3 Idempotency-Key + +Endpoint **tukar**, **transfer**, dan **tukar voucher** wajib header `Idempotency-Key` +(string unik, maks. 50 karakter, mis. UUID v4; `X-Idempotency-Key` juga diterima). + +- Buat **satu key baru saat customer menekan tombol konfirmasi**. +- Bila request gagal karena jaringan/timeout, **kirim ulang dengan key yang sama**. + Server mengembalikan hasil pertama dengan `"replayed": true` dan tidak memotong saldo + dua kali. +- Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`). + +--- + +## 3. Layar yang perlu dibuat + +| Layar | Endpoint utama | Butuh PIN | +|---|---|---| +| Beranda wallet | `GET /customer/wallet` | – | +| Riwayat mutasi | `GET /customer/wallet/transactions` | – | +| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – | +| Daftar outlet | `GET /customer/outlets` | – | +| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – | +| 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/*` | – | +| Daftar game + webview game | `GET /customer/enakgame/games` | – | +| Riwayat main | `GET /customer/enakgame/sessions` | – | +| Katalog voucher | `GET /customer/vouchers` | – | +| Tukar voucher | `POST /customer/vouchers/:id/redeem` | Ya | +| Voucher saya | `GET /customer/vouchers/redemptions` | – | +| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – | + +--- + +## 4. Beranda wallet, riwayat, kedaluwarsa + +### 4.1 Beranda — `GET /customer/wallet` + +```json +{ + "point_balance": 12500, + "coin_balance": 8, + "point_value": 1, + "point_discount_value": 12500, + "nearest_expiring": { + "point": { "amount": 150, "date": "2026-12-31" }, + "coin": null + }, + "recent_transactions": [ /* sama dengan item riwayat §4.2, maksimal 5 */ ] +} +``` + +Tampilkan: +- Saldo EnakPoint (`point_balance`) dengan keterangan "setara potongan Rp + {point_discount_value}" (format ribuan Indonesia: `Rp 12.500`). +- Saldo EnakCoin (`coin_balance`). +- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan + kedaluwarsa pada {date}" yang membuka layar §4.3. +- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2. +- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8), Voucher (§9). + +Muat ulang beranda setelah setiap transaksi, saat webview game ditutup, dan saat +menerima push (§5). + +Field `total_points`, `points_history`, `last_updated` di response ini **deprecated**; +jangan dipakai. + +### 4.2 Riwayat — `GET /customer/wallet/transactions` + +Query (semua opsional): + +| Query | Contoh | Keterangan | +|---|---|---| +| `page` | `1` | Mulai dari 1 | +| `limit` | `20` | 1–100, default 20 | +| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin | +| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter | +| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif | + +```json +{ + "data": [ + { + "id": "…", + "currency": "POINT", + "type": "EARN", + "amount": 875, + "balance_after": 12500, + "description": "Belanja #ORD-0123 di Outlet Kemang", + "source": { "type": "ORDER", "id": "…" }, + "outlet_id": "…", + "group_id": null, + "expires_at": "2026-12-31T23:59:59+07:00", + "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], + "created_at": "2026-09-30T12:01:00Z" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } +} +``` + +Aturan tampilan: +- `amount` bertanda: positif tampil hijau dengan `+`, negatif merah dengan `−`. +- Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. +- `description` sudah siap tampil (nama lawan transfer sudah disamarkan, nama game dan + voucher sudah tertulis). Tampilkan apa adanya. +- Mutasi masuk yang punya `expires_at` menampilkan "Berlaku sampai {tanggal}". +- Infinite scroll memakai `pagination.total_pages`. +- Riwayat tidak pernah berubah atau hilang; koreksi muncul sebagai baris baru. + +Label tipe: + +| `type` | Mata uang | Label | Arah | +|---|---|---|---| +| `EARN` | keduanya | Dari belanja | + | +| `EARN_REVERSAL` | keduanya | Dibatalkan (order di-void/refund) | − | +| `EXCHANGE_OUT` | EnakCoin | Ditukar ke EnakPoint | − | +| `EXCHANGE_IN` | EnakPoint | Hasil tukar EnakCoin | + | +| `TRANSFER_OUT` | keduanya | Transfer keluar | − | +| `TRANSFER_IN` | keduanya | Transfer masuk | + | +| `GAME_SPEND` | EnakCoin | Main game | − | +| `GAME_SPEND_REFUND` | EnakCoin | Biaya main dikembalikan | + | +| `GAME_REWARD` | EnakCoin | Hadiah game | + | +| `REWARD_REDEEM` | EnakPoint | Ditukar ke voucher | − | +| `REWARD_REDEEM_REFUND` | EnakPoint | Penukaran voucher dibatalkan | + | +| `EXPIRE` | keduanya | Kedaluwarsa | − | +| `ADJUSTMENT` | keduanya | Koreksi | + / − | +| `MIGRATION` | keduanya | Saldo awal | + | + +Tipe yang tidak dikenal (bila backend menambah tipe baru): tampilkan `description` dan +arah dari tanda `amount`, tanpa label. + +### 4.3 Akan kedaluwarsa — `GET /customer/wallet/expiring` + +```json +{ + "point": [ + { "amount": 150, "date": "2026-10-31" }, + { "amount": 200, "date": "2026-12-31" } + ], + "coin": [] +} +``` + +Daftar per tanggal, paling dekat di atas. Daftar kosong: tampilkan "Tidak ada saldo +yang akan kedaluwarsa". Saldo yang kedaluwarsa hangus tanpa kompensasi. + +### 4.4 Daftar outlet — `GET /customer/outlets` + +Outlet aktif di organisasi customer, tempat saldo EnakPoint & EnakCoin berlaku. Urut +berdasarkan nama. + +```json +[ + { + "id": "…", + "name": "Gokuna Kemang", + "address": "Jl. Kemang Raya 10", + "earns_points": true, + "earns_coins": false + } +] +``` + +- `address` bisa `null`. +- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin. +- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend. + +### 4.5 Riwayat order — `GET /customer/orders` dan `GET /customer/orders/:id` + +Order milik customer yang login di semua outlet organisasinya, terbaru di atas. Order +hanya masuk ke sini bila kasir mengaitkannya ke customer. + +`GET /api/v1/customer/orders?page=1&limit=20` (`limit` 1–100, default 20): + +```json +{ + "data": [ + { + "id": "…", + "order_number": "ORD-0123", + "outlet_id": "…", + "outlet_name": "Gokuna 1", + "order_type": "dine_in", + "status": "completed", + "payment_status": "completed", + "total_amount": 99000, + "item_count": 2, + "is_void": false, + "is_refund": false, + "points_earned": 865, + "coins_earned": 3, + "created_at": "2026-09-30T12:01:00Z" + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } +} +``` + +`GET /api/v1/customer/orders/{id}` mengembalikan field yang sama, ditambah: + +```json +{ + "table_number": "A3", + "subtotal": 90000, + "discount_amount": 0, + "tax_amount": 9000, + "refund_amount": 0, + "items": [ + { + "id": "…", + "product_id": "…", + "product_name": "Kopi Susu", + "variant_name": "Large", + "quantity": 2, + "unit_price": 25000, + "total_price": 50000, + "refund_quantity": 0, + "modifiers": [], + "status": "completed" + }, + { + "id": "…", + "product_id": "…", + "product_name": "Ikan Tude", + "variant_name": null, + "quantity": 1, + "weight": 4.2, + "unit_name": "ons", + "unit_price": 4500, + "total_price": 18900, + "refund_quantity": 0, + "modifiers": [], + "status": "completed" + } + ], + "payments": [ + { "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" } + ] +} +``` + +- Order customer lain atau yang tidak ada → `404`. +- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada. +- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons". +- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" / + "Direfund". + +--- + +## 5. Notifikasi push (FCM) + +### 5.1 Registrasi device + +Setelah login berhasil **dan** setiap kali FCM memberi token baru (`onTokenRefresh`): + +`PUT /api/v1/customer/devices` + +```json +{ "device_id": "", "fcm_token": "", "platform": "android", "app_version": "2.4.0" } +``` + +- `device_id` wajib, stabil untuk satu instalasi (simpan di secure storage). +- `platform`: `android`, `ios`, atau `web`. +- Satu token FCM hanya milik satu customer: bila customer lain login di HP yang sama, + customer sebelumnya tidak lagi menerima notifikasi di HP itu. +- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum + menghapus token login. + +Tanpa registrasi ini, customer tidak menerima push apa pun. + +### 5.2 Tipe push + +Semua nilai di `data` berupa string. + +| `data.type` | Kapan | Isi `data` lain | Aksi saat di-tap | +|---|---|---|---| +| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | Buka riwayat, sorot transaksi itu | +| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo hangus | `currency`, `amount`, `expiry_date` | Buka layar kedaluwarsa (§4.3) | +| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | Buka riwayat | +| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339 UTC) | Buka layar lupa PIN (§6.4) | + +Saat app terbuka dan menerima push wallet, muat ulang beranda. + +--- + +## 6. PIN + +### 6.1 Kapan diminta + +Jangan minta PIN saat registrasi. Minta saat customer **pertama kali** melakukan aksi +yang butuh PIN (tukar, transfer, tukar voucher). Cek dengan: + +`GET /api/v1/customer/pin/status` → `{ "has_pin": false, "locked_until": null, "transfer_blocked_until": null }` + +Bila `has_pin: false`, arahkan ke alur buat PIN, lalu kembali ke aksi semula. + +### 6.2 Buat PIN + +1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. + Response: `{ "purpose": "pin_setup", "otp_token": "…", "expires_at": "…" }`. + OTP dikirim ke WhatsApp customer. +2. Customer memasukkan kode OTP, lalu PIN dua kali. +3. `POST /api/v1/customer/pin` dengan + `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. + Response: status PIN. + +Validasi di app sebelum kirim (server juga memeriksa, jawab `304`): +- Tepat 6 digit angka, dan konfirmasi sama. +- Bukan satu digit berulang (`111111`). +- Bukan berurutan naik/turun (`123456`, `654321`). +- Bukan tanggal lahir customer (`DDMMYY` atau `YYMMDD`). + +Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur. + +### 6.3 Ganti PIN + +`PUT /api/v1/customer/pin` dengan `{ "old_pin": "…", "pin": "…", "confirm_pin": "…" }`. + +### 6.4 Lupa PIN + +1. `POST /customer/pin/otp` dengan `{ "purpose": "pin_reset" }`. +2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`. + +Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; +tukar EnakCoin dan tukar voucher tetap bisa. Beri tahu customer hal ini di layar sukses. + +### 6.5 Menangani error PIN + +Semua endpoint yang menerima `pin` bisa menjawab error PIN. Pada error ini **`data` +tidak `null`**: + +```json +{ "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [ … ] } +``` + +| `data.code` | Field tambahan | Tampilan | +|---|---|---| +| `PIN_NOT_SET` | – | Buka alur buat PIN (§6.2) | +| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa {n} percobaan." Kosongkan input PIN | +| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai {jam}." Tombol "Lupa PIN" | +| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada {waktu}." | + +5 kali salah berturut-turut mengunci PIN 30 menit; selama terkunci PIN yang benar pun +ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app. + +--- + +## 7. Tukar dan transfer + +### 7.1 Tukar EnakCoin → EnakPoint + +1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik): + + `GET /api/v1/customer/wallet/exchange/preview?coins=30` + + ```json + { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } + ``` + + - Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint. Tampilkan "10 EnakCoin = + 3 EnakPoint". + - Bila `valid: false`, tampilkan `reason` sebagai alasan dan nonaktifkan tombol. Jumlah + harus kelipatan `coin_amount`. + - Tampilkan "Kamu akan mendapat {points} EnakPoint". + +2. Konfirmasi (tukar tidak bisa dibatalkan) → minta PIN → + + `POST /api/v1/customer/wallet/exchange` + header `Idempotency-Key` + + ```json + { "coins": 30, "pin": "482913" } + ``` + + ```json + { + "group_id": "…", + "coins": 30, + "points": 9, + "coin_amount": 10, + "point_amount": 3, + "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], + "coin_balance": 5, + "point_balance": 9, + "replayed": false + } + ``` + +3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku + sampai {tanggal}". EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin + asalnya. + +Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan PIN. + +### 7.2 Transfer + +1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah. +2. Cek penerima: + + `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` + + ```json + { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } + ``` + + | Hasil | Tampilan | + |---|---| + | Sukses | "Kirim ke Bu*** Sa*** (08**-****-1234)?" | + | `404` | "Nomor ini tidak terdaftar" | + | `304` | "Tidak bisa mengirim ke nomor ini" (diri sendiri, akun nonaktif) | + +3. Konfirmasi (transfer final, tidak bisa dibatalkan) → minta PIN → + + `POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key` + + ```json + { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } + ``` + + ```json + { + "group_id": "…", + "currency": "POINT", + "amount": 120, + "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, + "lots": [ + { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, + { "amount": 20, "expires_at": null } + ], + "balance": 30, + "replayed": false + } + ``` + +4. Layar sukses: saldo tersisa (`balance`). Bila ada `lots[].expires_at`, tampilkan + "Saldo yang dikirim berlaku sampai {tanggal}" (tanggal kedaluwarsa ikut terbawa ke + penerima). + +Penolakan `304` yang mungkin: transfer dimatikan owner, di bawah minimal, di atas +maksimal per transaksi, melewati batas harian (reset tengah malam WIB), saldo tidak +cukup. Tampilkan pesan umum "Transfer tidak bisa diproses" plus alasan yang sesuai +bila bisa dikenali. Bila kena `TRANSFER_BLOCKED`, ikuti §6.5. + +Penerima mendapat push `WALLET_TRANSFER_IN`. + +--- + +## 8. Game (EnakGame) + +Game dimainkan di **webview** yang memuat `game_url` tiap game. Pembagian tugasnya: +aplikasi menampilkan daftar game, membuka webview, dan memberi token lewat bridge; game +EnakGame sendiri yang memulai session, memotong EnakCoin, mengirim hasil, dan +menampilkan hadiah ([`integration-enakgame.md`](./integration-enakgame.md)). Aplikasi +**tidak** memanggil `POST /customer/enakgame/sessions` atau `…/complete`. + +### 8.1 Daftar game — `GET /customer/enakgame/games` + +```json +[ + { + "id": "8a1f…", + "slug": "spin", + "name": "Spin Harian", + "description": null, + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html", + "version": "1.2.0", + "entry_cost": 5, + "session_ttl_seconds": 600, + "events": [ + { "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" } + ], + "prizes": [ { "entry": 1, "label": "Zonk", "amount": 0 } ] + } +] +``` + +Tampilkan: +- Kartu per game: `thumbnail_url`, `name`, biaya "{entry_cost} EnakCoin". +- Badge event bila `events` tidak kosong: `name` atau `banner_url`, dan "berakhir + {end_at}" (tampilkan dalam WIB). +- Tombol Main nonaktif dengan teks "EnakCoin kurang" bila `coin_balance` (§4.1) lebih + kecil dari `entry_cost`. +- `prizes` hanya dipakai game spin di dalam webview; aplikasi boleh mengabaikannya. + +Game yang dinonaktifkan admin hilang dari daftar ini. Muat ulang daftar setiap kali +layar dibuka. + +### 8.2 Membuka game + +1. Customer menekan Main → buka webview layar penuh dengan `game_url`. +2. Pasang bridge (§8.3) **sebelum** halaman dimuat. +3. Saat game mengirim `ready`, jawab dengan `init`. +4. Saat game mengirim `close`, tutup webview, lalu muat ulang beranda wallet (§4.1). + +Jangan menaruh token di URL `game_url` (query string atau fragment): URL bisa tercatat di +log server game dan riwayat webview. + +### 8.3 Bridge (sisi aplikasi) + +> **Usulan.** Kontrak ini sama dengan [`integration-enakgame.md`](./integration-enakgame.md) +> §2 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai. + +- Game → aplikasi: JavaScript channel webview bernama **`EnakGameHost`**; setiap pesan + berupa JSON string. +- Aplikasi → game: jalankan `window.enakGame.receive('')` di webview. + +| Pesan masuk dari game | Yang dilakukan aplikasi | +|---|---| +| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "/api/v1", "token": "", "game_id": "" }` | +| `{ "type": "token_expired" }` | Login ulang customer (tidak ada refresh token), lalu kirim `{ "type": "token", "token": "" }` | +| `{ "type": "balance_changed", "coin_balance": 15 }` | Perbarui saldo EnakCoin yang ditampilkan aplikasi | +| `{ "type": "close" }` | Tutup webview, muat ulang beranda | + +Abaikan pesan dengan `type` lain. Tombol back Android jangan langsung menutup webview: +tampilkan konfirmasi "Keluar dari game? EnakCoin yang sudah dipakai untuk main tidak +kembali", lalu tutup. Tidak perlu mengirim pesan ke game. + +### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20` + +```json +{ + "data": [ + { + "id": "…", "game_id": "8a1f…", "status": "COMPLETED", "entry_cost": 5, "reward_total": 10, + "started_at": "…", "expires_at": "…", "ended_at": "…", "refund_reason": null + } + ], + "pagination": { "page": 1, "limit": 20, "total_count": 3, "total_pages": 1 } +} +``` + +| `status` | Label | Keterangan | +|---|---|---| +| `STARTED` | Sedang dimainkan | | +| `COMPLETED` | Selesai | "Dapat {reward_total} EnakCoin" | +| `REFUNDED` | Dikembalikan | Entry cost kembali; `refund_reason` `SYSTEM_ERROR` atau `GAME_DEACTIVATED` | +| `EXPIRED` | Tidak selesai | Hasil tidak dikirim sebelum batas waktu; entry cost tidak kembali | + +Nama game diambil dari daftar game (§8.1) lewat `game_id`. Detail satu session: +`GET /customer/enakgame/sessions/:id`. + +--- + +## 9. Voucher (tukar EnakPoint) + +### 9.1 Katalog — `GET /customer/vouchers` + +Voucher yang bisa ditukar sekarang: aktif, dalam masa berlaku, dan masih ada stoknya. + +```json +[ + { + "id": "…", + "name": "Kopi Susu Gratis", + "description": "Berlaku untuk ukuran regular", + "image_url": "https://…/kopi.png", + "voucher_type": "FREE_ITEM", + "face_value": 20000, + "point_cost": 15000, + "max_per_customer": 2, + "valid_until": "2026-12-31T16:59:59Z", + "terms": { "…": "syarat & ketentuan, objek JSON bebas" }, + "available": 120 + } +] +``` + +- `point_cost`: EnakPoint yang dipotong. Tombol Tukar nonaktif bila `point_balance` + kurang. +- `face_value`: nilai voucher dalam rupiah, tampilkan sebagai "senilai Rp 20.000". +- `available`: sisa stok; `null` berarti stok tidak dihitung. Bila 0, tampilkan "Habis". +- `max_per_customer`: batas tukar per customer; `null` = tanpa batas. +- `terms`: objek JSON yang isinya diatur admin. Sepakati bentuknya dengan tim + backoffice; sebelum itu tampilkan `description` saja. + +| `voucher_type` | Label usulan | +|---|---| +| `FIXED_VALUE` | Potongan Rp {face_value} | +| `PERCENTAGE` | Potongan persen | +| `FREE_ITEM` | Gratis item | +| `MERCHANT_BENEFIT` | Benefit merchant | + +### 9.2 Tukar — `POST /customer/vouchers/:id/redeem` + +Konfirmasi ("Tukar {point_cost} EnakPoint dengan {name}? Tidak bisa dibatalkan.") → +minta PIN → kirim dengan header `Idempotency-Key`: + +```json +{ "pin": "482913" } +``` + +```json +{ + "id": "…", + "voucher_id": "…", + "voucher_name": "Kopi Susu Gratis", + "voucher_image_url": "https://…/kopi.png", + "voucher_type": "FREE_ITEM", + "status": "COMPLETED", + "face_value": 20000, + "point_cost": 15000, + "code": "KOPI-7F3C-2291", + "code_expires_at": "2026-12-31T16:59:59Z", + "completed_at": "…", + "created_at": "…", + "point_balance": 2500, + "replayed": false +} +``` + +- Layar sukses: voucher, `code` bila ada (bisa disalin), masa berlaku, dan saldo + EnakPoint baru (`point_balance`). +- `code` bisa `null`: voucher ini tidak memakai kode; tunjukkan layar voucher ke kasir. +- `status: "PENDING"`: voucher sedang diproses penyedia luar (belum ada voucher seperti + ini di katalog, tapi tangani dari sekarang). EnakPoint sudah terpotong; + tampilkan "Voucher sedang diproses" dan cek lagi di Voucher saya (§9.3). Bila akhirnya + `FAILED`, EnakPoint dikembalikan otomatis (mutasi `REWARD_REDEEM_REFUND`). +- Error PIN ditangani sesuai §6.5. + +| Penolakan `304` (`cause`) | Tampilan | +|---|---| +| `not enough EnakPoint` | "EnakPoint kamu kurang." | +| `the voucher is out of stock` | "Voucher sudah habis." Muat ulang katalog | +| `this voucher can be redeemed at most … times per customer` | "Kamu sudah mencapai batas penukaran voucher ini." | +| `the voucher is not available`, `… cannot be redeemed yet`, `… has ended`, `… not available yet` | "Voucher tidak tersedia." Muat ulang katalog | +| `the customer is not active` | "Akun tidak aktif." | +| `this Idempotency-Key was already used to redeem another voucher` | Bug di app: key dipakai ulang | + +### 9.3 Voucher saya — `GET /customer/vouchers/redemptions?page=1&limit=20` + +Daftar penukaran customer, terbaru di atas, dengan bentuk item sama seperti response +§9.2 (tanpa `point_balance` dan `replayed`), dibungkus `data` + `pagination`. + +| `status` | Tampilan | +|---|---| +| `COMPLETED` | Voucher siap dipakai: nama, `code` (bila ada), berlaku sampai `code_expires_at` | +| `PENDING` | "Sedang diproses" | +| `FAILED` | "Gagal, EnakPoint sudah dikembalikan" | + +**Memakai voucher di outlet:** customer menunjukkan layar voucher ke kasir. POS belum +bisa menandai voucher terpakai, jadi aplikasi belum bisa menampilkan status "sudah +dipakai" ([`integration-pos.md`](./integration-pos.md) §5). + +--- + +## 10. Yang sudah dihapus / deprecated + +Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada): + +| Lama | Pengganti | +|---|---| +| `POST /customer/spin` | Game EnakGame di webview (§8) | +| `GET /customer/games`, `GET /customer/ferris-wheel` | `GET /customer/enakgame/games` | +| `coins_used`, `coins_remaining`, `prize_won`, `game_play` di response spin | Tidak ada; hasil game ditampilkan di dalam game | +| `metadata.coin_cost` pada data game | `entry_cost` | +| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | +| `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` | +| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar | +| `accepts_point_payment` di `GET /customer/outlets` | – | +| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – | +| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi | + +Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru): + +| Lama | Pengganti | +|---|---| +| `GET /customer/points` | `GET /customer/wallet` → `point_balance` | +| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | + +--- + +## 11. Checklist selesai + +- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat. +- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, dan label semua tipe di §4.2, termasuk tipe game dan voucher. +- [ ] Layar saldo akan kedaluwarsa. +- [ ] Registrasi device FCM setelah login dan saat token berganti; unregister saat logout. +- [ ] Penanganan tap untuk keempat tipe push. +- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP. +- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN (tukar, transfer, voucher). +- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama. +- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama. +- [ ] Daftar game dengan biaya, badge event, dan tombol nonaktif bila EnakCoin kurang. +- [ ] Webview game dengan bridge §8.3; token tidak pernah di URL; beranda dimuat ulang saat game ditutup. +- [ ] Riwayat main dengan label status. +- [ ] Katalog voucher, tukar dengan PIN dan `Idempotency-Key`, status `PENDING` ditangani. +- [ ] Voucher saya dengan kode yang bisa disalin. +- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat). +- [ ] Tidak ada pemakaian endpoint atau field di §10. +- [ ] PIN dan token tidak pernah disimpan sembarangan, di-log, atau dikirim ke analytics. diff --git a/docs/integration-pos.md b/docs/integration-pos.md new file mode 100644 index 0000000..1ba3105 --- /dev/null +++ b/docs/integration-pos.md @@ -0,0 +1,142 @@ +# Integrasi POS: EnakPoint, EnakCoin & Voucher + +**Untuk:** tim aplikasi POS (kasir) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 + +Kamu mengerjakan aplikasi **POS** yang dipakai kasir di outlet. Dokumen ini menjelaskan +bagian program loyalitas yang menyentuh POS: mengaitkan customer ke order, menampilkan +EnakPoint dan EnakCoin yang didapat, void/refund, dan voucher. Jangan mengarang +endpoint, field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, +tanyakan ke tim backend. + +Dokumen ini menggantikan bagian POS di `integration-enakpoint.md` dan `api-enakpoint.md`. + +--- + +## 1. Yang perlu diketahui kasir + +| | EnakPoint (`POINT`) | EnakCoin (`COIN`) | +|---|---|---| +| Didapat dari | Belanja (order lunas), hasil tukar EnakCoin, koreksi admin | Belanja, hadiah game, koreksi admin | +| Dipakai untuk | **Ditukar ke voucher** di aplikasi customer | Main game, ditukar ke EnakPoint | +| Bisa membayar order | **Tidak** | **Tidak** | + +- **EnakPoint bukan alat bayar.** Tidak ada payment method EnakPoint di POS, dan saldo + tidak bisa dicairkan. Customer menukar EnakPoint ke voucher di aplikasinya sendiri. +- Saldo berlaku di **semua outlet** organisasi. Berapa yang didapat per order diatur + **per outlet** oleh owner di backoffice. +- Semua jumlah bilangan bulat. + +--- + +## 2. Mengaitkan customer ke order + +Earning hanya terjadi bila order dikaitkan ke customer terdaftar. Order tanpa customer, +dengan **customer default (walk-in)**, atau dengan customer nonaktif tidak mendapat +apa-apa. + +1. **Cari customer:** `GET /api/v1/customers?search=0812…&page=1&limit=20` + (cocok dengan nama, email, atau nomor HP). Abaikan customer dengan `is_default: true`. +2. **Kaitkan** dengan salah satu cara: + - saat membuat order: `POST /api/v1/orders` dengan `"customer_id": "…"`, atau + - setelah order dibuat: `PUT /api/v1/orders/:id/customer` dengan + `{ "customer_id": "…" }`. + +**Kaitkan sebelum order lunas.** Earning dihitung saat order menjadi lunas penuh. +Customer yang dikaitkan setelah lunas tetap mendapat earning lewat job susulan yang +berjalan tiap 30 menit untuk order lunas 72 jam terakhir, tapi tidak langsung, sehingga +struk akan menulis 0. + +--- + +## 3. Earning: yang didapat dari order + +Earning berjalan otomatis di backend saat order lunas lewat jalur pembayaran mana pun +(`POST /payments`, update order, split bill). POS tidak memanggil apa-apa. + +- **Basis** = `subtotal − discount_amount`, **sebelum pajak** dan biaya lain. +- Rumus per outlet (diatur owner): mode `PER_AMOUNT` + `floor(basis ÷ earn_per_amount) × earn_value`, atau mode `PERCENTAGE` + `floor(basis × earn_percent ÷ 100)`, dengan minimal belanja dan batas per order. +- Contoh: basis Rp 87.500, outlet memberi 1 EnakPoint per Rp 100 dan 1 EnakCoin per + Rp 25.000 → **875 EnakPoint** dan **3 EnakCoin**. + +Response order (`GET /api/v1/orders/:id` dan response order lainnya) membawa: + +```json +{ "points_earned": 875, "coins_earned": 3 } +``` + +Keduanya 0 bila order tidak mendapat apa-apa. **Cetak di struk**, mis. "Kamu mendapat +875 EnakPoint & 3 EnakCoin". Ambil nilainya setelah pembayaran terakhir berhasil; bila +masih 0 padahal customer sudah dikaitkan, earning akan menyusul (§2). + +--- + +## 4. Void dan refund + +Tidak ada langkah tambahan di POS. Saat order di-void atau direfund, backend menarik +kembali yang didapat dari order itu (mutasi `EARN_REVERSAL` di riwayat customer): + +| Kejadian | Yang ditarik | +|---|---| +| Void | Semua EnakPoint dan EnakCoin dari order itu | +| Refund (sebagian atau penuh) | `floor(earned × total_refund ÷ basis)`, tidak pernah lebih dari yang didapat; refund berikutnya hanya menarik sisanya | + +Bila saldo customer sudah terpakai, yang ditarik sebanyak yang ada. **Refund tidak +pernah diblokir** karena ini. + +--- + +## 5. Voucher dari EnakPoint + +Customer menukar EnakPoint ke voucher di aplikasi customer. Voucher yang didapat tampil +di menu "Voucher saya" di aplikasi itu, dengan nama, nilai (`face_value`), jenis, dan +bila ada, **kode** serta tanggal berlakunya. + +> **Belum tersedia:** POS belum punya endpoint untuk **mengecek** atau **menandai +> voucher sudah dipakai**. Ini pekerjaan lanjutan di backend. + +Sampai endpoint itu ada: + +1. Kasir melihat voucher di layar aplikasi customer (nama, nilai, kode, masa berlaku). +2. Kasir memasukkan potongannya sebagai **diskon biasa** di order, sesuai jenisnya: + + | `voucher_type` | Cara memasukkan | + |---|---| + | `FIXED_VALUE` | Diskon nominal sebesar `face_value` | + | `PERCENTAGE` | Diskon persen sesuai syarat voucher | + | `FREE_ITEM` | Item gratis sesuai syarat voucher | + | `MERCHANT_BENEFIT` | Sesuai syarat voucher | + +3. Karena backend belum mencatat voucher terpakai, outlet perlu mencatat kode yang + sudah dipakai secara manual supaya voucher yang sama tidak dipakai dua kali. + +Diskon dari voucher mengurangi basis earning seperti diskon lain (§3). + +--- + +## 6. Yang sudah dihapus + +Bayar dengan EnakPoint dihapus pada 7 Okt 2026. Jangan dipanggil atau ditampilkan lagi; +tidak ada penggantinya. + +| Dihapus | Catatan | +|---|---| +| Payment method tipe `point` ("EnakPoint") | Tidak ada di daftar payment method | +| Field `points` dan `payment_code` di `POST /payments` | `amount` wajib seperti pembayaran lain | +| `GET /orders/:id/point-payment/preview` | – | +| Kode bayar dari aplikasi customer | – | +| `points_used`, `point_value` di response pembayaran | – | +| `point_amount`, `points_used`, `total_with_points`, `counts_as_cash_in` di laporan payment method | `summary.total_amount` adalah total semua method | + +--- + +## 7. Checklist + +- [ ] Kasir bisa mencari dan mengaitkan customer ke order sebelum pembayaran. +- [ ] Customer default (walk-in) tidak ditawarkan sebagai pemilik earning. +- [ ] Struk mencetak `points_earned` dan `coins_earned`. +- [ ] Tidak ada payment method EnakPoint dan tidak ada field pembayaran EnakPoint di + request. +- [ ] Void/refund tidak menampilkan langkah tambahan untuk EnakPoint/EnakCoin. +- [ ] SOP outlet untuk voucher manual (§5) sudah disepakati sampai endpoint POS tersedia. diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md index c6e374f..5bf9355 100644 --- a/docs/prd-point-coin.md +++ b/docs/prd-point-coin.md @@ -388,7 +388,8 @@ beredar. Karena itu: > **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). +> [RFC EnakGame](rfc-enakgame.md) §14, [integration-backoffice.md](integration-backoffice.md) §8.4, +> dan [integration-enakgame.md](integration-enakgame.md). > EnakCoin tetap mata uang untuk bermain game. - **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin