# API EnakPoint & EnakCoin 30 Sep 2026 Semua endpoint EnakPoint (`POINT`, bisa bayar order) 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. ## 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`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain. **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/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,PAYMENT` | | `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 bayar, kode bayar, 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; pembayaran dan 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: bayar, exchange, transfer, game | Method | Path | PIN | Idempotency-Key | | --- | --- | --- | --- | | POST | `/customer/wallet/payment-code` | Ya | – | | POST | `/customer/orders/:id/pay-with-points` | Ya | – | | 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/spin` | – | – | ### POST /customer/wallet/payment-code Body `{ "pin": "482913" }`. Response: ```json { "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } ``` Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama. ### POST /customer/orders/:id/pay-with-points Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama. ### 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`. ### POST /customer/spin Body `{ "spin_id": "" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). ```json { "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, "prize_won": { "id": "…", "name": "Voucher 10rb" }, "coins_remaining": 7 } ``` EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong. ## POS: pembayaran EnakPoint Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir. | Method | Path | Keterangan | | --- | --- | --- | | GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini | | POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) | | POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint | 1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya. 2. POS memanggil preview untuk tombol "pakai maksimal". 3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa. ### GET /orders/:id/point-payment/preview ```json { "order_id": "…", "customer_id": "…", "eligible": true, "point_balance": 12500, "point_value": 1, "remaining_amount": 87500, "min_payment_points": 1, "max_payment_percent": 100, "max_points": 12500, "max_amount": 12500 } ``` Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai: ``` batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint) maks_point = min(saldo, floor(batas_rupiah / point_value)) ``` ### POST /payments Header `X-Idempotency-Key` wajib. ```json { "order_id": "…", "payment_method_id": "", "points": 12500, "payment_code": "482913" } ``` - `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian). - `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`). - Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`. - Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru. - Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint. ### Void dan refund - **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint. - **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus. - **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`. - EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; 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 dan penerimaan EnakPoint 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_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, "coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null }, "point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 } } ``` Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100. ### /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" } } ``` | 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": "PAYMENT", "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, exchange, atau refund 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` | | `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` | | `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | | `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. | Lama | Pengganti | | --- | --- | | `GET /customer/points` | `GET /customer/wallet` → `point_balance` | | `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` | | `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` | | `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` | | `sort_by=token_used` di daftar game play | `sort_by=coins_used` | Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).