# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard **Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint lama tetap jalan sebagai alias (lihat §8) Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan ada di [`prd-point-coin.md`](./prd-point-coin.md). > **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi > `000102`). 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 §8. --- ## 1. Konsep inti | | EnakPoint (`POINT`) | EnakCoin (`COIN`) | |---|---|---| | Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin | | Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint | | Bisa ditransfer | Ya | Ya | | Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner | Aturan yang berlaku di seluruh dokumen ini: 1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint". 2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, dan EnakPoint tidak bisa dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …". 3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange dan transfer. Main game tidak butuh PIN. 4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa diatur per organisasi; earning per outlet. 5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah dihapus. Koreksi muncul sebagai baris baru. ### Format response Semua endpoint memakai amplop yang sama: ```json { "success": true, "data": { … }, "errors": null } ``` ```json { "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] } ``` | `code` | HTTP | Arti | |---|---|---| | `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format | | `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya | | `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) | | `429` | 429 | Terlalu cepat meminta ulang (OTP) | | `PIN_NOT_SET` | 403 | Customer belum membuat PIN | | `PIN_INVALID` | 400 | PIN salah | | `PIN_LOCKED` | 423 | PIN terkunci | | `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | | `900` | 500 | Kesalahan server | --- ## 2. Customer app — saldo & riwayat Semua endpoint customer memakai header `Authorization: Bearer `. ### 2.1 Saldo `GET /api/v1/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": [ … ] } ``` - `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**. - `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai "setara potongan Rp 12.500". - `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa. - `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2. ### 2.2 Riwayat `GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,TRANSFER_IN&from=2026-09-01&to=2026-09-30` Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma. `from` / `to` 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": "…", "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: positif menambah saldo, negatif mengurangi. - Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk `{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, game play, dst.). - `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer berubah belakangan. Nama lawan transfer sudah disamarkan. - Dua baris exchange atau transfer berbagi `group_id` yang sama. | `type` | Arah | Arti | `source` / `destination` | |---|---|---|---| | `EARN` | + | Didapat dari order lunas | `ORDER` | | `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` | | `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) | | `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) | | `GAME_SPEND` | − | Main game | `GAME_PLAY` | | `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` | | `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` | | `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` | ### 2.3 Yang akan kedaluwarsa `GET /api/v1/customer/wallet/expiring` ```json { "point": [ { "amount": 150, "date": "2026-10-31" }, { "amount": 200, "date": "2026-12-31" } ], "coin": [] } ``` Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa. ### 2.4 Notifikasi push (FCM) Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token baru**: `PUT /api/v1/customer/devices` ```json { "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" } ``` `platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil `DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis tidak menerima notifikasi di HP itu lagi. Push yang dikirim, dibedakan lewat `data.type`: | `data.type` | Kapan | Isi `data` lainnya | |---|---|---| | `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | | `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, 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) | Semua nilai di `data` berupa string, sesuai aturan FCM. --- ## 3. Customer app — PIN PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol di depan tidak hilang. PIN tidak pernah dikembalikan di response. ### 3.1 Cek status `GET /api/v1/customer/pin/status` ```json { "has_pin": true, "locked_until": null, "transfer_blocked_until": null } ``` Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN (`has_pin: false`), bukan saat registrasi. ### 3.2 Membuat PIN pertama kali 1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`. 2. `POST /api/v1/customer/pin` dengan `{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`. PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat menghasilkan `429`. ### 3.3 Mengganti dan mereset PIN - **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`. - **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**; exchange tetap bisa. ### 3.4 Menangani error PIN Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data` **tidak** `null`: ```json { "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }] } ``` | `data.code` | Field tambahan | Yang ditampilkan aplikasi | |---|---|---| | `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) | | `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" | | `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN | | `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" | Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan reinstall atau ganti HP. --- ## 4. Earning, void, dan refund EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan `POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar, bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8). Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan diberikan saat order lunas. EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah diblokir karena ini. --- ## 5. Exchange EnakCoin → EnakPoint Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). 1. **Preview** sebelum minta PIN: `GET /api/v1/customer/wallet/exchange/preview?coins=30` ```json { "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true } ``` Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`, atau EnakCoin tidak cukup). 2. **Tukar:** `POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib, maks. 50 karakter, satu key per percobaan tukar) ```json { "coins": 30, "pin": "482913" } ``` ```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 } ``` - Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum** PIN dicek, jadi tidak memakan jatah percobaan PIN. - Exchange tidak bisa dibatalkan; tampilkan konfirmasi. - Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu. `Idempotency-Key` yang sama untuk jumlah berbeda ditolak. - EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots` menunjukkan tanggalnya). --- ## 6. Transfer ke customer lain 1. **Cek penerima** sebelum konfirmasi: `GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234` ```json { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" } ``` Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri, customer walk-in, atau customer nonaktif dijawab `304`. 2. **Kirim:** `POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib) ```json { "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" } ``` ```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`, satu jenis per transfer. - Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas ditolak `304` sebelum PIN dicek. - Transfer final dan tidak bisa dibatalkan customer. - Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`). Tampilkan ini ke pengirim. - Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4). - Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama (`replayed: true`) dan tidak dihitung dua kali terhadap batas harian. --- ## 7. Game Game lama (`POST /api/v1/customer/spin`, `GET /customer/games`, `GET /customer/ferris-wheel`, dan admin `/marketing/games`, `/marketing/game-prizes`, `/marketing/rewards`) sudah dihapus. Semua game, termasuk spin, sekarang game EnakGame: - Customer: `GET /api/v1/customer/enakgame/games`, lalu `POST /api/v1/customer/enakgame/sessions` (wajib `Idempotency-Key`, memotong `entry_cost` EnakCoin), lalu `POST /api/v1/customer/enakgame/sessions/:id/complete` (server menghitung hadiah EnakCoin). Tanpa PIN. - Dashboard: game dan biaya per main (`entry_cost`, bilangan bulat ≥ 1) diatur di `/marketing/enakgame/games`, hadiahnya di reward config. Langkah admin dan alur aplikasi untuk spin ada di [`enakgame-spin.md`](enakgame-spin.md). --- ## 8. Endpoint lama (deprecated) Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Aplikasi baru jangan memakainya. | Lama | Ganti dengan | |---|---| | `GET /customer/points` | `GET /customer/wallet` (`point_balance`) | | `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` | Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, supaya alias ini bisa dihapus. Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`. Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya: - Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`, dan `GET /orders/:id/point-payment/preview`. - Payment method tipe `point`, serta field `points` dan `payment_code` di `POST /payments`; `amount` kembali wajib seperti pembayaran lain. - `points_used` dan `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`. - Objek `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di setting outlet (§9.1). - Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di `summary`, serta `points_used` dan `counts_as_cash_in` per baris. `summary.total_amount` kembali total semua method, dan persentase dihitung dari total itu. - Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi. --- ## 9. Dashboard Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager. ### 9.1 Pengaturan per outlet `GET` / `PUT /api/v1/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 } } ``` Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan `point_value` organisasi dan `point_cashback_percent` (`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value` pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di samping setting** supaya owner tidak salah membaca skala: default di atas setara cashback 1%. ### 9.2 Pengaturan organisasi `GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview tanpa menyimpan) ```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 … }, "enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 } } ``` Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan: - `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan `point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan. - `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026". - `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya (`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu. - `changes`: key yang berubah. **Kedaluwarsa** diatur per currency dengan salah satu model: | `mode` | Cara kerja | Field yang dipakai | |---|---|---| | `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) | | `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` | - `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum hangus (0 = tanpa pengingat). - Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya. - Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`). - Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal. Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20` (tambah `outlet_id=` untuk setting outlet). ### 9.3 Wallet customer - `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer, admin, kasir, outlet). Query riwayat sama seperti §2.2. - `POST /api/v1/marketing/customers/:id/wallet/adjust` ```json { "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" } ``` `amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak. Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan". - `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer atau exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A yang mentransfer ke B. ### 9.4 PIN customer - `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin **tidak bisa** membuat, mengganti, atau melihat PIN. - `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan: `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat. --- ## 10. Checklist integrasi **Customer app** - [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout. - [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN. - [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang key yang sama saat retry. - [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang. - [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama. **POS** - [ ] Cetak `points_earned` dan `coins_earned` di struk. - [ ] Jangan menampilkan EnakPoint sebagai payment method (§4). **Dashboard** - [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan `expiry_activations` sebelum owner menyimpan setting. - [ ] Buat ulang game (termasuk spin) di `/marketing/enakgame/games` dengan `entry_cost` dan reward config ([`enakgame-spin.md`](enakgame-spin.md)).