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