Files
apskel-pos-backend/docs/integration-backoffice.md
T
efrilmandClaude Opus 5.5 c43baa53e1 docs: EnakGame game list, API list and in-game login
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>
2026-10-09 22:59:58 +07:00

43 KiB
Raw Blame History

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, enakgame-prd.md, dan 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.

{
  "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.

{
  "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.

"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&currency=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30 (semua query opsional; tanggal WIB, inklusif)

{
  "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

{ "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.

{
  "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:
{
  "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.

{ "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": "…" }
{
  "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
{
  "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:

    {
      "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
{
  "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

{
  "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

{
  "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

{ "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": "…" }
{
  "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": "…" }
{
  "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).

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.
{ "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

{
  "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.

{
  "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.

{
  "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 §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.