docs(loyalty): EnakPoint & EnakCoin integration guide
Adds docs/integration-enakpoint.md for the customer app, POS and dashboard teams (PC-602), in the style of the weight-based products guide. It covers the response envelope and error codes, balances and history with every ledger type, the expiring list and FCM push types with their data, the PIN flows and the four PIN error codes, paying with EnakPoint at the cashier (payment code, preview, POST /payments) and in the app, void and refund rules, exchange and transfer with Idempotency-Key, games on EnakCoin, the deprecated endpoints and fields with their replacements, the dashboard's outlet and organization settings including both expiry models, the customer wallet, adjustments, trace and PIN removal, and a checklist per team. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d1e543a79f
commit
3cd88a55c8
@@ -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 <token customer>`.
|
||||||
|
|
||||||
|
### 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": "<id method EnakPoint>",
|
||||||
|
"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": "<id game>" }`. 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.
|
||||||
Reference in New Issue
Block a user