930 lines
42 KiB
Markdown
930 lines
42 KiB
Markdown
# 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.
|