integration-enakgame.md was only the flow of a play. It now also has: - §2 the games: Spin is the only one; what each reward_type needs from the client at complete; what to agree on to register a new game. - §3 the endpoints the client calls, in one table. - §5 tokens: with no token, or one the backend refuses, the game shows a customer login (POST /customer-auth/login) and repeats the request once. Standalone mode for a browser without the app finds its game_id by slug. The login errors (304, 429 with locked_until), the attempt limit and the phone formats accepted. A JS helper for all of it. The bridge loses token_expired and token: the game logs the customer in itself. Sections are renumbered. integration-mobile-customer.md: customer phone numbers are 62… (§2), a login section with its errors and the change from 900 to 304 (§2.4), 62… examples, and init carrying the access token. integration-backoffice.md: example responses are the data field, so a list is data.data in a raw response. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
931 lines
43 KiB
Markdown
931 lines
43 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" }] }`. Contoh response di
|
||
dokumen ini adalah isi `data`. Daftar berhalaman isinya
|
||
`{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }`,
|
||
jadi pada response mentah array-nya ada di `data.data`; `limit` maks. 100 (default 20).
|
||
|
||
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke
|
||
voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke
|
||
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.
|