# API EnakPoint & EnakCoin 30 Sep 2026 Semua endpoint EnakPoint (`POINT`, hanya untuk ditukar ke voucher) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat. > **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus, karena EnakPoint sekarang hanya bisa ditukar ke voucher: tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus ada di Referensi → Endpoint dan field yang dihapus. ## Konvensi umum | Klien | Autentikasi | Prefix | | --- | --- | --- | | Customer app / self-order | `Authorization: Bearer ` | `/api/v1/customer` | | POS | Token user (kasir/manager) | `/api/v1` | | Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` | **Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan. | `code` | HTTP | Arti | | --- | --- | --- | | `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | | `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) | | `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain | | `429` | 429 | OTP diminta ulang terlalu cepat | | `PIN_NOT_SET` | 403 | Customer belum membuat PIN | | `PIN_INVALID` | 400 | PIN salah | | `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah | | `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN | | `900` | 500 | Kesalahan server | **Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit. **Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. **Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya. ## Customer app: saldo & riwayat | Method | Path | Keterangan | | --- | --- | --- | | GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir | | GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter | | GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal | | PUT | `/customer/devices` | Daftarkan token FCM device | | DELETE | `/customer/devices/:device_id` | Hapus device saat logout | | GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `earns_points`, `earns_coins` | | GET | `/customer/orders` | Riwayat order customer (`page`, `limit`), dengan `points_earned` / `coins_earned` | | GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint/EnakCoin yang didapat; order customer lain → `404` | Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di [`mobile-customer-enakpoint.md`](./mobile-customer-enakpoint.md) §4.4–§4.5. ### GET /customer/wallet ```json { "point_balance": 12500, "coin_balance": 8, "point_value": 1, "point_discount_value": 12500, "nearest_expiring": { "point": { "amount": 150, "date": "2026-12-31" }, "coin": null }, "recent_transactions": [ "… sama seperti item riwayat …" ] } ``` - `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang. - `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang. - `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa. ### GET /customer/wallet/transactions | Query | Tipe | Keterangan | | --- | --- | --- | | `page` | int | Default 1 | | `limit` | int | 1–100, default 20 | | `currency` | `POINT` \| `COIN` | Opsional | | `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,TRANSFER_IN` | | `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif | ```json { "data": [ { "id": "…", "currency": "POINT", "type": "EARN", "amount": 875, "balance_after": 12500, "description": "Belanja #ORD-0123 di Outlet Kemang", "source": { "type": "ORDER", "id": "…" }, "outlet_id": "…", "group_id": null, "expires_at": "2026-12-31T23:59:59+07:00", "lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }], "created_at": "2026-09-30T12:01:00Z" } ], "pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 } } ``` `amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi. ### GET /customer/wallet/expiring ```json { "point": [ { "amount": 150, "date": "2026-10-31" }, { "amount": 200, "date": "2026-12-31" } ], "coin": [] } ``` Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa. ### PUT /customer/devices ```json { "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } ``` Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`. ## Customer app: PIN PIN 6 digit wajib untuk exchange dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu. | Method | Path | Body | Response | | --- | --- | --- | --- | | GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` | | POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` | | POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | | PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN | | POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN | 1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer. 2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; exchange tetap bisa. 3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama. PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum. ## Customer app: exchange, transfer, game | Method | Path | PIN | Idempotency-Key | | --- | --- | --- | --- | | GET | `/customer/wallet/exchange/preview?coins=` | – | – | | POST | `/customer/wallet/exchange` | Ya | Wajib | | GET | `/customer/wallet/transfer/recipient?phone=` | – | – | | POST | `/customer/wallet/transfer` | Ya | Wajib | | POST | `/customer/enakgame/sessions` | – | Wajib | ### GET /customer/wallet/exchange/preview?coins=30 ```json { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } ``` Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`. ### POST /customer/wallet/exchange Body `{ "coins": 30, "pin": "482913" }`. Response: ```json { "group_id": "…", "coins": 30, "points": 9, "coin_amount": 10, "point_amount": 3, "lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }], "coin_balance": 5, "point_balance": 9, "replayed": false } ``` `coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`). ### GET /customer/wallet/transfer/recipient?phone=081234561234 ```json { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } ``` Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`. ### POST /customer/wallet/transfer Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response: ```json { "group_id": "…", "currency": "POINT", "amount": 120, "recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }, "lots": [ { "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" }, { "amount": 20, "expires_at": null } ], "balance": 30, "replayed": false } ``` `currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`. ### Game (EnakGame) `POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`: - `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda). - `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`. - `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar). Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md). ## POS: earning, void, dan refund EnakPoint bukan payment method: tidak ada lagi tipe `point`, dan `POST /payments` memakai `amount` seperti pembayaran lain. Response order membawa `points_earned` dan `coins_earned` untuk struk. Saat order di-void atau direfund, EnakPoint dan EnakCoin yang didapat dari order itu ikut ditarik (`EARN_REVERSAL`); bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan. ## Dashboard Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md). | Method | Path | Keterangan | | --- | --- | --- | | GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning EnakPoint dan EnakCoin per outlet | | GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) | | GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) | | GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli | | POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual | | GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir | | DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer | | GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) | Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak. ### /outlets/:outlet_id/loyalty-settings ```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 } } ``` Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal. Objek `point_payment` sudah dihapus; `PUT` yang masih mengirimnya ditolak `310` (field tidak dikenal). ### /marketing/loyalty-settings ```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": { "enabled": false, "mode": "FIXED_DATE", "fixed_dates": ["12-31"], "grace_months": 3, "period": 12, "unit": "MONTH", "end_of_month": false, "reminder_days": 7 }, "coin_expiry": { "…": "sama dengan point_expiry" }, "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } } ``` | Field kedaluwarsa | Dipakai mode | Nilai | | --- | --- | --- | | `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) | | `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak | | `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya | | `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` | | `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan | | `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat | Response menambahkan: - `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs. - `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). - `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal. - `changes` dan `dry_run`. ### POST /marketing/customers/:id/wallet/adjust ```json { "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } ``` `amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. ### GET /marketing/wallet-transactions/:id/trace ```json { "transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "TRANSFER_OUT", "amount": -30, "…": "…" }, "lots": [ { "amount": 30, "chain": [ { "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } }, { "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } } ] } ] } ``` Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer atau exchange sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`. ### PIN customer `DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat. ## Referensi ### Tipe mutasi (`type`) | `type` | Arah | Arti | `source` / `destination` | | --- | --- | --- | --- | | `EARN` | + | Didapat dari order lunas | `ORDER` | | `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | | `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) | | `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) | | `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) | | `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) | | `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` | | `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | | `ADJUSTMENT` | + / − | Koreksi admin | `USER` | | `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | ### Notifikasi push (FCM) Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`. | `data.type` | Kapan | Isi `data` lainnya | | --- | --- | --- | | `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | | `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` | | `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | | `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) | ### Endpoint dan field deprecated Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`. | Lama | Pengganti | | --- | --- | | `GET /customer/points` | `GET /customer/wallet` → `point_balance` | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | ### Endpoint dan field yang dihapus Bayar dengan EnakPoint dihapus pada 7 Okt 2026 karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya; jangan dipanggil lagi. | Dihapus | Catatan | | --- | --- | | `POST /customer/wallet/payment-code` | Kode bayar untuk kasir | | `POST /customer/orders/:id/pay-with-points` | Bayar order dari app / self-order | | `GET /orders/:id/point-payment/preview` | Batas pembayaran EnakPoint di POS | | Payment method tipe `point`; field `points` dan `payment_code` di `POST /payments` | `amount` kembali wajib seperti pembayaran lain | | `points_used`, `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id` | – | | `accepts_point_payment` di `GET /customer/outlets` | – | | `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di `/outlets/:outlet_id/loyalty-settings` | `PUT` yang masih mengirimnya ditolak `310` | | `summary.point_amount`, `summary.points_used`, `summary.total_with_points`, serta `points_used` dan `counts_as_cash_in` per baris di analytics payment method | `summary.total_amount` kembali total semua method; persentase dihitung dari total itu | | Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` | Tidak ditulis lagi | Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).