# 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). --- ## 1. Konsep inti | | EnakPoint (`POINT`) | EnakCoin (`COIN`) | |---|---|---| | Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin | | Dipakai untuk | **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, tidak ada kembalian, dan bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …". 3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar, buat kode bayar, exchange, 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 dan penerimaan pembayaran 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,PAYMENT&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, pembayaran, 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` | | `PAYMENT` | − | Membayar order | `PAYMENT` | | `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` | | `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**; pembayaran dan 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. Membayar dengan EnakPoint Ada dua jalur. Keduanya memakai logika perhitungan yang sama. ### 4.1 Batas pembayaran EnakPoint maksimal yang bisa dipakai untuk satu order: ``` batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint) maks_point = min(saldo_customer, floor(batas_rupiah / point_value)) ``` Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu `points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split). ### 4.2 POS — kode bayar dari aplikasi customer PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri: 1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`. ```json { "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" } ``` Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**, sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama. 2. **POS:** tampilkan batas untuk tombol "pakai maksimal": `GET /api/v1/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.). 3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method bertipe `point`: `POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain) ```json { "order_id": "…", "payment_method_id": "", "points": 12500, "payment_code": "482913" } ``` `amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`). Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya "EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi `completed`; jika belum, sisanya dibayar dengan method lain. Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri, lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta customer membuat kode baru. **Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim `?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint. ### 4.3 Customer app / self-order — bayar order sendiri `POST /api/v1/customer/orders/:id/pay-with-points` ```json { "points": 12500, "pin": "482913" } ``` Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama dengan response pembayaran di §4.2. ### 4.4 Void dan refund - **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint. - **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah 1 EnakPoint hangus. - **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya sendiri; mencoba lewat tunai dijawab `304`. - EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari sejak refund. - 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. ### 4.5 Earning di layar order dan struk Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas. --- ## 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 `POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). Response: ```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 dijawab `304`; tidak ada EnakCoin yang terpotong. Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. --- ## 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`. --- ## 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_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 } } ``` Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan `point_value` organisasi dan `point_cashback_percent` (`earn_value × point_value / earn_per_amount × 100`). **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 … } } ``` 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, exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari pembayaran 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** - [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir. - [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal". - [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk. - [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai. **Dashboard** - [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan `expiry_activations` sebelum owner menyimpan setting. - [ ] Isi `metadata.coin_cost` untuk setiap game.