feat(loyalty): remove paying with EnakPoint

EnakPoint can only be redeemed for vouchers now: it can no longer pay for
orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001,
EG-002). No order was ever paid with EnakPoint, so there is no data to
move.

Removed:
- POST /customer/wallet/payment-code, POST /customer/orders/:id/pay-with-points
  and GET /orders/:id/point-payment/preview, with their processors,
  repositories, services, handlers and tests.
- The point payment method type: paying, splitting and refunding with it,
  the outlet filter on the method list, and the system-method guard.
- points and payment_code on CreatePayment; points_used and point_value
  on payments; accepts_point_payment on the customer outlets.
- The outlet point_payment settings. A PUT that still sends them is
  rejected as an unknown field.
- The EnakPoint split in the payment method analytics.
- PAYMENT and PAYMENT_REFUND from the wallet type rules. Tests that used
  them as a generic EnakPoint debit use REWARD_REDEEM.
- The EnakPoint-paid part from the earning basis, which is
  subtotal − discount again.

Migration 000102 drops the trigger, the point methods and their index,
the payments columns, and the outlet settings, and restores the method
type CHECK without point. payments.payment_method_id is ON DELETE
RESTRICT, so it fails rather than lose a payment made with EnakPoint.

The integration docs list the removed endpoints and fields, and the
EnakPoint & EnakCoin PRD and tasks note what is superseded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 13:48:29 +07:00
co-authored by Claude Opus 5.5
parent 3ebc09f818
commit 2c9753fae7
87 changed files with 423 additions and 3071 deletions
+33 -94
View File
@@ -2,7 +2,9 @@
30 Sep 2026
Semua endpoint EnakPoint (`POINT`, bisa bayar order) 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.
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
@@ -28,7 +30,7 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk
**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.
**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`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain.
**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.
@@ -41,9 +43,9 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk
| 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 |
| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `accepts_point_payment`, `earns_points`, `earns_coins` |
| 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` |
| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint yang dipakai; order customer lain → `404` |
| 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.
@@ -74,7 +76,7 @@ Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` ops
| `page` | int | Default 1 |
| `limit` | int | 1–100, default 20 |
| `currency` | `POINT` \| `COIN` | Opsional |
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` |
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,TRANSFER_IN` |
| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif |
```json
@@ -125,7 +127,7 @@ Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `f
## Customer app: PIN
PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
PIN 6 digit wajib untuk exchange dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
| Method | Path | Body | Response |
| --- | --- | --- | --- |
@@ -136,37 +138,21 @@ PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta custome
| 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.
2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa.
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.
## Customer app: bayar, exchange, transfer, game
## Customer app: exchange, transfer, game
| Method | Path | PIN | Idempotency-Key |
| --- | --- | --- | --- |
| POST | `/customer/wallet/payment-code` | Ya | – |
| POST | `/customer/orders/:id/pay-with-points` | Ya | – |
| 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/spin` | – | – |
### POST /customer/wallet/payment-code
Body `{ "pin": "482913" }`. Response:
```json
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
```
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama.
### POST /customer/orders/:id/pay-with-points
Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama.
### GET /customer/wallet/exchange/preview?coins=30
```json
@@ -238,69 +224,9 @@ Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong.
## POS: pembayaran EnakPoint
## POS: earning, void, dan refund
Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir.
| Method | Path | Keterangan |
| --- | --- | --- |
| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini |
| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) |
| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint |
1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya.
2. POS memanggil preview untuk tombol "pakai maksimal".
3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa.
### GET /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.). Batas yang dipakai:
```
batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint)
maks_point = min(saldo, floor(batas_rupiah / point_value))
```
### POST /payments
Header `X-Idempotency-Key` wajib.
```json
{
"order_id": "…",
"payment_method_id": "<id method EnakPoint>",
"points": 12500,
"payment_code": "482913"
}
```
- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian).
- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`).
- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`.
- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru.
- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint.
### Void dan refund
- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint.
- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus.
- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`.
- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.
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
@@ -308,7 +234,7 @@ Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke
| Method | Path | Keterangan |
| --- | --- | --- |
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet |
| 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 |
@@ -324,12 +250,11 @@ Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang;
```json
{
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
"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 }
}
```
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, `max_payment_percent` 0–100.
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
@@ -380,7 +305,7 @@ Response menambahkan:
```json
{
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" },
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "TRANSFER_OUT", "amount": -30, "…": "…" },
"lots": [
{
"amount": 30,
@@ -393,7 +318,7 @@ Response menambahkan:
}
```
Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`.
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
@@ -407,8 +332,6 @@ Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat
| --- | --- | --- | --- |
| `EARN` | + | Didapat dari order lunas | `ORDER` |
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` |
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
| `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`) |
@@ -438,4 +361,20 @@ Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi p
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
### 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).
+15 -28
View File
@@ -4,6 +4,8 @@
Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game.
> **Perubahan 7 Okt 2026:** bayar 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). Akibatnya setting outlet tidak lagi punya `point_payment`, method "EnakPoint" (tipe `point`) tidak ada lagi di Payment Method, dan laporan per payment method tidak lagi membawa `point_amount`, `points_used`, `total_with_points`, atau `counts_as_cash_in`; `summary.total_amount` kembali total semua method.
## Layar yang perlu dibuat
Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`.
@@ -20,21 +22,20 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a
Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada.
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan.
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan.
**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error).
## Setting loyalitas outlet
Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya.
Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order. Semua nilai default mati sampai owner menyalakannya.
`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak.
`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak (termasuk `point_payment` yang sudah dihapus).
```json
{
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
"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 }
}
```
@@ -47,17 +48,14 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord
| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal |
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
**Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint.
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak.
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu.
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan".
## Setting loyalitas organisasi
@@ -102,7 +100,7 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s
| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint |
| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … |
Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu.
Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: exchange yang sudah terjadi memakai kurs saat itu.
## Pengaturan kedaluwarsa
@@ -159,7 +157,7 @@ Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo da
### Saldo, lot, dan riwayat
`GET /marketing/customers/:id/wallet?page=1&limit=20&currency=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
`GET /marketing/customers/:id/wallet?page=1&limit=20&currency=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
```json
{
@@ -189,7 +187,7 @@ Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo da
- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit).
- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`.
- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall).
- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata` (kurs, rumus earning, shortfall).
### Adjustment manual
@@ -214,7 +212,7 @@ Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-tr
```json
{
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" },
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "reference_id": "…", "created_at": "…" },
"lots": [
{
"amount": 30,
@@ -229,7 +227,7 @@ Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-tr
Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya.
## PIN, riwayat setting, game, dan method EnakPoint
## PIN, riwayat setting, dan game
### PIN & keamanan customer
@@ -270,22 +268,12 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
### Method pembayaran EnakPoint
Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`):
- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti.
- Tipe `point` tidak ditawarkan saat membuat method baru.
- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint".
Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk.
## Pesan error dan checklist
| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan |
| --- | --- | --- | --- |
| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer |
| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast |
| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong | `cause` di dekat field atau di toast |
| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" |
| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" |
@@ -302,8 +290,7 @@ Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalt
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`.
- [ ] Tombol Telusuri ada di setiap baris riwayat.
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
- [ ] Method EnakPoint tampil sebagai method sistem.
- [ ] Form game punya input `coin_cost`.
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu.
Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu.
+54 -138
View File
@@ -6,6 +6,12 @@ lama tetap jalan sebagai alias (lihat §8)
Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan
ada di [`prd-point-coin.md`](./prd-point-coin.md).
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi
> `000102`). EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai
> sebagai alat bayar dan tidak bisa dicairkan
> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus
> ada di §8.
---
## 1. Konsep inti
@@ -13,21 +19,21 @@ ada di [`prd-point-coin.md`](./prd-point-coin.md).
| | 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 |
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
| Bisa ditransfer | Ya | Ya |
| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner |
Aturan yang berlaku di seluruh dokumen ini:
1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint".
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, 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.
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, dan EnakPoint tidak bisa
dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan
Rp …"**, bukan "saldo Rp …".
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange
dan transfer. Main game tidak butuh PIN.
4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet
organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan
kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet.
kedaluwarsa diatur per organisasi; earning per outlet.
5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah
dihapus. Koreksi muncul sebagai baris baru.
@@ -91,7 +97,7 @@ Semua endpoint customer memakai header `Authorization: Bearer <token customer>`.
### 2.2 Riwayat
`GET /api/v1/customer/wallet/transactions?page=1&limit=20&currency=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30`
`GET /api/v1/customer/wallet/transactions?page=1&limit=20&currency=POINT&type=EARN,TRANSFER_IN&from=2026-09-01&to=2026-09-30`
Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma.
`from` / `to` tanggal WIB, inklusif.
@@ -119,8 +125,8 @@ Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipis
- `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.).
`{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, game play,
dst.).
- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer
berubah belakangan. Nama lawan transfer sudah disamarkan.
- Dua baris exchange atau transfer berbagi `group_id` yang sama.
@@ -129,8 +135,6 @@ Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipis
|---|---|---|---|
| `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` |
@@ -219,7 +223,7 @@ menghasilkan `429`.
- **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.
exchange tetap bisa.
### 3.4 Menangani error PIN
@@ -247,123 +251,19 @@ reinstall atau ganti HP.
---
## 4. Membayar dengan EnakPoint
## 4. Earning, void, dan refund
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
EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan
`POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar,
bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8).
Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang
dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas.
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan
diberikan saat order lunas.
EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila
saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah
diblokir karena ini.
---
@@ -504,6 +404,24 @@ Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena
EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2).
Tidak ada penggantinya:
- Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`,
dan `GET /orders/:id/point-payment/preview`.
- Payment method tipe `point`, serta field `points` dan `payment_code` di
`POST /payments`; `amount` kembali wajib seperti pembayaran lain.
- `points_used` dan `point_value` di response pembayaran dan di `payments` pada
`GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`.
- Objek `point_payment` (`accept_payment`, `min_payment_points`,
`max_payment_percent`) di setting outlet (§9.1).
- Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di
`summary`, serta `points_used` dan `counts_as_cash_in` per baris.
`summary.total_amount` kembali total semua method, dan persentase dihitung dari total
itu.
- Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi.
---
## 9. Dashboard
@@ -517,12 +435,12 @@ Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
```json
{
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
}
```
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih
mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan
`point_value` organisasi dan `point_cashback_percent`
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di
@@ -597,10 +515,10 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=
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.
butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer atau
exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar
B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A yang
mentransfer ke B.
### 9.4 PIN customer
@@ -624,10 +542,8 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=
- [ ] 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.
- [ ] Cetak `points_earned` dan `coins_earned` di struk.
- [ ] Jangan menampilkan EnakPoint sebagai payment method (§4).
**Dashboard**
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
+27 -56
View File
@@ -12,20 +12,26 @@ aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan dulu.
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|---|---|---|
| Didapat dari | Belanja (order lunas), koreksi admin, tukar EnakCoin | Belanja, koreksi admin |
| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint |
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
| Bisa dikirim ke customer lain | Ya | Ya |
| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan |
Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin, dan endpoint serta
field bernama token sudah dihapus dari API.
> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint (kode bayar di kasir, bayar order
> dari app) sudah dihapus dari backend. 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). Jangan membangun layar bayar atau kode
> bayar; endpoint dan field yang dihapus ada di §9.
### Aturan yang wajib dipatuhi di UI
1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin.
2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan
Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai.
3. **PIN 6 digit wajib** untuk: membuat kode bayar, tukar
EnakCoin, dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat
Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai, dan
EnakPoint tidak bisa dipakai membayar.
3. **PIN 6 digit wajib** untuk: tukar EnakCoin dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat
tidak butuh PIN.
4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya
nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau
@@ -107,7 +113,6 @@ Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik,
| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – |
| Daftar outlet | `GET /customer/outlets` | – |
| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – |
| Kode bayar (angka + QR) | `POST /customer/wallet/payment-code` | Ya |
| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya |
| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya |
| PIN (buat, ganti, lupa) | `/customer/pin/*` | – |
@@ -141,7 +146,7 @@ Tampilkan:
- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan
kedaluwarsa pada {date}" yang membuka layar §4.3.
- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2.
- Tombol aksi: Bayar di kasir (§7.1), Tukar EnakCoin (§8.1), Transfer (§8.2), Main game (§9).
- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8).
Muat ulang beranda setelah setiap transaksi dan saat menerima push (§5).
@@ -157,7 +162,7 @@ Query (semua opsional):
| `page` | `1` | Mulai dari 1 |
| `limit` | `20` | 1–100, default 20 |
| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin |
| `type` | `EARN,PAYMENT` | Satu atau beberapa tipe dipisah koma, untuk filter |
| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter |
| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif |
```json
@@ -196,8 +201,6 @@ Label tipe:
|---|---|---|
| `EARN` | Dari belanja | + |
| `EARN_REVERSAL` | Dibatalkan (order di-void/refund) | − |
| `PAYMENT` | Bayar pesanan | − |
| `PAYMENT_REFUND` | Pengembalian pembayaran | + |
| `EXCHANGE_OUT` | Ditukar ke EnakPoint | − |
| `EXCHANGE_IN` | Hasil tukar EnakCoin | + |
| `TRANSFER_OUT` | Transfer keluar | − |
@@ -235,7 +238,6 @@ berdasarkan nama.
"id": "…",
"name": "Gokuna Kemang",
"address": "Jl. Kemang Raya 10",
"accepts_point_payment": true,
"earns_points": true,
"earns_coins": false
}
@@ -243,8 +245,6 @@ berdasarkan nama.
```
- `address` bisa `null`.
- `accepts_point_payment`: kasir di outlet ini menerima pembayaran EnakPoint. Pakai
untuk label "Bisa bayar pakai EnakPoint".
- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin.
- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend.
@@ -318,8 +318,7 @@ hanya masuk ke sini bila kasir mengaitkannya ke customer.
}
],
"payments": [
{ "id": "…", "method_name": "EnakPoint", "method_type": "point", "amount": 12500, "status": "completed", "refund_amount": 0, "points_used": 12500, "point_value": 1, "created_at": "…" },
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 86500, "status": "completed", "refund_amount": 0, "created_at": "…" }
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" }
]
}
```
@@ -327,7 +326,6 @@ hanya masuk ke sini bila kasir mengaitkannya ke customer.
- Order customer lain atau yang tidak ada → `404`.
- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada.
- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons".
- Pembayaran EnakPoint membawa `points_used`; tampilkan "EnakPoint 12.500 (Rp 12.500)".
- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" /
"Direfund".
@@ -405,7 +403,7 @@ Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur.
2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`.
Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
bayar dan tukar tetap bisa. Beri tahu customer hal ini di layar sukses.
tukar tetap bisa. Beri tahu customer hal ini di layar sukses.
### 6.5 Menangani error PIN
@@ -428,41 +426,9 @@ ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app
---
## 7. Membayar dengan EnakPoint
## 7. Tukar dan transfer
App customer tidak membuat atau membayar order; order hanya bisa dilihat (§4.5).
EnakPoint hanya dipakai membayar di kasir, lewat kode bayar dari app. Jangan membangun
layar checkout atau memanggil `POST /customer/orders/:id/pay-with-points`.
### 7.1 Di kasir — kode bayar
Customer tidak pernah mengetik PIN di mesin kasir. Alurnya:
1. Customer membuka "Bayar di kasir" dan memasukkan PIN.
2. `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`:
```json
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
```
3. Tampilkan `code` besar (angka) **dan** QR dari `qr_payload` (string apa adanya).
4. Tampilkan hitung mundur ke `expires_at` (2 menit). Setelah habis, sembunyikan kode
dan tampilkan tombol "Buat kode baru".
5. Kasir memindai/mengetik kode dan memilih jumlah EnakPoint. App tidak menerima
callback; setelah customer kembali ke beranda, muat ulang saldo.
Kode sekali pakai. Membuat kode baru membatalkan kode lama.
### 7.2 Refund
Bila order yang dibayar EnakPoint dibatalkan atau direfund, EnakPoint kembali sebagai
EnakPoint (tidak pernah tunai) dan muncul di riwayat sebagai `PAYMENT_REFUND`.
---
## 8. Tukar dan transfer
### 8.1 Tukar EnakCoin → EnakPoint
### 7.1 Tukar EnakCoin → EnakPoint
1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik):
@@ -503,7 +469,7 @@ EnakPoint (tidak pernah tunai) dan muncul di riwayat sebagai `PAYMENT_REFUND`.
3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku
sampai {tanggal}".
### 8.2 Transfer
### 7.2 Transfer
1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah.
2. Cek penerima:
@@ -556,7 +522,7 @@ Penerima mendapat push `WALLET_TRANSFER_IN`.
---
## 9. Game (memakai EnakCoin)
## 8. Game (memakai EnakCoin)
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
@@ -577,7 +543,7 @@ Penerima mendapat push `WALLET_TRANSFER_IN`.
---
## 10. Yang sudah dihapus / deprecated
## 9. Yang sudah dihapus / deprecated
Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
@@ -586,6 +552,12 @@ Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` |
| `total_tokens`, `tokens_history` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` |
| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` |
| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar |
| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar |
| `GET /orders/:id/point-payment/preview` (POS) | Tidak ada; EnakPoint tidak bisa untuk bayar |
| `accepts_point_payment` di `GET /customer/outlets` | – |
| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – |
| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi |
Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
@@ -596,7 +568,7 @@ Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
---
## 11. Checklist selesai
## 10. Checklist selesai
- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat.
- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, label tipe sesuai §4.2.
@@ -605,10 +577,9 @@ Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
- [ ] Penanganan tap untuk keempat tipe push.
- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP.
- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN.
- [ ] Kode bayar: angka + QR, hitung mundur 2 menit, tombol buat ulang.
- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama.
- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama.
- [ ] Game memakai `coins_used` / `coins_remaining` dan menampilkan biaya per game.
- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat).
- [ ] Tidak ada pemakaian endpoint atau field di §10.
- [ ] Tidak ada pemakaian endpoint atau field di §9.
- [ ] PIN tidak pernah disimpan, di-log, atau dikirim ke analytics.
+12
View File
@@ -7,6 +7,14 @@ dengan EnakPoint, exchange EnakCoin → EnakPoint, transfer antar customer, keda
saldo, PIN customer, pengaturan per outlet dan per organisasi, migrasi dari Token
**Out of scope:** Penukaran reward, tier otomatis, eksekusi campaign rules (lihat §11)
> **Catatan 2026-10-07:** F9 (Bayar Order dengan EnakPoint), bagian pembayaran dari K2,
> dan bagian terkait (aturan kembalian/refund EnakPoint di K7, PIN untuk bayar dan kode
> bayar di K8, payment method EnakPoint, ledger `PAYMENT` / `PAYMENT_REFUND`, endpoint
> pembayaran di §9, dan bagian pembayaran fase 3 di §13) digantikan oleh
> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher,
> tidak bisa dipakai membayar dan tidak bisa dicairkan. Fitur tersebut sudah dihapus dari
> backend (migrasi `000102`). Dokumen ini dibiarkan apa adanya sebagai riwayat keputusan.
---
## 1. Latar Belakang
@@ -389,6 +397,10 @@ beredar. Karena itu:
### F9 — Bayar Order dengan EnakPoint
> **Dihapus 2026-10-07:** digantikan oleh [`enakgame-prd.md`](./enakgame-prd.md) §3.2
> (EnakPoint hanya untuk voucher) dan sudah dihapus dari backend (migrasi `000102`);
> lihat catatan di awal dokumen.
**Payment method.** Setiap organisasi otomatis punya satu payment method sistem
bernama **EnakPoint** dengan tipe baru `point` di `payment_methods`. Method ini tidak
bisa dihapus atau diubah tipenya. Muncul di kasir hanya jika outlet mengaktifkan
+7
View File
@@ -3,6 +3,13 @@
**Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md)
**Tanggal:** 2026-09-29
> **Catatan 2026-10-07:** fase 3 (Pembayaran EnakPoint) dihapus sesuai
> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher,
> bukan alat bayar. Payment method EnakPoint, kode bayar, bayar di kasir & app, refund
> EnakPoint, dan EnakPoint di laporan (PC-303 – PC-308) sudah dihapus dari backend
> (migrasi `000102`). PIN customer (PC-301) dan pengaturan loyalitas organisasi (PC-302)
> tetap dipakai. Sisa dokumen ini dibiarkan sebagai riwayat.
Setiap task menyebut bagian PRD yang dikerjakan, lapisan kode yang disentuh, task yang
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
**L** 4–5 hari.