From c43baa53e1d18ac47531af521540f03785601299 Mon Sep 17 00:00:00 2001 From: efrilm Date: Fri, 9 Oct 2026 22:59:58 +0700 Subject: [PATCH] docs: EnakGame game list, API list and in-game login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit integration-enakgame.md was only the flow of a play. It now also has: - §2 the games: Spin is the only one; what each reward_type needs from the client at complete; what to agree on to register a new game. - §3 the endpoints the client calls, in one table. - §5 tokens: with no token, or one the backend refuses, the game shows a customer login (POST /customer-auth/login) and repeats the request once. Standalone mode for a browser without the app finds its game_id by slug. The login errors (304, 429 with locked_until), the attempt limit and the phone formats accepted. A JS helper for all of it. The bridge loses token_expired and token: the game logs the customer in itself. Sections are renumbered. integration-mobile-customer.md: customer phone numbers are 62… (§2), a login section with its errors and the change from 900 to 304 (§2.4), 62… examples, and init carrying the access token. integration-backoffice.md: example responses are the data field, so a list is data.data in a raw response. Co-Authored-By: Claude Opus 5.5 --- docs/integration-backoffice.md | 7 +- docs/integration-enakgame.md | 388 ++++++++++++++++++++++++---- docs/integration-mobile-customer.md | 50 +++- 3 files changed, 384 insertions(+), 61 deletions(-) diff --git a/docs/integration-backoffice.md b/docs/integration-backoffice.md index 608ca03..c5fabbf 100644 --- a/docs/integration-backoffice.md +++ b/docs/integration-backoffice.md @@ -29,9 +29,10 @@ data organisasi lain dijawab `404`. 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). +`{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Contoh response di +dokumen ini adalah isi `data`. Daftar berhalaman isinya +`{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }`, +jadi pada response mentah array-nya ada di `data.data`; `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 diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md index 5e611fb..3a64c40 100644 --- a/docs/integration-enakgame.md +++ b/docs/integration-enakgame.md @@ -1,6 +1,6 @@ # Integrasi EnakGame: Game Client (Phaser) -**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026 +**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 9 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 @@ -12,7 +12,7 @@ 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) | +| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) | | 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 | @@ -29,15 +29,90 @@ Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan 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`, `sessionStorage`, cookie, log, atau - analytics. (`session_id` boleh disimpan di `sessionStorage`, §4.4.) -5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. -6. **Main game tidak butuh PIN.** +4. **Token customer adalah rahasia.** Hanya didapat dari bridge atau dari login di + game, disimpan di memori, tidak pernah ditaruh di URL, `localStorage`, + `sessionStorage`, cookie, log, atau analytics. Begitu juga password customer. + (`session_id` boleh disimpan di `sessionStorage`, §6.4.) +5. **Tanpa token, jangan panggil API customer.** Tampilkan layar login akun customer + (§5.2). +6. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. +7. **Main game tidak butuh PIN.** --- -## 2. Bridge dengan aplikasi customer +## 2. Daftar game + +Game bukan daftar tetap di kode. Setiap game adalah data yang dibuat admin di +backoffice per organisasi (nama, `slug`, `game_url`, biaya main, aturan hadiah) dan +dibaca game lewat `GET /customer/enakgame/games` (§6.1). Satu `game_url` = satu game. + +### 2.1 Game yang ada sekarang + +| Game | `slug` | Jenis hadiah (`reward_type`) | Body complete | Status | +|---|---|---|---|---| +| Spin Harian | `spin` | `PROBABILITY`: server mengundi segmen roda | `{}` | Backend siap. Panduan: §9 | + +Runner, Memory, dan Puzzle hanya disebut sebagai contoh di PRD; belum ada spesifikasi, +`slug`, atau aturan hadiahnya. Game baru harus didaftarkan dulu (§2.3) sebelum bisa +dimainkan. + +### 2.2 Jenis hadiah menentukan apa yang dikirim game + +API customer tidak memberi tahu `reward_type`. Jenisnya disepakati saat game +didaftarkan (§2.3), dan game dibangun untuk jenis itu. + +| `reward_type` | Hadiah dihitung dari | Body complete | Bila field-nya tidak dikirim | +|---|---|---|---| +| `FIXED` | Jumlah tetap per main | `{}` | – | +| `SCORE_BASED` | Rentang skor yang diatur admin | `{ "score": 800 }` | Hadiah 0 | +| `OUTCOME_BASED` | Hasil main, mis. `PERFECT`, `GOOD`, `FAIL` | `{ "outcome": "PERFECT" }` | Hadiah 0. `outcome` yang tidak terdaftar juga 0 | +| `PROBABILITY` | Undian server; peluang tidak pernah dikirim ke game | `{}` | – | + +Admin bisa mengubah besar hadiah kapan saja tanpa build game baru. Supaya game tetap +benar bila admin juga mengganti jenisnya, **kirim semua hasil yang dimiliki game**: +game berbasis skor selalu mengirim `score`, game berbasis hasil selalu mengirim +`outcome`. Field yang tidak dipakai jenis hadiah aktif diabaikan. + +### 2.3 Mendaftarkan game baru + +Sepakati dengan tim backoffice, lalu admin membuatnya +([`integration-backoffice.md`](./integration-backoffice.md) §8): + +| Yang disepakati | Contoh | Catatan | +|---|---|---| +| `slug` | `runner` | Huruf kecil, angka, `-`; unik per organisasi. Game memakainya untuk memeriksa dirinya (§6.1) | +| `game_url` | `https://…/runner/index.html` | URL build game; dibuka aplikasi di webview | +| `version` | `1.0.0` | Versi build | +| `reward_type` | `SCORE_BASED` | §2.2 | +| Daftar `outcome` | `WIN`, `LOSE` | Hanya game `OUTCOME_BASED`; harus sama persis (huruf besar/kecil) | +| `result_rules` | `max_score`, `min_duration_seconds`, `max_score_per_second` | Hasil di luar batas ini mendapat hadiah 0 tanpa pemberitahuan (§6.3). Isi dengan skor dan durasi wajar game-mu | +| `session_ttl_seconds` | `600` | Batas waktu satu main. Harus lebih lama dari durasi main terpanjang, ditambah jeda jaringan | +| `entry_cost` | `5` | Ditentukan bisnis, minimal 1 | + +--- + +## 3. Daftar API + +Semua endpoint diawali `api_base_url` (dari `init`, §4, atau dari config build pada +mode mandiri, §5.4), mis. `https://api.example.com/api/v1`. Selain login, semua wajib +memakai header `Authorization: Bearer `. Request ber-body memakai +`Content-Type: application/json`. + +| # | Endpoint | Header tambahan | Body | Dipakai saat | Detail | +|---|---|---|---|---|---| +| 0 | `POST /customer-auth/login` | Tanpa `Authorization` | `{ "phone_number", "password" }` | Tidak ada token, atau token ditolak | §5.3 | +| 1 | `GET /customer/enakgame/games` | – | – | Setelah ada token: biaya main, event, segmen roda | §6.1 | +| 2 | `POST /customer/enakgame/sessions` | `Idempotency-Key` (wajib) | `{ "game_id" }` | Customer menekan Main; EnakCoin dipotong | §6.2 | +| 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 | +| 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 | +| 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 | + +Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN +adalah tugas aplikasi customer. + +--- + +## 4. 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 @@ -53,9 +128,7 @@ Semua pesan berupa JSON string dengan field `type`. | 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` | +| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready`. `token` boleh kosong bila customer belum login | | game → app | `balance_changed` | `coin_balance` | Setelah start dan complete berhasil | | game → app | `close` | – | Customer keluar dari game | @@ -65,37 +138,216 @@ Contoh `init`: { "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. +Di dalam aplikasi, jangan memanggil API apa pun sebelum `init` diterima. Token yang +kosong atau ditolak tidak dikembalikan ke aplikasi: game sendiri yang meminta customer +login (§5.2). Tanpa aplikasi (browser biasa), game berjalan dalam mode mandiri (§5.4). --- -## 3. Koneksi ke API +## 5. Koneksi ke API dan token + +### 5.1 Bentuk response dan error -- 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. +- Contoh response di dokumen ini adalah isi `data`, kecuali yang menampilkan amplop + lengkap (§6.4). | `errors[0].code` | HTTP | Arti | Yang dilakukan game | |---|---|---|---| -| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum | +| `304` dengan `entity` `auth_handler` | 400 | **Token tidak ada atau tidak berlaku** | Layar login (§5.2) | | `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint | +| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum | | `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi | -| `900` | 500 | Error server | Retry (§6) | +| `900` | 500 | Error server | Retry (§8) | -**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. +Backend **tidak** memakai HTTP 401. Token yang ditolak dijawab HTTP 400 dengan code +`304`, sama seperti penolakan bisnis; bedakan lewat `entity`: `auth_handler` untuk token, +`enakgame_service` untuk aturan EnakGame. + +### 5.2 Token tidak ada atau ditolak: customer login di game + +Token didapat dari `init` (game dibuka dari aplikasi) atau dari login di game. Setiap +kali tidak ada token yang berlaku, game menampilkan **layar login akun customer** +(§5.3). + +| Keadaan | Cara mengenali | Yang dilakukan game | +|---|---|---| +| `init` datang dengan token | `token` terisi | Pakai token itu, tanpa layar login | +| `init` datang tanpa token | `token` kosong, `null`, atau tidak ada | Layar login | +| Dibuka tanpa aplikasi (browser biasa) | `window.EnakGameHost` tidak ada | Mode mandiri (§5.4), dimulai dari layar login | +| `init` tidak datang | Tidak ada `init` 5 detik setelah `ready` | Kirim `ready` sekali lagi. Masih tidak datang dalam 5 detik: mode mandiri (§5.4) | +| Token ditolak backend | HTTP 400, `code` `304`, `entity` `auth_handler` | Layar login dengan pesan "Sesi kamu berakhir, silakan login lagi". Setelah login berhasil, ulangi request yang sama **satu kali** (body dan `Idempotency-Key` sama) | +| Token hasil login langsung ditolak lagi | Penolakan `auth_handler` kedua untuk request yang sama | Berhenti: "Login bermasalah, coba buka ulang game" dengan tombol Keluar. Jangan menampilkan login berulang-ulang | +| Customer login dengan akun lain | `GET /sessions/:id` untuk session tersimpan menjawab `404` | Hapus `session_id` dari `sessionStorage`, tampilkan layar awal (§6.4) | + +Token ditolak sebelum apa pun diproses: start yang ditolak tidak memotong EnakCoin, dan +complete yang ditolak tidak menyelesaikan session. Jadi aman mengulang request yang +sama setelah login. Timer `expires_at` tetap berjalan selama customer login; complete +setelah `expires_at` ditolak dan entry cost **tidak** dikembalikan (§7). + +Token hasil login di game tidak dikirim ke aplikasi; aplikasi mengurus tokennya +sendiri. + +`cause` dari `auth_handler` hanya untuk debugging, jangan dicocokkan di kode: + +| `cause` | Penyebab | +|---|---| +| `Authorization header is required` | Header tidak dikirim. Bug di game: memanggil API sebelum ada token | +| `Invalid authorization header format` | Header bukan `Bearer ` | +| `Invalid token: …` | Token kedaluwarsa, rusak, atau dari environment lain (staging vs. produksi) | +| `Invalid token type` | Yang dipakai refresh token, bukan access token | +| `Token is not valid`, `Customer ID not found in token`, `Phone number not found in token` | Token bukan token customer yang sah | + +### 5.3 Layar login — `POST /customer-auth/login` + +Form berisi nomor HP dan password akun customer, sama dengan akun di aplikasi. Endpoint +ini tidak memakai header `Authorization`. + +```json +{ "phone_number": "6281234561234", "password": "rahasia123" } +``` + +Response lengkap, termasuk amplopnya. Token ada di **`data.data.access_token`**: + +```json +{ + "success": true, + "data": { + "status": "SUCCESS", + "message": "Login successful.", + "data": { + "access_token": "eyJ…", + "refresh_token": "eyJ…", + "user": { "id": "…", "name": "Budi", "phone_number": "6281234561234", "birth_date": "2000-01-31" } + } + }, + "errors": null +} +``` + +- Pakai `access_token` saja. `refresh_token` ditolak endpoint customer dan belum ada + endpoint refresh; abaikan dan jangan disimpan. +- Token hanya di memori (§1). Halaman dimuat ulang berarti login lagi, kecuali di dalam + aplikasi yang mengirim token lewat `init`. +- Password hanya dipegang selama request login: jangan disimpan, di-log, atau dikirim + ke analytics. +- `user.name` boleh ditampilkan, mis. "Main sebagai Budi", dengan tombol "Ganti akun" + yang menghapus token dari memori lalu menampilkan login lagi. +- Nomor HP boleh ditulis `0812…`, `+62 812…`, `62812…`, atau `812…` (spasi dan `-` + boleh). Backend mengubahnya menjadi format baku **`62812…`**, dan format itu juga yang + dikembalikan di `user.phone_number`. Hanya nomor HP Indonesia (`628…`) yang diterima; + selain itu ditolak `invalid phone number format`. +- Registrasi tidak ada di game. Tampilkan "Belum punya akun atau pendaftaran belum + selesai? Lanjutkan di aplikasi." + +| `code` | `entity` | HTTP | Arti (`cause`) | Tampilan | +|---|---|---|---|---| +| `304` | `customer_auth_service` | 400 | Nomor HP tidak terdaftar atau password salah (`invalid phone number or password`), atau pendaftaran belum selesai (`customer not properly registered`) | **"Nomor HP atau password salah."** | +| `304` | `request` | 400 | Format salah: `invalid phone number format`, `phone number is required`, `password is required` | "Periksa nomor HP dan password." Cek juga di game sebelum mengirim | +| `303` | `request` | 400 | Body tidak lengkap | Bug di game | +| `429` | `customer_auth_service` | 429 | Terlalu banyak percobaan login untuk nomor ini; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." Nonaktifkan tombol Masuk sampai `locked_until` | +| `900` | – | 500 | Error server | "Gagal login, coba lagi." | + +Login yang gagal jangan diulang otomatis. Setiap nomor HP hanya boleh mencoba login +5 kali dalam 15 menit, berhasil maupun gagal; login yang berhasil memulai hitungan +dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga bila +password-nya benar. Batas ini berlaku untuk nomor HP, bukan perangkat, dan sama untuk +aplikasi customer. + +### 5.4 Mode mandiri (tanpa aplikasi) + +Dipakai saat game dibuka di browser biasa, atau saat aplikasi tidak mengirim `init` +(§5.2). Juga dipakai untuk development dengan config staging. + +1. `api_base_url` diambil dari config build per environment (staging, produksi), bukan + dari URL atau input customer. +2. Tampilkan layar login (§5.3). +3. Panggil `GET /customer/enakgame/games` dan ambil game dengan `slug` milik build ini; + `id`-nya menjadi `game_id`. Tidak ada → "Game tidak tersedia untuk akun ini." +4. Lanjutkan seperti biasa: pemulihan session (§6.4), lalu layar awal. + +Tanpa aplikasi tidak ada bridge: `balance_changed` dan `close` tidak dikirim, dan tombol +Keluar kembali ke layar awal game. + +### 5.5 Contoh helper API + +Helper ini menangani token dari `init`, layar login, dan token yang ditolak: + +```js +let apiBaseUrl = CONFIG.apiBaseUrl; // config build per environment; init menimpanya +let gameId = null; // dari init; mode mandiri: dicari lewat slug (§5.4) +let token = null; // hanya di memori +let loginWaiters = []; // request yang menunggu customer login + +const hasHost = () => typeof window.EnakGameHost !== 'undefined'; +const toHost = (msg) => hasHost() && window.EnakGameHost.postMessage(JSON.stringify(msg)); + +window.enakGame = { + receive(raw) { + const msg = JSON.parse(raw); + if (msg.type !== 'init') return; + apiBaseUrl = msg.api_base_url; + gameId = msg.game_id; + token = msg.token || null; // kosong → layar login saat request pertama + onInit(); // pemulihan session (§6.4), lalu layar awal + }, +}; + +// Menampilkan layar login; selesai setelah customer berhasil login. +function waitForLogin(message) { + return new Promise((resolve) => { + if (loginWaiters.length === 0) showLoginScreen(message); + loginWaiters.push(resolve); + }); +} + +// Dipanggil tombol Masuk. Error dilempar ke layar login dan ditampilkan sesuai §5.3. +async function login(phoneNumber, password) { + const res = await fetch(apiBaseUrl + '/customer-auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone_number: phoneNumber, password }), + }); + const json = await res.json().catch(() => null); + if (!json?.success) throw json?.errors?.[0] ?? { code: String(res.status) }; + token = json.data.data.access_token; + hideLoginScreen(); + loginWaiters.splice(0).forEach((resolve) => resolve()); +} + +// Mengembalikan isi `data`, atau melempar errors[0]. Error jaringan ikut dilempar, +// lalu ditangani pemanggil sesuai §8. +async function api(method, path, { body, idempotencyKey } = {}) { + for (let attempt = 0; ; attempt++) { + if (!token) await waitForLogin(attempt === 0 ? null : 'Sesi kamu berakhir, silakan login lagi.'); + const headers = { Authorization: `Bearer ${token}` }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; + const res = await fetch(apiBaseUrl + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const json = await res.json().catch(() => null); + if (json?.success) return json.data; + const err = json?.errors?.[0] ?? { code: String(res.status) }; + if (err.entity === 'auth_handler' && attempt === 0) { + token = null; // login lagi, lalu ulangi request yang sama sekali + continue; + } + throw err; + } +} +``` --- -## 4. Alur satu kali main +## 6. Alur satu kali main ``` -init ─► cek session yang masih berjalan (§4.4) +init ─► token ada? tidak: login (§5.2) ─► cek session yang masih berjalan (§6.4) ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) ─► permainan berjalan (batas waktu: expires_at) @@ -103,10 +355,10 @@ init ─► cek session yang masih berjalan (§4.4) ─► tampilkan hadiah ─► main lagi atau close ``` -### 4.1 Data game — `GET /customer/enakgame/games` +### 6.1 Data game — `GET /customer/enakgame/games` Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama dengan -`game_id` dari `init`. +`game_id` dari `init`; pada mode mandiri, yang `slug`-nya milik build ini (§5.4). ```json [ @@ -140,11 +392,15 @@ Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama den - `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`. +- `slug` bukan yang dibangun untuk build ini (mis. build spin menerima game `runner`) + → `game_url` salah dipasang admin. Tampilkan "Game sedang tidak tersedia", `close`, + dan laporkan ke tim backoffice. -### 4.2 Mulai — `POST /customer/enakgame/sessions` +### 6.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. +customer menekan Main; pakai key yang sama bila request diulang karena jaringan atau +karena customer login ulang (§5.2). ```json { "game_id": "8a1f…" } @@ -177,9 +433,10 @@ customer menekan Main; pakai key yang sama bila request diulang karena jaringan. | `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` +### 6.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete` -Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja: +Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja, +sesuai jenis hadiah game (§2.2): | Field | Tipe | Untuk | |---|---|---| @@ -188,8 +445,8 @@ Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saj | `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. +`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game +(§2.3). ```json { @@ -216,20 +473,20 @@ sepakati daftarnya dengan tim backoffice. 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. + sudah habis, field yang dibutuhkan jenis hadiahnya tidak dikirim (§2.2), 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` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §7) | | `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 Pemulihan setelah reload +### 6.4 Pemulihan setelah reload Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash) saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi @@ -249,9 +506,32 @@ session-nya. Dua endpoint dipakai: **Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1` -Bentuk item sama dengan di atas, dibungkus `data` + `pagination`, terbaru di atas. -Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, -`EXPIRED`), `page`, `limit`. `status` atau `game_id` yang tidak valid ditolak `304`. +Response lengkap, termasuk amplopnya. Daftar session ada di **`data.data`**, terbaru di +atas: + +```json +{ + "success": true, + "data": { + "data": [ + { + "id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0, + "started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null + } + ], + "pagination": { "page": 1, "limit": 1, "total_count": 1, "total_pages": 1 } + }, + "errors": null +} +``` + +Tidak ada session yang cocok: `data.data` berupa array kosong `[]` (tidak pernah +`null`). Bentuk lain berarti error, bukan "tidak ada session". Semua query opsional: +`game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, `EXPIRED`), `page`, `limit`. +`status` atau `game_id` yang tidak valid ditolak `304`. + +Bila pengecekan ini gagal (jaringan, `5xx`), **jangan** menganggap tidak ada session: +customer bisa terpotong EnakCoin dua kali. Tampilkan "Coba lagi" sampai berhasil. **Alurnya, setiap kali menerima `init`:** @@ -265,23 +545,25 @@ Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, | Keadaan | Yang dilakukan game | |---|---| -| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete | +| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete. Complete setelah `expires_at` ditolak, jadi **akhiri ronde otomatis dan kirim skor saat itu** beberapa detik sebelum `expires_at` (mis. 5 detik, untuk jeda jaringan dan selisih jam perangkat) | | `STARTED`, `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru | | `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}`): server mengembalikan jawaban yang sama persis, termasuk `prize`, tanpa hadiah dobel. Tampilkan hasilnya | | `REFUNDED` | "EnakCoin kamu dikembalikan." | | `EXPIRED` | "Waktu bermain habis." | +| `404` untuk `session_id` tersimpan | Session milik akun lain (customer berganti akun). Hapus `session_id`, lanjut seperti tidak ada session | | Tidak ada session | Tampilkan layar awal seperti biasa | Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game. --- -## 5. Batas waktu dan refund +## 7. 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 tertahan karena customer harus login ulang sampai `expires_at` lewat | Sama dengan di atas: `EXPIRED`, **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`) | @@ -291,24 +573,25 @@ sudah dipakai tidak kembali". --- -## 6. Retry dan jaringan +## 8. 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 | +| Token ditolak (`entity` `auth_handler`) | Bukan error jaringan; layar login (§5.2) | Setelah login, ulangi sekali | +| Login gagal | Tampilkan pesan (§5.3) | Jangan diulang otomatis | Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama complete diulang. --- -## 7. Spin +## 9. Spin -1. Gambar roda dari `prizes` (§4.1): satu segmen per entri, urut, dengan `label` +1. Gambar roda dari `prizes` (§6.1): satu segmen per entri, urut, dengan `label` (atau `amount` bila `label` `null`). -2. Tap Putar → start session (§4.2). +2. Tap Putar → start session (§6.2). 3. Mulai animasi berputar, lalu langsung kirim complete dengan `{}`. 4. Dari response, hentikan roda di segmen `prize.entry`, lalu tampilkan `reward_total`. @@ -318,11 +601,16 @@ saja. --- -## 8. Checklist +## 10. Checklist -- [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. +- [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1). +- [ ] Body complete sesuai jenis hadiah game (§2.2). +- [ ] Bridge sesuai kontrak §4 yang sudah disepakati dengan tim aplikasi. - [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. -- [ ] Pemulihan setelah reload (§4.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang. +- [ ] Tanpa token tidak ada request; token kosong atau ditolak → layar login → ulangi request sekali (§5.2). +- [ ] Login memakai `access_token`; password tidak disimpan; nomor HP/password salah tampil sebagai pesan yang jelas (§5.3). +- [ ] Mode mandiri (§5.4): dibuka di browser atau `init` tidak datang → login, `game_id` dicari lewat `slug`. +- [ ] Pemulihan setelah reload (§6.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang. - [ ] 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. diff --git a/docs/integration-mobile-customer.md b/docs/integration-mobile-customer.md index e1ee629..46c9c3f 100644 --- a/docs/integration-mobile-customer.md +++ b/docs/integration-mobile-customer.md @@ -52,13 +52,19 @@ Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin. - Semua jumlah di request dan response berupa integer. - Belum ada endpoint refresh token: bila token ditolak (§2.2, `entity` `auth_handler`), customer login ulang. +- **Nomor HP customer selalu disimpan dan dikembalikan dalam format `62…`** (mis. + `6281234561234`). Di request (registrasi, login, kirim ulang OTP, cek nomor, penerima + transfer) nomor boleh ditulis `0812…`, `+62 812…`, `62812…`, atau `812…`; backend + mengubahnya ke `62…`. Hanya nomor HP Indonesia (`628…`) yang diterima; selain itu + ditolak `304` `invalid phone number format`. Pengecualian: nomor penerima transfer + yang disamarkan tetap ditampilkan dalam format lokal, `08**-****-1234` (§7.2). ### 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" } +{ "phone_number": "6281234561234", "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. @@ -78,6 +84,10 @@ Sukses: { "success": true, "data": { … }, "errors": null } ``` +Semua contoh response di dokumen ini adalah isi `data`. Untuk daftar berhalaman, isi +itu sendiri berbentuk `{ "data": [ … ], "pagination": { … } }`, jadi array-nya ada di +`data.data` pada response mentah. + Gagal: ```json @@ -89,7 +99,7 @@ Gagal: | `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 | +| `429` | 429 | Minta OTP terlalu cepat, atau terlalu banyak percobaan login (§2.4) | 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 | @@ -107,6 +117,25 @@ Endpoint **tukar**, **transfer**, dan **tukar voucher** wajib header `Idempotenc dua kali. - Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`). +### 2.4 Login — `POST /api/v1/customer-auth/login` + +`{ "phone_number": "…", "password": "…" }` → token di `data.data.access_token`. + +| `errors[0].code` | HTTP | Arti | Tampilan | +|---|---|---|---| +| `304`, `entity` `customer_auth_service` | 400 | Nomor HP tidak terdaftar, password salah, atau pendaftaran belum selesai | "Nomor HP atau password salah." | +| `304`, `entity` `request` | 400 | Format nomor HP salah, field kosong | "Periksa nomor HP dan password." | +| `429` | 429 | Terlalu banyak percobaan; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." | +| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" | + +> **Berubah per 9 Okt 2026.** Sebelumnya nomor HP atau password salah dijawab `900` +> (HTTP 500) dengan `cause` `invalid password` / `customer not found`. Bila app +> menangani login gagal dari HTTP 500 atau teks `cause` itu, ganti ke tabel di atas. + +Setiap nomor HP hanya boleh mencoba login 5 kali dalam 15 menit; login yang berhasil +memulai hitungan dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga +bila password-nya benar. + --- ## 3. Layar yang perlu dibuat @@ -495,7 +524,7 @@ Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan 1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah. 2. Cek penerima: - `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` + `GET /api/v1/customer/wallet/transfer/recipient?phone=6281234561234` ```json { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } @@ -512,7 +541,7 @@ Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan `POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key` ```json - { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } + { "currency": "POINT", "amount": 120, "recipient_phone": "6281234561234", "pin": "482913" } ``` ```json @@ -597,7 +626,7 @@ 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. +> §4 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai. - Game → aplikasi: JavaScript channel webview bernama **`EnakGameHost`**; setiap pesan berupa JSON string. @@ -605,11 +634,14 @@ log server game dan riwayat 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": "ready" }` | Kirim `{ "type": "init", "api_base_url": "/api/v1", "token": "", "game_id": "" }`. Jawab setiap `ready`, termasuk setelah halaman game dimuat ulang. Access token, bukan refresh token (ditolak backend) | | `{ "type": "balance_changed", "coin_balance": 15 }` | Perbarui saldo EnakCoin yang ditampilkan aplikasi | | `{ "type": "close" }` | Tutup webview, muat ulang beranda | +Bila token kosong atau ditolak backend, game menampilkan layar login akun customer +sendiri; aplikasi tidak perlu menangani apa pun, dan token hasil login di game tidak +dikirim balik ke aplikasi. + 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. @@ -731,7 +763,9 @@ minta PIN → kirim dengan header `Idempotency-Key`: ### 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`. +§9.2 (tanpa `point_balance` dan `replayed`). Seperti semua daftar berhalaman di dokumen +ini, array-nya ada di `data.data` dan pagination di `data.pagination` di dalam amplop +response (§2.2); daftar kosong berupa `[]`. | `status` | Tampilan | |---|---|