Files

60 KiB
Raw Permalink Blame History

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_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_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.

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_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.

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_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

-- 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.