diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md new file mode 100644 index 0000000..1b5e816 --- /dev/null +++ b/docs/integration-enakpoint.md @@ -0,0 +1,635 @@ +# 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, "token_used": 1, "created_at": "…" }, + "prize_won": { "id": "…", "name": "Voucher 10rb", … }, + "coins_remaining": 7, + "tokens_remaining": 7 +} +``` + +EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada +EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan +`tokens_remaining` hanya salinan untuk versi aplikasi lama. + +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`) | +| `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 respons game | `coins_used`, `coins_remaining` | +| `sort_by=token_used` di daftar game play | `sort_by=coins_used` | + +Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri, +supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus. + +--- + +## 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.