1039 lines
60 KiB
Markdown
1039 lines
60 KiB
Markdown
# PRD: EnakPoint & EnakCoin
|
||||
|
|
|
|||
|
|
**Status:** Draft
|
|||
|
|
**Tanggal:** 2026-09-29
|
|||
|
|
**Scope:** Wallet customer (EnakPoint & EnakCoin), earning dari order, pembayaran order
|
|||
|
|
dengan EnakPoint, exchange EnakCoin → EnakPoint, transfer antar customer, kedaluwarsa
|
|||
|
|
saldo, PIN customer, pengaturan per outlet dan per organisasi, migrasi dari Token
|
|||
|
|
**Out of scope:** Penukaran reward, tier otomatis, eksekusi campaign rules (lihat §11)
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. Latar Belakang
|
|||
|
|
|
|||
|
|
Sistem saat ini punya dua saldo customer: **Point** (`customer_points`) dan **Token**
|
|||
|
|
(`customer_tokens`, per jenis `SPIN` / `RAFFLE` / `MINIGAME`). Keduanya baru sebatas
|
|||
|
|
tabel dan endpoint baca:
|
|||
|
|
|
|||
|
|
- Tidak ada jalur yang menambah saldo. Order selesai tidak menghasilkan apa pun.
|
|||
|
|
- `AddPoints` / `DeductPoints` masih `not implemented`.
|
|||
|
|
- Tidak ada riwayat transaksi. "History" di `/customer/points` sebenarnya adalah baris
|
|||
|
|
saldo itu sendiri.
|
|||
|
|
- Token hanya dipakai untuk spin game, dan pemotongannya tidak atomik (repository tidak
|
|||
|
|
memakai `DBFromContext`, jumlah baris ter-update tidak dicek).
|
|||
|
|
- Point belum bisa dipakai untuk apa pun, termasuk membayar.
|
|||
|
|
|
|||
|
|
PRD ini mendefinisikan ulang saldo customer menjadi **EnakPoint** dan **EnakCoin**,
|
|||
|
|
lengkap dengan cara mendapatkannya, memakainya, memindahkannya, masa berlakunya, dan
|
|||
|
|
jejak auditnya.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. Tujuan
|
|||
|
|
|
|||
|
|
1. Customer mendapat EnakPoint dan EnakCoin otomatis dari order yang lunas, dengan
|
|||
|
|
besaran yang diatur **per outlet**.
|
|||
|
|
2. Customer bisa **membayar order dengan EnakPoint**, penuh atau sebagian. EnakCoin
|
|||
|
|
tidak bisa dipakai membayar.
|
|||
|
|
3. Customer bisa menukar EnakCoin ke EnakPoint, satu arah, dengan kurs yang bisa diatur
|
|||
|
|
(default **1 EnakCoin = 1 EnakPoint**).
|
|||
|
|
4. Customer bisa mentransfer EnakPoint dan EnakCoin ke customer lain.
|
|||
|
|
5. EnakPoint dan EnakCoin bisa **kedaluwarsa**, dengan masa berlaku yang diatur sendiri
|
|||
|
|
oleh owner.
|
|||
|
|
6. Setiap perubahan saldo tercatat di ledger, lengkap dengan asal dan tujuannya
|
|||
|
|
**sampai ke tiap butir**, dan bisa diaudit serta direkonsiliasi dengan saldo.
|
|||
|
|
|
|||
|
|
### Bukan tujuan
|
|||
|
|
|
|||
|
|
- Menukar EnakPoint ke EnakCoin. Exchange hanya satu arah.
|
|||
|
|
- Membayar dengan EnakCoin.
|
|||
|
|
- Menunaikan EnakPoint/EnakCoin dalam bentuk apa pun (lihat K7). Ini larangan, bukan
|
|||
|
|
fitur yang ditunda.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. Istilah
|
|||
|
|
|
|||
|
|
| Istilah | Kode | Arti |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **EnakPoint** | `POINT` | Saldo yang bernilai rupiah. Satu-satunya saldo yang bisa dipakai membayar order. |
|
|||
|
|
| **EnakCoin** | `COIN` | Pengganti Token. **Mata uang untuk bermain game** (spin, ferris wheel, raffle, minigame, dan game berikutnya). Bisa ditukar ke EnakPoint. **Tidak bisa** dipakai membayar. |
|
|||
|
|
| **Wallet** | – | Saldo EnakPoint dan EnakCoin milik satu customer. |
|
|||
|
|
| **Ledger** | – | Catatan setiap mutasi saldo. Saldo wallet = jumlah seluruh mutasi di ledger. |
|
|||
|
|
| **Lot** | `wallet_lots` | Satu "paket" saldo yang masuk bersamaan, dengan asal dan tanggal kedaluwarsa sendiri. Saldo wallet = jumlah sisa semua lot. |
|
|||
|
|
| **Nilai EnakPoint** | `point_value` | Nilai rupiah dari 1 EnakPoint saat dipakai membayar. Default Rp 1. |
|
|||
|
|
| **Kurs exchange** | – | Berapa EnakCoin ditukar menjadi berapa EnakPoint. Default 1 : 1. |
|
|||
|
|
| **Basis earning** | – | Nominal order yang dipakai untuk menghitung EnakPoint/EnakCoin yang didapat. |
|
|||
|
|
|
|||
|
|
Di dokumen ini, "Point" dan "Coin" adalah singkatan dari EnakPoint dan EnakCoin.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. Keputusan Inti
|
|||
|
|
|
|||
|
|
**K1 — Token diganti EnakCoin, dan EnakCoin hanya satu jenis.**
|
|||
|
|
Jenis `SPIN` / `RAFFLE` / `MINIGAME` dihapus. **Semua game memakai EnakCoin yang
|
|||
|
|
sama**. Tidak ada lagi saldo terpisah per jenis game, dan game baru tidak menambah jenis
|
|||
|
|
saldo baru. Biaya main diatur per game (F8).
|
|||
|
|
|
|||
|
|
**K2 — Hanya EnakPoint yang bisa dipakai membayar.**
|
|||
|
|
EnakPoint didaftarkan sebagai payment method, sejajar dengan cash, kartu, dan e-wallet.
|
|||
|
|
EnakCoin tidak punya payment method, dan ledger menolak mutasi pembayaran bercurrency
|
|||
|
|
`COIN` di level database (§8). Customer yang ingin memakai EnakCoin untuk belanja harus
|
|||
|
|
menukarnya dulu ke EnakPoint (F4).
|
|||
|
|
|
|||
|
|
**K3 — Exchange hanya EnakCoin → EnakPoint. Kurs bisa diatur, default 1 : 1.**
|
|||
|
|
Kurs diatur per organisasi (F2) dalam bentuk bilangan bulat "X EnakCoin = Y EnakPoint",
|
|||
|
|
dengan default 1 : 1. Mengubah kurs langsung mengubah nilai seluruh EnakCoin yang
|
|||
|
|
beredar, jadi perubahan kurs berlaku ke depan saja, kurs yang dipakai dibekukan di
|
|||
|
|
setiap exchange, dan setiap perubahan tercatat (F2).
|
|||
|
|
|
|||
|
|
> **Implikasi.** Karena EnakCoin bisa menjadi EnakPoint, dan EnakPoint bernilai
|
|||
|
|
> rupiah, maka **setiap EnakCoin yang diberikan secara efektif juga bernilai rupiah**:
|
|||
|
|
> `nilai 1 EnakCoin = (Y / X) × nilai EnakPoint`. Besaran earning EnakCoin (F1) dan
|
|||
|
|
> hadiah EnakCoin di game perlu dihitung sebagai biaya, sama seperti EnakPoint.
|
|||
|
|
|
|||
|
|
**K4 — Wallet, nilai EnakPoint, kurs, dan kedaluwarsa berada di level organisasi.
|
|||
|
|
Earning di level outlet.**
|
|||
|
|
`customers` sudah terikat ke `organization_id`, dan `customers.phone_number` unik secara
|
|||
|
|
global, sehingga satu nomor telepon adalah satu customer di satu organisasi (Q7,
|
|||
|
|
diputuskan). Saldo berlaku di semua outlet dalam organisasi tersebut. Karena itu nilai
|
|||
|
|
EnakPoint, kurs exchange, dan aturan kedaluwarsa **harus sama di semua outlet** dan
|
|||
|
|
diatur per organisasi. Outlet hanya menentukan berapa yang didapat dari order di
|
|||
|
|
outlet itu, dan apakah outlet menerima pembayaran EnakPoint. Transfer hanya boleh antar
|
|||
|
|
customer dalam organisasi yang sama.
|
|||
|
|
|
|||
|
|
**K5 — Setiap EnakPoint dan EnakCoin punya jejak: dapat dari mana, hilang ke mana.**
|
|||
|
|
Satu mutasi = satu baris ledger, dan saldo tidak pernah diubah tanpa ledger. Setiap
|
|||
|
|
baris **wajib** menunjuk sumbernya (untuk penambahan) atau tujuannya (untuk
|
|||
|
|
pengurangan). Contohnya order mana, pembayaran mana, customer mana, game play mana,
|
|||
|
|
lot mana yang kedaluwarsa, atau admin siapa. Mutasi tanpa asal/tujuan ditolak oleh
|
|||
|
|
database, bukan cuma oleh aplikasi. Perubahan saldo dan penulisan ledger terjadi dalam
|
|||
|
|
satu transaksi database. Rinciannya di §8.1.
|
|||
|
|
|
|||
|
|
**K6 — Semua nilai EnakPoint dan EnakCoin berupa bilangan bulat.**
|
|||
|
|
Pecahan hasil perhitungan earning dibulatkan ke bawah. Pembayaran memakai EnakPoint
|
|||
|
|
utuh (tidak ada "setengah Point").
|
|||
|
|
|
|||
|
|
**K7 — EnakPoint dan EnakCoin tidak bisa ditunaikan.**
|
|||
|
|
Nilai rupiah EnakPoint hanya berlaku sebagai potongan tagihan order. EnakPoint dan
|
|||
|
|
EnakCoin tidak pernah keluar dari sistem dalam bentuk uang: tidak ada pencairan, tidak
|
|||
|
|
ada kembalian, dan tidak ada refund tunai atas bagian yang dibayar EnakPoint. Semua
|
|||
|
|
jalur yang bisa menjadi jalan untuk menunaikan ditutup:
|
|||
|
|
|
|||
|
|
| Celah | Aturan |
|
|||
|
|
|---|---|
|
|||
|
|
| Pencairan langsung | Tidak ada endpoint, menu, atau tipe ledger untuk menarik saldo menjadi uang |
|
|||
|
|
| Kembalian | Pembayaran EnakPoint tidak boleh melebihi sisa tagihan. Tidak ada kembalian tunai dari EnakPoint (F9) |
|
|||
|
|
| Refund / void order | Bagian yang dibayar EnakPoint **selalu** kembali sebagai EnakPoint, tidak pernah tunai, transfer bank, atau method lain (F9) |
|
|||
|
|
| Refund sebagian | Sisa rupiah di bawah 1 EnakPoint hangus, tidak dibayar tunai (F9) |
|
|||
|
|
| Order fiktif untuk dibatalkan | Order dibayar EnakPoint lalu di-void hanya mengembalikan EnakPoint |
|
|||
|
|
| Kedaluwarsa | Saldo yang kedaluwarsa hangus tanpa kompensasi dalam bentuk apa pun (F12) |
|
|||
|
|
| Adjustment admin | Mengurangi saldo lewat adjustment tidak disertai pembayaran uang ke customer. Alasan adjustment tidak boleh "pencairan" |
|
|||
|
|
| Transfer | Transfer hanya memindahkan saldo antar customer. Jual-beli saldo di luar sistem tidak difasilitasi dan dilarang di Syarat & Ketentuan |
|
|||
|
|
| Nilai di aplikasi | Nilai rupiah ditampilkan sebagai "setara potongan Rp …", bukan "saldo Rp …", supaya tidak terbaca seperti uang elektronik |
|
|||
|
|
|
|||
|
|
Aturan ini juga menjaga agar EnakPoint tetap berupa program loyalitas dan tidak
|
|||
|
|
diperlakukan sebagai uang elektronik (lihat catatan N3).
|
|||
|
|
|
|||
|
|
**K8 — Saldo yang dipindahkan atas permintaan customer wajib disetujui dengan PIN.**
|
|||
|
|
Customer punya PIN 6 digit yang terpisah dari password login (F11). PIN wajib untuk
|
|||
|
|
transfer, pembayaran EnakPoint, dan exchange. Password login tidak dipakai untuk
|
|||
|
|
menyetujui transaksi, karena sesi yang sudah login (HP dipinjam, HP tidak dikunci)
|
|||
|
|
tidak boleh cukup untuk memindahkan saldo.
|
|||
|
|
|
|||
|
|
| Aksi | Butuh PIN | Alasan |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Transfer EnakPoint / EnakCoin (F5) | **Ya** | Saldo keluar ke orang lain dan tidak bisa dibatalkan |
|
|||
|
|
| Bayar EnakPoint di app / self-order (F9) | **Ya** | Saldo dipakai |
|
|||
|
|
| Buat kode bayar untuk kasir (F9) | **Ya** | Kode bayar sama dengan izin memakai EnakPoint |
|
|||
|
|
| Exchange EnakCoin → EnakPoint (F4) | **Ya** | Tidak bisa dibatalkan |
|
|||
|
|
| Main game (F8) | Tidak | Nilainya kecil per aksi, dan PIN di setiap permainan merusak pengalaman bermain |
|
|||
|
|
| Lihat saldo & riwayat (F6) | Tidak | Tidak memindahkan saldo |
|
|||
|
|
| Reversal, refund, kedaluwarsa, adjustment admin | Tidak berlaku | Dijalankan sistem atau admin, bukan customer |
|
|||
|
|
|
|||
|
|
**K9 — Saldo disimpan per lot, dan saldo yang paling cepat kedaluwarsa dipakai lebih
|
|||
|
|
dulu.**
|
|||
|
|
Setiap penambahan saldo membuat lot baru dengan tanggal kedaluwarsanya sendiri. Setiap
|
|||
|
|
pengurangan mengambil dari lot yang **paling cepat kedaluwarsa**, dan setiap
|
|||
|
|
pengambilan dicatat (lot mana, berapa banyak). Dengan cara ini:
|
|||
|
|
- Customer tidak dirugikan: saldo yang hampir hangus terpakai lebih dulu.
|
|||
|
|
- Kedaluwarsa tidak bisa diakali. Transfer dan exchange **membawa tanggal kedaluwarsa
|
|||
|
|
asal**, sehingga saldo tidak bisa "diperpanjang" dengan mengirimnya bolak-balik atau
|
|||
|
|
menukarnya.
|
|||
|
|
- Asal setiap butir bisa ditelusuri, tidak hanya asal setiap mutasi (Q9, diputuskan).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. User Story
|
|||
|
|
|
|||
|
|
| # | Sebagai | Saya ingin | Supaya |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| U1 | Customer | mendapat EnakPoint dan EnakCoin setelah membayar order | belanja saya dihargai |
|
|||
|
|
| U2 | Customer | membayar order dengan EnakPoint, penuh atau sebagian | saldo saya bisa dipakai belanja |
|
|||
|
|
| U3 | Customer | melihat saldo dan riwayat, termasuk asal dan tujuan tiap mutasi | tahu dari mana saldo saya berasal dan dipakai untuk apa |
|
|||
|
|
| U4 | Customer | menukar EnakCoin menjadi EnakPoint | EnakCoin saya bisa ikut dipakai belanja |
|
|||
|
|
| U5 | Customer | mengirim EnakPoint atau EnakCoin ke teman | bisa berbagi atau menggabungkan saldo |
|
|||
|
|
| U6 | Customer | melihat berapa saldo yang akan kedaluwarsa dan kapan, serta diingatkan sebelumnya | bisa memakainya sebelum hangus |
|
|||
|
|
| U7 | Kasir | menerima pembayaran EnakPoint dengan persetujuan customer | EnakPoint customer tidak bisa dipakai tanpa izinnya |
|
|||
|
|
| U8 | Kasir | melihat EnakPoint & EnakCoin yang didapat dan dipakai di struk | bisa memberi tahu customer |
|
|||
|
|
| U9 | Owner/Manager | mengatur earning dan penerimaan EnakPoint per outlet | bisa membedakan promo antar outlet |
|
|||
|
|
| U10 | Owner/Manager | mengatur nilai rupiah EnakPoint, kurs exchange, dan masa berlaku saldo | bisa mengendalikan biaya program loyalitas |
|
|||
|
|
| U11 | Owner/Manager | melihat mutasi wallet seorang customer | bisa menangani komplain |
|
|||
|
|
| U12 | Owner/Manager | menyesuaikan saldo secara manual dengan alasan | bisa mengoreksi kesalahan |
|
|||
|
|
| U13 | Customer | menyetujui transfer, pembayaran, dan exchange dengan PIN | saldo saya aman walaupun HP saya dipinjam orang |
|
|||
|
|
| U14 | Customer | mereset PIN lewat OTP kalau lupa | tidak kehilangan akses ke saldo saya |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 6. Kebutuhan Fungsional
|
|||
|
|
|
|||
|
|
### F1 — Pengaturan per Outlet
|
|||
|
|
|
|||
|
|
Disimpan di `outlet_settings` (key–value, sudah ada).
|
|||
|
|
|
|||
|
|
**Earning.** Pengaturan EnakPoint dan EnakCoin berdiri sendiri.
|
|||
|
|
|
|||
|
|
| Key | Tipe | Default | Arti |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `loyalty.point.enabled` | bool | `false` | Outlet memberi EnakPoint |
|
|||
|
|
| `loyalty.point.earn_per_amount` | int (Rp) | `100` | Setiap kelipatan nominal ini… |
|
|||
|
|
| `loyalty.point.earn_value` | int | `1` | …mendapat sekian EnakPoint |
|
|||
|
|
| `loyalty.point.min_order_amount` | int (Rp) | `0` | Basis minimal agar dapat EnakPoint |
|
|||
|
|
| `loyalty.point.max_per_order` | int, nullable | kosong | Batas atas EnakPoint per order |
|
|||
|
|
| `loyalty.coin.enabled` | bool | `false` | Outlet memberi EnakCoin |
|
|||
|
|
| `loyalty.coin.earn_per_amount` | int (Rp) | `25000` | |
|
|||
|
|
| `loyalty.coin.earn_value` | int | `1` | |
|
|||
|
|
| `loyalty.coin.min_order_amount` | int (Rp) | `0` | |
|
|||
|
|
| `loyalty.coin.max_per_order` | int, nullable | kosong | |
|
|||
|
|
|
|||
|
|
Dengan nilai EnakPoint default Rp 1, default earning 1 EnakPoint per Rp 100 setara
|
|||
|
|
**cashback 1%**. Dashboard selalu menampilkan persentase cashback efektif di samping
|
|||
|
|
setting ini: `earn_value × nilai EnakPoint / earn_per_amount`. Tujuannya supaya owner
|
|||
|
|
tidak salah mengira skala.
|
|||
|
|
|
|||
|
|
**Pembayaran EnakPoint.** Hanya ada untuk EnakPoint, tidak ada padanannya untuk
|
|||
|
|
EnakCoin.
|
|||
|
|
|
|||
|
|
| Key | Tipe | Default | Arti |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `loyalty.point.accept_payment` | bool | `false` | Outlet menerima pembayaran EnakPoint |
|
|||
|
|
| `loyalty.point.min_payment_points` | int | `1` | EnakPoint minimal per pembayaran |
|
|||
|
|
| `loyalty.point.max_payment_percent` | int (0–100) | `100` | Porsi maksimal total order yang boleh dibayar EnakPoint |
|
|||
|
|
|
|||
|
|
**Rumus earning:**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
basis = subtotal − discount_amount − dibayar_dengan_enakpoint
|
|||
|
|
jumlah = 0 jika basis < min_order_amount
|
|||
|
|
jumlah = floor(basis / earn_per_amount) × earn_value
|
|||
|
|
jumlah = min(jumlah, max_per_order) jika max_per_order diisi
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- Basis dihitung **sebelum pajak** (Q1, diputuskan). `tax_amount` tidak ikut dihitung,
|
|||
|
|
begitu juga service charge atau biaya lain yang ditambahkan di atas subtotal.
|
|||
|
|
- Bagian order yang dibayar EnakPoint **tidak** menghasilkan earning (Q10, diputuskan),
|
|||
|
|
supaya tidak ada "Point dari Point".
|
|||
|
|
|
|||
|
|
**Contoh.** Subtotal setelah diskon Rp 87.500, dibayar tunai penuh. Outlet memberi
|
|||
|
|
1 EnakPoint per Rp 100 dan 1 EnakCoin per Rp 25.000. Customer mendapat **875
|
|||
|
|
EnakPoint** dan **3 EnakCoin**. Jika Rp 20.000 dari order itu dibayar dengan EnakPoint,
|
|||
|
|
basisnya menjadi Rp 67.500, sehingga customer mendapat 675 EnakPoint dan 2 EnakCoin.
|
|||
|
|
|
|||
|
|
Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `min_order_amount ≥ 0`,
|
|||
|
|
`max_per_order ≥ 0`, `0 ≤ max_payment_percent ≤ 100`. Hanya role Admin/Manager yang
|
|||
|
|
bisa mengubah.
|
|||
|
|
|
|||
|
|
### F2 — Pengaturan per Organisasi
|
|||
|
|
|
|||
|
|
Disimpan di pengaturan organisasi. Berlaku untuk semua outlet (K4).
|
|||
|
|
|
|||
|
|
| Key | Tipe | Default | Arti |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `loyalty.point.value` | int (Rp), ≥ 1 | `1` | Nilai rupiah 1 EnakPoint saat membayar (Q11, diputuskan) |
|
|||
|
|
| `loyalty.exchange.coin_amount` | int, ≥ 1 | `1` | Kurs: sekian EnakCoin… |
|
|||
|
|
| `loyalty.exchange.point_amount` | int, ≥ 1 | `1` | …ditukar menjadi sekian EnakPoint (Q11, diputuskan) |
|
|||
|
|
| `loyalty.transfer.enabled` | bool | `true` | Transfer diizinkan |
|
|||
|
|
| `loyalty.transfer.min_amount` | int | `1` | |
|
|||
|
|
| `loyalty.transfer.max_per_transaction` | int, nullable | kosong | |
|
|||
|
|
| `loyalty.transfer.daily_limit` | int, nullable | kosong | |
|
|||
|
|
| `loyalty.{point,coin}.expiry_*` | – | nonaktif | Kedaluwarsa, lihat F12 |
|
|||
|
|
|
|||
|
|
**Mengubah nilai EnakPoint atau kurs exchange** langsung mengubah daya beli saldo yang
|
|||
|
|
beredar. Karena itu:
|
|||
|
|
- Dashboard menampilkan peringatan beserta total saldo beredar dan nilai rupiahnya
|
|||
|
|
sebelum dan sesudah perubahan.
|
|||
|
|
- Perubahan berlaku ke depan saja. Pembayaran, refund, dan exchange yang sudah terjadi
|
|||
|
|
memakai nilai yang dibekukan saat transaksi tersebut.
|
|||
|
|
- Setiap perubahan setting loyalitas dicatat: key, nilai lama, nilai baru, siapa, dan
|
|||
|
|
kapan.
|
|||
|
|
|
|||
|
|
### F3 — Earning dari Order
|
|||
|
|
|
|||
|
|
- **Pemicu:** order berpindah ke `payment_status = completed`, yaitu lunas penuh
|
|||
|
|
(termasuk lunas lewat split bill).
|
|||
|
|
- **Syarat:** order punya `customer_id`, customer tersebut **bukan** customer default
|
|||
|
|
(walk-in, `is_default = true`), dan customer aktif.
|
|||
|
|
- **Semua kanal diperlakukan sama** (Q2, diputuskan): order dari kasir, self-order (QR
|
|||
|
|
meja), dan customer app memakai aturan dan setting outlet yang sama.
|
|||
|
|
- **Sekali per order.** Ledger memakai idempotency key `earn:{order_id}:{currency}`,
|
|||
|
|
sehingga pemicu ganda (retry, webhook ganda) tidak menggandakan saldo.
|
|||
|
|
- **Snapshot setting.** Nilai setting yang dipakai disimpan di metadata ledger, supaya
|
|||
|
|
perubahan setting berikutnya tidak mengubah arti earning yang sudah terjadi dan
|
|||
|
|
reversal bisa dihitung dengan setting yang sama.
|
|||
|
|
- Earning membuat lot baru dengan tanggal kedaluwarsa sesuai F12.
|
|||
|
|
- Kegagalan earning **tidak boleh** menggagalkan pembayaran order. Kegagalan dicatat
|
|||
|
|
di log dan bisa di-retry, dan idempotency key menjamin retry aman.
|
|||
|
|
- Response order dan data struk menyertakan `points_earned` dan `coins_earned`.
|
|||
|
|
|
|||
|
|
### F4 — Exchange EnakCoin → EnakPoint
|
|||
|
|
|
|||
|
|
- Kurs sesuai F2: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1).
|
|||
|
|
- Customer memasukkan jumlah EnakCoin. Jumlahnya harus **kelipatan `coin_amount`**,
|
|||
|
|
supaya tidak ada EnakCoin yang hilang karena pembulatan. Aplikasi menampilkan
|
|||
|
|
EnakPoint yang akan didapat sebelum konfirmasi.
|
|||
|
|
```
|
|||
|
|
point_didapat = (coin_ditukar / coin_amount) × point_amount
|
|||
|
|
```
|
|||
|
|
- EnakCoin berkurang dan EnakPoint bertambah dalam satu transaksi.
|
|||
|
|
- Menghasilkan dua baris ledger: `EXCHANGE_OUT` (COIN, −) dan `EXCHANGE_IN` (POINT, +),
|
|||
|
|
keduanya dengan `group_id` yang sama. Kurs yang dipakai dibekukan di metadata keduanya.
|
|||
|
|
- **Kedaluwarsa ikut terbawa** (K9). Lot EnakPoint hasil exchange kedaluwarsa pada
|
|||
|
|
`min(kedaluwarsa lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`. Exchange
|
|||
|
|
tidak bisa dipakai untuk memperpanjang umur saldo.
|
|||
|
|
- Tidak bisa dibatalkan, jadi aplikasi menampilkan konfirmasi dan meminta PIN (K8).
|
|||
|
|
- Request wajib membawa `Idempotency-Key`.
|
|||
|
|
|
|||
|
|
### F5 — Transfer ke Customer Lain
|
|||
|
|
|
|||
|
|
- Mata uang: EnakPoint **atau** EnakCoin, satu jenis per transfer.
|
|||
|
|
- **Penerima** diidentifikasi dengan nomor telepon (identitas login customer app).
|
|||
|
|
Sebelum konfirmasi, aplikasi menampilkan nama penerima yang sudah disamarkan (mis.
|
|||
|
|
"Bu*** Sa***") supaya pengirim bisa memastikan.
|
|||
|
|
- **Syarat penerima:** customer aktif, organisasi sama, bukan customer default, dan
|
|||
|
|
bukan diri sendiri.
|
|||
|
|
- **Konfirmasi:** pengirim memasukkan **PIN** (K8, F11; Q5 diputuskan).
|
|||
|
|
- **Batas:** diatur per organisasi (F2). Defaultnya tanpa batas, dan owner bisa
|
|||
|
|
memasang batas per transaksi atau harian (Q4, diputuskan).
|
|||
|
|
- Menghasilkan dua baris ledger: `TRANSFER_OUT` (pengirim, −N) dan `TRANSFER_IN`
|
|||
|
|
(penerima, +N), dengan `group_id` sama dan referensi silang ke customer lawan.
|
|||
|
|
- **Kedaluwarsa ikut terbawa** (K9). Saldo diambil dari lot pengirim yang paling cepat
|
|||
|
|
kedaluwarsa, dan penerima mendapat lot dengan tanggal kedaluwarsa yang sama persis.
|
|||
|
|
Aplikasi pengirim menampilkan bahwa saldo yang dikirim akan kedaluwarsa pada tanggal
|
|||
|
|
tersebut.
|
|||
|
|
- Final dan tidak bisa dibatalkan oleh customer. Koreksi hanya lewat adjustment admin
|
|||
|
|
(F7).
|
|||
|
|
- Request wajib membawa `Idempotency-Key`.
|
|||
|
|
- Penerima mendapat notifikasi push (memakai `NotificationService` yang sudah ada).
|
|||
|
|
|
|||
|
|
### F6 — Saldo & Riwayat (Customer App)
|
|||
|
|
|
|||
|
|
- `GET /customer/wallet` mengembalikan saldo EnakPoint (beserta nilai rupiahnya saat
|
|||
|
|
ini), saldo EnakCoin, **saldo yang akan kedaluwarsa terdekat** (jumlah dan tanggal),
|
|||
|
|
dan beberapa mutasi terakhir.
|
|||
|
|
- `GET /customer/wallet/expiring` mengembalikan rincian saldo yang akan kedaluwarsa,
|
|||
|
|
dikelompokkan per tanggal.
|
|||
|
|
- `GET /customer/wallet/transactions` mengembalikan daftar mutasi dengan pagination,
|
|||
|
|
bisa difilter per currency, tipe, dan rentang tanggal.
|
|||
|
|
- Setiap mutasi menampilkan: tipe, jumlah bertanda (+/−), saldo setelahnya,
|
|||
|
|
**asal (untuk penambahan) atau tujuan (untuk pengurangan)** sesuai §8.1, dan waktu.
|
|||
|
|
Mutasi masuk juga menampilkan tanggal kedaluwarsanya.
|
|||
|
|
- Mutasi bisa dibuka ke detail: order (nomor, outlet, total), pembayaran (nominal
|
|||
|
|
rupiah yang ditutup), lawan transfer (nama tersamar), game play (hadiah yang
|
|||
|
|
didapat), atau pasangan exchange-nya.
|
|||
|
|
- Riwayat tidak pernah hilang. Mutasi yang dikoreksi tetap tampil, bersama baris
|
|||
|
|
koreksinya.
|
|||
|
|
|
|||
|
|
### F7 — Admin (Dashboard)
|
|||
|
|
|
|||
|
|
- Melihat wallet dan mutasi seorang customer, dengan asal/tujuan tampil penuh (nama
|
|||
|
|
asli lawan transfer, admin pelaku adjustment, kasir penerima pembayaran).
|
|||
|
|
- **Telusuri mutasi:** dari satu mutasi, lompat ke referensinya, yaitu order,
|
|||
|
|
pembayaran, baris pasangan transfer/exchange, baris asal dari sebuah reversal, game
|
|||
|
|
play, atau lot yang kedaluwarsa.
|
|||
|
|
- **Telusuri per butir:** dari satu pengurangan (misalnya pembayaran), lihat lot mana
|
|||
|
|
yang terpakai, lalu dari lot itu telusuri asalnya sampai ke earning awal, termasuk
|
|||
|
|
jika saldo itu sudah melewati beberapa transfer atau exchange.
|
|||
|
|
- Melihat semua mutasi yang berasal dari satu order (earning, pembayaran, reversal,
|
|||
|
|
refund) dari halaman detail order.
|
|||
|
|
- **Adjustment manual:** tambah atau kurangi EnakPoint/EnakCoin dengan alasan wajib.
|
|||
|
|
Tercatat sebagai `ADJUSTMENT` beserta `user_id` admin. Saldo tidak boleh menjadi
|
|||
|
|
negatif. Adjustment tambah membuat lot dengan kedaluwarsa sesuai F12.
|
|||
|
|
- Mengatur F1 per outlet serta F2 dan F12 per organisasi.
|
|||
|
|
|
|||
|
|
### F8 — Game Memakai EnakCoin
|
|||
|
|
|
|||
|
|
- **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin
|
|||
|
|
yang sama. `POST /customer/spin` memotong EnakCoin, bukan Token `SPIN`.
|
|||
|
|
- Biaya per main diatur per game di `games.metadata.coin_cost` (default 1), sehingga
|
|||
|
|
game yang hadiahnya lebih besar bisa lebih mahal.
|
|||
|
|
- Pemotongan EnakCoin, pencatatan `game_plays`, pengurangan stok hadiah, dan ledger
|
|||
|
|
`GAME_SPEND` terjadi dalam satu transaksi. Jika stok hadiah gagal dikurangi, seluruh
|
|||
|
|
permainan dibatalkan (saat ini hanya di-`Printf`).
|
|||
|
|
- `game_plays.token_used` berganti arti menjadi jumlah EnakCoin yang dipakai (diganti
|
|||
|
|
nama menjadi `coins_used`).
|
|||
|
|
|
|||
|
|
### F9 — Bayar Order dengan EnakPoint
|
|||
|
|
|
|||
|
|
**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
|
|||
|
|
`loyalty.point.accept_payment`. Tidak ada payment method untuk EnakCoin.
|
|||
|
|
|
|||
|
|
**Siapa yang dipotong.** Yang dipotong selalu saldo **customer yang tercatat di order**
|
|||
|
|
(`orders.customer_id`). Order walk-in (customer default) tidak bisa dibayar EnakPoint.
|
|||
|
|
Kalau customer ingin memakai EnakPoint milik orang lain, pemiliknya harus mentransfer
|
|||
|
|
dulu (F5).
|
|||
|
|
|
|||
|
|
**Perhitungan.**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
nilai = loyalty.point.value (dibaca saat pembayaran, lalu dibekukan)
|
|||
|
|
batas_rupiah = min(remaining_amount,
|
|||
|
|
total_amount × max_payment_percent / 100
|
|||
|
|
− yang_sudah_dibayar_enakpoint_di_order_ini)
|
|||
|
|
maks_point = min(saldo_point, floor(batas_rupiah / nilai))
|
|||
|
|
point_dipakai = pilihan customer, min_payment_points ≤ point_dipakai ≤ maks_point
|
|||
|
|
nominal_rupiah = point_dipakai × nilai
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- EnakPoint tidak pernah menghasilkan kembalian (K7). `nominal_rupiah` tidak boleh
|
|||
|
|
melebihi `remaining_amount`. Jika nilai EnakPoint diatur lebih dari Rp 1, sisa tagihan
|
|||
|
|
yang bukan kelipatan `nilai` dibayar dengan method lain lewat split payment yang sudah
|
|||
|
|
ada.
|
|||
|
|
- Aplikasi dan kasir menyediakan tombol "Pakai maksimal" yang mengisi `maks_point`.
|
|||
|
|
- EnakPoint diambil dari lot yang paling cepat kedaluwarsa (K9).
|
|||
|
|
|
|||
|
|
**Contoh.** Nilai EnakPoint Rp 1 (default), sisa tagihan Rp 87.550, saldo 50.000
|
|||
|
|
EnakPoint, batas 100%. `maks_point = min(50000, floor(87550 / 1)) = 50000`. Customer
|
|||
|
|
memakai 50.000 EnakPoint (Rp 50.000), lalu sisa Rp 37.550 dibayar tunai.
|
|||
|
|
|
|||
|
|
**Persetujuan customer.** Kasir tidak boleh bisa memakai EnakPoint customer tanpa
|
|||
|
|
izinnya.
|
|||
|
|
- **Di kasir (POS):** customer membuka aplikasi, memasukkan **PIN**, lalu aplikasi
|
|||
|
|
menampilkan **kode bayar**, yaitu kode 6 digit/QR sekali pakai yang berlaku 2 menit.
|
|||
|
|
Kasir memindai atau mengetik kode tersebut. Kode terikat ke customer, sehingga kode
|
|||
|
|
milik customer lain ditolak. PIN **tidak pernah** diketik di perangkat kasir, supaya
|
|||
|
|
kasir tidak bisa melihat atau merekamnya. Customer tanpa aplikasi belum didukung
|
|||
|
|
(catatan N1).
|
|||
|
|
- **Di customer app / self-order:** customer memasukkan PIN sebelum pembayaran
|
|||
|
|
diproses. Sesi login saja tidak cukup (K8).
|
|||
|
|
|
|||
|
|
**Pencatatan.** Dalam satu transaksi database:
|
|||
|
|
1. Kunci wallet customer.
|
|||
|
|
2. Ambil saldo dari lot yang paling cepat kedaluwarsa dan catat alokasinya (§8).
|
|||
|
|
3. Potong saldo EnakPoint (update bersyarat, §7).
|
|||
|
|
4. Buat baris `payments` dengan method EnakPoint, `status = completed`,
|
|||
|
|
`amount = nominal_rupiah`, `points_used`, dan `point_value` (nilai yang dibekukan).
|
|||
|
|
5. Tulis ledger `PAYMENT` (POINT, −N) yang menunjuk `payments.id` dan `outlet_id`.
|
|||
|
|
6. Perbarui `remaining_amount` / `payment_status` order seperti pembayaran lain.
|
|||
|
|
|
|||
|
|
Idempotency key: `payment:{payment_id}`. Kasir yang menekan tombol dua kali tidak
|
|||
|
|
memotong dua kali.
|
|||
|
|
|
|||
|
|
**Void / refund pembayaran EnakPoint.**
|
|||
|
|
- Pengembalian **hanya dalam bentuk EnakPoint** (K7). Endpoint refund menolak permintaan
|
|||
|
|
yang mengembalikan bagian EnakPoint lewat method lain (tunai, kartu, transfer). Kasir
|
|||
|
|
tidak diberi pilihan method refund untuk bagian ini.
|
|||
|
|
- EnakPoint dikembalikan ke customer yang sama sebagai `PAYMENT_REFUND` (POINT, +N),
|
|||
|
|
dengan `reverses_transaction_id` menunjuk baris `PAYMENT` asal.
|
|||
|
|
- Jumlah yang dikembalikan dihitung dengan **`point_value` yang dibekukan di
|
|||
|
|
pembayaran**, bukan nilai saat ini. Customer mendapat kembali EnakPoint sebanyak yang
|
|||
|
|
dipakai, tidak lebih dan tidak kurang, walaupun nilai EnakPoint sudah diubah.
|
|||
|
|
- **Kedaluwarsa dipulihkan.** EnakPoint yang dikembalikan kembali ke lot dengan tanggal
|
|||
|
|
kedaluwarsa asalnya. Jika tanggal itu sudah lewat atau tinggal kurang dari 7 hari,
|
|||
|
|
masa berlakunya diperpanjang menjadi 7 hari sejak refund, supaya customer sempat
|
|||
|
|
memakainya (catatan N4).
|
|||
|
|
- **Void order:** semua pembayaran EnakPoint di order itu dikembalikan penuh.
|
|||
|
|
- **Refund sebagian:** mengikuti alur refund per-`payments` yang sudah ada. Refund atas
|
|||
|
|
pembayaran EnakPoint dilakukan dalam EnakPoint utuh:
|
|||
|
|
`point_kembali = floor(refund_amount / point_value)`. Sisa rupiah di bawah 1
|
|||
|
|
EnakPoint hangus, tidak dikembalikan sebagai EnakPoint maupun tunai (Q13,
|
|||
|
|
diputuskan).
|
|||
|
|
- Akumulasi EnakPoint yang dikembalikan tidak boleh melebihi `points_used`.
|
|||
|
|
|
|||
|
|
**Tampilan.** Struk dan detail order menampilkan baris "EnakPoint: 50.000 (Rp 50.000)".
|
|||
|
|
|
|||
|
|
**Laporan.** Laporan per payment method menampilkan EnakPoint terpisah. EnakPoint yang
|
|||
|
|
dipakai membayar **bukan kas masuk**. Perlakuan akuntansinya ditunda (catatan N2).
|
|||
|
|
|
|||
|
|
### F10 — Reversal Earning saat Void / Refund
|
|||
|
|
|
|||
|
|
- **Void** order yang sudah memberi earning: EnakPoint dan EnakCoin dari order itu
|
|||
|
|
ditarik kembali sepenuhnya.
|
|||
|
|
- **Refund sebagian:** penarikan proporsional,
|
|||
|
|
`floor(earned × refund_amount / basis)`, dengan akumulasi penarikan tidak melebihi
|
|||
|
|
yang pernah diberikan.
|
|||
|
|
- **Lot yang ditarik:** pertama dari lot yang dibuat oleh `EARN` order tersebut
|
|||
|
|
(kalau masih ada sisanya), lalu dari lot lain dengan urutan K9.
|
|||
|
|
- **Saldo tidak cukup** (misalnya sudah dipakai atau ditransfer): tarik sebanyak saldo
|
|||
|
|
yang ada sampai 0, lalu catat kekurangannya di metadata ledger (`shortfall`). Saldo
|
|||
|
|
tidak boleh negatif, dan refund **tidak pernah diblokir** karena saldo tidak cukup
|
|||
|
|
(Q3, diputuskan).
|
|||
|
|
- Tipe ledger: `EARN_REVERSAL`, dengan `reverses_transaction_id` menunjuk `EARN` asal.
|
|||
|
|
- Jika order dibayar sebagian dengan EnakPoint, maka pada void yang sama earning ditarik
|
|||
|
|
(F10) **dan** EnakPoint pembayaran dikembalikan (F9). Keduanya tercatat sebagai
|
|||
|
|
baris terpisah.
|
|||
|
|
|
|||
|
|
### F11 — PIN Customer
|
|||
|
|
|
|||
|
|
**Format.** 6 digit angka. Terpisah dari password login.
|
|||
|
|
|
|||
|
|
**Membuat PIN.**
|
|||
|
|
- Diminta saat customer pertama kali melakukan aksi yang butuh PIN (K8), bukan saat
|
|||
|
|
registrasi. Customer yang hanya mengumpulkan saldo tidak dipaksa membuat PIN.
|
|||
|
|
- Customer yang belum punya PIN tetap bisa **menerima** transfer dan earning, tapi tidak
|
|||
|
|
bisa mengirim, membayar, atau exchange sampai PIN dibuat.
|
|||
|
|
- Membuat PIN pertama kali memerlukan OTP ke nomor telepon customer (memakai
|
|||
|
|
`OtpProcessor` yang sudah ada, dengan purpose baru `pin_setup`). Ini memastikan PIN
|
|||
|
|
dibuat oleh pemilik nomor, bukan oleh orang yang kebetulan memegang HP yang sedang
|
|||
|
|
login.
|
|||
|
|
- PIN ditolak jika terlalu mudah ditebak: semua digit sama (`111111`), berurutan
|
|||
|
|
(`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`, dari
|
|||
|
|
`customers.birth_date`).
|
|||
|
|
- PIN dimasukkan dua kali untuk konfirmasi.
|
|||
|
|
|
|||
|
|
**Penyimpanan.** Hanya hash (bcrypt, sama seperti `password_hash`). PIN tidak pernah
|
|||
|
|
disimpan, dicatat di log, atau dikembalikan di response dalam bentuk asli. Admin tidak
|
|||
|
|
bisa melihat PIN.
|
|||
|
|
|
|||
|
|
**Salah PIN** (Q17, diputuskan).
|
|||
|
|
- Setiap salah PIN menambah penghitung. Setelah **5 kali salah berturut-turut**, PIN
|
|||
|
|
dikunci selama **30 menit**. Selama terkunci, semua aksi yang butuh PIN ditolak,
|
|||
|
|
termasuk PIN yang benar.
|
|||
|
|
- PIN yang benar mereset penghitung ke 0.
|
|||
|
|
- Penghitung disimpan di database, bukan hanya di cache, supaya tidak bisa dilewati
|
|||
|
|
dengan menunggu cache hilang atau menembak server yang berbeda.
|
|||
|
|
- Response saat salah PIN menyebutkan sisa percobaan. Saat terkunci, response
|
|||
|
|
menyebutkan kapan kunci dibuka.
|
|||
|
|
- Setiap kali PIN terkunci, customer mendapat notifikasi push.
|
|||
|
|
|
|||
|
|
**Mengganti PIN.** Customer memasukkan PIN lama, lalu PIN baru dua kali.
|
|||
|
|
|
|||
|
|
**Lupa PIN.** Customer meminta reset, memverifikasi OTP ke nomor telepon (purpose
|
|||
|
|
`pin_reset`), lalu membuat PIN baru. Reset lewat OTP juga membuka kunci PIN. Setelah
|
|||
|
|
reset, **transfer keluar ditahan 24 jam**, sedangkan pembayaran dan exchange tetap bisa
|
|||
|
|
(Q16, diputuskan). Ini membatasi kerugian jika nomor telepon customer diambil alih.
|
|||
|
|
|
|||
|
|
**Admin.** Admin tidak bisa membuat atau mengganti PIN customer. Admin hanya bisa
|
|||
|
|
**menghapus PIN** (misalnya atas permintaan customer yang kehilangan akses), sehingga
|
|||
|
|
customer harus membuat PIN baru lewat OTP. Aksi ini tercatat beserta admin pelaku dan
|
|||
|
|
alasannya.
|
|||
|
|
|
|||
|
|
**Jejak.** Semua peristiwa PIN dicatat di log keamanan: dibuat, diganti, di-reset,
|
|||
|
|
salah, terkunci, dan dihapus admin. Setiap peristiwa menyimpan waktu, customer, dan
|
|||
|
|
perangkat/IP. Peristiwa ini bukan mutasi saldo, jadi tidak masuk `wallet_transactions`.
|
|||
|
|
|
|||
|
|
### F12 — Kedaluwarsa Saldo
|
|||
|
|
|
|||
|
|
> **Ditunda: model kedaluwarsa belum diputuskan (catatan N4).** Isi bagian ini
|
|||
|
|
> menggambarkan model **per saldo masuk** sebagai draft. Alternatifnya adalah model
|
|||
|
|
> **tanggal tetap** (gaya Telkomsel POIN / XL Poin, semua hangus di tanggal yang sama).
|
|||
|
|
> Yang sudah pasti dan tidak bergantung pada N4: saldo bisa kedaluwarsa, owner bisa
|
|||
|
|
> mengatur sendiri, saldo disimpan per lot (K9), transfer dan exchange membawa tanggal
|
|||
|
|
> kedaluwarsa asal, dan saldo yang hangus tercatat sebagai `EXPIRE`.
|
|||
|
|
|
|||
|
|
**Pengaturan** (per organisasi, terpisah untuk EnakPoint dan EnakCoin; Q9, diputuskan):
|
|||
|
|
|
|||
|
|
| Key | Tipe | Default | Arti |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| `loyalty.point.expiry_enabled` | bool | `false` | EnakPoint bisa kedaluwarsa |
|
|||
|
|
| `loyalty.point.expiry_period` | int, ≥ 1 | `12` | Lama masa berlaku… |
|
|||
|
|
| `loyalty.point.expiry_unit` | `DAY` / `MONTH` | `MONTH` | …dalam satuan ini |
|
|||
|
|
| `loyalty.point.expiry_end_of_month` | bool | `false` | Dibulatkan ke akhir bulan (mis. semua saldo Maret 2026 hangus 31 Maret 2027) |
|
|||
|
|
| `loyalty.point.expiry_reminder_days` | int, ≥ 0 | `7` | Pengingat dikirim sekian hari sebelum kedaluwarsa (0 = tanpa pengingat) |
|
|||
|
|
| `loyalty.coin.expiry_*` | | sama | Pengaturan yang sama untuk EnakCoin |
|
|||
|
|
|
|||
|
|
Dashboard menampilkan contoh hasil setting, misalnya "EnakPoint yang didapat hari ini
|
|||
|
|
kedaluwarsa pada 30 Sep 2027".
|
|||
|
|
|
|||
|
|
**Tanggal kedaluwarsa per lot:**
|
|||
|
|
|
|||
|
|
| Saldo masuk lewat | Kedaluwarsa |
|
|||
|
|
|---|---|
|
|||
|
|
| `EARN`, `ADJUSTMENT` (+) | Sejak saat masuk + masa berlaku currency tersebut. Kosong (tidak kedaluwarsa) jika expiry nonaktif |
|
|||
|
|
| `TRANSFER_IN` | **Sama persis** dengan lot pengirim yang terpakai |
|
|||
|
|
| `EXCHANGE_IN` | `min(kedaluwarsa lot EnakCoin asal, sekarang + masa berlaku EnakPoint)` |
|
|||
|
|
| `PAYMENT_REFUND` | Kedaluwarsa lot asal. Jika sudah lewat atau kurang dari 7 hari lagi, menjadi 7 hari sejak refund (catatan N4) |
|
|||
|
|
| `MIGRATION` | Kosong, sampai expiry diaktifkan (lihat aturan aktivasi di bawah) |
|
|||
|
|
|
|||
|
|
**Proses kedaluwarsa.**
|
|||
|
|
- Job terjadwal berjalan setiap jam. Job mencari lot dengan `expires_at ≤ sekarang` dan
|
|||
|
|
sisa > 0.
|
|||
|
|
- Untuk setiap lot, job menulis satu baris ledger `EXPIRE` (−sisa) yang menunjuk lot
|
|||
|
|
tersebut, lalu menjadikan sisa lot 0. Semua ini dalam satu transaksi dengan lock
|
|||
|
|
wallet. Idempotency key: `expire:{lot_id}`.
|
|||
|
|
- Saldo yang kedaluwarsa hangus tanpa kompensasi (K7).
|
|||
|
|
- Customer mendapat notifikasi saat saldo kedaluwarsa, dengan jumlahnya.
|
|||
|
|
|
|||
|
|
**Pengingat.** Sekian hari sebelum kedaluwarsa (`expiry_reminder_days`), customer
|
|||
|
|
mendapat notifikasi push: "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026". Pengingat
|
|||
|
|
dikelompokkan per tanggal, sehingga satu notifikasi per tanggal kedaluwarsa, bukan satu
|
|||
|
|
per lot.
|
|||
|
|
|
|||
|
|
**Mengubah pengaturan.**
|
|||
|
|
- Mengubah masa berlaku hanya berlaku untuk lot yang masuk **setelah** perubahan. Lot
|
|||
|
|
yang sudah ada tetap memakai tanggalnya.
|
|||
|
|
- **Mengaktifkan** expiry untuk pertama kali: lot yang sudah ada tanpa tanggal
|
|||
|
|
kedaluwarsa diberi tanggal `waktu aktivasi + masa berlaku`, sehingga customer mendapat
|
|||
|
|
masa berlaku penuh sejak aturan diumumkan (catatan N4).
|
|||
|
|
- **Menonaktifkan** expiry: lot baru tidak kedaluwarsa. Lot yang sudah punya tanggal
|
|||
|
|
tetap kedaluwarsa sesuai jadwalnya (catatan N4).
|
|||
|
|
- Setiap perubahan tercatat (F2), dan dashboard menampilkan berapa saldo customer yang
|
|||
|
|
terdampak sebelum owner menyimpan.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. Aturan Konsistensi
|
|||
|
|
|
|||
|
|
1. **Saldo tidak pernah negatif.** Dijaga oleh `CHECK` di database dan update bersyarat
|
|||
|
|
(`WHERE balance >= ?`) yang **mengecek jumlah baris ter-update**. Update yang
|
|||
|
|
mengenai 0 baris dianggap saldo tidak cukup.
|
|||
|
|
2. **Satu transaksi database per operasi.** Semua repository wallet memakai
|
|||
|
|
`DBFromContext` agar ikut transaksi dari `TxManager`. Pembayaran EnakPoint berada di
|
|||
|
|
transaksi yang sama dengan pembuatan baris `payments`.
|
|||
|
|
3. **Urutan lock.** Setiap operasi, termasuk job kedaluwarsa, mengunci wallet
|
|||
|
|
(`SELECT … FOR UPDATE`) sebelum mengubah wallet atau lot-nya. Transfer mengunci dua
|
|||
|
|
wallet berurutan berdasarkan `customer_id` untuk mencegah deadlock. Operasi yang
|
|||
|
|
bersamaan untuk customer yang sama akan antre di lock yang sama, sehingga saldo tidak
|
|||
|
|
terpakai dua kali dan tidak terpakai setelah kedaluwarsa.
|
|||
|
|
4. **Idempotensi.** `wallet_transactions.idempotency_key` unik. Request ulang dengan key
|
|||
|
|
yang sama mengembalikan hasil pertama, bukan error dan bukan mutasi baru.
|
|||
|
|
5. **Rekonsiliasi.** Job pemeriksaan memastikan, per customer per currency:
|
|||
|
|
- `SUM(amount)` ledger = saldo wallet = `SUM(remaining_amount)` semua lot.
|
|||
|
|
- Untuk setiap lot: `original_amount − SUM(alokasi) = remaining_amount`.
|
|||
|
|
- Untuk setiap mutasi keluar: `SUM(alokasi)` = nilai absolut `amount`-nya.
|
|||
|
|
- Untuk setiap `payments` bermethod EnakPoint: `points_used` = nilai absolut baris
|
|||
|
|
`PAYMENT`-nya.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 8. Model Data (Usulan)
|
|||
|
|
|
|||
|
|
### `customer_wallets`: menggantikan `customer_points` dan `customer_tokens`
|
|||
|
|
|
|||
|
|
Satu baris per customer. Baris ini juga menjadi titik lock untuk semua operasi wallet
|
|||
|
|
customer tersebut.
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE customer_wallets (
|
|||
|
|
customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE RESTRICT,
|
|||
|
|
organization_id UUID NOT NULL REFERENCES organizations(id),
|
|||
|
|
point_balance BIGINT NOT NULL DEFAULT 0 CHECK (point_balance >= 0),
|
|||
|
|
coin_balance BIGINT NOT NULL DEFAULT 0 CHECK (coin_balance >= 0),
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|||
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### `wallet_transactions`: ledger
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE wallet_transactions (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
organization_id UUID NOT NULL,
|
|||
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
|||
|
|
currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')),
|
|||
|
|
type VARCHAR(30) NOT NULL,
|
|||
|
|
amount BIGINT NOT NULL CHECK (amount <> 0), -- bertanda
|
|||
|
|
balance_after BIGINT NOT NULL,
|
|||
|
|
group_id UUID, -- menyatukan pasangan exchange / transfer
|
|||
|
|
|
|||
|
|
-- Asal (amount > 0) atau tujuan (amount < 0). Wajib untuk semua tipe.
|
|||
|
|
reference_type VARCHAR(30) NOT NULL, -- ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER, ...
|
|||
|
|
reference_id UUID NOT NULL,
|
|||
|
|
|
|||
|
|
counterparty_customer_id UUID REFERENCES customers(id), -- TRANSFER_IN / _OUT
|
|||
|
|
reverses_transaction_id UUID REFERENCES wallet_transactions(id), -- EARN_REVERSAL / PAYMENT_REFUND
|
|||
|
|
outlet_id UUID, -- EARN, EARN_REVERSAL, PAYMENT, PAYMENT_REFUND
|
|||
|
|
created_by_user UUID, -- ADJUSTMENT: admin; PAYMENT / PAYMENT_REFUND: kasir
|
|||
|
|
reason VARCHAR(255), -- ADJUSTMENT: alasan
|
|||
|
|
|
|||
|
|
description VARCHAR(255) NOT NULL, -- teks siap tampil, dibekukan saat dibuat
|
|||
|
|
metadata JSONB DEFAULT '{}', -- snapshot setting, point_value, kurs, shortfall
|
|||
|
|
idempotency_key VARCHAR(100) UNIQUE,
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|||
|
|
|
|||
|
|
-- Hanya EnakPoint yang bisa membayar (K2)
|
|||
|
|
CONSTRAINT chk_point_only_types CHECK (
|
|||
|
|
type NOT IN ('PAYMENT','PAYMENT_REFUND','EXCHANGE_IN','REWARD_REDEEM')
|
|||
|
|
OR currency = 'POINT'),
|
|||
|
|
CONSTRAINT chk_coin_only_types CHECK (
|
|||
|
|
type NOT IN ('EXCHANGE_OUT','GAME_SPEND') OR currency = 'COIN'),
|
|||
|
|
|
|||
|
|
CONSTRAINT chk_transfer_counterparty CHECK (
|
|||
|
|
type NOT IN ('TRANSFER_IN','TRANSFER_OUT') OR counterparty_customer_id IS NOT NULL),
|
|||
|
|
CONSTRAINT chk_reversal_source CHECK (
|
|||
|
|
type NOT IN ('EARN_REVERSAL','PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL),
|
|||
|
|
CONSTRAINT chk_adjustment_actor CHECK (
|
|||
|
|
type <> 'ADJUSTMENT' OR (created_by_user IS NOT NULL AND reason IS NOT NULL)),
|
|||
|
|
CONSTRAINT chk_expire_lot CHECK (
|
|||
|
|
type <> 'EXPIRE' OR reference_type = 'LOT')
|
|||
|
|
);
|
|||
|
|
-- index: (customer_id, created_at DESC), (reference_type, reference_id), (group_id),
|
|||
|
|
-- (counterparty_customer_id), (reverses_transaction_id)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Ledger bersifat **append-only**. Baris tidak pernah di-`UPDATE` atau di-`DELETE`.
|
|||
|
|
Koreksi dilakukan dengan baris baru (`EARN_REVERSAL`, `PAYMENT_REFUND`, atau
|
|||
|
|
`ADJUSTMENT`) yang menunjuk baris yang dikoreksi. Customer yang punya riwayat tidak bisa
|
|||
|
|
dihapus permanen, cukup dinonaktifkan.
|
|||
|
|
|
|||
|
|
### `wallet_lots` dan `wallet_lot_allocations`: saldo per butir (K9)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE wallet_lots (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
organization_id UUID NOT NULL,
|
|||
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
|||
|
|
currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')),
|
|||
|
|
source_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), -- mutasi masuk pembuatnya
|
|||
|
|
origin_lot_id UUID REFERENCES wallet_lots(id), -- lot asal: transfer / exchange / refund
|
|||
|
|
original_amount BIGINT NOT NULL CHECK (original_amount > 0),
|
|||
|
|
remaining_amount BIGINT NOT NULL CHECK (remaining_amount >= 0
|
|||
|
|
AND remaining_amount <= original_amount),
|
|||
|
|
expires_at TIMESTAMPTZ, -- NULL = tidak kedaluwarsa
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|||
|
|
);
|
|||
|
|
-- urutan pemakaian (K9): paling cepat kedaluwarsa dulu, yang tanpa tanggal paling akhir
|
|||
|
|
CREATE INDEX idx_wallet_lots_consume ON wallet_lots
|
|||
|
|
(customer_id, currency, expires_at NULLS LAST, created_at) WHERE remaining_amount > 0;
|
|||
|
|
CREATE INDEX idx_wallet_lots_expiry ON wallet_lots (expires_at) WHERE remaining_amount > 0;
|
|||
|
|
|
|||
|
|
-- Setiap mutasi keluar mencatat lot mana yang dipakai dan berapa banyak
|
|||
|
|
CREATE TABLE wallet_lot_allocations (
|
|||
|
|
transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), -- mutasi keluar
|
|||
|
|
lot_id UUID NOT NULL REFERENCES wallet_lots(id),
|
|||
|
|
amount BIGINT NOT NULL CHECK (amount > 0),
|
|||
|
|
PRIMARY KEY (transaction_id, lot_id)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`wallet_lots.remaining_amount` adalah satu-satunya kolom yang di-`UPDATE`, sebagai
|
|||
|
|
ringkasan untuk mempercepat pemakaian. Nilainya selalu bisa dihitung ulang dari
|
|||
|
|
`original_amount − SUM(wallet_lot_allocations.amount)` (§7.5). Ledger dan alokasi tetap
|
|||
|
|
append-only.
|
|||
|
|
|
|||
|
|
**Contoh.** Customer A punya lot 100 EnakPoint (dari order #ORD-1, kedaluwarsa 31 Des)
|
|||
|
|
dan lot 50 EnakPoint (dari order #ORD-2, kedaluwarsa 31 Jan). A mentransfer 120 ke B.
|
|||
|
|
- `TRANSFER_OUT` A dialokasikan 100 dari lot #ORD-1 dan 20 dari lot #ORD-2.
|
|||
|
|
- B mendapat dua lot: 100 (kedaluwarsa 31 Des, `origin_lot_id` = lot #ORD-1) dan 20
|
|||
|
|
(kedaluwarsa 31 Jan, `origin_lot_id` = lot #ORD-2).
|
|||
|
|
- Kalau B lalu membayar dengan 30 EnakPoint, alokasinya menunjukkan bahwa 30 EnakPoint
|
|||
|
|
itu berasal dari order #ORD-1 milik A.
|
|||
|
|
|
|||
|
|
### Perubahan tabel yang sudah ada
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- payment_methods.type: tambah nilai 'point'
|
|||
|
|
-- (validator saat ini: oneof=cash card digital_wallet)
|
|||
|
|
|
|||
|
|
-- PIN customer (F11)
|
|||
|
|
ALTER TABLE customers
|
|||
|
|
ADD COLUMN pin_hash VARCHAR(255),
|
|||
|
|
ADD COLUMN pin_set_at TIMESTAMPTZ,
|
|||
|
|
ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0,
|
|||
|
|
ADD COLUMN pin_locked_until TIMESTAMPTZ,
|
|||
|
|
ADD COLUMN transfer_blocked_until TIMESTAMPTZ; -- 24 jam setelah reset PIN
|
|||
|
|
|
|||
|
|
CREATE TABLE customer_security_events (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
|||
|
|
event VARCHAR(30) NOT NULL, -- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED,
|
|||
|
|
-- PIN_LOCKED, PIN_REMOVED_BY_ADMIN
|
|||
|
|
actor_user UUID, -- diisi untuk PIN_REMOVED_BY_ADMIN
|
|||
|
|
reason VARCHAR(255),
|
|||
|
|
ip_address VARCHAR(45),
|
|||
|
|
user_agent VARCHAR(255),
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
ALTER TABLE payments
|
|||
|
|
ADD COLUMN points_used BIGINT, -- diisi hanya untuk method EnakPoint
|
|||
|
|
ADD COLUMN point_value DECIMAL(10,2), -- nilai 1 EnakPoint saat dibayar (beku)
|
|||
|
|
ADD CONSTRAINT chk_payments_point_pair CHECK (
|
|||
|
|
(points_used IS NULL AND point_value IS NULL)
|
|||
|
|
OR (points_used > 0 AND point_value > 0));
|
|||
|
|
|
|||
|
|
-- Riwayat perubahan setting loyalitas (F2, F12)
|
|||
|
|
CREATE TABLE loyalty_setting_changes (
|
|||
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|||
|
|
organization_id UUID NOT NULL,
|
|||
|
|
outlet_id UUID, -- NULL untuk setting organisasi
|
|||
|
|
key VARCHAR(100) NOT NULL,
|
|||
|
|
old_value TEXT,
|
|||
|
|
new_value TEXT,
|
|||
|
|
changed_by UUID NOT NULL,
|
|||
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 8.1 Jejak Asal & Tujuan per Tipe
|
|||
|
|
|
|||
|
|
| Tipe | Currency | Arah | Dari mana / ke mana | `reference_type` → `reference_id` | Kolom wajib tambahan | Contoh `description` |
|
|||
|
|
|---|---|---|---|---|---|---|
|
|||
|
|
| `EARN` | POINT / COIN | masuk | Order yang lunas | `ORDER` → `orders.id` | `outlet_id` | "Belanja #ORD-0123 di Outlet Kemang" |
|
|||
|
|
| `EARN_REVERSAL` | POINT / COIN | keluar | Ditarik karena order di-void/refund | `ORDER` → `orders.id` | `reverses_transaction_id` (baris `EARN` asal), `outlet_id` | "Batal #ORD-0123 di Outlet Kemang" |
|
|||
|
|
| `PAYMENT` | POINT | keluar | Dipakai membayar order | `PAYMENT` → `payments.id` | `outlet_id`, `created_by_user` (kasir, jika via POS) | "Bayar #ORD-0123 di Outlet Kemang (Rp 50.000)" |
|
|||
|
|
| `PAYMENT_REFUND` | POINT | masuk | Dikembalikan karena pembayaran di-void/refund | `PAYMENT` → `payments.id` | `reverses_transaction_id` (baris `PAYMENT` asal), `outlet_id` | "Pengembalian #ORD-0123 di Outlet Kemang" |
|
|||
|
|
| `EXCHANGE_OUT` | COIN | keluar | Ditukar menjadi EnakPoint | `WALLET_TX` → baris `EXCHANGE_IN` pasangannya | `group_id` | "Tukar 50 EnakCoin ke EnakPoint" |
|
|||
|
|
| `EXCHANGE_IN` | POINT | masuk | Hasil tukar EnakCoin | `WALLET_TX` → baris `EXCHANGE_OUT` pasangannya | `group_id` | "Dari tukar 50 EnakCoin" |
|
|||
|
|
| `TRANSFER_OUT` | POINT / COIN | keluar | Dikirim ke customer lain | `WALLET_TX` → baris `TRANSFER_IN` penerima | `counterparty_customer_id`, `group_id` | "Transfer ke Bu*** Sa*** (08**-****-1234)" |
|
|||
|
|
| `TRANSFER_IN` | POINT / COIN | masuk | Diterima dari customer lain | `WALLET_TX` → baris `TRANSFER_OUT` pengirim | `counterparty_customer_id`, `group_id` | "Transfer dari An*** (08**-****-5678)" |
|
|||
|
|
| `GAME_SPEND` | COIN | keluar | Dipakai bermain game | `GAME_PLAY` → `game_plays.id` | – | "Main Spin Wheel: dapat Voucher 10rb" |
|
|||
|
|
| `EXPIRE` | POINT / COIN | keluar | Hangus karena masa berlaku habis | `LOT` → `wallet_lots.id` | – | "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098" |
|
|||
|
|
| `ADJUSTMENT` | POINT / COIN | masuk/keluar | Koreksi manual oleh admin | `USER` → `users.id` admin | `created_by_user`, `reason` | "Koreksi oleh admin: komplain #45" |
|
|||
|
|
| `MIGRATION` | POINT / COIN | masuk | Saldo lama sebelum sistem ini | `LEGACY_POINTS` / `LEGACY_TOKENS` → id baris lama | – | "Saldo awal dari sistem lama" |
|
|||
|
|
| `REWARD_REDEEM` | POINT | keluar | Ditukar reward (fase berikut) | `REWARD_REDEMPTION` → id penukaran | – | "Tukar reward: Tumbler" |
|
|||
|
|
|
|||
|
|
Setiap mutasi **masuk** membuat satu atau lebih lot. Setiap mutasi **keluar** mencatat
|
|||
|
|
alokasi ke lot yang dipakai. Dengan begitu jejak bisa ditelusuri di dua tingkat:
|
|||
|
|
|
|||
|
|
**Per mutasi** (lewat `reference_*`):
|
|||
|
|
- **EnakPoint yang dipakai bayar:** `PAYMENT` → `payments` → order, outlet, kasir, dan
|
|||
|
|
nominal rupiah yang ditutup.
|
|||
|
|
- **EnakPoint yang kembali:** `PAYMENT_REFUND` → `PAYMENT` asal → order.
|
|||
|
|
- **Saldo yang masuk lewat transfer:** `TRANSFER_IN` → baris `TRANSFER_OUT` pengirim.
|
|||
|
|
- **EnakPoint hasil tukar:** `EXCHANGE_IN` → `EXCHANGE_OUT` (EnakCoin).
|
|||
|
|
- **Earning yang ditarik:** `EARN_REVERSAL` → `EARN` asal → order.
|
|||
|
|
- **Saldo yang hangus:** `EXPIRE` → lot → mutasi masuk yang membuat lot tersebut.
|
|||
|
|
|
|||
|
|
**Per butir** (lewat `wallet_lot_allocations` dan `origin_lot_id`): dari pengurangan
|
|||
|
|
mana pun, lihat lot yang terpakai, lalu ikuti `origin_lot_id` ke belakang melewati
|
|||
|
|
transfer, exchange, atau refund, sampai ke lot pertama yang dibuat oleh `EARN`,
|
|||
|
|
`ADJUSTMENT`, atau `MIGRATION`.
|
|||
|
|
|
|||
|
|
**`description` dibekukan saat dibuat.** Nama outlet, nomor order, atau nama penerima
|
|||
|
|
yang berubah belakangan tidak mengubah riwayat. Prinsipnya sama seperti snapshot harga
|
|||
|
|
di `order_items`. Nama penerima/pengirim disamarkan di `description`. Nama lengkap hanya
|
|||
|
|
terlihat oleh admin lewat `counterparty_customer_id`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 9. API (Usulan)
|
|||
|
|
|
|||
|
|
### Customer app (`/customer`, `CustomerAuthMiddleware`)
|
|||
|
|
|
|||
|
|
| Method | Path | Keterangan |
|
|||
|
|
|---|---|---|
|
|||
|
|
| GET | `/wallet` | Saldo, nilai rupiah EnakPoint, saldo yang akan kedaluwarsa terdekat, mutasi terakhir (menggantikan `/points`, `/tokens`) |
|
|||
|
|
| GET | `/wallet/transactions` | Riwayat, pagination & filter |
|
|||
|
|
| GET | `/wallet/expiring` | Rincian saldo yang akan kedaluwarsa per tanggal |
|
|||
|
|
| POST | `/wallet/payment-code` | `{ "pin" }` → kode bayar EnakPoint sekali pakai `{ "code", "qr", "expires_at" }` |
|
|||
|
|
| GET | `/wallet/exchange/preview?coins=` | Kurs saat ini dan EnakPoint yang akan didapat |
|
|||
|
|
| POST | `/wallet/exchange` | `{ "coins": 50, "pin": "..." }` |
|
|||
|
|
| GET | `/wallet/transfer/recipient?phone=` | Cek penerima, mengembalikan nama tersamar |
|
|||
|
|
| POST | `/wallet/transfer` | `{ "currency": "POINT", "amount": 100, "recipient_phone": "...", "pin": "..." }` |
|
|||
|
|
| POST | `/orders/:id/pay-with-points` | Bayar order milik customer sendiri (self-order / app) → `{ "points": 50000, "pin": "..." }` |
|
|||
|
|
| POST | `/spin` | Tetap, kini memotong EnakCoin. Tanpa PIN |
|
|||
|
|
| GET | `/pin/status` | `{ "has_pin", "locked_until", "transfer_blocked_until" }` |
|
|||
|
|
| POST | `/pin/otp` | Kirim OTP untuk `pin_setup` / `pin_reset` |
|
|||
|
|
| POST | `/pin` | Buat PIN pertama: `{ "otp_code", "pin", "confirm_pin" }` |
|
|||
|
|
| PUT | `/pin` | Ganti PIN: `{ "old_pin", "pin", "confirm_pin" }` |
|
|||
|
|
| POST | `/pin/reset` | Lupa PIN: `{ "otp_code", "pin", "confirm_pin" }` |
|
|||
|
|
|
|||
|
|
Semua endpoint yang menerima `pin` mengembalikan error yang bisa dibedakan oleh
|
|||
|
|
aplikasi: `PIN_NOT_SET`, `PIN_INVALID` (beserta sisa percobaan), `PIN_LOCKED` (beserta
|
|||
|
|
`locked_until`), dan `TRANSFER_BLOCKED` (beserta `transfer_blocked_until`).
|
|||
|
|
|
|||
|
|
Endpoint `/points` dan `/tokens` dipertahankan sementara sebagai alias yang membaca
|
|||
|
|
dari `customer_wallets`, lalu dihapus setelah aplikasi diperbarui.
|
|||
|
|
|
|||
|
|
### POS / Dashboard (`/api/v1`)
|
|||
|
|
|
|||
|
|
| Method | Path | Role | Keterangan |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| GET | `/orders/:id/point-payment/preview` | Kasir | `maks_point`, nilai EnakPoint, nominal rupiah untuk customer order |
|
|||
|
|
| POST | `/orders/:id/payments` | Kasir | Endpoint pembayaran yang sudah ada. Untuk method EnakPoint, body membawa `{ "points": 50000, "payment_code": "482913" }` |
|
|||
|
|
| GET/PUT | `/outlets/:id/loyalty-settings` | Admin/Manager | F1 |
|
|||
|
|
| GET/PUT | `/marketing/loyalty-settings` | Admin/Manager | F2 dan F12 (nilai EnakPoint, kurs, transfer, kedaluwarsa) |
|
|||
|
|
| GET | `/marketing/loyalty-settings/history` | Admin/Manager | Riwayat perubahan setting |
|
|||
|
|
| GET | `/marketing/customers/:id/wallet` | Admin/Manager | Saldo, lot aktif, mutasi |
|
|||
|
|
| GET | `/marketing/wallet-transactions/:id/trace` | Admin/Manager | Telusuri per butir: alokasi lot dan rantai `origin_lot_id` |
|
|||
|
|
| POST | `/marketing/customers/:id/wallet/adjust` | Admin/Manager | `{ "currency", "amount", "reason" }` |
|
|||
|
|
| DELETE | `/marketing/customers/:id/pin` | Admin/Manager | Hapus PIN customer: `{ "reason" }` |
|
|||
|
|
| GET | `/marketing/customers/:id/security-events` | Admin/Manager | Log keamanan PIN |
|
|||
|
|
|
|||
|
|
Payment method bertipe `point` ditolak di endpoint pembayaran jika: outlet tidak
|
|||
|
|
menerima EnakPoint, order tanpa customer atau walk-in, kode bayar salah/kedaluwarsa/
|
|||
|
|
milik customer lain, atau `points` di luar batas F9.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 10. Migrasi dari Token
|
|||
|
|
|
|||
|
|
1. Buat `customer_wallets`, `wallet_transactions`, `wallet_lots`,
|
|||
|
|
`wallet_lot_allocations`, dan `loyalty_setting_changes`.
|
|||
|
|
2. Salin saldo:
|
|||
|
|
- `point_balance` diisi dari `customer_points.balance`.
|
|||
|
|
- `coin_balance` diisi dari **jumlah seluruh jenis** `customer_tokens.balance` milik
|
|||
|
|
customer tersebut (Q6, diputuskan). Contoh: SPIN 5 + RAFFLE 2 + MINIGAME 1 =
|
|||
|
|
8 EnakCoin. Rincian saldo per jenis disimpan di `metadata` baris ledger
|
|||
|
|
`MIGRATION`, supaya asal saldo awal tetap bisa ditelusuri.
|
|||
|
|
3. Tulis satu baris ledger `MIGRATION` dan satu lot (tanpa tanggal kedaluwarsa) per
|
|||
|
|
customer per currency yang saldonya > 0, supaya rekonsiliasi (§7.5) langsung
|
|||
|
|
berlaku.
|
|||
|
|
4. `campaigns.type` / `campaign_rules.reward_type`: nilai `TOKENS` diganti `COINS`.
|
|||
|
|
5. Tambah tipe `point` ke `payment_methods`, lalu buat payment method sistem
|
|||
|
|
"EnakPoint" untuk setiap organisasi. Tambah kolom `points_used` / `point_value` ke
|
|||
|
|
`payments`.
|
|||
|
|
6. Tambah kolom PIN ke `customers` dan tabel `customer_security_events`. Semua customer
|
|||
|
|
yang sudah ada mulai tanpa PIN, dan akan diminta membuatnya lewat OTP saat pertama
|
|||
|
|
kali transfer, membayar, atau exchange.
|
|||
|
|
7. `customer_points` dan `customer_tokens` dibiarkan read-only selama satu rilis, lalu
|
|||
|
|
di-drop di migrasi berikutnya.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 11. Di Luar Scope
|
|||
|
|
|
|||
|
|
- **Penukaran reward dengan EnakPoint.** Katalog reward sudah ada. Alurnya akan dibahas
|
|||
|
|
di PRD terpisah dan memakai tipe ledger `REWARD_REDEEM`.
|
|||
|
|
- **Tier otomatis** berdasarkan EnakPoint. Dibahas di PRD terpisah (lihat Q8 untuk
|
|||
|
|
arahannya).
|
|||
|
|
- **Eksekusi campaign rules** (bonus/multiplier) di atas earning dasar outlet.
|
|||
|
|
- **Earning untuk order tanpa customer terdaftar** (klaim belakangan lewat struk/QR).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 12. Pertanyaan & Catatan
|
|||
|
|
|
|||
|
|
### 12.1 Sudah Diputuskan
|
|||
|
|
|
|||
|
|
| # | Pertanyaan | Keputusan | Tercermin di |
|
|||
|
|
|---|---|---|---|
|
|||
|
|
| Q1 | Basis earning: sebelum atau sesudah pajak/service charge? | **Sebelum pajak** (`subtotal − discount`) | F1 |
|
|||
|
|
| Q2 | Apakah earning dari self-order (QR meja) diperlakukan sama? | **Ya**, semua kanal sama selama order punya customer | F3 |
|
|||
|
|
| Q3 | Saat reversal earning dan saldo tidak cukup: tarik sampai 0, izinkan saldo negatif, atau blokir refund? | **Tarik sampai 0 dan catat shortfall.** Refund tidak diblokir | F10 |
|
|||
|
|
| Q4 | Batas transfer diatur per organisasi atau global? Perlu limit harian? | **Per organisasi**, default tanpa batas | F2, F5 |
|
|||
|
|
| Q5 | Konfirmasi transfer pakai password, PIN khusus, atau OTP? | **PIN customer 6 digit**, juga untuk pembayaran dan exchange | K8, F11 |
|
|||
|
|
| Q6 | Saldo Token `RAFFLE`/`MINIGAME` yang ada ikut dikonversi ke EnakCoin? | **Ya**, semua jenis dijumlahkan menjadi EnakCoin. EnakCoin adalah mata uang untuk semua game | K1, F8, §10 |
|
|||
|
|
| Q7 | Customer app melayani satu organisasi atau banyak? | **Satu nomor telepon = satu customer di satu organisasi**, sesuai `customers.phone_number` yang unik secara global | K4 |
|
|||
|
|
| Q8 | Bagaimana tier (level keanggotaan, mis. Silver/Gold; tabel `tiers` sudah ada tapi belum terhubung ke customer) berhubungan dengan EnakPoint? | **Dibahas di PRD terpisah.** Arahan untuk PRD itu: tier dihitung dari **total `EARN` dalam 12 bulan terakhir**, bukan dari saldo, supaya customer tidak turun tier karena memakai, mentransfer, atau kehilangan saldo karena kedaluwarsa, dan tier tidak bisa "dibeli" lewat transfer. Ledger di PRD ini sudah mencatat `EARN`, jadi tidak ada yang perlu diubah di sini | §11 |
|
|||
|
|
| Q9 | Jejak cukup per mutasi, atau harus per butir? | **Per butir**, lewat lot. EnakPoint dan EnakCoin **bisa kedaluwarsa**, dengan masa berlaku yang diatur sendiri | K9, F12, §8 |
|
|||
|
|
| Q10 | Apakah bagian order yang dibayar EnakPoint tetap menghasilkan earning? | **Tidak.** Basis earning dikurangi nominal EnakPoint | F1 |
|
|||
|
|
| Q11 | Berapa nilai rupiah 1 EnakPoint, dan kurs EnakCoin → EnakPoint? | **1 EnakPoint = Rp 1** dan **1 EnakCoin = 1 EnakPoint** sebagai default. Keduanya bisa diubah di setting | K3, F2, F4 |
|
|||
|
|
| Q13 | Refund sebagian yang tidak habis dibagi nilai EnakPoint: sisa rupiahnya ke mana? | **Dibulatkan ke bawah**, sisanya hangus | F9 |
|
|||
|
|
| Q16 | Setelah reset PIN, berapa lama transfer keluar ditahan? | **24 jam, hanya transfer.** Pembayaran dan exchange tetap bisa | F11 |
|
|||
|
|
| Q17 | Parameter kunci PIN? | **5 kali salah, terkunci 30 menit** | F11 |
|
|||
|
|
|
|||
|
|
### 12.2 Masih Terbuka
|
|||
|
|
|
|||
|
|
Tidak ada. Semua hal yang belum diputuskan sudah dipindahkan ke catatan N1–N4 di
|
|||
|
|
bawah, masing-masing dengan batas waktu.
|
|||
|
|
|
|||
|
|
### 12.3 Ditunda (Catatan agar Tidak Lupa)
|
|||
|
|
|
|||
|
|
Hal-hal berikut sengaja belum diputuskan. Masing-masing punya batas waktu, yaitu fase
|
|||
|
|
yang tidak boleh dirilis sebelum catatan ini ditutup.
|
|||
|
|
|
|||
|
|
**N1 — Pembayaran EnakPoint di kasir untuk customer tanpa aplikasi** (sebelumnya Q12)
|
|||
|
|
- **Situasi:** persetujuan pembayaran di kasir saat ini hanya lewat kode bayar dari
|
|||
|
|
aplikasi (F9). Customer yang tidak punya aplikasi, HP-nya mati, atau tidak ada
|
|||
|
|
internet belum bisa membayar dengan EnakPoint di kasir.
|
|||
|
|
- **Batasan yang harus tetap dijaga:** PIN tidak boleh diketik di layar kasir (K8).
|
|||
|
|
- **Opsi yang sudah terpikir:** PIN pad atau layar yang menghadap customer; OTP ke
|
|||
|
|
nomor telepon (butuh HP tapi tidak butuh aplikasi); atau memang tidak didukung.
|
|||
|
|
- **Batas waktu:** tidak memblokir fase 3. Fase 3 bisa rilis tanpa jalur ini, tetapi
|
|||
|
|
kasir perlu tahu apa yang harus dikatakan ke customer tanpa aplikasi.
|
|||
|
|
- **Pemilik keputusan:** product owner.
|
|||
|
|
|
|||
|
|
**N2 — Perlakuan akuntansi EnakPoint dan EnakCoin** (sebelumnya Q14)
|
|||
|
|
- **Situasi:** EnakPoint yang dipakai membayar bukan kas masuk. Saldo yang beredar
|
|||
|
|
berpotensi menjadi kewajiban. Saldo yang kedaluwarsa (F12) menjadi "breakage" yang
|
|||
|
|
juga perlu dicatat.
|
|||
|
|
- **Yang perlu diputuskan:** apakah EnakPoint yang dipakai dicatat sebagai beban
|
|||
|
|
promosi atau pengurang liabilitas loyalitas; apakah saldo beredar dicatat sebagai
|
|||
|
|
liabilitas; bagaimana breakage dari kedaluwarsa dicatat; apakah EnakCoin (yang bisa
|
|||
|
|
ditukar ke EnakPoint, K3) ikut dihitung; dan apakah perlu jurnal otomatis ke modul
|
|||
|
|
chart of account yang sudah ada.
|
|||
|
|
- **Dampak ke sistem:** laporan payment method, laporan penjualan (penjualan kotor vs
|
|||
|
|
kas masuk), dan kemungkinan jurnal otomatis.
|
|||
|
|
- **Batas waktu:** sebelum fase 3 (pembayaran EnakPoint) dirilis ke outlet pertama.
|
|||
|
|
- **Pemilik keputusan:** tim keuangan.
|
|||
|
|
|
|||
|
|
**N3 — Tinjauan regulasi uang elektronik** (sebelumnya Q15)
|
|||
|
|
- **Situasi:** EnakPoint bernilai rupiah, bisa dipakai membayar, dan bisa ditransfer
|
|||
|
|
antar customer. Kombinasi ini mirip dengan uang elektronik yang diatur Bank
|
|||
|
|
Indonesia.
|
|||
|
|
- **Mitigasi yang sudah ada di desain:** tidak bisa ditunaikan (K7), hanya berlaku di
|
|||
|
|
outlet dalam organisasi yang sama (K4), tampilan "setara potongan", bukan "saldo
|
|||
|
|
rupiah", dan bisa kedaluwarsa (F12).
|
|||
|
|
- **Yang perlu dicek ke legal:** apakah fitur transfer (F5) masih aman; apakah perlu
|
|||
|
|
batas transfer wajib (F2); dan apa yang harus ada di Syarat & Ketentuan.
|
|||
|
|
- **Batas waktu:** sebelum fase 3 (pembayaran) dan fase 4 (transfer) dirilis.
|
|||
|
|
- **Pemilik keputusan:** legal.
|
|||
|
|
|
|||
|
|
**N4 — Model kedaluwarsa saldo**
|
|||
|
|
- **Situasi:** sudah diputuskan bahwa EnakPoint dan EnakCoin bisa kedaluwarsa dan
|
|||
|
|
owner bisa mengatur sendiri (Q9). Yang belum diputuskan adalah **modelnya**.
|
|||
|
|
- **Pilihan:**
|
|||
|
|
|
|||
|
|
| | A. Tanggal tetap (gaya Telkomsel POIN / XL Poin) | B. Per saldo masuk (draft F12 saat ini) |
|
|||
|
|
|---|---|---|
|
|||
|
|
| Cara kerja | Semua saldo hangus di tanggal yang sama, mis. tiap 31 Des (1× setahun) atau tiap 30 Jun & 31 Des (2× setahun) | Tiap saldo punya tanggal sendiri, mis. 12 bulan sejak didapat |
|
|||
|
|
| Mudah dipahami customer | Sangat mudah: "semua hangus 31 Desember" | Lebih rumit: "150 hangus 3 Okt, 200 hangus 18 Nov" |
|
|||
|
|
| Pengingat | Satu kampanye besar menjelang tanggal hangus | Banyak pengingat kecil |
|
|||
|
|
| Adil | Kurang. Saldo yang didapat sehari sebelum tanggal hangus langsung hilang. Bisa ditutup dengan **periode tanggung**, mis. saldo yang didapat < 3 bulan sebelum tanggal hangus ikut ke tanggal hangus berikutnya | Adil. Semua saldo punya umur yang sama |
|
|||
|
|
| Efek bisnis | Lonjakan belanja menjelang tanggal hangus | Lebih rata |
|
|||
|
|
|
|||
|
|
- **Pilihan ketiga:** keduanya didukung sebagai mode di setting, dan owner memilih.
|
|||
|
|
Ini tidak mengubah struktur data. Lot, alokasi, dan `EXPIRE` tetap sama. Yang berbeda
|
|||
|
|
hanya rumus `expires_at` saat lot dibuat:
|
|||
|
|
- A: tanggal hangus berikutnya setelah (tanggal didapat + periode tanggung)
|
|||
|
|
- B: tanggal didapat + masa berlaku
|
|||
|
|
- **Usulan sementara (belum disetujui):** dukung keduanya, dengan default model A
|
|||
|
|
setahun sekali tiap 31 Desember dan periode tanggung 3 bulan, karena model ini sudah
|
|||
|
|
familiar bagi customer di Indonesia.
|
|||
|
|
- **Pertanyaan turunan yang ikut diputuskan bersama N4:**
|
|||
|
|
- Saat kedaluwarsa pertama kali diaktifkan, bagaimana dengan saldo lama yang belum
|
|||
|
|
punya tanggal kedaluwarsa? Usulan: diberi masa berlaku penuh sejak tanggal aktivasi
|
|||
|
|
(model B), atau ikut tanggal hangus kedua berikutnya (model A).
|
|||
|
|
- EnakPoint yang dikembalikan karena refund, padahal lot asalnya sudah atau hampir
|
|||
|
|
kedaluwarsa? Usulan: diberi masa berlaku minimal 7 hari sejak refund.
|
|||
|
|
- Saat kedaluwarsa dinonaktifkan, apakah saldo yang sudah terjadwal kedaluwarsa ikut
|
|||
|
|
dibatalkan? Usulan: tidak, hanya saldo baru yang tidak kedaluwarsa.
|
|||
|
|
- Pengingat dikirim berapa hari sebelum tanggal hangus, dan berapa kali?
|
|||
|
|
- **Dampak ke sistem:** tabel pengaturan di F12, rumus kedaluwarsa per lot, isi
|
|||
|
|
pengingat, dan tampilan "saldo yang akan kedaluwarsa" di aplikasi.
|
|||
|
|
- **Batas waktu:** sebelum fase 5 (kedaluwarsa) dikerjakan. Fase 1–4 tidak terblokir,
|
|||
|
|
karena lot sudah dibuat sejak fase 1 dan dipakai oleh kedua model.
|
|||
|
|
- **Pemilik keputusan:** product owner.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 13. Tahapan Rilis
|
|||
|
|
|
|||
|
|
| Fase | Isi | Syarat rilis |
|
|||
|
|
|---|---|---|
|
|||
|
|
| **1. Fondasi** | Tabel wallet, ledger, dan lot; migrasi Token → EnakCoin; repository transaksional; endpoint saldo & riwayat; adjustment admin; riwayat perubahan setting | – |
|
|||
|
|
| **2. Earning** | Setting outlet (F1), earning saat order lunas (F3), reversal (F10), `points_earned`/`coins_earned` di response order | – |
|
|||
|
|
| **3. Pembayaran EnakPoint** | PIN customer (F11), setting organisasi (F2), payment method EnakPoint, kode bayar, bayar di kasir & app (F9), refund EnakPoint, laporan payment method | N2 dan N3 ditutup |
|
|||
|
|
| **4. Pergerakan saldo** | Exchange dengan kurs (F4), transfer (F5), semua game memakai EnakCoin (F8), telusuri per butir di dashboard | N3 ditutup |
|
|||
|
|
| **5. Kedaluwarsa** | Setting kedaluwarsa (F12), job kedaluwarsa, pengingat, tampilan saldo yang akan kedaluwarsa | N4 ditutup |
|
|||
|
|
| **6. Lanjutan** | Penukaran reward, tier (Q8), campaign rules | – |
|
|||
|
|
|
|||
|
|
Lot sudah dibuat sejak fase 1, meskipun kedaluwarsa baru aktif di fase 5. Kalau lot
|
|||
|
|
baru ditambahkan belakangan, seluruh riwayat alokasi harus direkonstruksi ulang dari
|
|||
|
|
ledger.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 14. Metrik Keberhasilan
|
|||
|
|
|
|||
|
|
- 0 selisih pada job rekonsiliasi: ledger vs saldo vs lot, alokasi vs mutasi, dan
|
|||
|
|
pembayaran EnakPoint vs ledger.
|
|||
|
|
- 0 mutasi tanpa asal/tujuan (dijamin oleh constraint, tetapi diverifikasi di job
|
|||
|
|
rekonsiliasi).
|
|||
|
|
- 0 earning ganda per order, 0 pemotongan ganda per pembayaran, dan 0 kedaluwarsa ganda
|
|||
|
|
per lot (dicek dari idempotency key).
|
|||
|
|
- 0 refund non-EnakPoint atas pembayaran EnakPoint (dicek dari job rekonsiliasi).
|
|||
|
|
- 0 lot yang lewat tanggal kedaluwarsa lebih dari 1 jam tanpa diproses.
|
|||
|
|
- Persentase order lunas dengan customer terdaftar yang mendapat earning (target: 100%
|
|||
|
|
untuk outlet dengan setting aktif).
|
|||
|
|
- Persentase transaksi yang dibayar (sebagian) dengan EnakPoint, dan total nilai
|
|||
|
|
rupiahnya per bulan.
|
|||
|
|
- Jumlah EnakPoint/EnakCoin yang kedaluwarsa per bulan, dan persentase customer yang
|
|||
|
|
memakai saldonya setelah menerima pengingat.
|
|||
|
|
- Volume exchange dan transfer per minggu sebagai indikator adopsi.
|