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>
61 KiB
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)
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 olehenakgame-prd.md§3.2: EnakPoint hanya bisa ditukar ke voucher, tidak bisa dipakai membayar dan tidak bisa dicairkan. Fitur tersebut sudah dihapus dari backend (migrasi000102). Dokumen ini dibiarkan apa adanya sebagai riwayat keputusan.
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/DeductPointsmasihnot implemented.- Tidak ada riwayat transaksi. "History" di
/customer/pointssebenarnya 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
- Customer mendapat EnakPoint dan EnakCoin otomatis dari order yang lunas, dengan besaran yang diatur per outlet.
- Customer bisa membayar order dengan EnakPoint, penuh atau sebagian. EnakCoin tidak bisa dipakai membayar.
- Customer bisa menukar EnakCoin ke EnakPoint, satu arah, dengan kurs yang bisa diatur (default 1 EnakCoin = 1 EnakPoint).
- Customer bisa mentransfer EnakPoint dan EnakCoin ke customer lain.
- EnakPoint dan EnakCoin bisa kedaluwarsa, dengan masa berlaku yang diatur sendiri oleh owner.
- 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_mode |
PER_AMOUNT / PERCENTAGE |
PER_AMOUNT |
Cara menghitung earning |
loyalty.point.earn_per_amount |
int (Rp) | 100 |
PER_AMOUNT: setiap kelipatan nominal ini… |
loyalty.point.earn_value |
int | 1 |
…mendapat sekian EnakPoint |
loyalty.point.earn_percent |
desimal (0–100, maks. 2 angka desimal) | 1 |
PERCENTAGE: sekian persen dari basis menjadi 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_mode |
PER_AMOUNT / PERCENTAGE |
PER_AMOUNT |
|
loyalty.coin.earn_per_amount |
int (Rp) | 25000 |
|
loyalty.coin.earn_value |
int | 1 |
|
loyalty.coin.earn_percent |
desimal (0–100, maks. 2 angka desimal) | 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, atau pada mode
PERCENTAGE: earn_percent × nilai EnakPoint. Tujuannya supaya owner tidak salah
mengira skala.
Setting mode yang sedang tidak dipakai tetap tersimpan, sehingga berpindah mode tidak menghapus nilai mode sebelumnya.
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 mode PER_AMOUNT
jumlah = floor(basis × earn_percent / 100) mode PERCENTAGE
jumlah = min(jumlah, max_per_order) jika max_per_order diisi
- Basis dihitung sebelum pajak (Q1, diputuskan).
tax_amounttidak 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.
Pada mode PERCENTAGE, earn_percent adalah persen dari basis yang menjadi
jumlah EnakPoint/EnakCoin (bukan nilai rupiahnya): 2,5% dari basis Rp 87.500
menghasilkan 2.187 EnakPoint.
Validasi: earn_per_amount > 0, earn_value ≥ 0, 0 ≤ earn_percent ≤ 100 dengan
paling banyak dua angka desimal, 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_earneddancoins_earned.
F4 — Exchange EnakCoin → EnakPoint
- Kurs sesuai F2:
coin_amountEnakCoin =point_amountEnakPoint (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, −) danEXCHANGE_IN(POINT, +), keduanya dengangroup_idyang 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) danTRANSFER_IN(penerima, +N), dengangroup_idsama 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
NotificationServiceyang sudah ada).
F6 — Saldo & Riwayat (Customer App)
GET /customer/walletmengembalikan saldo EnakPoint (beserta nilai rupiahnya saat ini), saldo EnakCoin, saldo yang akan kedaluwarsa terdekat (jumlah dan tanggal), dan beberapa mutasi terakhir.GET /customer/wallet/expiringmengembalikan rincian saldo yang akan kedaluwarsa, dikelompokkan per tanggal.GET /customer/wallet/transactionsmengembalikan 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
ADJUSTMENTbesertauser_idadmin. 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/spinmemotong EnakCoin, bukan TokenSPIN. - 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 ledgerGAME_SPENDterjadi dalam satu transaksi. Jika stok hadiah gagal dikurangi, seluruh permainan dibatalkan (saat ini hanya di-Printf). game_plays.token_usedberganti arti menjadi jumlah EnakCoin yang dipakai (diganti nama menjadicoins_used).
F9 — Bayar Order dengan EnakPoint
Dihapus 2026-10-07: digantikan oleh
enakgame-prd.md§3.2 (EnakPoint hanya untuk voucher) dan sudah dihapus dari backend (migrasi000102); 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
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_rupiahtidak boleh melebihiremaining_amount. Jika nilai EnakPoint diatur lebih dari Rp 1, sisa tagihan yang bukan kelipatannilaidibayar 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:
- Kunci wallet customer.
- Ambil saldo dari lot yang paling cepat kedaluwarsa dan catat alokasinya (§8).
- Potong saldo EnakPoint (update bersyarat, §7).
- Buat baris
paymentsdengan method EnakPoint,status = completed,amount = nominal_rupiah,points_used, danpoint_value(nilai yang dibekukan). - Tulis ledger
PAYMENT(POINT, −N) yang menunjukpayments.iddanoutlet_id. - Perbarui
remaining_amount/payment_statusorder 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), denganreverses_transaction_idmenunjuk barisPAYMENTasal. - Jumlah yang dikembalikan dihitung dengan
point_valueyang 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-
paymentsyang 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
EARNorder 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, denganreverses_transaction_idmenunjukEARNasal. - 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
OtpProcessoryang sudah ada, dengan purpose barupin_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, daricustomers.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 ≤ sekarangdan 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
- Saldo tidak pernah negatif. Dijaga oleh
CHECKdi database dan update bersyarat (WHERE balance >= ?) yang mengecek jumlah baris ter-update. Update yang mengenai 0 baris dianggap saldo tidak cukup. - Satu transaksi database per operasi. Semua repository wallet memakai
DBFromContextagar ikut transaksi dariTxManager. Pembayaran EnakPoint berada di transaksi yang sama dengan pembuatan barispayments. - Urutan lock. Setiap operasi, termasuk job kedaluwarsa, mengunci wallet
(
SELECT … FOR UPDATE) sebelum mengubah wallet atau lot-nya. Transfer mengunci dua wallet berurutan berdasarkancustomer_iduntuk 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. - Idempotensi.
wallet_transactions.idempotency_keyunik. Request ulang dengan key yang sama mengembalikan hasil pertama, bukan error dan bukan mutasi baru. - 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 absolutamount-nya. - Untuk setiap
paymentsbermethod EnakPoint:points_used= nilai absolut barisPAYMENT-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.
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
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)
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_OUTA 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
-- 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→PAYMENTasal → order. - Saldo yang masuk lewat transfer:
TRANSFER_IN→ barisTRANSFER_OUTpengirim. - EnakPoint hasil tukar:
EXCHANGE_IN→EXCHANGE_OUT(EnakCoin). - Earning yang ditarik:
EARN_REVERSAL→EARNasal → 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
- Buat
customer_wallets,wallet_transactions,wallet_lots,wallet_lot_allocations, danloyalty_setting_changes. - Salin saldo:
point_balancediisi daricustomer_points.balance.coin_balancediisi dari jumlah seluruh jeniscustomer_tokens.balancemilik customer tersebut (Q6, diputuskan). Contoh: SPIN 5 + RAFFLE 2 + MINIGAME 1 = 8 EnakCoin. Rincian saldo per jenis disimpan dimetadatabaris ledgerMIGRATION, supaya asal saldo awal tetap bisa ditelusuri.
- Tulis satu baris ledger
MIGRATIONdan satu lot (tanpa tanggal kedaluwarsa) per customer per currency yang saldonya > 0, supaya rekonsiliasi (§7.5) langsung berlaku. campaigns.type/campaign_rules.reward_type: nilaiTOKENSdigantiCOINS.- Tambah tipe
pointkepayment_methods, lalu buat payment method sistem "EnakPoint" untuk setiap organisasi. Tambah kolompoints_used/point_valuekepayments. - Tambah kolom PIN ke
customersdan tabelcustomer_security_events. Semua customer yang sudah ada mulai tanpa PIN, dan akan diminta membuatnya lewat OTP saat pertama kali transfer, membayar, atau exchange. customer_pointsdancustomer_tokensdibiarkan 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
EXPIREtetap sama. Yang berbeda hanya rumusexpires_atsaat 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.