# 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. ```sql CREATE TABLE customer_wallets ( customer_id UUID PRIMARY KEY REFERENCES customers(id) ON DELETE RESTRICT, organization_id UUID NOT NULL REFERENCES organizations(id), point_balance BIGINT NOT NULL DEFAULT 0 CHECK (point_balance >= 0), coin_balance BIGINT NOT NULL DEFAULT 0 CHECK (coin_balance >= 0), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); ``` ### `wallet_transactions`: ledger ```sql CREATE TABLE wallet_transactions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')), type VARCHAR(30) NOT NULL, amount BIGINT NOT NULL CHECK (amount <> 0), -- bertanda balance_after BIGINT NOT NULL, group_id UUID, -- menyatukan pasangan exchange / transfer -- Asal (amount > 0) atau tujuan (amount < 0). Wajib untuk semua tipe. reference_type VARCHAR(30) NOT NULL, -- ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER, ... reference_id UUID NOT NULL, counterparty_customer_id UUID REFERENCES customers(id), -- TRANSFER_IN / _OUT reverses_transaction_id UUID REFERENCES wallet_transactions(id), -- EARN_REVERSAL / PAYMENT_REFUND outlet_id UUID, -- EARN, EARN_REVERSAL, PAYMENT, PAYMENT_REFUND created_by_user UUID, -- ADJUSTMENT: admin; PAYMENT / PAYMENT_REFUND: kasir reason VARCHAR(255), -- ADJUSTMENT: alasan description VARCHAR(255) NOT NULL, -- teks siap tampil, dibekukan saat dibuat metadata JSONB DEFAULT '{}', -- snapshot setting, point_value, kurs, shortfall idempotency_key VARCHAR(100) UNIQUE, created_at TIMESTAMPTZ DEFAULT NOW(), -- Hanya EnakPoint yang bisa membayar (K2) CONSTRAINT chk_point_only_types CHECK ( type NOT IN ('PAYMENT','PAYMENT_REFUND','EXCHANGE_IN','REWARD_REDEEM') OR currency = 'POINT'), CONSTRAINT chk_coin_only_types CHECK ( type NOT IN ('EXCHANGE_OUT','GAME_SPEND') OR currency = 'COIN'), CONSTRAINT chk_transfer_counterparty CHECK ( type NOT IN ('TRANSFER_IN','TRANSFER_OUT') OR counterparty_customer_id IS NOT NULL), CONSTRAINT chk_reversal_source CHECK ( type NOT IN ('EARN_REVERSAL','PAYMENT_REFUND') OR reverses_transaction_id IS NOT NULL), CONSTRAINT chk_adjustment_actor CHECK ( type <> 'ADJUSTMENT' OR (created_by_user IS NOT NULL AND reason IS NOT NULL)), CONSTRAINT chk_expire_lot CHECK ( type <> 'EXPIRE' OR reference_type = 'LOT') ); -- index: (customer_id, created_at DESC), (reference_type, reference_id), (group_id), -- (counterparty_customer_id), (reverses_transaction_id) ``` Ledger bersifat **append-only**. Baris tidak pernah di-`UPDATE` atau di-`DELETE`. Koreksi dilakukan dengan baris baru (`EARN_REVERSAL`, `PAYMENT_REFUND`, atau `ADJUSTMENT`) yang menunjuk baris yang dikoreksi. Customer yang punya riwayat tidak bisa dihapus permanen, cukup dinonaktifkan. ### `wallet_lots` dan `wallet_lot_allocations`: saldo per butir (K9) ```sql CREATE TABLE wallet_lots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL, customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')), source_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), -- mutasi masuk pembuatnya origin_lot_id UUID REFERENCES wallet_lots(id), -- lot asal: transfer / exchange / refund original_amount BIGINT NOT NULL CHECK (original_amount > 0), remaining_amount BIGINT NOT NULL CHECK (remaining_amount >= 0 AND remaining_amount <= original_amount), expires_at TIMESTAMPTZ, -- NULL = tidak kedaluwarsa created_at TIMESTAMPTZ DEFAULT NOW() ); -- urutan pemakaian (K9): paling cepat kedaluwarsa dulu, yang tanpa tanggal paling akhir CREATE INDEX idx_wallet_lots_consume ON wallet_lots (customer_id, currency, expires_at NULLS LAST, created_at) WHERE remaining_amount > 0; CREATE INDEX idx_wallet_lots_expiry ON wallet_lots (expires_at) WHERE remaining_amount > 0; -- Setiap mutasi keluar mencatat lot mana yang dipakai dan berapa banyak CREATE TABLE wallet_lot_allocations ( transaction_id UUID NOT NULL REFERENCES wallet_transactions(id), -- mutasi keluar lot_id UUID NOT NULL REFERENCES wallet_lots(id), amount BIGINT NOT NULL CHECK (amount > 0), PRIMARY KEY (transaction_id, lot_id) ); ``` `wallet_lots.remaining_amount` adalah satu-satunya kolom yang di-`UPDATE`, sebagai ringkasan untuk mempercepat pemakaian. Nilainya selalu bisa dihitung ulang dari `original_amount − SUM(wallet_lot_allocations.amount)` (§7.5). Ledger dan alokasi tetap append-only. **Contoh.** Customer A punya lot 100 EnakPoint (dari order #ORD-1, kedaluwarsa 31 Des) dan lot 50 EnakPoint (dari order #ORD-2, kedaluwarsa 31 Jan). A mentransfer 120 ke B. - `TRANSFER_OUT` A dialokasikan 100 dari lot #ORD-1 dan 20 dari lot #ORD-2. - B mendapat dua lot: 100 (kedaluwarsa 31 Des, `origin_lot_id` = lot #ORD-1) dan 20 (kedaluwarsa 31 Jan, `origin_lot_id` = lot #ORD-2). - Kalau B lalu membayar dengan 30 EnakPoint, alokasinya menunjukkan bahwa 30 EnakPoint itu berasal dari order #ORD-1 milik A. ### Perubahan tabel yang sudah ada ```sql -- payment_methods.type: tambah nilai 'point' -- (validator saat ini: oneof=cash card digital_wallet) -- PIN customer (F11) ALTER TABLE customers ADD COLUMN pin_hash VARCHAR(255), ADD COLUMN pin_set_at TIMESTAMPTZ, ADD COLUMN pin_failed_attempts INT NOT NULL DEFAULT 0, ADD COLUMN pin_locked_until TIMESTAMPTZ, ADD COLUMN transfer_blocked_until TIMESTAMPTZ; -- 24 jam setelah reset PIN CREATE TABLE customer_security_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT, event VARCHAR(30) NOT NULL, -- PIN_SET, PIN_CHANGED, PIN_RESET, PIN_FAILED, -- PIN_LOCKED, PIN_REMOVED_BY_ADMIN actor_user UUID, -- diisi untuk PIN_REMOVED_BY_ADMIN reason VARCHAR(255), ip_address VARCHAR(45), user_agent VARCHAR(255), created_at TIMESTAMPTZ DEFAULT NOW() ); ALTER TABLE payments ADD COLUMN points_used BIGINT, -- diisi hanya untuk method EnakPoint ADD COLUMN point_value DECIMAL(10,2), -- nilai 1 EnakPoint saat dibayar (beku) ADD CONSTRAINT chk_payments_point_pair CHECK ( (points_used IS NULL AND point_value IS NULL) OR (points_used > 0 AND point_value > 0)); -- Riwayat perubahan setting loyalitas (F2, F12) CREATE TABLE loyalty_setting_changes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), organization_id UUID NOT NULL, outlet_id UUID, -- NULL untuk setting organisasi key VARCHAR(100) NOT NULL, old_value TEXT, new_value TEXT, changed_by UUID NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); ``` ### 8.1 Jejak Asal & Tujuan per Tipe | Tipe | Currency | Arah | Dari mana / ke mana | `reference_type` → `reference_id` | Kolom wajib tambahan | Contoh `description` | |---|---|---|---|---|---|---| | `EARN` | POINT / COIN | masuk | Order yang lunas | `ORDER` → `orders.id` | `outlet_id` | "Belanja #ORD-0123 di Outlet Kemang" | | `EARN_REVERSAL` | POINT / COIN | keluar | Ditarik karena order di-void/refund | `ORDER` → `orders.id` | `reverses_transaction_id` (baris `EARN` asal), `outlet_id` | "Batal #ORD-0123 di Outlet Kemang" | | `PAYMENT` | POINT | keluar | Dipakai membayar order | `PAYMENT` → `payments.id` | `outlet_id`, `created_by_user` (kasir, jika via POS) | "Bayar #ORD-0123 di Outlet Kemang (Rp 50.000)" | | `PAYMENT_REFUND` | POINT | masuk | Dikembalikan karena pembayaran di-void/refund | `PAYMENT` → `payments.id` | `reverses_transaction_id` (baris `PAYMENT` asal), `outlet_id` | "Pengembalian #ORD-0123 di Outlet Kemang" | | `EXCHANGE_OUT` | COIN | keluar | Ditukar menjadi EnakPoint | `WALLET_TX` → baris `EXCHANGE_IN` pasangannya | `group_id` | "Tukar 50 EnakCoin ke EnakPoint" | | `EXCHANGE_IN` | POINT | masuk | Hasil tukar EnakCoin | `WALLET_TX` → baris `EXCHANGE_OUT` pasangannya | `group_id` | "Dari tukar 50 EnakCoin" | | `TRANSFER_OUT` | POINT / COIN | keluar | Dikirim ke customer lain | `WALLET_TX` → baris `TRANSFER_IN` penerima | `counterparty_customer_id`, `group_id` | "Transfer ke Bu*** Sa*** (08**-****-1234)" | | `TRANSFER_IN` | POINT / COIN | masuk | Diterima dari customer lain | `WALLET_TX` → baris `TRANSFER_OUT` pengirim | `counterparty_customer_id`, `group_id` | "Transfer dari An*** (08**-****-5678)" | | `GAME_SPEND` | COIN | keluar | Dipakai bermain game | `GAME_PLAY` → `game_plays.id` | – | "Main Spin Wheel: dapat Voucher 10rb" | | `EXPIRE` | POINT / COIN | keluar | Hangus karena masa berlaku habis | `LOT` → `wallet_lots.id` | – | "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098" | | `ADJUSTMENT` | POINT / COIN | masuk/keluar | Koreksi manual oleh admin | `USER` → `users.id` admin | `created_by_user`, `reason` | "Koreksi oleh admin: komplain #45" | | `MIGRATION` | POINT / COIN | masuk | Saldo lama sebelum sistem ini | `LEGACY_POINTS` / `LEGACY_TOKENS` → id baris lama | – | "Saldo awal dari sistem lama" | | `REWARD_REDEEM` | POINT | keluar | Ditukar reward (fase berikut) | `REWARD_REDEMPTION` → id penukaran | – | "Tukar reward: Tumbler" | Setiap mutasi **masuk** membuat satu atau lebih lot. Setiap mutasi **keluar** mencatat alokasi ke lot yang dipakai. Dengan begitu jejak bisa ditelusuri di dua tingkat: **Per mutasi** (lewat `reference_*`): - **EnakPoint yang dipakai bayar:** `PAYMENT` → `payments` → order, outlet, kasir, dan nominal rupiah yang ditutup. - **EnakPoint yang kembali:** `PAYMENT_REFUND` → `PAYMENT` asal → order. - **Saldo yang masuk lewat transfer:** `TRANSFER_IN` → baris `TRANSFER_OUT` pengirim. - **EnakPoint hasil tukar:** `EXCHANGE_IN` → `EXCHANGE_OUT` (EnakCoin). - **Earning yang ditarik:** `EARN_REVERSAL` → `EARN` asal → order. - **Saldo yang hangus:** `EXPIRE` → lot → mutasi masuk yang membuat lot tersebut. **Per butir** (lewat `wallet_lot_allocations` dan `origin_lot_id`): dari pengurangan mana pun, lihat lot yang terpakai, lalu ikuti `origin_lot_id` ke belakang melewati transfer, exchange, atau refund, sampai ke lot pertama yang dibuat oleh `EARN`, `ADJUSTMENT`, atau `MIGRATION`. **`description` dibekukan saat dibuat.** Nama outlet, nomor order, atau nama penerima yang berubah belakangan tidak mengubah riwayat. Prinsipnya sama seperti snapshot harga di `order_items`. Nama penerima/pengirim disamarkan di `description`. Nama lengkap hanya terlihat oleh admin lewat `counterparty_customer_id`. --- ## 9. API (Usulan) ### Customer app (`/customer`, `CustomerAuthMiddleware`) | Method | Path | Keterangan | |---|---|---| | GET | `/wallet` | Saldo, nilai rupiah EnakPoint, saldo yang akan kedaluwarsa terdekat, mutasi terakhir (menggantikan `/points`, `/tokens`) | | GET | `/wallet/transactions` | Riwayat, pagination & filter | | GET | `/wallet/expiring` | Rincian saldo yang akan kedaluwarsa per tanggal | | POST | `/wallet/payment-code` | `{ "pin" }` → kode bayar EnakPoint sekali pakai `{ "code", "qr", "expires_at" }` | | GET | `/wallet/exchange/preview?coins=` | Kurs saat ini dan EnakPoint yang akan didapat | | POST | `/wallet/exchange` | `{ "coins": 50, "pin": "..." }` | | GET | `/wallet/transfer/recipient?phone=` | Cek penerima, mengembalikan nama tersamar | | POST | `/wallet/transfer` | `{ "currency": "POINT", "amount": 100, "recipient_phone": "...", "pin": "..." }` | | POST | `/orders/:id/pay-with-points` | Bayar order milik customer sendiri (self-order / app) → `{ "points": 50000, "pin": "..." }` | | POST | `/spin` | Tetap, kini memotong EnakCoin. Tanpa PIN | | GET | `/pin/status` | `{ "has_pin", "locked_until", "transfer_blocked_until" }` | | POST | `/pin/otp` | Kirim OTP untuk `pin_setup` / `pin_reset` | | POST | `/pin` | Buat PIN pertama: `{ "otp_code", "pin", "confirm_pin" }` | | PUT | `/pin` | Ganti PIN: `{ "old_pin", "pin", "confirm_pin" }` | | POST | `/pin/reset` | Lupa PIN: `{ "otp_code", "pin", "confirm_pin" }` | Semua endpoint yang menerima `pin` mengembalikan error yang bisa dibedakan oleh aplikasi: `PIN_NOT_SET`, `PIN_INVALID` (beserta sisa percobaan), `PIN_LOCKED` (beserta `locked_until`), dan `TRANSFER_BLOCKED` (beserta `transfer_blocked_until`). Endpoint `/points` dan `/tokens` dipertahankan sementara sebagai alias yang membaca dari `customer_wallets`, lalu dihapus setelah aplikasi diperbarui. ### POS / Dashboard (`/api/v1`) | Method | Path | Role | Keterangan | |---|---|---|---| | GET | `/orders/:id/point-payment/preview` | Kasir | `maks_point`, nilai EnakPoint, nominal rupiah untuk customer order | | POST | `/orders/:id/payments` | Kasir | Endpoint pembayaran yang sudah ada. Untuk method EnakPoint, body membawa `{ "points": 50000, "payment_code": "482913" }` | | GET/PUT | `/outlets/:id/loyalty-settings` | Admin/Manager | F1 | | GET/PUT | `/marketing/loyalty-settings` | Admin/Manager | F2 dan F12 (nilai EnakPoint, kurs, transfer, kedaluwarsa) | | GET | `/marketing/loyalty-settings/history` | Admin/Manager | Riwayat perubahan setting | | GET | `/marketing/customers/:id/wallet` | Admin/Manager | Saldo, lot aktif, mutasi | | GET | `/marketing/wallet-transactions/:id/trace` | Admin/Manager | Telusuri per butir: alokasi lot dan rantai `origin_lot_id` | | POST | `/marketing/customers/:id/wallet/adjust` | Admin/Manager | `{ "currency", "amount", "reason" }` | | DELETE | `/marketing/customers/:id/pin` | Admin/Manager | Hapus PIN customer: `{ "reason" }` | | GET | `/marketing/customers/:id/security-events` | Admin/Manager | Log keamanan PIN | Payment method bertipe `point` ditolak di endpoint pembayaran jika: outlet tidak menerima EnakPoint, order tanpa customer atau walk-in, kode bayar salah/kedaluwarsa/ milik customer lain, atau `points` di luar batas F9. --- ## 10. Migrasi dari Token 1. Buat `customer_wallets`, `wallet_transactions`, `wallet_lots`, `wallet_lot_allocations`, dan `loyalty_setting_changes`. 2. Salin saldo: - `point_balance` diisi dari `customer_points.balance`. - `coin_balance` diisi dari **jumlah seluruh jenis** `customer_tokens.balance` milik customer tersebut (Q6, diputuskan). Contoh: SPIN 5 + RAFFLE 2 + MINIGAME 1 = 8 EnakCoin. Rincian saldo per jenis disimpan di `metadata` baris ledger `MIGRATION`, supaya asal saldo awal tetap bisa ditelusuri. 3. Tulis satu baris ledger `MIGRATION` dan satu lot (tanpa tanggal kedaluwarsa) per customer per currency yang saldonya > 0, supaya rekonsiliasi (§7.5) langsung berlaku. 4. `campaigns.type` / `campaign_rules.reward_type`: nilai `TOKENS` diganti `COINS`. 5. Tambah tipe `point` ke `payment_methods`, lalu buat payment method sistem "EnakPoint" untuk setiap organisasi. Tambah kolom `points_used` / `point_value` ke `payments`. 6. Tambah kolom PIN ke `customers` dan tabel `customer_security_events`. Semua customer yang sudah ada mulai tanpa PIN, dan akan diminta membuatnya lewat OTP saat pertama kali transfer, membayar, atau exchange. 7. `customer_points` dan `customer_tokens` dibiarkan read-only selama satu rilis, lalu di-drop di migrasi berikutnya. --- ## 11. Di Luar Scope - **Penukaran reward dengan EnakPoint.** Katalog reward sudah ada. Alurnya akan dibahas di PRD terpisah dan memakai tipe ledger `REWARD_REDEEM`. - **Tier otomatis** berdasarkan EnakPoint. Dibahas di PRD terpisah (lihat Q8 untuk arahannya). - **Eksekusi campaign rules** (bonus/multiplier) di atas earning dasar outlet. - **Earning untuk order tanpa customer terdaftar** (klaim belakangan lewat struk/QR). --- ## 12. Pertanyaan & Catatan ### 12.1 Sudah Diputuskan | # | Pertanyaan | Keputusan | Tercermin di | |---|---|---|---| | Q1 | Basis earning: sebelum atau sesudah pajak/service charge? | **Sebelum pajak** (`subtotal − discount`) | F1 | | Q2 | Apakah earning dari self-order (QR meja) diperlakukan sama? | **Ya**, semua kanal sama selama order punya customer | F3 | | Q3 | Saat reversal earning dan saldo tidak cukup: tarik sampai 0, izinkan saldo negatif, atau blokir refund? | **Tarik sampai 0 dan catat shortfall.** Refund tidak diblokir | F10 | | Q4 | Batas transfer diatur per organisasi atau global? Perlu limit harian? | **Per organisasi**, default tanpa batas | F2, F5 | | Q5 | Konfirmasi transfer pakai password, PIN khusus, atau OTP? | **PIN customer 6 digit**, juga untuk pembayaran dan exchange | K8, F11 | | Q6 | Saldo Token `RAFFLE`/`MINIGAME` yang ada ikut dikonversi ke EnakCoin? | **Ya**, semua jenis dijumlahkan menjadi EnakCoin. EnakCoin adalah mata uang untuk semua game | K1, F8, §10 | | Q7 | Customer app melayani satu organisasi atau banyak? | **Satu nomor telepon = satu customer di satu organisasi**, sesuai `customers.phone_number` yang unik secara global | K4 | | Q8 | Bagaimana tier (level keanggotaan, mis. Silver/Gold; tabel `tiers` sudah ada tapi belum terhubung ke customer) berhubungan dengan EnakPoint? | **Dibahas di PRD terpisah.** Arahan untuk PRD itu: tier dihitung dari **total `EARN` dalam 12 bulan terakhir**, bukan dari saldo, supaya customer tidak turun tier karena memakai, mentransfer, atau kehilangan saldo karena kedaluwarsa, dan tier tidak bisa "dibeli" lewat transfer. Ledger di PRD ini sudah mencatat `EARN`, jadi tidak ada yang perlu diubah di sini | §11 | | Q9 | Jejak cukup per mutasi, atau harus per butir? | **Per butir**, lewat lot. EnakPoint dan EnakCoin **bisa kedaluwarsa**, dengan masa berlaku yang diatur sendiri | K9, F12, §8 | | Q10 | Apakah bagian order yang dibayar EnakPoint tetap menghasilkan earning? | **Tidak.** Basis earning dikurangi nominal EnakPoint | F1 | | Q11 | Berapa nilai rupiah 1 EnakPoint, dan kurs EnakCoin → EnakPoint? | **1 EnakPoint = Rp 1** dan **1 EnakCoin = 1 EnakPoint** sebagai default. Keduanya bisa diubah di setting | K3, F2, F4 | | Q13 | Refund sebagian yang tidak habis dibagi nilai EnakPoint: sisa rupiahnya ke mana? | **Dibulatkan ke bawah**, sisanya hangus | F9 | | Q16 | Setelah reset PIN, berapa lama transfer keluar ditahan? | **24 jam, hanya transfer.** Pembayaran dan exchange tetap bisa | F11 | | Q17 | Parameter kunci PIN? | **5 kali salah, terkunci 30 menit** | F11 | ### 12.2 Masih Terbuka Tidak ada. Semua hal yang belum diputuskan sudah dipindahkan ke catatan N1–N4 di bawah, masing-masing dengan batas waktu. ### 12.3 Ditunda (Catatan agar Tidak Lupa) Hal-hal berikut sengaja belum diputuskan. Masing-masing punya batas waktu, yaitu fase yang tidak boleh dirilis sebelum catatan ini ditutup. **N1 — Pembayaran EnakPoint di kasir untuk customer tanpa aplikasi** (sebelumnya Q12) - **Situasi:** persetujuan pembayaran di kasir saat ini hanya lewat kode bayar dari aplikasi (F9). Customer yang tidak punya aplikasi, HP-nya mati, atau tidak ada internet belum bisa membayar dengan EnakPoint di kasir. - **Batasan yang harus tetap dijaga:** PIN tidak boleh diketik di layar kasir (K8). - **Opsi yang sudah terpikir:** PIN pad atau layar yang menghadap customer; OTP ke nomor telepon (butuh HP tapi tidak butuh aplikasi); atau memang tidak didukung. - **Batas waktu:** tidak memblokir fase 3. Fase 3 bisa rilis tanpa jalur ini, tetapi kasir perlu tahu apa yang harus dikatakan ke customer tanpa aplikasi. - **Pemilik keputusan:** product owner. **N2 — Perlakuan akuntansi EnakPoint dan EnakCoin** (sebelumnya Q14) - **Situasi:** EnakPoint yang dipakai membayar bukan kas masuk. Saldo yang beredar berpotensi menjadi kewajiban. Saldo yang kedaluwarsa (F12) menjadi "breakage" yang juga perlu dicatat. - **Yang perlu diputuskan:** apakah EnakPoint yang dipakai dicatat sebagai beban promosi atau pengurang liabilitas loyalitas; apakah saldo beredar dicatat sebagai liabilitas; bagaimana breakage dari kedaluwarsa dicatat; apakah EnakCoin (yang bisa ditukar ke EnakPoint, K3) ikut dihitung; dan apakah perlu jurnal otomatis ke modul chart of account yang sudah ada. - **Dampak ke sistem:** laporan payment method, laporan penjualan (penjualan kotor vs kas masuk), dan kemungkinan jurnal otomatis. - **Batas waktu:** sebelum fase 3 (pembayaran EnakPoint) dirilis ke outlet pertama. - **Pemilik keputusan:** tim keuangan. **N3 — Tinjauan regulasi uang elektronik** (sebelumnya Q15) - **Situasi:** EnakPoint bernilai rupiah, bisa dipakai membayar, dan bisa ditransfer antar customer. Kombinasi ini mirip dengan uang elektronik yang diatur Bank Indonesia. - **Mitigasi yang sudah ada di desain:** tidak bisa ditunaikan (K7), hanya berlaku di outlet dalam organisasi yang sama (K4), tampilan "setara potongan", bukan "saldo rupiah", dan bisa kedaluwarsa (F12). - **Yang perlu dicek ke legal:** apakah fitur transfer (F5) masih aman; apakah perlu batas transfer wajib (F2); dan apa yang harus ada di Syarat & Ketentuan. - **Batas waktu:** sebelum fase 3 (pembayaran) dan fase 4 (transfer) dirilis. - **Pemilik keputusan:** legal. **N4 — Model kedaluwarsa saldo** - **Situasi:** sudah diputuskan bahwa EnakPoint dan EnakCoin bisa kedaluwarsa dan owner bisa mengatur sendiri (Q9). Yang belum diputuskan adalah **modelnya**. - **Pilihan:** | | A. Tanggal tetap (gaya Telkomsel POIN / XL Poin) | B. Per saldo masuk (draft F12 saat ini) | |---|---|---| | Cara kerja | Semua saldo hangus di tanggal yang sama, mis. tiap 31 Des (1× setahun) atau tiap 30 Jun & 31 Des (2× setahun) | Tiap saldo punya tanggal sendiri, mis. 12 bulan sejak didapat | | Mudah dipahami customer | Sangat mudah: "semua hangus 31 Desember" | Lebih rumit: "150 hangus 3 Okt, 200 hangus 18 Nov" | | Pengingat | Satu kampanye besar menjelang tanggal hangus | Banyak pengingat kecil | | Adil | Kurang. Saldo yang didapat sehari sebelum tanggal hangus langsung hilang. Bisa ditutup dengan **periode tanggung**, mis. saldo yang didapat < 3 bulan sebelum tanggal hangus ikut ke tanggal hangus berikutnya | Adil. Semua saldo punya umur yang sama | | Efek bisnis | Lonjakan belanja menjelang tanggal hangus | Lebih rata | - **Pilihan ketiga:** keduanya didukung sebagai mode di setting, dan owner memilih. Ini tidak mengubah struktur data. Lot, alokasi, dan `EXPIRE` tetap sama. Yang berbeda hanya rumus `expires_at` saat lot dibuat: - A: tanggal hangus berikutnya setelah (tanggal didapat + periode tanggung) - B: tanggal didapat + masa berlaku - **Usulan sementara (belum disetujui):** dukung keduanya, dengan default model A setahun sekali tiap 31 Desember dan periode tanggung 3 bulan, karena model ini sudah familiar bagi customer di Indonesia. - **Pertanyaan turunan yang ikut diputuskan bersama N4:** - Saat kedaluwarsa pertama kali diaktifkan, bagaimana dengan saldo lama yang belum punya tanggal kedaluwarsa? Usulan: diberi masa berlaku penuh sejak tanggal aktivasi (model B), atau ikut tanggal hangus kedua berikutnya (model A). - EnakPoint yang dikembalikan karena refund, padahal lot asalnya sudah atau hampir kedaluwarsa? Usulan: diberi masa berlaku minimal 7 hari sejak refund. - Saat kedaluwarsa dinonaktifkan, apakah saldo yang sudah terjadwal kedaluwarsa ikut dibatalkan? Usulan: tidak, hanya saldo baru yang tidak kedaluwarsa. - Pengingat dikirim berapa hari sebelum tanggal hangus, dan berapa kali? - **Dampak ke sistem:** tabel pengaturan di F12, rumus kedaluwarsa per lot, isi pengingat, dan tampilan "saldo yang akan kedaluwarsa" di aplikasi. - **Batas waktu:** sebelum fase 5 (kedaluwarsa) dikerjakan. Fase 1–4 tidak terblokir, karena lot sudah dibuat sejak fase 1 dan dipakai oleh kedua model. - **Pemilik keputusan:** product owner. --- ## 13. Tahapan Rilis | Fase | Isi | Syarat rilis | |---|---|---| | **1. Fondasi** | Tabel wallet, ledger, dan lot; migrasi Token → EnakCoin; repository transaksional; endpoint saldo & riwayat; adjustment admin; riwayat perubahan setting | – | | **2. Earning** | Setting outlet (F1), earning saat order lunas (F3), reversal (F10), `points_earned`/`coins_earned` di response order | – | | **3. Pembayaran EnakPoint** | PIN customer (F11), setting organisasi (F2), payment method EnakPoint, kode bayar, bayar di kasir & app (F9), refund EnakPoint, laporan payment method | N2 dan N3 ditutup | | **4. Pergerakan saldo** | Exchange dengan kurs (F4), transfer (F5), semua game memakai EnakCoin (F8), telusuri per butir di dashboard | N3 ditutup | | **5. Kedaluwarsa** | Setting kedaluwarsa (F12), job kedaluwarsa, pengingat, tampilan saldo yang akan kedaluwarsa | N4 ditutup | | **6. Lanjutan** | Penukaran reward, tier (Q8), campaign rules | – | Lot sudah dibuat sejak fase 1, meskipun kedaluwarsa baru aktif di fase 5. Kalau lot baru ditambahkan belakangan, seluruh riwayat alokasi harus direkonstruksi ulang dari ledger. --- ## 14. Metrik Keberhasilan - 0 selisih pada job rekonsiliasi: ledger vs saldo vs lot, alokasi vs mutasi, dan pembayaran EnakPoint vs ledger. - 0 mutasi tanpa asal/tujuan (dijamin oleh constraint, tetapi diverifikasi di job rekonsiliasi). - 0 earning ganda per order, 0 pemotongan ganda per pembayaran, dan 0 kedaluwarsa ganda per lot (dicek dari idempotency key). - 0 refund non-EnakPoint atas pembayaran EnakPoint (dicek dari job rekonsiliasi). - 0 lot yang lewat tanggal kedaluwarsa lebih dari 1 jam tanpa diproses. - Persentase order lunas dengan customer terdaftar yang mendapat earning (target: 100% untuk outlet dengan setting aktif). - Persentase transaksi yang dibayar (sebagian) dengan EnakPoint, dan total nilai rupiahnya per bulan. - Jumlah EnakPoint/EnakCoin yang kedaluwarsa per bulan, dan persentase customer yang memakai saldonya setelah menerima pengingat. - Volume exchange dan transfer per minggu sebagai indikator adopsi.