Files
apskel-pos-backend/docs/api-enakpoint.md
T

378 lines
19 KiB
Markdown
Raw Normal View History

# API EnakPoint & EnakCoin
30 Sep 2026
2026-10-07 13:48:29 +07:00
Semua endpoint EnakPoint (`POINT`, hanya untuk ditukar ke voucher) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat.
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus, karena 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 Referensi → Endpoint dan field yang dihapus.
## Konvensi umum
| Klien | Autentikasi | Prefix |
| --- | --- | --- |
| Customer app / self-order | `Authorization: Bearer <token customer>` | `/api/v1/customer` |
| POS | Token user (kasir/manager) | `/api/v1` |
| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` |
**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan.
| `code` | HTTP | Arti |
| --- | --- | --- |
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) |
| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain |
| `429` | 429 | OTP diminta ulang terlalu cepat |
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
| `PIN_INVALID` | 400 | PIN salah |
| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah |
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN |
| `900` | 500 | Kesalahan server |
**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit.
2026-10-07 13:48:29 +07:00
**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`.
**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya.
## Customer app: saldo & riwayat
| Method | Path | Keterangan |
| --- | --- | --- |
| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir |
| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter |
| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal |
| PUT | `/customer/devices` | Daftarkan token FCM device |
| DELETE | `/customer/devices/:device_id` | Hapus device saat logout |
2026-10-07 13:48:29 +07:00
| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `earns_points`, `earns_coins` |
| GET | `/customer/orders` | Riwayat order customer (`page`, `limit`), dengan `points_earned` / `coins_earned` |
2026-10-07 13:48:29 +07:00
| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint/EnakCoin yang didapat; order customer lain → `404` |
Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di [`mobile-customer-enakpoint.md`](./mobile-customer-enakpoint.md) §4.4–§4.5.
### GET /customer/wallet
```json
{
"point_balance": 12500,
"coin_balance": 8,
"point_value": 1,
"point_discount_value": 12500,
"nearest_expiring": {
"point": { "amount": 150, "date": "2026-12-31" },
"coin": null
},
"recent_transactions": [ "… sama seperti item riwayat …" ]
}
```
- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang.
- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang.
- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa.
### GET /customer/wallet/transactions
| Query | Tipe | Keterangan |
| --- | --- | --- |
| `page` | int | Default 1 |
| `limit` | int | 1–100, default 20 |
| `currency` | `POINT` \| `COIN` | Opsional |
2026-10-07 13:48:29 +07:00
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,TRANSFER_IN` |
| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif |
```json
{
"data": [
{
"id": "…",
"currency": "POINT",
"type": "EARN",
"amount": 875,
"balance_after": 12500,
"description": "Belanja #ORD-0123 di Outlet Kemang",
"source": { "type": "ORDER", "id": "…" },
"outlet_id": "…",
"group_id": null,
"expires_at": "2026-12-31T23:59:59+07:00",
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
"created_at": "2026-09-30T12:01:00Z"
}
],
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
}
```
`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi.
### GET /customer/wallet/expiring
```json
{
"point": [
{ "amount": 150, "date": "2026-10-31" },
{ "amount": 200, "date": "2026-12-31" }
],
"coin": []
}
```
Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
### PUT /customer/devices
```json
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
```
Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`.
## Customer app: PIN
2026-10-07 13:48:29 +07:00
PIN 6 digit wajib untuk exchange dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
| Method | Path | Body | Response |
| --- | --- | --- | --- |
| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` |
| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` |
| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN |
| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer.
2026-10-07 13:48:29 +07:00
2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; exchange tetap bisa.
3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama.
PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum.
2026-10-07 13:48:29 +07:00
## Customer app: exchange, transfer, game
| Method | Path | PIN | Idempotency-Key |
| --- | --- | --- | --- |
| GET | `/customer/wallet/exchange/preview?coins=` | – | – |
| POST | `/customer/wallet/exchange` | Ya | Wajib |
| GET | `/customer/wallet/transfer/recipient?phone=` | – | – |
| POST | `/customer/wallet/transfer` | Ya | Wajib |
| POST | `/customer/enakgame/sessions` | – | Wajib |
### GET /customer/wallet/exchange/preview?coins=30
```json
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
```
Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`.
### POST /customer/wallet/exchange
Body `{ "coins": 30, "pin": "482913" }`. Response:
```json
{
"group_id": "…",
"coins": 30,
"points": 9,
"coin_amount": 10,
"point_amount": 3,
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
"coin_balance": 5,
"point_balance": 9,
"replayed": false
}
```
`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`).
### GET /customer/wallet/transfer/recipient?phone=081234561234
```json
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
```
Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`.
### POST /customer/wallet/transfer
Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response:
```json
{
"group_id": "…",
"currency": "POINT",
"amount": 120,
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
"lots": [
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
{ "amount": 20, "expires_at": null }
],
"balance": 30,
"replayed": false
}
```
`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`.
### Game (EnakGame)
`POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`:
- `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda).
- `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`.
- `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar).
Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md).
2026-10-07 13:48:29 +07:00
## POS: earning, void, dan refund
2026-10-07 13:48:29 +07:00
EnakPoint bukan payment method: tidak ada lagi tipe `point`, dan `POST /payments` memakai `amount` seperti pembayaran lain. Response order membawa `points_earned` dan `coins_earned` untuk struk. Saat order di-void atau direfund, EnakPoint dan EnakCoin yang didapat dari order itu ikut ditarik (`EARN_REVERSAL`); bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.
## Dashboard
Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md).
| Method | Path | Keterangan |
| --- | --- | --- |
2026-10-07 13:48:29 +07:00
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning EnakPoint dan EnakCoin per outlet |
| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) |
| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) |
| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli |
| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual |
| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir |
| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer |
| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) |
Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak.
### /outlets/:outlet_id/loyalty-settings
```json
{
2026-10-02 14:19:12 +07:00
"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 },
2026-10-07 13:48:29 +07:00
"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 }
}
```
2026-10-07 13:48:29 +07:00
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal. Objek `point_payment` sudah dihapus; `PUT` yang masih mengirimnya ditolak `310` (field tidak dikenal).
### /marketing/loyalty-settings
```json
{
"point_value": 1,
"exchange": { "coin_amount": 1, "point_amount": 1 },
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
"point_expiry": {
"enabled": false,
"mode": "FIXED_DATE",
"fixed_dates": ["12-31"],
"grace_months": 3,
"period": 12,
"unit": "MONTH",
"end_of_month": false,
"reminder_days": 7
},
"coin_expiry": { "…": "sama dengan point_expiry" },
"enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 }
}
```
| Field kedaluwarsa | Dipakai mode | Nilai |
| --- | --- | --- |
| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) |
| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak |
| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya |
| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` |
| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan |
| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat |
Response menambahkan:
- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs.
- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak).
- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal.
- `changes` dan `dry_run`.
### POST /marketing/customers/:id/wallet/adjust
```json
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
```
`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`.
### GET /marketing/wallet-transactions/:id/trace
```json
{
2026-10-07 13:48:29 +07:00
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "TRANSFER_OUT", "amount": -30, "…": "…" },
"lots": [
{
"amount": 30,
"chain": [
{ "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } },
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } }
]
}
]
}
```
2026-10-07 13:48:29 +07:00
Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer atau exchange sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`.
### PIN customer
`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat.
## Referensi
### Tipe mutasi (`type`)
| `type` | Arah | Arti | `source` / `destination` |
| --- | --- | --- | --- |
| `EARN` | + | Didapat dari order lunas | `ORDER` |
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) |
| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) |
| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) |
| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) |
| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` |
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
| `ADJUSTMENT` | + / − | Koreksi admin | `USER` |
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
### Notifikasi push (FCM)
Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`.
| `data.type` | Kapan | Isi `data` lainnya |
| --- | --- | --- |
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` |
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
### Endpoint dan field deprecated
Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
| Lama | Pengganti |
| --- | --- |
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
2026-10-07 13:48:29 +07:00
### Endpoint dan field yang dihapus
Bayar dengan EnakPoint dihapus pada 7 Okt 2026 karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya; jangan dipanggil lagi.
| Dihapus | Catatan |
| --- | --- |
| `POST /customer/wallet/payment-code` | Kode bayar untuk kasir |
| `POST /customer/orders/:id/pay-with-points` | Bayar order dari app / self-order |
| `GET /orders/:id/point-payment/preview` | Batas pembayaran EnakPoint di POS |
| Payment method tipe `point`; field `points` dan `payment_code` di `POST /payments` | `amount` kembali wajib seperti pembayaran lain |
| `points_used`, `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id` | – |
| `accepts_point_payment` di `GET /customer/outlets` | – |
| `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di `/outlets/:outlet_id/loyalty-settings` | `PUT` yang masih mengirimnya ditolak `310` |
| `summary.point_amount`, `summary.points_used`, `summary.total_with_points`, serta `points_used` dan `counts_as_cash_in` per baris di analytics payment method | `summary.total_amount` kembali total semua method; persentase dihitung dari total itu |
| Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` | Tidak ditulis lagi |
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).