Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a787dd96fd | ||
|
|
c43baa53e1 | ||
|
|
a01e651709 | ||
|
|
19afa50b9c | ||
|
|
4b7eddbd3e |
@@ -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
|
||||
|
||||
+338
-50
@@ -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 <token>`. 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 <token>` 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 <token>` |
|
||||
| `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.
|
||||
|
||||
@@ -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": "<base URL>/api/v1", "token": "<token customer>", "game_id": "<id game yang dibuka>" }` |
|
||||
| `{ "type": "token_expired" }` | Login ulang customer (tidak ada refresh token), lalu kirim `{ "type": "token", "token": "<token baru>" }` |
|
||||
| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "<base URL>/api/v1", "token": "<access token customer>", "game_id": "<id game yang dibuka>" }`. 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 |
|
||||
|---|---|
|
||||
|
||||
+3
-1
@@ -307,6 +307,7 @@ type repositories struct {
|
||||
campaignRepo repository.CampaignRepository
|
||||
campaignRuleRepo repository.CampaignRuleRepository
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
customerLoginAttemptRepo repository.CustomerLoginAttemptRepository
|
||||
otpRepo repository.OtpRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
txManager *repository.TxManager
|
||||
@@ -359,6 +360,7 @@ func (a *App) initRepositories() *repositories {
|
||||
campaignRepo: repository.NewCampaignRepository(a.db),
|
||||
campaignRuleRepo: repository.NewCampaignRuleRepository(a.db),
|
||||
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
|
||||
customerLoginAttemptRepo: repository.NewCustomerLoginAttemptRepository(a.redisClient),
|
||||
otpRepo: repository.NewOtpRepository(a.db),
|
||||
sessionRepo: repository.NewSessionRepository(a.redisClient),
|
||||
txManager: repository.NewTxManager(a.db),
|
||||
@@ -488,7 +490,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo),
|
||||
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
||||
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, repos.customerLoginAttemptRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager))),
|
||||
otpProcessor: otpProcessor,
|
||||
fileClient: fileClient,
|
||||
|
||||
@@ -67,6 +67,7 @@ const (
|
||||
EnakGameServiceEntity = "enakgame_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
CustomerPinServiceEntity = "customer_pin_service"
|
||||
CustomerAuthServiceEntity = "customer_auth_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
@@ -161,13 +164,36 @@ func (h *CustomerAuthHandler) Login(c *gin.Context) {
|
||||
response, err := h.customerAuthService.Login(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::Login")
|
||||
util.HandleResponse(c.Writer, c.Request, loginErrorResponse(err), "CustomerAuthHandler::Login")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::Login")
|
||||
}
|
||||
|
||||
// loginErrorResponse answers a phone number with too many attempts with 429 and when it
|
||||
// may try again, a refused login with 304 and its reason, and anything else with 900.
|
||||
func loginErrorResponse(err error) *contract.Response {
|
||||
var locked *processor.CustomerLoginLockedError
|
||||
if errors.As(err, &locked) {
|
||||
return &contract.Response{
|
||||
Success: false,
|
||||
Data: map[string]interface{}{"locked_until": locked.Until},
|
||||
Errors: []*contract.ResponseError{contract.NewResponseError(constants.TooManyRequestsErrorCode, constants.CustomerAuthServiceEntity, locked.Error())},
|
||||
}
|
||||
}
|
||||
for _, refused := range []error{processor.ErrCustomerLoginInvalid, processor.ErrCustomerNotRegistered} {
|
||||
if errors.Is(err, refused) {
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.CustomerAuthServiceEntity, refused.Error()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CustomerAuthHandler) ResendOtp(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// customerAuthRepoFake holds customers by phone number. Only the login lookup is used.
|
||||
type customerAuthRepoFake struct {
|
||||
customers map[string]*entities.Customer
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *customerAuthRepoFake) GetCustomerByPhoneNumber(_ context.Context, phone string) (*entities.Customer, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.customers[phone], nil
|
||||
}
|
||||
|
||||
func (f *customerAuthRepoFake) GetCustomerByID(context.Context, string) (*entities.Customer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) CreateCustomer(context.Context, *entities.Customer) error { return nil }
|
||||
func (f *customerAuthRepoFake) UpdateCustomer(context.Context, *entities.Customer) error { return nil }
|
||||
func (f *customerAuthRepoFake) CheckPhoneNumberExists(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) SetCustomerPassword(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) OrganizationExists(context.Context, uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) OrganizationIDs(context.Context, int) ([]uuid.UUID, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// loginAttemptsFake counts attempts per phone number in one window that never ends.
|
||||
type loginAttemptsFake struct {
|
||||
counts map[string]int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *loginAttemptsFake) Hit(_ context.Context, phone string, window time.Duration) (int64, time.Duration, error) {
|
||||
if f.err != nil {
|
||||
return 0, 0, f.err
|
||||
}
|
||||
f.counts[phone]++
|
||||
return f.counts[phone], window, nil
|
||||
}
|
||||
|
||||
func (f *loginAttemptsFake) Reset(_ context.Context, phone string) error {
|
||||
delete(f.counts, phone)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
loginTestPassword = "rahasia123"
|
||||
loginTestRegistered = "6281234561234"
|
||||
loginTestUnfinished = "6281234569999"
|
||||
)
|
||||
|
||||
type loginTest struct {
|
||||
repo *customerAuthRepoFake
|
||||
attempts *loginAttemptsFake
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
func newLoginTest(t *testing.T) *loginTest {
|
||||
t.Helper()
|
||||
applogger.Setup("fatal", "json")
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(loginTestPassword), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
hashStr := string(hash)
|
||||
registered, unfinished := loginTestRegistered, loginTestUnfinished
|
||||
birth := time.Date(2000, 1, 31, 0, 0, 0, 0, time.UTC)
|
||||
lt := &loginTest{
|
||||
repo: &customerAuthRepoFake{customers: map[string]*entities.Customer{
|
||||
registered: {ID: uuid.New(), Name: "Budi", PhoneNumber: ®istered, BirthDate: &birth, PasswordHash: &hashStr},
|
||||
unfinished: {ID: uuid.New(), Name: "Sari", PhoneNumber: &unfinished},
|
||||
}},
|
||||
attempts: &loginAttemptsFake{counts: map[string]int64{}},
|
||||
}
|
||||
h := NewCustomerAuthHandler(
|
||||
service.NewCustomerAuthService(processor.NewCustomerAuthProcessor(lt.repo, lt.attempts, nil, nil, "test", 60)),
|
||||
validator.NewCustomerAuthValidator(),
|
||||
)
|
||||
gin.SetMode(gin.TestMode)
|
||||
lt.router = gin.New()
|
||||
lt.router.POST("/customer-auth/login", h.Login)
|
||||
return lt
|
||||
}
|
||||
|
||||
func (lt *loginTest) login(t *testing.T, phone, password string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"phone_number": phone, "password": password})
|
||||
rec := httptest.NewRecorder()
|
||||
lt.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/customer-auth/login", bytes.NewReader(body)))
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func firstLoginError(t *testing.T, out map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
errs, ok := out["errors"].([]any)
|
||||
require.True(t, ok, "errors: %v", out)
|
||||
require.NotEmpty(t, errs)
|
||||
return errs[0].(map[string]any)
|
||||
}
|
||||
|
||||
func TestCustomerLoginRefusalsAreValidationErrors(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
|
||||
for name, tc := range map[string]struct{ phone, password, cause string }{
|
||||
"wrong password": {loginTestRegistered, "salah", "invalid phone number or password"},
|
||||
"unknown phone": {"6280000000000", loginTestPassword, "invalid phone number or password"},
|
||||
"registration unfinished": {loginTestUnfinished, loginTestPassword, "customer not properly registered"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
status, out := lt.login(t, tc.phone, tc.password)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
e := firstLoginError(t, out)
|
||||
assert.Equal(t, "304", e["code"])
|
||||
assert.Equal(t, "customer_auth_service", e["entity"])
|
||||
assert.Equal(t, tc.cause, e["cause"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("right password", func(t *testing.T) {
|
||||
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
require.Equal(t, http.StatusOK, status, "%v", out)
|
||||
inner := out["data"].(map[string]any)["data"].(map[string]any)
|
||||
assert.NotEmpty(t, inner["access_token"])
|
||||
})
|
||||
|
||||
t.Run("the number written another way finds the same customer", func(t *testing.T) {
|
||||
for _, phone := range []string{"081234561234", "+62 812-3456-1234", "81234561234"} {
|
||||
status, out := lt.login(t, phone, loginTestPassword)
|
||||
require.Equal(t, http.StatusOK, status, "%s: %v", phone, out)
|
||||
user := out["data"].(map[string]any)["data"].(map[string]any)["user"].(map[string]any)
|
||||
assert.Equal(t, loginTestRegistered, user["phone_number"], phone)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a number that is not an Indonesian mobile number", func(t *testing.T) {
|
||||
status, out := lt.login(t, "021-1234567", loginTestPassword)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
e := firstLoginError(t, out)
|
||||
assert.Equal(t, "304", e["code"])
|
||||
assert.Equal(t, "invalid phone number format", e["cause"])
|
||||
})
|
||||
|
||||
t.Run("a failing lookup is still a server error", func(t *testing.T) {
|
||||
lt.repo.err = errors.New("connection refused")
|
||||
defer func() { lt.repo.err = nil }()
|
||||
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
assert.Equal(t, http.StatusInternalServerError, status)
|
||||
assert.Equal(t, "900", firstLoginError(t, out)["code"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestCustomerLoginLocksAfterTooManyAttempts(t *testing.T) {
|
||||
t.Run("the sixth attempt is refused, even with the right password", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, _ := lt.login(t, loginTestRegistered, "salah")
|
||||
require.Equal(t, http.StatusBadRequest, status, "attempt %d", i+1)
|
||||
}
|
||||
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
assert.Equal(t, http.StatusTooManyRequests, status)
|
||||
e := firstLoginError(t, out)
|
||||
assert.Equal(t, "429", e["code"])
|
||||
assert.Equal(t, "customer_auth_service", e["entity"])
|
||||
data, ok := out["data"].(map[string]any)
|
||||
require.True(t, ok, "data: %v", out)
|
||||
until, err := time.Parse(time.RFC3339, data["locked_until"].(string))
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().Add(15*time.Minute), until, time.Minute)
|
||||
|
||||
// Another number is not affected.
|
||||
status, _ = lt.login(t, loginTestUnfinished, loginTestPassword)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
})
|
||||
|
||||
t.Run("writing the number another way counts toward the same limit", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for _, phone := range []string{"081234561234", "+6281234561234", "81234561234", "0812-3456-1234", loginTestRegistered} {
|
||||
lt.login(t, phone, "salah")
|
||||
}
|
||||
status, _ := lt.login(t, "081234561234", loginTestPassword)
|
||||
assert.Equal(t, http.StatusTooManyRequests, status)
|
||||
})
|
||||
|
||||
t.Run("unknown numbers lock the same way", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
lt.login(t, "6280000000000", "salah")
|
||||
}
|
||||
status, _ := lt.login(t, "6280000000000", "salah")
|
||||
assert.Equal(t, http.StatusTooManyRequests, status)
|
||||
})
|
||||
|
||||
t.Run("a successful login starts the count again", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for i := 0; i < 4; i++ {
|
||||
lt.login(t, loginTestRegistered, "salah")
|
||||
}
|
||||
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, _ := lt.login(t, loginTestRegistered, "salah")
|
||||
require.Equal(t, http.StatusBadRequest, status, "attempt %d after the login", i+1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logins go on when the counter is down", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
lt.attempts.err = errors.New("redis: connection refused")
|
||||
for i := 0; i < 6; i++ {
|
||||
lt.login(t, loginTestRegistered, "salah")
|
||||
}
|
||||
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ func CORS() gin.HandlerFunc {
|
||||
}
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, Idempotency-Key, X-Idempotency-Key")
|
||||
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
|
||||
@@ -2,12 +2,14 @@ package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/util"
|
||||
@@ -16,6 +18,32 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrCustomerLoginInvalid means no customer has the phone number or the password is
|
||||
// wrong. Which of the two is not told.
|
||||
ErrCustomerLoginInvalid = errors.New("invalid phone number or password")
|
||||
// ErrCustomerNotRegistered means the customer never set a password: registration
|
||||
// stopped before its last step.
|
||||
ErrCustomerNotRegistered = errors.New("customer not properly registered")
|
||||
)
|
||||
|
||||
// Login attempts a phone number may make before it has to wait, and the window they
|
||||
// are counted in. A successful login starts the count again.
|
||||
const (
|
||||
customerLoginMaxAttempts = 5
|
||||
customerLoginWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
// CustomerLoginLockedError means the phone number made too many login attempts and may
|
||||
// try again at Until.
|
||||
type CustomerLoginLockedError struct {
|
||||
Until time.Time
|
||||
}
|
||||
|
||||
func (e *CustomerLoginLockedError) Error() string {
|
||||
return fmt.Sprintf("too many login attempts, try again after %s", e.Until.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
type CustomerAuthProcessor interface {
|
||||
CheckPhoneNumber(ctx context.Context, req *contract.CheckPhoneRequest) (*models.CheckPhoneResponse, error)
|
||||
StartRegistration(ctx context.Context, req *contract.RegisterStartRequest) (*models.RegisterStartResponse, error)
|
||||
@@ -26,20 +54,22 @@ type CustomerAuthProcessor interface {
|
||||
}
|
||||
|
||||
type customerAuthProcessor struct {
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
otpProcessor OtpProcessor
|
||||
otpRepo repository.OtpRepository
|
||||
jwtSecret string
|
||||
tokenTTLMinutes int
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
loginAttemptsRepo repository.CustomerLoginAttemptRepository
|
||||
otpProcessor OtpProcessor
|
||||
otpRepo repository.OtpRepository
|
||||
jwtSecret string
|
||||
tokenTTLMinutes int
|
||||
}
|
||||
|
||||
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
|
||||
func NewCustomerAuthProcessor(customerAuthRepo repository.CustomerAuthRepository, loginAttemptsRepo repository.CustomerLoginAttemptRepository, otpProcessor OtpProcessor, otpRepo repository.OtpRepository, jwtSecret string, tokenTTLMinutes int) CustomerAuthProcessor {
|
||||
return &customerAuthProcessor{
|
||||
customerAuthRepo: customerAuthRepo,
|
||||
otpProcessor: otpProcessor,
|
||||
otpRepo: otpRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
tokenTTLMinutes: tokenTTLMinutes,
|
||||
customerAuthRepo: customerAuthRepo,
|
||||
loginAttemptsRepo: loginAttemptsRepo,
|
||||
otpProcessor: otpProcessor,
|
||||
otpRepo: otpRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
tokenTTLMinutes: tokenTTLMinutes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,6 +374,21 @@ func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.R
|
||||
}
|
||||
|
||||
func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.CustomerLoginRequest) (*models.CustomerLoginResponse, error) {
|
||||
// Counted before the password is checked, so attempts sent at once all count, and
|
||||
// for numbers without a customer too, so a refusal never tells which numbers have
|
||||
// one.
|
||||
attempts, left, err := p.loginAttemptsRepo.Hit(ctx, req.PhoneNumber, customerLoginWindow)
|
||||
switch {
|
||||
case err != nil:
|
||||
// Without the counter, logins go on unlimited rather than stop for everyone.
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerAuthProcessor::Login -> failed to count the attempt")
|
||||
case attempts > customerLoginMaxAttempts:
|
||||
if left <= 0 {
|
||||
left = customerLoginWindow
|
||||
}
|
||||
return nil, &CustomerLoginLockedError{Until: time.Now().Add(left).UTC().Truncate(time.Second)}
|
||||
}
|
||||
|
||||
// Get customer by phone number
|
||||
customer, err := p.customerAuthRepo.GetCustomerByPhoneNumber(ctx, req.PhoneNumber)
|
||||
if err != nil {
|
||||
@@ -351,16 +396,19 @@ func (p *customerAuthProcessor) Login(ctx context.Context, req *contract.Custome
|
||||
}
|
||||
|
||||
if customer == nil {
|
||||
return nil, fmt.Errorf("customer not found")
|
||||
return nil, ErrCustomerLoginInvalid
|
||||
}
|
||||
|
||||
if customer.PasswordHash == nil {
|
||||
return nil, fmt.Errorf("customer not properly registered")
|
||||
return nil, ErrCustomerNotRegistered
|
||||
}
|
||||
|
||||
// Verify password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*customer.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, fmt.Errorf("invalid password")
|
||||
return nil, ErrCustomerLoginInvalid
|
||||
}
|
||||
if err := p.loginAttemptsRepo.Reset(ctx, req.PhoneNumber); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerAuthProcessor::Login -> failed to reset the attempts")
|
||||
}
|
||||
|
||||
// Generate JWT tokens using customer JWT util
|
||||
|
||||
@@ -152,7 +152,7 @@ func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, acti
|
||||
|
||||
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi Santoso", "081234561234")
|
||||
c := e.member("Budi Santoso", "6281234561234")
|
||||
e.earnCoins(t, c, 50, nil)
|
||||
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
@@ -187,7 +187,7 @@ func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
|
||||
func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 35, nil)
|
||||
|
||||
preview, err := e.exchanges().Preview(e.ctx, c, 30)
|
||||
@@ -206,7 +206,7 @@ func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
|
||||
func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 50, nil)
|
||||
|
||||
preview, err := e.exchanges().Preview(e.ctx, c, 25)
|
||||
@@ -227,7 +227,7 @@ func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
|
||||
func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
soon, later := e.at(24*time.Hour), e.at(48*time.Hour)
|
||||
first := e.earnCoins(t, c, 15, soon)
|
||||
second := e.earnCoins(t, c, 15, later)
|
||||
@@ -268,7 +268,7 @@ func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
|
||||
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, e.at(time.Hour))
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
@@ -281,7 +281,7 @@ func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
|
||||
|
||||
func TestWalletExchange_NotEnoughCoins(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
preview, err := e.exchanges().Preview(e.ctx, c, 6)
|
||||
@@ -295,7 +295,7 @@ func TestWalletExchange_NotEnoughCoins(t *testing.T) {
|
||||
|
||||
func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
_, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{})
|
||||
@@ -307,7 +307,7 @@ func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
|
||||
|
||||
func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 100, nil)
|
||||
|
||||
first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
@@ -330,7 +330,7 @@ func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
|
||||
|
||||
func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
_, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{})
|
||||
@@ -344,7 +344,7 @@ func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.now = wib(2026, 6, 1, 10, 0)
|
||||
e.settings.PointExpiry = rolling(30, "DAY", false)
|
||||
c := e.member("Budi", "081234561234")
|
||||
c := e.member("Budi", "6281234561234")
|
||||
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
|
||||
e.earnCoins(t, c, 10, soon)
|
||||
e.earnCoins(t, c, 10, later)
|
||||
|
||||
@@ -54,7 +54,7 @@ func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcesso
|
||||
|
||||
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
ord := earn(a, 150, e.at(-time.Hour))
|
||||
ord.Description = "Belanja #ORD-0098"
|
||||
due := e.credit(t, ord)
|
||||
@@ -102,7 +102,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
||||
|
||||
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
@@ -128,7 +128,7 @@ func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
|
||||
|
||||
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
||||
p, repo := e.expiry(nil)
|
||||
// A lot listed that ExpireLot cannot find.
|
||||
@@ -142,7 +142,7 @@ func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
|
||||
|
||||
func TestWalletExpiry_NothingDue(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 150, e.at(time.Hour)))
|
||||
notifier := ¬ifierFake{}
|
||||
p, _ := e.expiry(notifier)
|
||||
@@ -206,7 +206,7 @@ func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
|
||||
e.now = wib(2026, 10, 25, 9, 0)
|
||||
e.settings.PointExpiry.ReminderDays = 7
|
||||
e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
oct31 := wib(2026, 10, 31, 23, 59)
|
||||
nov30 := wib(2026, 11, 30, 23, 59)
|
||||
e.credit(t, earn(a, 100, &oct31))
|
||||
|
||||
@@ -2,6 +2,7 @@ package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
@@ -42,7 +43,7 @@ func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) {
|
||||
require.NoError(t, err)
|
||||
|
||||
org, a, b = uuid.New(), uuid.New(), uuid.New()
|
||||
phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10]
|
||||
phoneA, phoneB := walletTestPhone(a), walletTestPhone(b)
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`,
|
||||
a, org, phoneA, b, org, phoneB).Error)
|
||||
@@ -117,7 +118,7 @@ func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
|
||||
_, err := wallet.Credit(ctx, earn(b, 100, nil))
|
||||
return err
|
||||
}))
|
||||
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] }
|
||||
phone := walletTestPhone
|
||||
|
||||
const rounds = 10
|
||||
errs := make(chan error, 2*rounds)
|
||||
@@ -235,3 +236,8 @@ func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, int64(10), expires)
|
||||
assert.Zero(t, left)
|
||||
}
|
||||
|
||||
// walletTestPhone is a phone number in its stored form, 628…, unique to the customer.
|
||||
func walletTestPhone(id uuid.UUID) string {
|
||||
return fmt.Sprintf("628%09d", binary.BigEndian.Uint64(id[:8])%1_000_000_000)
|
||||
}
|
||||
|
||||
@@ -85,8 +85,8 @@ func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string
|
||||
// redeems 30. Tracing B's redemption leads to A's order #ORD-1.
|
||||
func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi Santoso", "6281234561234")
|
||||
ord1 := earn(a, 100, e.at(30*24*time.Hour))
|
||||
ord1.Description = "Belanja #ORD-1"
|
||||
ord2 := earn(a, 50, e.at(60*24*time.Hour))
|
||||
@@ -121,8 +121,8 @@ func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
|
||||
func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi", "6281234561234")
|
||||
e.credit(t, earn(a, 100, e.at(time.Hour)))
|
||||
e.credit(t, earn(a, 50, nil))
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
@@ -153,7 +153,7 @@ func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
|
||||
|
||||
func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
res := e.credit(t, earn(a, 10, nil))
|
||||
|
||||
_, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// ErrWalletRecipientNotFound means no customer of the sender's organization has the
|
||||
@@ -213,10 +214,13 @@ func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UU
|
||||
// them: an active customer of the same organization, not the walk-in customer, and
|
||||
// not the sender.
|
||||
func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) {
|
||||
phoneNumber = strings.TrimSpace(phoneNumber)
|
||||
if phoneNumber == "" {
|
||||
if strings.TrimSpace(phoneNumber) == "" {
|
||||
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
|
||||
}
|
||||
phoneNumber, err := util.NormalizePhoneNumber(phoneNumber)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: the recipient's phone number is not valid", ErrWalletMoveRejected)
|
||||
}
|
||||
recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber)
|
||||
if errors.Is(err, repository.ErrWalletNotFound) {
|
||||
return nil, ErrWalletRecipientNotFound
|
||||
@@ -282,10 +286,14 @@ func maskName(name string) string {
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// maskPhoneNumber keeps the first two and the last four digits:
|
||||
// "081234561234" → "08**-****-1234".
|
||||
// maskPhoneNumber keeps the first two and the last four digits of the number as
|
||||
// customers write it, with 0 for 62: "6281234561234" → "08**-****-1234".
|
||||
func maskPhoneNumber(phone string) string {
|
||||
runes := []rune(strings.TrimSpace(phone))
|
||||
phone = strings.TrimSpace(phone)
|
||||
if strings.HasPrefix(phone, "62") {
|
||||
phone = "0" + phone[2:]
|
||||
}
|
||||
runes := []rune(phone)
|
||||
if len(runes) < 8 {
|
||||
return "****"
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ func sendPoints(amount int64, phone string) models.WalletTransfer {
|
||||
|
||||
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi Santoso", "6281234561234")
|
||||
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
|
||||
first := e.credit(t, earn(a, 100, dec))
|
||||
second := e.credit(t, earn(a, 50, jan))
|
||||
@@ -97,8 +97,8 @@ func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_Coins(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi", "6281234561234")
|
||||
e.earnCoins(t, a, 10, nil)
|
||||
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
@@ -109,14 +109,14 @@ func TestWalletTransfer_Coins(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
|
||||
walkIn := e.member("Walk-in", "081100000000")
|
||||
walkIn := e.member("Walk-in", "6281100000000")
|
||||
e.customers.byID[walkIn].IsDefault = true
|
||||
inactive := e.member("Old", "081100000001")
|
||||
inactive := e.member("Old", "6281100000001")
|
||||
e.customers.byID[inactive].IsActive = false
|
||||
elsewhere := e.member("Other Org", "081100000002")
|
||||
elsewhere := e.member("Other Org", "6281100000002")
|
||||
e.customers.byID[elsewhere].OrganizationID = uuid.New()
|
||||
|
||||
for phone, want := range map[string]error{
|
||||
@@ -126,6 +126,7 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
"081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody
|
||||
"081999999999": ErrWalletRecipientNotFound,
|
||||
"": ErrWalletMoveRejected,
|
||||
"021-1234567": ErrWalletMoveRejected, // not a mobile number
|
||||
} {
|
||||
_, err := e.transfers(nil).Recipient(e.ctx, a, phone)
|
||||
assert.ErrorIs(t, err, want, phone)
|
||||
@@ -138,18 +139,20 @@ func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi Santoso", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.member("Budi Santoso", "6281234561234")
|
||||
|
||||
got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got)
|
||||
for _, phone := range []string{" 081234561234 ", "6281234561234", "+62 812-3456-1234"} {
|
||||
got, err := e.transfers(nil).Recipient(e.ctx, a, phone)
|
||||
require.NoError(t, err, phone)
|
||||
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got, phone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletTransfer_OrganizationLimits(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.member("Budi", "6281234561234")
|
||||
e.credit(t, earn(a, 1000, nil))
|
||||
e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))}
|
||||
send := func(amount int64, key string) error {
|
||||
@@ -177,8 +180,8 @@ func TestWalletTransfer_OrganizationLimits(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
e.member("Budi", "6281234561234")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
until := e.now.Add(time.Hour)
|
||||
e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until}
|
||||
@@ -192,8 +195,8 @@ func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi", "6281234561234")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
// An expired lot cannot be sent even before the expiry job takes it.
|
||||
e.credit(t, earn(a, 50, e.at(-time.Hour)))
|
||||
@@ -205,9 +208,9 @@ func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
|
||||
|
||||
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.member("Citra", "081255550000")
|
||||
a := e.member("Anita", "6281200005678")
|
||||
b := e.member("Budi", "6281234561234")
|
||||
e.member("Citra", "6281255550000")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const customerLoginAttemptKeyPrefix = "customer_login:attempts:"
|
||||
|
||||
// CustomerLoginAttemptRepository counts customer login attempts per phone number, so a
|
||||
// password cannot be guessed by trying many.
|
||||
type CustomerLoginAttemptRepository interface {
|
||||
// Hit counts one attempt for the phone number. It returns the attempts counted in
|
||||
// the current window, this one included, and how long the window still runs. A
|
||||
// window starts at the first attempt after the previous one ended.
|
||||
Hit(ctx context.Context, phoneNumber string, window time.Duration) (int64, time.Duration, error)
|
||||
// Reset forgets the attempts of the phone number.
|
||||
Reset(ctx context.Context, phoneNumber string) error
|
||||
}
|
||||
|
||||
type customerLoginAttemptRepository struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewCustomerLoginAttemptRepository(client *redis.Client) CustomerLoginAttemptRepository {
|
||||
return &customerLoginAttemptRepository{client: client}
|
||||
}
|
||||
|
||||
func (r *customerLoginAttemptRepository) Hit(ctx context.Context, phoneNumber string, window time.Duration) (int64, time.Duration, error) {
|
||||
key := customerLoginAttemptKeyPrefix + phoneNumber
|
||||
// One transaction, so the key never exists without its expiry: concurrent attempts
|
||||
// all count, and a crash cannot leave a number locked for good.
|
||||
pipe := r.client.TxPipeline()
|
||||
pipe.SetNX(ctx, key, 0, window)
|
||||
count := pipe.Incr(ctx, key)
|
||||
left := pipe.PTTL(ctx, key)
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
return 0, 0, fmt.Errorf("count login attempt: %w", err)
|
||||
}
|
||||
return count.Val(), left.Val(), nil
|
||||
}
|
||||
|
||||
func (r *customerLoginAttemptRepository) Reset(ctx context.Context, phoneNumber string) error {
|
||||
if err := r.client.Del(ctx, customerLoginAttemptKeyPrefix+phoneNumber).Err(); err != nil {
|
||||
return fmt.Errorf("reset login attempts: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalidPhoneNumber means a phone number is not an Indonesian mobile number.
|
||||
var ErrInvalidPhoneNumber = errors.New("invalid phone number format")
|
||||
|
||||
// customerPhonePattern is an Indonesian mobile number in its stored form: 62, then the
|
||||
// number without its leading 0 (628…, 10 to 14 digits in all).
|
||||
var customerPhonePattern = regexp.MustCompile(`^628\d{7,11}$`)
|
||||
|
||||
// NormalizePhoneNumber turns a customer's phone number into the one form it is stored
|
||||
// and looked up in, 62…: "0812-3456-1234", "+62 812 3456 1234", "62812…" and "812…"
|
||||
// all become "6281234561234". Spaces, dashes, dots and parentheses are dropped.
|
||||
func NormalizePhoneNumber(raw string) (string, error) {
|
||||
digits := strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case ' ', '-', '.', '(', ')':
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, strings.TrimSpace(raw))
|
||||
digits = strings.TrimPrefix(digits, "+")
|
||||
switch {
|
||||
case strings.HasPrefix(digits, "620"):
|
||||
// +62 typed in front of a number that kept its 0.
|
||||
digits = "62" + digits[3:]
|
||||
case strings.HasPrefix(digits, "0"):
|
||||
digits = "62" + digits[1:]
|
||||
case strings.HasPrefix(digits, "8"):
|
||||
digits = "62" + digits
|
||||
}
|
||||
if !customerPhonePattern.MatchString(digits) {
|
||||
return "", ErrInvalidPhoneNumber
|
||||
}
|
||||
return digits, nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizePhoneNumber(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"6281234561234",
|
||||
"+6281234561234",
|
||||
"081234561234",
|
||||
"81234561234",
|
||||
"0812-3456-1234",
|
||||
"+62 812 3456 1234",
|
||||
"(+62) 812.3456.1234",
|
||||
"+62081234561234",
|
||||
" 081234561234 ",
|
||||
} {
|
||||
got, err := NormalizePhoneNumber(raw)
|
||||
require.NoError(t, err, raw)
|
||||
assert.Equal(t, "6281234561234", got, raw)
|
||||
}
|
||||
|
||||
shortest, err := NormalizePhoneNumber("0811234567")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "62811234567", shortest)
|
||||
longest, err := NormalizePhoneNumber("0812345678901")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "62812345678901", longest)
|
||||
_, err = NormalizePhoneNumber("08123456789012")
|
||||
assert.ErrorIs(t, err, ErrInvalidPhoneNumber, "too long")
|
||||
|
||||
for _, raw := range []string{
|
||||
"",
|
||||
"0",
|
||||
"021-1234567", // a landline, cannot get the WhatsApp OTP
|
||||
"+6521234567", // not Indonesian
|
||||
"+1 415 555 0100", // not Indonesian
|
||||
"62812", // too short
|
||||
"0812abc61234",
|
||||
"0812/3456/1234",
|
||||
} {
|
||||
_, err := NormalizePhoneNumber(raw)
|
||||
assert.ErrorIs(t, err, ErrInvalidPhoneNumber, raw)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
type CustomerAuthValidator interface {
|
||||
@@ -36,7 +37,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCheckPhoneRequest(req *contract.Chec
|
||||
return errors.New("phone number is required"), constants.ValidationErrorCode
|
||||
}
|
||||
|
||||
if !v.isValidPhoneNumber(req.PhoneNumber) {
|
||||
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
||||
return errors.New("invalid phone number format"), constants.ValidationErrorCode
|
||||
}
|
||||
|
||||
@@ -53,7 +54,7 @@ func (v *CustomerAuthValidatorImpl) ValidateRegisterStartRequest(req *contract.R
|
||||
return errors.New("phone number is required"), constants.ValidationErrorCode
|
||||
}
|
||||
|
||||
if !v.isValidPhoneNumber(req.PhoneNumber) {
|
||||
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
||||
return errors.New("invalid phone number format"), constants.ValidationErrorCode
|
||||
}
|
||||
|
||||
@@ -161,7 +162,7 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C
|
||||
return errors.New("phone number is required"), constants.ValidationErrorCode
|
||||
}
|
||||
|
||||
if !v.isValidPhoneNumber(req.PhoneNumber) {
|
||||
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
||||
return errors.New("invalid phone number format"), constants.ValidationErrorCode
|
||||
}
|
||||
|
||||
@@ -174,10 +175,15 @@ func (v *CustomerAuthValidatorImpl) ValidateCustomerLoginRequest(req *contract.C
|
||||
}
|
||||
|
||||
// Helper validation functions
|
||||
func (v *CustomerAuthValidatorImpl) isValidPhoneNumber(phoneNumber string) bool {
|
||||
// Basic phone number validation - adjust regex based on your requirements
|
||||
phoneRegex := regexp.MustCompile(`^\+?[1-9]\d{1,14}$`)
|
||||
return phoneRegex.MatchString(phoneNumber)
|
||||
// normalizePhoneNumber rewrites the phone number in the form it is stored in, 62…
|
||||
// (util.NormalizePhoneNumber), and reports false when it is not a valid one.
|
||||
func (v *CustomerAuthValidatorImpl) normalizePhoneNumber(phoneNumber *string) bool {
|
||||
normalized, err := util.NormalizePhoneNumber(*phoneNumber)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
*phoneNumber = normalized
|
||||
return true
|
||||
}
|
||||
|
||||
func (v *CustomerAuthValidatorImpl) isValidDateFormat(date string) bool {
|
||||
@@ -208,7 +214,7 @@ func (v *CustomerAuthValidatorImpl) ValidateResendOtpRequest(req *contract.Resen
|
||||
}
|
||||
|
||||
// Validate phone number format
|
||||
if !v.isValidPhoneNumber(req.PhoneNumber) {
|
||||
if !v.normalizePhoneNumber(&req.PhoneNumber) {
|
||||
return errors.New("invalid phone number format"), constants.CustomerEntity
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Nothing to undo: the forms the numbers were stored in before are not kept, and 62… is
|
||||
-- what the code before this migration accepted too.
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Customer phone numbers are stored in one form, 62… without + (util.NormalizePhoneNumber),
|
||||
-- and every number a customer types is rewritten to it before it is looked up. This
|
||||
-- rewrites the numbers stored before, so 0812…, +62 812… and 812… become 62812….
|
||||
--
|
||||
-- Only one customer may have a number. When several stored numbers become the same one,
|
||||
-- the customer that already has it keeps it, else a registered one (with a password),
|
||||
-- else the oldest; the others are left as they are. A number that is not an Indonesian
|
||||
-- mobile number is left as it is too. Neither can log in until it is fixed by hand:
|
||||
--
|
||||
-- SELECT id, name, phone_number FROM customers
|
||||
-- WHERE phone_number IS NOT NULL AND phone_number !~ '^628[0-9]{7,11}$';
|
||||
|
||||
WITH stripped AS (
|
||||
SELECT id, phone_number, password_hash, created_at,
|
||||
regexp_replace(regexp_replace(phone_number, '[[:space:]().-]', '', 'g'), '^\+', '') AS p
|
||||
FROM customers
|
||||
WHERE phone_number IS NOT NULL
|
||||
),
|
||||
normalized AS (
|
||||
SELECT id, phone_number, password_hash, created_at,
|
||||
CASE
|
||||
WHEN p LIKE '620%' THEN '62' || substr(p, 4)
|
||||
WHEN p LIKE '0%' THEN '62' || substr(p, 2)
|
||||
WHEN p LIKE '8%' THEN '62' || p
|
||||
ELSE p
|
||||
END AS new_phone
|
||||
FROM stripped
|
||||
),
|
||||
ranked AS (
|
||||
SELECT id, new_phone,
|
||||
row_number() OVER (
|
||||
PARTITION BY new_phone
|
||||
ORDER BY phone_number = new_phone DESC, password_hash IS NOT NULL DESC, created_at, id
|
||||
) AS rank
|
||||
FROM normalized
|
||||
WHERE new_phone ~ '^628[0-9]{7,11}$'
|
||||
)
|
||||
UPDATE customers c
|
||||
SET phone_number = r.new_phone, updated_at = NOW()
|
||||
FROM ranked r
|
||||
WHERE c.id = r.id AND r.rank = 1 AND c.phone_number <> r.new_phone;
|
||||
|
||||
-- A registration started before this migration finishes with the number in its OTP
|
||||
-- session, and a resent OTP is found by it.
|
||||
UPDATE otp_sessions
|
||||
SET phone_number = n.new_phone, updated_at = NOW()
|
||||
FROM (
|
||||
SELECT id,
|
||||
CASE
|
||||
WHEN p LIKE '620%' THEN '62' || substr(p, 4)
|
||||
WHEN p LIKE '0%' THEN '62' || substr(p, 2)
|
||||
WHEN p LIKE '8%' THEN '62' || p
|
||||
ELSE p
|
||||
END AS new_phone
|
||||
FROM (
|
||||
SELECT id, regexp_replace(regexp_replace(phone_number, '[[:space:]().-]', '', 'g'), '^\+', '') AS p
|
||||
FROM otp_sessions
|
||||
) stripped
|
||||
) n
|
||||
WHERE otp_sessions.id = n.id
|
||||
AND n.new_phone ~ '^628[0-9]{7,11}$'
|
||||
AND otp_sessions.phone_number <> n.new_phone;
|
||||
Reference in New Issue
Block a user