Compare commits

...
3 Commits
Author SHA1 Message Date
aefril 053d6a4243 Merge pull request 'Feat/disable point payment' (#49) from feat/disable-point-payment into main
Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/49
2026-10-12 17:21:28 +02:00
efrilmandClaude Opus 5.5 2c9753fae7 feat(loyalty): remove paying with EnakPoint
EnakPoint can only be redeemed for vouchers now: it can no longer pay for
orders and is never cashed out (docs/enakgame-prd.md §3.2, EG-001,
EG-002). No order was ever paid with EnakPoint, so there is no data to
move.

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:48:29 +07:00
efrilmandClaude Opus 5.5 3ebc09f818 docs(enakgame): PRD, RFC, and task breakdown
EnakGame: pay EnakCoin to play, earn EnakCoin from the result, exchange
into EnakPoint, and redeem EnakPoint for vouchers only.

- enakgame-prd.md: economy and business rules, including entry cost and
  automatic refund, monthly global budget with a separate budget per
  event (event = campaign), and EnakPoint being voucher-only.
- rfc-enakgame.md: built on the existing wallet, ledger and lots. Game
  sessions with a state machine, versioned reward configs, Economy Guard
  counters, vouchers with internal codes and external providers, and
  realized cost attributed to budgets by tracing the lots spent.
- tasks-enakgame.md: EG-001 to EG-1003 in eleven phases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:48:17 +07:00
90 changed files with 3529 additions and 3071 deletions
+33 -94
View File
@@ -2,7 +2,9 @@
30 Sep 2026
Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat.
Semua endpoint EnakPoint (`POINT`, hanya untuk ditukar ke voucher) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat.
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus, karena EnakPoint sekarang hanya bisa ditukar ke voucher: tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus ada di Referensi → Endpoint dan field yang dihapus.
## Konvensi umum
@@ -28,7 +30,7 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk
**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit.
**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain.
**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`.
**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya.
@@ -41,9 +43,9 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk
| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal |
| PUT | `/customer/devices` | Daftarkan token FCM device |
| DELETE | `/customer/devices/:device_id` | Hapus device saat logout |
| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `accepts_point_payment`, `earns_points`, `earns_coins` |
| GET | `/customer/outlets` | Outlet aktif di organisasi customer, dengan `earns_points`, `earns_coins` |
| GET | `/customer/orders` | Riwayat order customer (`page`, `limit`), dengan `points_earned` / `coins_earned` |
| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint yang dipakai; order customer lain → `404` |
| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint/EnakCoin yang didapat; order customer lain → `404` |
Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di [`mobile-customer-enakpoint.md`](./mobile-customer-enakpoint.md) §4.4–§4.5.
@@ -74,7 +76,7 @@ Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` ops
| `page` | int | Default 1 |
| `limit` | int | 1–100, default 20 |
| `currency` | `POINT` \| `COIN` | Opsional |
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` |
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,TRANSFER_IN` |
| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif |
```json
@@ -125,7 +127,7 @@ Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `f
## Customer app: PIN
PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
PIN 6 digit wajib untuk exchange dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
| Method | Path | Body | Response |
| --- | --- | --- | --- |
@@ -136,37 +138,21 @@ PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta custome
| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer.
2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa.
2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; exchange tetap bisa.
3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama.
PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum.
## Customer app: bayar, exchange, transfer, game
## Customer app: exchange, transfer, game
| Method | Path | PIN | Idempotency-Key |
| --- | --- | --- | --- |
| POST | `/customer/wallet/payment-code` | Ya | – |
| POST | `/customer/orders/:id/pay-with-points` | Ya | – |
| GET | `/customer/wallet/exchange/preview?coins=` | – | – |
| POST | `/customer/wallet/exchange` | Ya | Wajib |
| GET | `/customer/wallet/transfer/recipient?phone=` | – | – |
| POST | `/customer/wallet/transfer` | Ya | Wajib |
| POST | `/customer/spin` | – | – |
### POST /customer/wallet/payment-code
Body `{ "pin": "482913" }`. Response:
```json
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
```
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama.
### POST /customer/orders/:id/pay-with-points
Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama.
### GET /customer/wallet/exchange/preview?coins=30
```json
@@ -238,69 +224,9 @@ Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong.
## POS: pembayaran EnakPoint
## POS: earning, void, dan refund
Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir.
| Method | Path | Keterangan |
| --- | --- | --- |
| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini |
| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) |
| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint |
1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya.
2. POS memanggil preview untuk tombol "pakai maksimal".
3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa.
### GET /orders/:id/point-payment/preview
```json
{
"order_id": "…",
"customer_id": "…",
"eligible": true,
"point_balance": 12500,
"point_value": 1,
"remaining_amount": 87500,
"min_payment_points": 1,
"max_payment_percent": 100,
"max_points": 12500,
"max_amount": 12500
}
```
Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai:
```
batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint)
maks_point = min(saldo, floor(batas_rupiah / point_value))
```
### POST /payments
Header `X-Idempotency-Key` wajib.
```json
{
"order_id": "…",
"payment_method_id": "<id method EnakPoint>",
"points": 12500,
"payment_code": "482913"
}
```
- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian).
- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`).
- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`.
- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru.
- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint.
### Void dan refund
- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint.
- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus.
- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`.
- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.
EnakPoint bukan payment method: tidak ada lagi tipe `point`, dan `POST /payments` memakai `amount` seperti pembayaran lain. Response order membawa `points_earned` dan `coins_earned` untuk struk. Saat order di-void atau direfund, EnakPoint dan EnakCoin yang didapat dari order itu ikut ditarik (`EARN_REVERSAL`); bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.
## Dashboard
@@ -308,7 +234,7 @@ Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke
| Method | Path | Keterangan |
| --- | --- | --- |
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet |
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning EnakPoint dan EnakCoin per outlet |
| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) |
| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) |
| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli |
@@ -324,12 +250,11 @@ Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang;
```json
{
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
}
```
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal, `max_payment_percent` 0–100.
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal. Objek `point_payment` sudah dihapus; `PUT` yang masih mengirimnya ditolak `310` (field tidak dikenal).
### /marketing/loyalty-settings
@@ -380,7 +305,7 @@ Response menambahkan:
```json
{
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" },
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "TRANSFER_OUT", "amount": -30, "…": "…" },
"lots": [
{
"amount": 30,
@@ -393,7 +318,7 @@ Response menambahkan:
}
```
Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`.
Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer atau exchange sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`.
### PIN customer
@@ -407,8 +332,6 @@ Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat
| --- | --- | --- | --- |
| `EARN` | + | Didapat dari order lunas | `ORDER` |
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` |
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) |
| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) |
| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) |
@@ -438,4 +361,20 @@ Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi p
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
### Endpoint dan field yang dihapus
Bayar dengan EnakPoint dihapus pada 7 Okt 2026 karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya; jangan dipanggil lagi.
| Dihapus | Catatan |
| --- | --- |
| `POST /customer/wallet/payment-code` | Kode bayar untuk kasir |
| `POST /customer/orders/:id/pay-with-points` | Bayar order dari app / self-order |
| `GET /orders/:id/point-payment/preview` | Batas pembayaran EnakPoint di POS |
| Payment method tipe `point`; field `points` dan `payment_code` di `POST /payments` | `amount` kembali wajib seperti pembayaran lain |
| `points_used`, `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id` | – |
| `accepts_point_payment` di `GET /customer/outlets` | – |
| `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di `/outlets/:outlet_id/loyalty-settings` | `PUT` yang masih mengirimnya ditolak `310` |
| `summary.point_amount`, `summary.points_used`, `summary.total_with_points`, serta `points_used` dan `counts_as_cash_in` per baris di analytics payment method | `summary.total_amount` kembali total semua method; persentase dihitung dari total itu |
| Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` | Tidak ditulis lagi |
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
+15 -28
View File
@@ -4,6 +4,8 @@
Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game.
> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint sudah dihapus karena EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Akibatnya setting outlet tidak lagi punya `point_payment`, method "EnakPoint" (tipe `point`) tidak ada lagi di Payment Method, dan laporan per payment method tidak lagi membawa `point_amount`, `points_used`, `total_with_points`, atau `counts_as_cash_in`; `summary.total_amount` kembali total semua method.
## Layar yang perlu dibuat
Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`.
@@ -20,21 +22,20 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a
Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada.
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan.
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang hanya bisa ditukar ke voucher, bukan alat bayar; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan.
**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error).
## Setting loyalitas outlet
Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya.
Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order. Semua nilai default mati sampai owner menyalakannya.
`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak.
`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak (termasuk `point_payment` yang sudah dihapus).
```json
{
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
}
```
@@ -47,17 +48,14 @@ Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari ord
| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal |
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
**Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint.
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak.
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu.
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan".
## Setting loyalitas organisasi
@@ -102,7 +100,7 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s
| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint |
| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … |
Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu.
Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: exchange yang sudah terjadi memakai kurs saat itu.
## Pengaturan kedaluwarsa
@@ -159,7 +157,7 @@ Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo da
### Saldo, lot, dan riwayat
`GET /marketing/customers/:id/wallet?page=1&limit=20&currency=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
`GET /marketing/customers/:id/wallet?page=1&limit=20&currency=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
```json
{
@@ -189,7 +187,7 @@ Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo da
- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit).
- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`.
- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall).
- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata` (kurs, rumus earning, shortfall).
### Adjustment manual
@@ -214,7 +212,7 @@ Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-tr
```json
{
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" },
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "reference_id": "…", "created_at": "…" },
"lots": [
{
"amount": 30,
@@ -229,7 +227,7 @@ Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-tr
Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya.
## PIN, riwayat setting, game, dan method EnakPoint
## PIN, riwayat setting, dan game
### PIN & keamanan customer
@@ -270,22 +268,12 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
### Method pembayaran EnakPoint
Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`):
- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti.
- Tipe `point` tidak ditawarkan saat membuat method baru.
- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint".
Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk.
## Pesan error dan checklist
| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan |
| --- | --- | --- | --- |
| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer |
| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast |
| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong | `cause` di dekat field atau di toast |
| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" |
| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" |
@@ -302,8 +290,7 @@ Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalt
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`.
- [ ] Tombol Telusuri ada di setiap baris riwayat.
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
- [ ] Method EnakPoint tampil sebagai method sistem.
- [ ] Form game punya input `coin_cost`.
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu.
Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu.
+1622
View File
File diff suppressed because it is too large Load Diff
+54 -138
View File
@@ -6,6 +6,12 @@ lama tetap jalan sebagai alias (lihat §8)
Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan
ada di [`prd-point-coin.md`](./prd-point-coin.md).
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi
> `000102`). EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai
> sebagai alat bayar dan tidak bisa dicairkan
> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus
> ada di §8.
---
## 1. Konsep inti
@@ -13,21 +19,21 @@ ada di [`prd-point-coin.md`](./prd-point-coin.md).
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|---|---|---|
| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin |
| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint |
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
| Bisa ditransfer | Ya | Ya |
| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner |
Aturan yang berlaku di seluruh dokumen ini:
1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint".
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, tidak ada kembalian, dan
bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan
nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …".
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar,
buat kode bayar, exchange, transfer. Main game tidak butuh PIN.
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, dan EnakPoint tidak bisa
dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan
Rp …"**, bukan "saldo Rp …".
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange
dan transfer. Main game tidak butuh PIN.
4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet
organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan
kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet.
kedaluwarsa diatur per organisasi; earning per outlet.
5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah
dihapus. Koreksi muncul sebagai baris baru.
@@ -91,7 +97,7 @@ Semua endpoint customer memakai header `Authorization: Bearer <token customer>`.
### 2.2 Riwayat
`GET /api/v1/customer/wallet/transactions?page=1&limit=20&currency=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30`
`GET /api/v1/customer/wallet/transactions?page=1&limit=20&currency=POINT&type=EARN,TRANSFER_IN&from=2026-09-01&to=2026-09-30`
Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma.
`from` / `to` tanggal WIB, inklusif.
@@ -119,8 +125,8 @@ Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipis
- `amount` bertanda: positif menambah saldo, negatif mengurangi.
- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk
`{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, pembayaran, game
play, dst.).
`{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, game play,
dst.).
- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer
berubah belakangan. Nama lawan transfer sudah disamarkan.
- Dua baris exchange atau transfer berbagi `group_id` yang sama.
@@ -129,8 +135,6 @@ Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipis
|---|---|---|---|
| `EARN` | + | Didapat dari order lunas | `ORDER` |
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
| `PAYMENT` | − | Membayar order | `PAYMENT` |
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) |
| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) |
| `GAME_SPEND` | − | Main game | `GAME_PLAY` |
@@ -219,7 +223,7 @@ menghasilkan `429`.
- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu
`POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga
membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
pembayaran dan exchange tetap bisa.
exchange tetap bisa.
### 3.4 Menangani error PIN
@@ -247,123 +251,19 @@ reinstall atau ganti HP.
---
## 4. Membayar dengan EnakPoint
## 4. Earning, void, dan refund
Ada dua jalur. Keduanya memakai logika perhitungan yang sama.
### 4.1 Batas pembayaran
EnakPoint maksimal yang bisa dipakai untuk satu order:
```
batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint)
maks_point = min(saldo_customer, floor(batas_rupiah / point_value))
```
Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu
`points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada
kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split).
### 4.2 POS — kode bayar dari aplikasi customer
PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri:
1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`.
```json
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
```
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**,
sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama.
2. **POS:** tampilkan batas untuk tombol "pakai maksimal":
`GET /api/v1/orders/:id/point-payment/preview`
```json
{
"order_id": "…",
"customer_id": "…",
"eligible": true,
"point_balance": 12500,
"point_value": 1,
"remaining_amount": 87500,
"min_payment_points": 1,
"max_payment_percent": 100,
"max_points": 12500,
"max_amount": 12500
}
```
Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak
menerima EnakPoint, saldo di bawah minimal, dst.).
3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method
bertipe `point`:
`POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain)
```json
{
"order_id": "…",
"payment_method_id": "<id method EnakPoint>",
"points": 12500,
"payment_code": "482913"
}
```
`amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa
angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`).
Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya
"EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi
`completed`; jika belum, sisanya dibayar dengan method lain.
Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau
customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar
batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method
EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri,
lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum
batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta
customer membuat kode baru.
**Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus
atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim
`?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint.
### 4.3 Customer app / self-order — bayar order sendiri
`POST /api/v1/customer/orders/:id/pay-with-points`
```json
{ "points": 12500, "pin": "482913" }
```
Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama
dengan response pembayaran di §4.2.
### 4.4 Void dan refund
- **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint.
- **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran
EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan
nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah
1 EnakPoint hangus.
- **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar
dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya
sendiri; mencoba lewat tunai dijawab `304`.
- EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari
sejak refund.
- EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila
saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah
diblokir karena ini.
### 4.5 Earning di layar order dan struk
EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan
`POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar,
bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8).
Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang
dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas.
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan
diberikan saat order lunas.
EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila
saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah
diblokir karena ini.
---
@@ -504,6 +404,24 @@ Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena
EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2).
Tidak ada penggantinya:
- Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`,
dan `GET /orders/:id/point-payment/preview`.
- Payment method tipe `point`, serta field `points` dan `payment_code` di
`POST /payments`; `amount` kembali wajib seperti pembayaran lain.
- `points_used` dan `point_value` di response pembayaran dan di `payments` pada
`GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`.
- Objek `point_payment` (`accept_payment`, `min_payment_points`,
`max_payment_percent`) di setting outlet (§9.1).
- Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di
`summary`, serta `points_used` dan `counts_as_cash_in` per baris.
`summary.total_amount` kembali total semua method, dan persentase dihitung dari total
itu.
- Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi.
---
## 9. Dashboard
@@ -517,12 +435,12 @@ Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
```json
{
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
}
```
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih
mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan
`point_value` organisasi dan `point_cashback_percent`
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di
@@ -597,10 +515,10 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=
Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan".
- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per
butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer,
exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari
pembayaran B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A
yang mentransfer ke B.
butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer atau
exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar
B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A yang
mentransfer ke B.
### 9.4 PIN customer
@@ -624,10 +542,8 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=
- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama.
**POS**
- [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir.
- [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal".
- [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk.
- [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai.
- [ ] Cetak `points_earned` dan `coins_earned` di struk.
- [ ] Jangan menampilkan EnakPoint sebagai payment method (§4).
**Dashboard**
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
+27 -56
View File
@@ -12,20 +12,26 @@ aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan dulu.
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|---|---|---|
| Didapat dari | Belanja (order lunas), koreksi admin, tukar EnakCoin | Belanja, koreksi admin |
| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint |
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
| Bisa dikirim ke customer lain | Ya | Ya |
| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan |
Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin, dan endpoint serta
field bernama token sudah dihapus dari API.
> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint (kode bayar di kasir, bayar order
> dari app) sudah dihapus dari backend. EnakPoint sekarang hanya bisa ditukar ke
> voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan
> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Jangan membangun layar bayar atau kode
> bayar; endpoint dan field yang dihapus ada di §9.
### Aturan yang wajib dipatuhi di UI
1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin.
2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan
Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai.
3. **PIN 6 digit wajib** untuk: membuat kode bayar, tukar
EnakCoin, dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat
Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai, dan
EnakPoint tidak bisa dipakai membayar.
3. **PIN 6 digit wajib** untuk: tukar EnakCoin dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat
tidak butuh PIN.
4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya
nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau
@@ -107,7 +113,6 @@ Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik,
| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – |
| Daftar outlet | `GET /customer/outlets` | – |
| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – |
| Kode bayar (angka + QR) | `POST /customer/wallet/payment-code` | Ya |
| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya |
| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya |
| PIN (buat, ganti, lupa) | `/customer/pin/*` | – |
@@ -141,7 +146,7 @@ Tampilkan:
- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan
kedaluwarsa pada {date}" yang membuka layar §4.3.
- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2.
- Tombol aksi: Bayar di kasir (§7.1), Tukar EnakCoin (§8.1), Transfer (§8.2), Main game (§9).
- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8).
Muat ulang beranda setelah setiap transaksi dan saat menerima push (§5).
@@ -157,7 +162,7 @@ Query (semua opsional):
| `page` | `1` | Mulai dari 1 |
| `limit` | `20` | 1–100, default 20 |
| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin |
| `type` | `EARN,PAYMENT` | Satu atau beberapa tipe dipisah koma, untuk filter |
| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter |
| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif |
```json
@@ -196,8 +201,6 @@ Label tipe:
|---|---|---|
| `EARN` | Dari belanja | + |
| `EARN_REVERSAL` | Dibatalkan (order di-void/refund) | − |
| `PAYMENT` | Bayar pesanan | − |
| `PAYMENT_REFUND` | Pengembalian pembayaran | + |
| `EXCHANGE_OUT` | Ditukar ke EnakPoint | − |
| `EXCHANGE_IN` | Hasil tukar EnakCoin | + |
| `TRANSFER_OUT` | Transfer keluar | − |
@@ -235,7 +238,6 @@ berdasarkan nama.
"id": "…",
"name": "Gokuna Kemang",
"address": "Jl. Kemang Raya 10",
"accepts_point_payment": true,
"earns_points": true,
"earns_coins": false
}
@@ -243,8 +245,6 @@ berdasarkan nama.
```
- `address` bisa `null`.
- `accepts_point_payment`: kasir di outlet ini menerima pembayaran EnakPoint. Pakai
untuk label "Bisa bayar pakai EnakPoint".
- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin.
- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend.
@@ -318,8 +318,7 @@ hanya masuk ke sini bila kasir mengaitkannya ke customer.
}
],
"payments": [
{ "id": "…", "method_name": "EnakPoint", "method_type": "point", "amount": 12500, "status": "completed", "refund_amount": 0, "points_used": 12500, "point_value": 1, "created_at": "…" },
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 86500, "status": "completed", "refund_amount": 0, "created_at": "…" }
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" }
]
}
```
@@ -327,7 +326,6 @@ hanya masuk ke sini bila kasir mengaitkannya ke customer.
- Order customer lain atau yang tidak ada → `404`.
- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada.
- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons".
- Pembayaran EnakPoint membawa `points_used`; tampilkan "EnakPoint 12.500 (Rp 12.500)".
- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" /
"Direfund".
@@ -405,7 +403,7 @@ Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur.
2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`.
Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
bayar dan tukar tetap bisa. Beri tahu customer hal ini di layar sukses.
tukar tetap bisa. Beri tahu customer hal ini di layar sukses.
### 6.5 Menangani error PIN
@@ -428,41 +426,9 @@ ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app
---
## 7. Membayar dengan EnakPoint
## 7. Tukar dan transfer
App customer tidak membuat atau membayar order; order hanya bisa dilihat (§4.5).
EnakPoint hanya dipakai membayar di kasir, lewat kode bayar dari app. Jangan membangun
layar checkout atau memanggil `POST /customer/orders/:id/pay-with-points`.
### 7.1 Di kasir — kode bayar
Customer tidak pernah mengetik PIN di mesin kasir. Alurnya:
1. Customer membuka "Bayar di kasir" dan memasukkan PIN.
2. `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`:
```json
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
```
3. Tampilkan `code` besar (angka) **dan** QR dari `qr_payload` (string apa adanya).
4. Tampilkan hitung mundur ke `expires_at` (2 menit). Setelah habis, sembunyikan kode
dan tampilkan tombol "Buat kode baru".
5. Kasir memindai/mengetik kode dan memilih jumlah EnakPoint. App tidak menerima
callback; setelah customer kembali ke beranda, muat ulang saldo.
Kode sekali pakai. Membuat kode baru membatalkan kode lama.
### 7.2 Refund
Bila order yang dibayar EnakPoint dibatalkan atau direfund, EnakPoint kembali sebagai
EnakPoint (tidak pernah tunai) dan muncul di riwayat sebagai `PAYMENT_REFUND`.
---
## 8. Tukar dan transfer
### 8.1 Tukar EnakCoin → EnakPoint
### 7.1 Tukar EnakCoin → EnakPoint
1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik):
@@ -503,7 +469,7 @@ EnakPoint (tidak pernah tunai) dan muncul di riwayat sebagai `PAYMENT_REFUND`.
3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku
sampai {tanggal}".
### 8.2 Transfer
### 7.2 Transfer
1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah.
2. Cek penerima:
@@ -556,7 +522,7 @@ Penerima mendapat push `WALLET_TRANSFER_IN`.
---
## 9. Game (memakai EnakCoin)
## 8. Game (memakai EnakCoin)
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
@@ -577,7 +543,7 @@ Penerima mendapat push `WALLET_TRANSFER_IN`.
---
## 10. Yang sudah dihapus / deprecated
## 9. Yang sudah dihapus / deprecated
Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
@@ -586,6 +552,12 @@ Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` |
| `total_tokens`, `tokens_history` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` |
| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` |
| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar |
| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar |
| `GET /orders/:id/point-payment/preview` (POS) | Tidak ada; EnakPoint tidak bisa untuk bayar |
| `accepts_point_payment` di `GET /customer/outlets` | – |
| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – |
| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi |
Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
@@ -596,7 +568,7 @@ Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
---
## 11. Checklist selesai
## 10. Checklist selesai
- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat.
- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, label tipe sesuai §4.2.
@@ -605,10 +577,9 @@ Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
- [ ] Penanganan tap untuk keempat tipe push.
- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP.
- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN.
- [ ] Kode bayar: angka + QR, hitung mundur 2 menit, tombol buat ulang.
- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama.
- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama.
- [ ] Game memakai `coins_used` / `coins_remaining` dan menampilkan biaya per game.
- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat).
- [ ] Tidak ada pemakaian endpoint atau field di §10.
- [ ] Tidak ada pemakaian endpoint atau field di §9.
- [ ] PIN tidak pernah disimpan, di-log, atau dikirim ke analytics.
+12
View File
@@ -7,6 +7,14 @@ dengan EnakPoint, exchange EnakCoin → EnakPoint, transfer antar customer, keda
saldo, PIN customer, pengaturan per outlet dan per organisasi, migrasi dari Token
**Out of scope:** Penukaran reward, tier otomatis, eksekusi campaign rules (lihat §11)
> **Catatan 2026-10-07:** F9 (Bayar Order dengan EnakPoint), bagian pembayaran dari K2,
> dan bagian terkait (aturan kembalian/refund EnakPoint di K7, PIN untuk bayar dan kode
> bayar di K8, payment method EnakPoint, ledger `PAYMENT` / `PAYMENT_REFUND`, endpoint
> pembayaran di §9, dan bagian pembayaran fase 3 di §13) digantikan oleh
> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher,
> tidak bisa dipakai membayar dan tidak bisa dicairkan. Fitur tersebut sudah dihapus dari
> backend (migrasi `000102`). Dokumen ini dibiarkan apa adanya sebagai riwayat keputusan.
---
## 1. Latar Belakang
@@ -389,6 +397,10 @@ beredar. Karena itu:
### F9 — Bayar Order dengan EnakPoint
> **Dihapus 2026-10-07:** digantikan oleh [`enakgame-prd.md`](./enakgame-prd.md) §3.2
> (EnakPoint hanya untuk voucher) dan sudah dihapus dari backend (migrasi `000102`);
> lihat catatan di awal dokumen.
**Payment method.** Setiap organisasi otomatis punya satu payment method sistem
bernama **EnakPoint** dengan tipe baru `point` di `payment_methods`. Method ini tidak
bisa dihapus atau diubah tipenya. Muncul di kasir hanya jika outlet mengaktifkan
+981
View File
@@ -0,0 +1,981 @@
# RFC: EnakGame — Game Session, Reward, Voucher & Budget
**Status:** Draft
**Tanggal:** 2026-10-07
**PRD:** [enakgame-prd.md](enakgame-prd.md)
**Scope:** Game catalog, game session + entry cost + refund, Reward Engine, Economy Guard,
voucher & redemption, budget & Budget Controller (recommendation mode), audit
**Out of scope:** Mission, leaderboard, reward `TIERED`, Budget Controller automatic mode
(lihat §16)
---
## 1. Ringkasan
EnakGame dibangun **di atas wallet EnakPoint/EnakCoin yang sudah ada**
([prd-point-coin.md](prd-point-coin.md)), bukan sebagai sistem saldo baru. Ledger, lot,
kedaluwarsa, idempotency, exchange Coin → Point, dan PIN sudah tersedia dan sudah
teruji. Yang dibangun baru:
| Komponen PRD | Kondisi sekarang | Rencana |
|---|---|---|
| Game Management (§9) | `games` ada, tanpa `organization_id`, tanpa status/slug/URL | **Extend** `games` |
| Game Session (§10) | Tidak ada. `game_plays` adalah main-instan tanpa session | **Baru**: `game_sessions` |
| Entry cost (§10.1) | Ada (`GAME_SPEND`, `metadata.coin_cost`) tapi tanpa idempotency | **Reuse** `GAME_SPEND`, ref baru `GAME_SESSION` |
| Refund entry cost (§10.2) | Tidak ada | **Baru**: tipe ledger `GAME_SPEND_REFUND` + job |
| Reward Engine (§11–13) | Tidak ada. Hadiah spin tidak memberi apa pun | **Baru**: `game_reward_configs` (versioned) |
| Event / campaign (§14–16) | Tidak ada. `campaigns` ada tapi tidak pernah dieksekusi | **Baru**: `game_events`, masing-masing dengan budget sendiri |
| Economy Guard & Limits (§17, §35) | Tidak ada | **Baru**: counter harian + settings organisasi |
| Coin Wallet & Ledger (§18–20) | **Ada lengkap** | **Reuse**, tambah 3 tipe ledger |
| Coin → Point, expiry | **Ada** (F4, F12) | **Reuse** tanpa perubahan |
| Voucher & Redemption (§21–28) | `rewards` ada tanpa org, tanpa redemption, tanpa kode | **Baru**: `vouchers`, `voucher_codes`, `voucher_redemptions` |
| Budget & Controller (§5–8, §29–34) | Tidak ada | **Baru**: `game_budgets` + atribusi cost per lot |
| Audit Log (§37) | Tidak ada yang generik | **Baru**: `audit_logs` |
Keputusan paling penting ada di §3, terutama **D5**: realized cost voucher diatribusikan ke
budget dengan menelusuri lot Point yang dipakai sampai ke asalnya.
---
## 2. Kondisi Sekarang
Temuan yang memengaruhi desain:
1. **Tabel game, reward, campaign, dan tier tidak punya `organization_id`.** Semua query
membaca semua tenant. Tabel wallet (`000090`) sudah punya.
2. **Main game sekarang tidak punya session.** `GamePlayProcessor.PlayGame`
(`processor/game_play_processor.go:141`) memotong Coin, memilih hadiah, dan mencatat
`game_plays` dalam satu request.
3. **Hadiah game tidak memberi apa pun.** Prize hanya tercatat sebagai
`game_plays.prize_id` dan teks deskripsi ledger. Tidak ada kredit Coin/Point, tidak ada
voucher.
4. **`GAME_SPEND` tanpa idempotency key** (`game_play_processor.go:186`). Tombol main yang
ditekan dua kali memotong Coin dua kali.
5. **Tidak ada alur penukaran.** Tipe ledger `REWARD_REDEEM` dan ref
`REWARD_REDEMPTION` sudah ada di `walletTypeRules` dan CHECK database, tapi belum pernah
dipakai.
6. **Wallet sudah mendukung semua kebutuhan dasar:** `Credit` / `Debit` dengan lock per
customer, lot FIFO berdasarkan kedaluwarsa, `idempotency_key` UNIQUE dengan replay,
`origin_lot_id` untuk menelusuri asal saldo, `RefundExpiry` untuk refund.
7. **Tidak ada scheduler library.** Semua job adalah goroutine `time.NewTicker` di
`app/app.go`, aman multi-instance lewat lock wallet dan idempotency key.
8. **`TxManager.WithTransaction` tidak me-reuse transaksi di context.** Pemanggilan
bersarang membuka transaksi baru yang independen.
---
## 3. Keputusan Inti
**D1 — EnakGame memakai wallet yang sudah ada.**
Entry cost, reward, refund, dan redemption semuanya lewat `WalletProcessor.Credit` /
`Debit`. Tidak ada tabel saldo baru. Konsekuensinya, aturan K5 (setiap mutasi punya asal
dan tujuan), K6 (bilangan bulat), dan K9 (lot FIFO) otomatis berlaku untuk EnakGame.
**D2 — `games` di-extend, game lama diarsipkan, `game_plays` tidak dipakai EnakGame.**
`games` sudah dibaca customer app. Kolom yang kurang ditambahkan (§5.1). Session baru masuk
ke `game_sessions`. Game lama (spin, ferris wheel) **dihapus dari sisi produk** dan spin
dibangun ulang sebagai game EnakGame. Secara data, baris lama diarsipkan, bukan di-`DELETE`
(§14).
**D3 — Coin dipotong saat session dibuat, dalam satu transaksi.**
Sesuai PRD §10.1. Idempotency key dari header `Idempotency-Key`, mengikuti pola exchange.
**D4 — Session punya state machine yang ditegakkan dengan UPDATE bersyarat.**
`STARTED → COMPLETED | REFUNDED | EXPIRED`. Setiap transisi adalah
`UPDATE ... WHERE id = ? AND status = 'STARTED'`. Complete dan refund tidak mungkin
sama-sama berhasil untuk satu session, karena hanya satu yang mendapat baris ter-update.
**D5 — Realized cost diatribusikan ke budget lewat lot.**
Point yang dipakai menukar voucher ditelusuri lewat `wallet_lot_allocations` →
`wallet_lots.origin_lot_id` sampai ke lot pertama. Lot pertama menunjuk mutasi asalnya:
`GAME_REWARD` (EnakGame, dengan budget yang tercatat), atau `EARN` / `ADJUSTMENT` /
`MIGRATION` (bukan dari game). Hasilnya dibekukan per redemption di
`voucher_redemption_costs`.
**Hanya bagian yang berasal dari `GAME_REWARD` yang dihitung ke budget.** Point dari
belanja (`EARN`) dan sumber lain tetap dicatat atribusinya (dengan `budget_id` kosong)
untuk reporting, tetapi tidak mengurangi budget mana pun.
Alasannya: Point bersifat fungible. Customer bisa memegang Point dari belanja, dari
exchange Coin hasil game, dan dari transfer sekaligus. Tanpa penelusuran lot, sistem tidak
bisa tahu berapa bagian voucher yang benar-benar dibiayai budget EnakGame atau budget
event tertentu. Lot sudah menyimpan jejak ini sejak PRD point-coin (Q9), jadi tidak ada
perubahan struktur wallet.
**D6 — Reward per budget dicatat sebagai baris ledger terpisah.**
Satu session bisa menghasilkan reward dari budget global (reward normal) dan dari budget
event (tambahan dari multiplier/bonus event). Masing-masing menjadi satu baris
`GAME_REWARD` dengan lot sendiri, dan `game_session_rewards` mencatat budget tiap baris.
Ini yang membuat D5 bisa membedakan budget global dan event tanpa menambah kolom di
`wallet_lots`.
Dalam RFC ini **event = campaign**: istilah yang sama untuk hal yang sama.
**D7 — Reward configuration immutable.**
Baris `game_reward_configs` tidak pernah di-UPDATE kecuali kolom `status`. Perubahan
reward = baris baru dengan `version + 1`. Session menyimpan `reward_config_id` saat
**Start Game**, sehingga perubahan config tidak memengaruhi session yang sedang berjalan.
**D8 — Semua tabel baru punya `organization_id`.**
Satu organisasi = satu ekonomi EnakGame (budget, limit, voucher, game). Org customer dibaca
dari tabel `customers` seperti flow wallet sekarang, karena JWT customer tidak membawa org.
Game, voucher, dan event milik org lain ditolak.
**D9 — Budget Controller v1 hanya recommendation mode.**
Sesuai default PRD §33. Automatic mode di luar scope RFC ini.
---
## 4. Prinsip
**P1 — Backend satu-satunya penentu reward.** Client hanya mengirim `score`, `outcome`, dan
data hasil. Request yang membawa angka reward diabaikan.
**P2 — Setiap mutasi uang punya idempotency key deterministik.** Diturunkan dari id
session/redemption, bukan dari waktu. Retry selalu menghasilkan key yang sama.
**P3 — Snapshot, bukan join.** Entry cost, reward config, face value voucher, dan point cost
dibekukan di baris transaksi saat terjadi, sama seperti `unit_price` di `order_items`.
**P4 — Lock wallet customer selalu diambil lebih dulu.** Semua alur (start, complete,
refund, redeem) mengunci `customer_wallets` sebelum menyentuh tabel lain, supaya urutan
lock konsisten dan tidak deadlock.
---
## 5. Model Data
Semua migrasi mengikuti golang-migrate di `migrations/`, nomor lanjut dari `000101`.
### 5.1 `games` (extend)
```sql
ALTER TABLE games
ADD COLUMN organization_id UUID,
ADD COLUMN slug VARCHAR(100),
ADD COLUMN description TEXT,
ADD COLUMN thumbnail_url VARCHAR(500),
ADD COLUMN game_url VARCHAR(500),
ADD COLUMN version VARCHAR(50),
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
ADD COLUMN entry_cost BIGINT,
ADD COLUMN session_ttl_seconds INT NOT NULL DEFAULT 600
CHECK (session_ttl_seconds > 0),
-- Batas validasi hasil: max_score, min_duration_seconds, max_score_per_second, outcome
-- yang valid. Dibaca Result Validator (§7.2).
ADD COLUMN result_rules JSONB NOT NULL DEFAULT '{}';
-- Game lama dihapus dari produk: diarsipkan, tidak di-DELETE (§14).
UPDATE games SET status = 'ARCHIVED', is_active = FALSE,
entry_cost = COALESCE((metadata->>'coin_cost')::bigint, 1);
ALTER TABLE games
ALTER COLUMN entry_cost SET NOT NULL,
ADD CONSTRAINT chk_games_entry_cost CHECK (entry_cost >= 1), -- PRD §10.1: tidak ada game gratis
-- Semua game EnakGame wajib punya org dan slug. Hanya arsip lama yang boleh kosong.
ADD CONSTRAINT chk_games_enakgame_identity CHECK (
status = 'ARCHIVED' OR (organization_id IS NOT NULL AND slug IS NOT NULL));
CREATE UNIQUE INDEX uq_games_org_slug ON games(organization_id, slug) WHERE slug IS NOT NULL;
CREATE INDEX idx_games_org_status ON games(organization_id, status);
```
**Catatan:**
- Baris lama tidak punya org, sehingga tidak bisa dijadikan game EnakGame. Mereka
diarsipkan dan tidak pernah tampil di endpoint EnakGame. Game baru (termasuk spin yang
dibangun ulang) dibuat sebagai baris baru dengan org.
- `is_active` tidak dipakai EnakGame dan dihapus bersama alur lama (§14). EnakGame hanya
membaca `status`.
### 5.2 `game_reward_configs`
```sql
CREATE TABLE game_reward_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
version INT NOT NULL,
reward_type VARCHAR(30) NOT NULL
CHECK (reward_type IN ('FIXED', 'SCORE_BASED', 'OUTCOME_BASED', 'PROBABILITY')),
rules JSONB NOT NULL, -- bentuk per tipe di §8
max_reward BIGINT NOT NULL CHECK (max_reward >= 0),
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')),
effective_at TIMESTAMPTZ,
created_by UUID NOT NULL,
reason VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (game_id, version)
);
-- Satu config aktif per game.
CREATE UNIQUE INDEX uq_game_reward_configs_active
ON game_reward_configs(game_id) WHERE status = 'ACTIVE';
```
`MULTIPLIER` tidak menjadi `reward_type` karena di PRD ia adalah modifier di atas base
reward, bukan cara menghitung base. Multiplier dan bonus hidup di `game_events` (§5.5).
### 5.3 `game_sessions`
```sql
CREATE TABLE game_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
reward_config_id UUID NOT NULL REFERENCES game_reward_configs(id), -- snapshot (D7)
entry_cost BIGINT NOT NULL CHECK (entry_cost >= 1), -- snapshot (P3)
status VARCHAR(20) NOT NULL DEFAULT 'STARTED'
CHECK (status IN ('STARTED', 'COMPLETED', 'REFUNDED', 'EXPIRED')),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
-- Hasil dari client (P1: hanya data, tanpa angka reward).
result JSONB,
-- Validasi & perhitungan: base, modifier event, cap guard, alasan penolakan, roll RNG.
reward_breakdown JSONB,
reward_total BIGINT NOT NULL DEFAULT 0 CHECK (reward_total >= 0),
flagged BOOLEAN NOT NULL DEFAULT FALSE,
spend_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
refund_transaction_id UUID REFERENCES wallet_transactions(id),
refund_reason VARCHAR(30)
CHECK (refund_reason IN ('SYSTEM_ERROR', 'GAME_DEACTIVATED')),
-- Diisi saat complete gagal karena error sistem (5xx), di transaksi terpisah (§7.3).
completion_failed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_game_sessions_refund CHECK (
(status = 'REFUNDED') = (refund_transaction_id IS NOT NULL AND refund_reason IS NOT NULL))
);
CREATE INDEX idx_game_sessions_customer ON game_sessions(customer_id, started_at DESC);
CREATE INDEX idx_game_sessions_open ON game_sessions(expires_at) WHERE status = 'STARTED';
CREATE INDEX idx_game_sessions_game_open ON game_sessions(game_id) WHERE status = 'STARTED';
```
`balance_before` yang diminta PRD §19 tidak perlu kolom: `balance_after - amount` di
`wallet_transactions` sudah memberikannya.
### 5.4 `game_session_rewards`
```sql
-- Satu baris per budget yang membiayai reward session (D6).
CREATE TABLE game_session_rewards (
session_id UUID NOT NULL REFERENCES game_sessions(id),
budget_id UUID NOT NULL REFERENCES game_budgets(id),
amount BIGINT NOT NULL CHECK (amount > 0),
wallet_transaction_id UUID NOT NULL UNIQUE REFERENCES wallet_transactions(id),
PRIMARY KEY (session_id, budget_id)
);
```
### 5.5 `game_events` dan `game_event_games`
```sql
CREATE TABLE game_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) NOT NULL,
description TEXT,
banner_url VARCHAR(500),
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Jakarta',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'ACTIVE', 'ENDED', 'CANCELLED')),
priority INT NOT NULL DEFAULT 0,
multiplier NUMERIC(5,2) CHECK (multiplier IS NULL OR multiplier > 0),
bonus BIGINT CHECK (bonus IS NULL OR bonus > 0),
-- Budget event sendiri, terpisah dari global (§5.6). Membiayai tambahan reward
-- dari multiplier dan bonus event ini.
budget_id UUID NOT NULL REFERENCES game_budgets(id),
reward_limit BIGINT, -- PRD §35 Event Limit
user_daily_limit BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug),
CHECK (end_at > start_at)
);
CREATE TABLE game_event_games (
event_id UUID NOT NULL REFERENCES game_events(id) ON DELETE CASCADE,
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
PRIMARY KEY (event_id, game_id)
);
```
Event adalah campaign dalam arti PRD §7: setiap event punya budget sendiri. Base reward
tetap dibiayai budget global. Hanya selisih yang ditambahkan event (multiplier + bonus)
yang dibiayai budget event. Budget event harus ber-`scope = 'EVENT'`, ditegakkan di
processor.
### 5.6 `game_budgets`
```sql
CREATE TABLE game_budgets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
scope VARCHAR(20) NOT NULL CHECK (scope IN ('GLOBAL', 'EVENT')),
name VARCHAR(255) NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
amount BIGINT NOT NULL CHECK (amount > 0), -- rupiah
-- {"warning": 70, "critical": 90} dalam persen utilisasi/forecast (PRD §8, §32).
thresholds JSONB NOT NULL DEFAULT '{}',
exhaustion_policy VARCHAR(30), -- PRD §34, menunggu keputusan
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (period_end >= period_start)
);
-- Budget global tidak boleh tumpang tindih di satu org: satu baris per periode.
CREATE UNIQUE INDEX uq_game_budgets_global_period
ON game_budgets(organization_id, period_start) WHERE scope = 'GLOBAL';
```
- **Global** default bulanan (PRD §5.2): `period_start` tanggal 1, `period_end` akhir bulan.
Periode lain bisa di-configure dengan mengisi rentang sendiri.
- **Event** periodenya rentang event. Realized cost dihitung ke budget event **kapan pun
Point-nya ditukar**, termasuk setelah event berakhir, karena biaya itu lahir dari reward
event tersebut.
### 5.7 Voucher
```sql
CREATE TABLE vouchers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
image_url VARCHAR(500),
voucher_type VARCHAR(30) NOT NULL
CHECK (voucher_type IN ('FIXED_VALUE', 'PERCENTAGE', 'FREE_ITEM', 'MERCHANT_BENEFIT')),
face_value BIGINT NOT NULL CHECK (face_value > 0), -- rupiah, dasar realized cost
point_cost BIGINT NOT NULL CHECK (point_cost > 0), -- boleh beda dari face_value (PRD §22)
business_cost BIGINT, -- reporting saja, bukan budget
stock_mode VARCHAR(20) NOT NULL
CHECK (stock_mode IN ('STATIC', 'CODE_POOL', 'EXTERNAL')),
stock BIGINT CHECK (stock IS NULL OR stock >= 0), -- hanya STATIC
provider VARCHAR(50), -- hanya EXTERNAL
provider_ref VARCHAR(255),
max_per_customer INT,
valid_from TIMESTAMPTZ,
valid_until TIMESTAMPTZ,
terms JSONB NOT NULL DEFAULT '{}',
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK ((stock_mode = 'STATIC') = (stock IS NOT NULL)),
CHECK ((stock_mode = 'EXTERNAL') = (provider IS NOT NULL))
);
CREATE TABLE voucher_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
voucher_id UUID NOT NULL REFERENCES vouchers(id) ON DELETE RESTRICT,
code VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE'
CHECK (status IN ('AVAILABLE', 'RESERVED', 'REDEEMED', 'EXPIRED', 'CANCELLED')),
redemption_id UUID,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (voucher_id, code),
CHECK ((status IN ('RESERVED', 'REDEEMED')) = (redemption_id IS NOT NULL))
);
CREATE INDEX idx_voucher_codes_available ON voucher_codes(voucher_id, created_at)
WHERE status = 'AVAILABLE';
CREATE TABLE voucher_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
voucher_id UUID NOT NULL REFERENCES vouchers(id),
idempotency_key VARCHAR(100) NOT NULL,
status VARCHAR(20) NOT NULL
CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')),
-- Snapshot (P3).
face_value BIGINT NOT NULL,
point_cost BIGINT NOT NULL,
voucher_code_id UUID REFERENCES voucher_codes(id),
external_code VARCHAR(255),
external_ref VARCHAR(255),
debit_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
refund_transaction_id UUID REFERENCES wallet_transactions(id),
failure_reason VARCHAR(255),
attempts INT NOT NULL DEFAULT 0,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (customer_id, idempotency_key),
CHECK ((status = 'FAILED') = (refund_transaction_id IS NOT NULL))
);
CREATE INDEX idx_voucher_redemptions_pending ON voucher_redemptions(updated_at)
WHERE status = 'PENDING';
-- Atribusi realized cost per budget, dibekukan saat redemption COMPLETED (D5).
CREATE TABLE voucher_redemption_costs (
redemption_id UUID NOT NULL REFERENCES voucher_redemptions(id),
-- NULL = Point yang bukan berasal dari EnakGame (EARN, ADJUSTMENT, MIGRATION).
budget_id UUID REFERENCES game_budgets(id),
source_type VARCHAR(30) NOT NULL, -- tipe ledger lot asal
points BIGINT NOT NULL CHECK (points > 0),
cost BIGINT NOT NULL CHECK (cost >= 0), -- rupiah, bagian dari face_value
recognized_at TIMESTAMPTZ NOT NULL, -- = completed_at, dasar periode budget
UNIQUE (redemption_id, budget_id, source_type)
);
CREATE INDEX idx_voucher_redemption_costs_budget
ON voucher_redemption_costs(budget_id, recognized_at);
```
### 5.8 Counter Economy Guard
```sql
-- Jumlah reward yang sudah diterbitkan per cakupan per hari (Asia/Jakarta).
CREATE TABLE game_reward_counters (
organization_id UUID NOT NULL,
scope_type VARCHAR(20) NOT NULL CHECK (scope_type IN ('USER', 'GAME', 'EVENT', 'GLOBAL')),
scope_id UUID NOT NULL, -- customer / game / event / organization
day DATE NOT NULL,
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
PRIMARY KEY (organization_id, scope_type, scope_id, day)
);
```
`EVENT` memakai `day = '0001-01-01'` untuk limit seumur event (PRD §35 Event Limit) dan
tanggal sebenarnya untuk `game_events.user_daily_limit`.
### 5.9 `audit_logs`
```sql
CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL,
actor_type VARCHAR(20) NOT NULL CHECK (actor_type IN ('USER', 'SYSTEM')),
actor_id UUID,
entity_type VARCHAR(50) NOT NULL,
entity_id UUID NOT NULL,
action VARCHAR(50) NOT NULL,
before JSONB,
after JSONB,
reason VARCHAR(255),
source VARCHAR(50) NOT NULL, -- 'admin_api', 'budget_controller', 'session_job', ...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id, created_at DESC);
```
### 5.10 Pengaturan organisasi
Limit global dan per user disimpan di `organization_settings` (key-value, `000091`),
dikelola lewat pola `LoyaltySettingsProcessor`: field descriptor dengan default dan
min/max, advisory lock, dan riwayat otomatis di `loyalty_setting_changes`. Audit perubahan
setting gratis didapat dari situ.
| Key | Tipe | Default | Arti |
|---|---|---|---|
| `enakgame.limit.user_daily` | int ≥ 0 | 0 (tanpa batas) | Coin maksimal yang didapat satu customer per hari |
| `enakgame.limit.global_daily` | int ≥ 0 | 0 | Coin maksimal yang diterbitkan seluruh org per hari |
Perilaku saat limit terlampaui tidak di-configure: reward selalu **dipotong ke sisa limit**
(§9).
Limit per game (`Game Daily Limit`) disimpan di `games.result_rules` agar ikut di-configure
per game.
---
## 6. Ledger
### 6.1 Tipe baru dan perubahan
| Tipe | Currency | Arah | Ref | Wajib tambahan | Idempotency key | Status |
|---|---|---|---|---|---|---|
| `GAME_SPEND` | COIN | keluar | `GAME_PLAY` (lama) **atau `GAME_SESSION`** | – | `game-entry:{customer}:{key}` | ref baru |
| `GAME_SPEND_REFUND` | COIN | masuk | `GAME_SESSION` | `reverses_transaction_id` | `game-refund:{session}` | **baru** |
| `GAME_REWARD` | COIN | masuk | `GAME_SESSION` | – | `game-reward:{session}:{budget}` | **baru** |
| `REWARD_REDEEM` | POINT | keluar | `REWARD_REDEMPTION` → `voucher_redemptions.id` | – | `redeem:{redemption}` | sudah ada, mulai dipakai |
| `REWARD_REDEEM_REFUND` | POINT | masuk | `REWARD_REDEMPTION` | `reverses_transaction_id` | `redeem-refund:{redemption}` | **baru** |
Pemetaan ke konsep PRD §19: `EARNED` = `GAME_REWARD`, `SPENT` = `GAME_SPEND` /
`REWARD_REDEEM`, `REFUND` = `GAME_SPEND_REFUND` / `REWARD_REDEEM_REFUND`, `EXPIRED` =
`EXPIRE`, `CONVERSION` = `EXCHANGE_OUT` + `EXCHANGE_IN`. `BONUS` dari event tetap
`GAME_REWARD`, dibedakan lewat budget dan `reward_breakdown`.
### 6.2 Perubahan yang harus dilakukan bersamaan
Menambah tipe berarti mengubah **dua tempat** yang harus sinkron:
1. `walletTypeRules` di `processor/wallet_processor.go:487`, plus konstanta di
`constants/wallet.go` (`WalletRefTypeGameSession`).
2. CHECK di `wallet_transactions` (drop + create ulang):
- `chk_wallet_transactions_point_only_types` + `REWARD_REDEEM_REFUND`
- `chk_wallet_transactions_coin_only_types` + `GAME_SPEND_REFUND`, `GAME_REWARD`
- `chk_wallet_transactions_reversal_source` + `GAME_SPEND_REFUND`, `REWARD_REDEEM_REFUND`
Reconciliation job (`service/wallet_reconciliation_job.go`) harus diperiksa: invariant
yang menghitung per tipe perlu mengenali tipe baru.
### 6.3 Lot
| Mutasi | Lot yang dibuat |
|---|---|
| `GAME_REWARD` | Satu lot, `expires_at = ComputeExpiry(CoinExpiry, now)`, tanpa `origin_lot_id` (lot akar) |
| `GAME_SPEND_REFUND` | Satu lot per alokasi `GAME_SPEND` asal: `expires_at = RefundExpiry(lot.expires_at, now)`, `origin_lot_id = lot asal`. Sama persis dengan pola `PAYMENT_REFUND` (`point_payment_refund.go`) |
| `REWARD_REDEEM_REFUND` | Sama dengan `GAME_SPEND_REFUND`, untuk Point |
---
## 7. Alur
### 7.1 Start Game
```
POST /customer/enakgame/sessions { game_id } Idempotency-Key: <≤50 char>
```
Satu transaksi:
1. Baca customer → `organization_id`. Tolak bila game bukan milik org tersebut atau
`status <> 'ACTIVE'`.
2. Baca reward config `ACTIVE` untuk game. Tolak bila tidak ada.
3. Pastikan ada budget global untuk periode berjalan. Tolak bila belum diatur, karena
reward yang nanti diterbitkan wajib menunjuk budget (D6).
4. `LockWallet(customer)`.
5. `FindTransaction("game-entry:{customer}:{key}")`. Bila ada → kembalikan session yang
menunjuknya (replay, tidak memotong lagi).
6. Buat `session_id` baru. `Debit` COIN `GAME_SPEND`, ref `GAME_SESSION → session_id`,
amount `games.entry_cost`. Saldo kurang → `ErrWalletInsufficientBalance` → tolak tanpa
apa pun tercatat.
7. Insert `game_sessions` dengan `entry_cost`, `reward_config_id`,
`expires_at = now + session_ttl_seconds`, `spend_transaction_id`.
Response: `session_id`, `expires_at`, `entry_cost`, `coin_balance`.
### 7.2 Complete Game
```
POST /customer/enakgame/sessions/:id/complete { score?, outcome?, data? }
```
Satu transaksi:
1. `LockWallet(customer)`.
2. Baca session. Bila `customer_id` beda → 404. Bila status sudah `COMPLETED` → kembalikan
hasil yang tersimpan (idempotent, PRD §20). Bila `REFUNDED` / `EXPIRED` → tolak.
3. Bila `now > expires_at` → tolak (session dibiarkan untuk job, §7.3).
4. Bila game sudah tidak `ACTIVE` → **refund** (`GAME_DEACTIVATED`) di transaksi ini, lalu
kembalikan response "game dinonaktifkan, Coin dikembalikan".
5. **Result Validator** (`games.result_rules`): durasi minimal sejak `started_at`, skor
maksimum, skor per detik, outcome yang dikenal. Gagal → reward 0, `flagged = true`,
alasan di `reward_breakdown`. Session tetap `COMPLETED`, tidak ada refund.
6. **Reward Engine** (§8): hitung base dari config snapshot.
7. **Event modifier**: event `ACTIVE` yang mencakup game, `start_at ≤ now < end_at`.
Hasilnya daftar komponen `{budget_id, amount}`.
8. **Economy Guard** (§9): naikkan counter dengan UPDATE bersyarat, potong komponen yang
melewati limit.
9. `UPDATE game_sessions SET status='COMPLETED' ... WHERE id=? AND status='STARTED'`.
0 baris → session baru saja di-refund job → rollback, kembalikan status terbaru.
10. Untuk tiap komponen > 0: `Credit` COIN `GAME_REWARD` dengan key
`game-reward:{session}:{budget}`, lalu insert `game_session_rewards`.
Response: `reward_total`, rincian yang aman ditampilkan (base, bonus event), `coin_balance`.
### 7.3 Refund otomatis & kedaluwarsa session
PRD §10.2: refund otomatis untuk **system error** dan **game dinonaktifkan**; session yang
ditinggal user tidak di-refund.
**Mendefinisikan "system error".** Backend hanya bisa membedakan dua hal ini bila ada jejak.
Aturannya:
- Bila complete (§7.2) gagal dengan error non-bisnis (DB error, panic, timeout → HTTP 5xx),
handler menulis `completion_failed_at = now()` di **transaksi terpisah**
(`DetachTransaction`, karena transaksi utama sudah rollback).
- Error validasi (4xx) dan error yang terjadi di client (Phaser crash, koneksi putus
sebelum request sampai) **tidak** meninggalkan jejak, sehingga diperlakukan sebagai
ditinggal user dan tidak di-refund (diputuskan).
**`GameSessionJob`** (ticker, pola `WalletExpiryJob`, per batch, satu transaksi per
session):
| Kondisi session `STARTED` | Aksi |
|---|---|
| Game tidak lagi `ACTIVE` | Refund `GAME_DEACTIVATED` segera, tanpa menunggu kedaluwarsa |
| Lewat `expires_at` dan `completion_failed_at IS NOT NULL` | Refund `SYSTEM_ERROR` |
| Lewat `expires_at`, tanpa jejak gagal | `EXPIRED`, tanpa refund |
Langkah refund: `LockWallet` → `UPDATE ... SET status='REFUNDED' WHERE status='STARTED'`
(0 baris = sudah diselesaikan pihak lain, lewati) → `Credit` COIN `GAME_SPEND_REFUND`
dengan `reverses_transaction_id = spend_transaction_id`, lot sesuai §6.3, key
`game-refund:{session}` → isi `refund_transaction_id`, `refund_reason` → `audit_logs`
dengan `actor_type = SYSTEM`.
Saat admin mengubah game ke `INACTIVE`, handler tidak perlu menyapu session. Job berikutnya
menanganinya, dan complete di §7.2 langkah 4 menangani yang lebih cepat.
### 7.4 Redemption voucher internal (`STATIC`, `CODE_POOL`)
```
POST /customer/enakgame/vouchers/:id/redeem { pin } Idempotency-Key: <≤50 char>
```
Satu transaksi, sehingga state `RESERVED` tidak diperlukan:
1. Validasi voucher `ACTIVE`, dalam masa berlaku, org sama.
2. `VerifyPin(..., PinActionRedeem)` — aksi PIN baru, konsisten dengan K8 (PIN untuk setiap
pemakaian saldo). Batas salah 5 kali / kunci 30 menit yang sudah ada ikut berlaku.
3. `LockWallet(customer)`.
4. Cari `voucher_redemptions (customer_id, idempotency_key)`. Ada → kembalikan (replay).
5. Cek `max_per_customer` dari `COUNT(*)` redemption `COMPLETED` / `PENDING`.
6. Ambil stok:
- `STATIC`: `UPDATE vouchers SET stock = stock - 1 WHERE id = ? AND stock > 0`.
- `CODE_POOL`: `SELECT ... FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE'
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, lalu set `REDEEMED`.
- Habis → tolak, tidak ada yang tercatat.
7. `Debit` POINT `REWARD_REDEEM` sebesar `point_cost`, key `redeem:{redemption}`.
8. Insert `voucher_redemptions` `COMPLETED` dengan snapshot `face_value`, `point_cost`.
9. **Atribusi cost** (§7.6) → insert `voucher_redemption_costs`.
### 7.5 Redemption voucher eksternal (`EXTERNAL`)
Panggilan ke provider tidak boleh berada di dalam transaksi database, jadi alurnya dua
tahap dengan pemulihan:
1. **Transaksi 1:** langkah 1–5 dan 7 di §7.4, lalu insert redemption `PENDING`. Point sudah
terpotong.
2. **Panggil provider** dengan `redemption_id` sebagai idempotency key provider.
3. **Transaksi 2**, tergantung hasil:
- Sukses → simpan `external_code` / `external_ref`, `COMPLETED`, atribusi cost (§7.6).
- Gagal pasti (provider menolak) → `Credit` POINT `REWARD_REDEEM_REFUND`, `FAILED`.
- Timeout / tidak jelas → biarkan `PENDING`, response "sedang diproses".
4. **`VoucherRedemptionRecoveryJob`** mengambil `PENDING` yang sudah lewat N menit, bertanya
ke provider (atau mengulang dengan key yang sama), lalu menjalankan transaksi 2. Setelah
batas percobaan, refund dan `FAILED`.
Dengan ini syarat PRD §26 terpenuhi: tidak ada keadaan "Point terpotong, voucher tidak
datang" yang tidak dipulihkan.
### 7.6 Atribusi realized cost
Dijalankan di dalam transaksi redemption, setelah debit `REWARD_REDEEM`:
```sql
WITH RECURSIVE chain AS (
SELECT a.lot_id AS spent_lot, a.amount AS points, l.origin_lot_id, l.source_transaction_id
FROM wallet_lot_allocations a
JOIN wallet_lots l ON l.id = a.lot_id
WHERE a.transaction_id = :redeem_tx
UNION ALL
SELECT c.spent_lot, c.points, p.origin_lot_id, p.source_transaction_id
FROM chain c
JOIN wallet_lots p ON p.id = c.origin_lot_id
)
SELECT c.points, t.type AS source_type, gsr.budget_id
FROM chain c
JOIN wallet_transactions t ON t.id = c.source_transaction_id
LEFT JOIN game_session_rewards gsr ON gsr.wallet_transaction_id = t.id
WHERE c.origin_lot_id IS NULL; -- lot akar
```
Rantai lot akar Point bisa melewati `EXCHANGE_IN` → lot Coin → `TRANSFER_IN` → ... sampai
`GAME_REWARD` (EnakGame) atau `EARN` / `ADJUSTMENT` / `MIGRATION` (bukan EnakGame).
`exchangeLots` dan transfer sudah mengisi `origin_lot_id`, jadi query ini bekerja dengan
data yang sudah ada.
Hasil dikelompokkan per `(budget_id, source_type)`, lalu `face_value` dibagi
proporsional terhadap Point:
```
cost_i = face_value × points_i / point_cost (dibulatkan; selisih pembulatan
diberikan ke bagian terbesar, sehingga
Σ cost_i = face_value persis)
```
Contoh: voucher face value Rp10.000, point cost 8.000. Point yang dipakai: 6.000 dari
`GAME_REWARD` (budget global Oktober), 2.000 dari `EARN`.
| budget_id | source_type | points | cost |
|---|---|---|---|
| global-2026-10 | `GAME_REWARD` | 6.000 | 7.500 |
| NULL | `EARN` | 2.000 | 2.500 |
Hanya Rp7.500 yang mengurangi budget global Oktober. Rp2.500 dari Point belanja tetap
tercatat untuk reporting Finance, tetapi tidak dihitung ke budget mana pun (D5).
---
## 8. Reward Engine
Satu interface, satu implementasi per tipe:
```go
type RewardCalculator interface {
Validate(rules json.RawMessage) error // saat config dibuat
Calculate(rules json.RawMessage, result SessionResult, rng RNG) (base int64, detail map[string]any, err error)
}
```
| Tipe | `rules` | Catatan |
|---|---|---|
| `FIXED` | `{"amount": 5}` | |
| `SCORE_BASED` | `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}` | Band tidak boleh tumpang tindih atau berlubang; band terakhir boleh tanpa `max` |
| `OUTCOME_BASED` | `{"outcomes": {"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0}}` | Outcome di luar daftar → ditolak Result Validator |
| `PROBABILITY` | `{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 889, "amount": 0}]}` | Bobot bilangan bulat, bukan persen desimal, supaya validasi "total = 100%" tidak bergantung float |
**PROBABILITY memakai `crypto/rand`**, bukan `math/rand` yang di-seed ulang dengan waktu
seperti `selectPrizeByWeight` sekarang. Angka acak yang ditarik disimpan di
`reward_breakdown` untuk audit. Hasil diundi saat **complete**, bukan saat start, supaya
client tidak bisa mengetahui hasil lalu meninggalkan session.
**Modifier event** (urutan tetap): `base × multiplier` → `+ bonus` → cap `max_reward` config.
Aturan tumpuk antar event mengikuti default PRD §16 (multiplier terkontrol, bonus dihitung
terpisah, cap selalu berlaku) sampai diputuskan (§19.2 #2).
**Pembulatan:** semua hasil perkalian **dibulatkan ke bawah** ke Coin utuh, mengikuti K6 di
PRD point-coin. Ini usulan untuk menutup Open Decision PRD §43 #1.
`TIERED` di luar scope: tabel `tiers` belum terhubung ke customer, dan aturan tier dibahas
di PRD terpisah (point-coin Q8).
---
## 9. Economy Guard & Limits
Pemeriksaan di §7.2 langkah 8, untuk setiap komponen reward:
1. Session valid, belum rewarded, user & game cocok — sudah dijamin §7.2 langkah 2–4 dan 9.
2. Untuk tiap limit yang berlaku (`USER` harian, `GAME` harian, `EVENT` total & per user,
`GLOBAL` harian):
```sql
INSERT INTO game_reward_counters (...) VALUES (..., 0) ON CONFLICT DO NOTHING;
UPDATE game_reward_counters SET amount = amount + :x
WHERE ... AND amount + :x <= :limit
RETURNING amount;
```
0 baris → limit terlampaui → `x` diturunkan ke sisa limit dan diulang. Sisa 0 berarti
komponen menjadi 0. Batas yang memotong dicatat di `reward_breakdown`, supaya customer
bisa diberi tahu kenapa reward-nya lebih kecil.
3. Status budget (§10) `EXHAUSTED` → terapkan `exhaustion_policy` (§19.2 #3).
Counter `USER` aman karena wallet customer sudah dikunci. Counter `GAME` / `GLOBAL` adalah
baris panas yang dikunci singkat oleh setiap complete di org tersebut. Untuk volume awal
ini dapat diterima. Lihat risiko di §17.
---
## 10. Budget & Budget Controller v1
**Metrik per budget** (PRD §8), dihitung on-read dari tabel yang ada, tanpa tabel agregat:
| Metrik | Sumber |
|---|---|
| Realized cost | `SUM(voucher_redemption_costs.cost)` untuk `budget_id`, `recognized_at` dalam periode (global) / tanpa batas waktu (event) |
| Coin issued | `SUM(game_session_rewards.amount)` untuk `budget_id` |
| Remaining | `amount − realized` |
| Utilization | `realized / amount` |
| Forecast | `realized + rata-rata realized harian 7 hari terakhir × sisa hari` |
| Exposure | Sisa Coin/Point beredar yang berasal dari budget ini (via lot), sebagai batas atas biaya yang masih bisa datang |
| Status | Bandingkan utilization dan forecast dengan `thresholds` → `HEALTHY` / `WARNING` / `CRITICAL` / `EXHAUSTED` |
**Rekomendasi** (recommendation mode, D9): bila forecast > budget, hitung multiplier yang
membuat forecast = budget, lalu batasi dengan step maksimum dan min/max multiplier (PRD
§31). Rekomendasi ditampilkan ke admin. Bila disetujui, admin membuat **reward config
versi baru** (§5.2). Tidak ada perubahan reward tanpa versi baru dan tanpa audit.
`GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat,
tambahkan snapshot harian, bukan cache yang di-invalidate.
---
## 11. API
### Customer (`/api/v1/customer/enakgame`, `ValidateCustomerToken`)
| Method | Path | Catatan |
|---|---|---|
| `GET` | `/games` | Game `ACTIVE` milik org customer, dengan `entry_cost` dan event aktif |
| `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` |
| `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header |
| `GET` | `/sessions/:id` | Status dan hasil |
| `GET` | `/sessions` | Riwayat main |
| `GET` | `/vouchers` | Katalog `ACTIVE` + stok tersedia |
| `POST` | `/vouchers/:id/redeem` | §7.4 / §7.5. Wajib `Idempotency-Key` + PIN |
| `GET` | `/redemptions` | Voucher milik customer, termasuk kode |
Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama.
### Admin (`/api/v1/marketing/enakgame`, `RequireAdminOrManager`)
| Resource | Endpoint |
|---|---|
| Games | CRUD, `PUT /:id/status` |
| Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi |
| Events | CRUD, `PUT /:id/status` |
| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation` |
| Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` |
| Redemptions | `GET` list, `GET /:id` dengan atribusi cost |
| Sessions | `GET` list + filter `flagged` |
| Settings | Lewat endpoint loyalty settings yang ada, dengan key baru (§5.10) |
Perubahan budget, voucher, dan reward config memerlukan `RequireLoyaltyManager`, sama
seperti adjustment saldo.
---
## 12. Background Jobs
Semua mengikuti pola goroutine + `time.NewTicker` di `app/app.go`, satu transaksi per item,
aman dijalankan di beberapa instance karena setiap item dikunci lewat UPDATE bersyarat.
| Job | Interval | Tugas |
|---|---|---|
| `GameSessionJob` | 1 menit | §7.3: refund / expire session `STARTED` |
| `VoucherRedemptionRecoveryJob` | 1 menit | §7.5: selesaikan `PENDING` eksternal |
| `VoucherCodeExpiryJob` | 1 jam | `AVAILABLE → EXPIRED` untuk kode lewat `expires_at` |
| `GameBudgetPeriodJob` | 1 hari | Membuat baris budget global bulan berikutnya dari bulan berjalan, bila belum ada |
---
## 13. Audit
`audit_logs` diisi untuk semua perubahan yang disebut PRD §37: reward config (buat,
aktifkan, pensiunkan), event, budget, voucher (termasuk impor kode), status game, refund
otomatis, dan penerimaan rekomendasi Budget Controller. Ditulis di transaksi yang sama
dengan perubahannya, sehingga tidak ada perubahan tanpa jejak.
Pengaturan limit sudah ter-audit lewat `loyalty_setting_changes` (§5.10). Adjustment saldo
sudah ter-audit lewat ledger `ADJUSTMENT`.
---
## 14. Legacy & Migrasi
| Bagian lama | Nasib |
|---|---|
| Baris `games` lama | Diarsipkan (`status = 'ARCHIVED'`), **tidak di-`DELETE`**. FK `game_plays.game_id` adalah `ON DELETE CASCADE`: menghapus game ikut menghapus `game_plays`, padahal ledger `GAME_SPEND` lama menunjuk ke sana lewat `reference_id`. Jejak asal-tujuan saldo (K5) akan putus |
| `POST /customer/spin`, `GET /customer/games`, `GET /customer/ferris-wheel` | Dihapus. Spin dibangun ulang sebagai game EnakGame dengan config `PROBABILITY` dan reward Coin |
| `game_prizes`, `game_plays` | Dibekukan (read-only). Data tetap disimpan untuk riwayat ledger `GAME_PLAY`. Kode processor/handler/route-nya dihapus |
| `rewards` | Diganti `vouchers`. Tidak punya org, tidak punya redemption, tidak dipakai flow mana pun, dan tidak punya data produksi (dikonfirmasi), sehingga tidak ada migrasi data |
| `campaigns`, `campaign_rules` | Tidak dipakai EnakGame. Campaign EnakGame adalah `game_events` |
| Bayar order dengan EnakPoint | **Dimatikan sebelum EnakGame rilis** (PRD §3.2). Belum ada order yang dibayar dengan Point, jadi tidak ada data yang dimigrasi. Yang dilepas: route point payment, `payment_methods` tipe `point` beserta trigger pembuatnya (`000094`), dan setting `loyalty.point.accept_payment`. Dikerjakan sebagai task terpisah |
---
## 15. Temuan Sampingan
Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak ke uang:
1. **Double charge di `/customer/spin`.** `GAME_SPEND` di `PlayGame` tanpa idempotency key.
2. **`/customer/spin` menerima game id mana pun**, tanpa cek tipe dan tanpa cek org
(`spin_game_service.go:27`). Customer org A bisa memainkan game org B.
Nomor 1 dan 2 hilang sendiri saat alur lama dihapus (§14). Bila penghapusannya tidak
segera, endpoint lama sebaiknya dimatikan lebih dulu daripada ditambal.
3. **RNG hadiah** di-seed ulang dengan `UnixNano` setiap panggilan (`game_play_processor.go:287`).
4. **`threshold` dan `fallback_prize_id`** di `game_prizes` tidak pernah dipakai.
5. **`GET /customer/ferris-wheel`** mengembalikan `First()` dari game SPIN aktif tanpa urutan,
sehingga game yang dikembalikan tidak pasti.
6. **`rewards`**: create menolak tipe `BALANCE`, update menerimanya, database tidak punya
CHECK.
7. **`campaigns`**: `GetActiveCampaigns` mengikat string `"now()"` sebagai parameter tanggal
(`campaign_repository.go:114`). Belum diverifikasi apakah Postgres menerimanya.
8. **`tiers.name` UNIQUE global**, bukan per org.
---
## 16. Di Luar Scope
- **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya.
- **Leaderboard** (PRD §15).
- **Reward `TIERED`** (§8).
- **Budget Controller automatic mode** (D9).
- **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game.
---
## 17. Urutan Implementasi
1. **Matikan bayar dengan EnakPoint** (§14). Independen, bisa paralel.
2. **Migrasi skema**, dengan urutan mengikuti foreign key: extend `games`,
`game_budgets`, `game_reward_configs`, `game_sessions`, `game_session_rewards`,
`audit_logs`; tipe ledger baru + CHECK (§6.2).
3. **Session + entry cost** (§7.1) dan **refund + `GameSessionJob`** (§7.3).
4. **Reward Engine** `FIXED`, `SCORE_BASED`, `OUTCOME_BASED`, `PROBABILITY` + Result
Validator + complete (§7.2, §8).
5. **Economy Guard + limits** (§9, §5.10).
6. **Voucher internal + redemption + atribusi cost** (§7.4, §7.6).
7. **Budget metrics + status** (§10).
8. **Events / campaign** dengan budget sendiri (§5.5).
9. **Voucher eksternal + recovery job** (§7.5). Butuh provider pertama yang konkret.
10. **Rekomendasi Budget Controller + analytics** (§10, PRD §36).
11. **Spin dibangun ulang sebagai game EnakGame**, lalu hapus kode alur lama dan arsipkan
datanya (§14). Endpoint lama bisa dimatikan lebih awal, kapan pun.
Langkah 2–4 sudah membuat game bisa dimainkan end-to-end dengan Coin. Langkah 6 membuat
Point bisa ditukar. Langkah 7 membuat Finance bisa melihat biaya.
---
## 18. Risiko
| Risiko | Dampak | Mitigasi |
|---|---|---|
| Satu dari dua tempat aturan ledger (§6.2) terlewat | Mutasi ditolak di produksi, atau lolos tanpa validasi | Test per tipe di `wallet_processor_test.go` + test DB yang benar-benar insert ke `wallet_transactions` |
| Farming lewat banyak akun + transfer Coin | Limit harian per user dilewati, karena Coin hasil game boleh ditransfer (diputuskan) | Setting transfer yang ada (`Transfer.DailyLimit`, `MaxPerTransaction`); pantau `GAME_REWARD` yang langsung diikuti `TRANSFER_OUT` di analytics |
| Counter `GLOBAL` / `GAME` jadi bottleneck | Complete melambat saat ramai | Diukur dulu. Bila perlu, pecah counter per shard dan jumlahkan saat cek |
| Rantai `origin_lot_id` panjang | Query atribusi lambat | Rantai praktis pendek (reward → exchange → transfer). Bila perlu, tambah kolom `root_lot_id` di `wallet_lots` |
| Provider eksternal tidak idempotent | Voucher terbit dua kali saat recovery | Syarat integrasi: provider wajib menerima idempotency key; bila tidak, recovery hanya boleh *query*, tidak boleh mengulang |
| Client mengirim skor palsu | Coin terbit tanpa main | Result Validator (§7.2) + `flagged`; reward tidak pernah dari client (P1) |
| Game lama di-`DELETE` alih-alih diarsipkan | `game_plays` ikut terhapus (CASCADE), ledger `GAME_SPEND` lama kehilangan tujuan | Migrasi §5.1 mengarsipkan; `chk_games_enakgame_identity` mencegah arsip lama tampil sebagai game EnakGame |
---
## 19. Keputusan & Pertanyaan Terbuka
### 19.1 Sudah Diputuskan (2026-10-07)
| # | Pertanyaan | Keputusan | Tercermin di |
|---|---|---|---|
| Q1 | Point dari belanja (`EARN`) yang ditukar voucher masuk budget EnakGame? | **Tidak.** Hanya Point yang berasal dari `GAME_REWARD` | D5, §7.6 |
| Q2 | Campaign itu apa? | **Campaign = event.** Setiap event punya budget sendiri | D6, §5.5, §5.6 |
| Q3 | Spin dan game lama? | **Dibangun ulang** sebagai game EnakGame | §14, §17 |
| Q4 | Baris `games` lama? | **Dihapus dari produk** (diarsipkan secara data, lihat §14) | §5.1, §14 |
| Q5 | Reward melewati limit? | **Dipotong ke sisa limit** | §9 |
| Q6 | Coin hasil game boleh ditransfer? | **Boleh** | §18 |
| Q7 | PIN untuk redemption voucher? | **Ya** | §7.4 |
| Q8 | Definisi "system error" untuk refund? | **Hanya error yang tercatat di server** (5xx saat complete). Crash di client = ditinggal user | §7.3 |
### 19.2 Masih Terbuka
Dari PRD §43:
1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6.
2. **Event stacking.** Sementara memakai default PRD §16.
3. **Budget exhaustion policy**, untuk budget global dan budget event.
4. **Threshold Budget Controller.**
5. **Timeout reservasi voucher.** Dengan §7.4, hanya relevan untuk voucher eksternal.
Tidak ada yang memblokir langkah 1–7 di §17. Nomor 3 dan 4 harus diputuskan sebelum langkah
7 (budget status) dirilis.
+503
View File
@@ -0,0 +1,503 @@
# Task Breakdown: EnakGame
**Sumber:** [RFC EnakGame](rfc-enakgame.md), [PRD EnakGame](enakgame-prd.md)
**Tanggal:** 2026-10-07
Setiap task menyebut bagian RFC yang dikerjakan, lapisan kode yang disentuh, task yang
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
**L** 4–5 hari.
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000101`),
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
`router`, dan wiring di `internal/app/app.go`. Repository **selalu** memakai
`DBFromContext`. Semua mutasi saldo **hanya** lewat `WalletProcessor`.
---
## Ringkasan
| Fase | Task | Terblokir oleh |
|---|---|---|
| 0. Matikan bayar dengan EnakPoint | EG-001 – EG-002 | – |
| 1. Fondasi | EG-101 – EG-106 | – |
| 2. Admin game & session | EG-201 – EG-206 | – |
| 3. Reward Engine & complete | EG-301 – EG-303 | Pembulatan (RFC §19.2 #1) sebelum **rilis** |
| 4. Economy Guard | EG-401 – EG-402 | – |
| 5. Voucher & redemption | EG-501 – EG-507 | – |
| 6. Budget metrics | EG-601 – EG-602 | Exhaustion policy & threshold (#3, #4) sebelum **rilis** |
| 7. Event / campaign | EG-701 – EG-704 | Event stacking (#2) sebelum **rilis** |
| 8. Voucher eksternal | EG-801 – EG-803 | Provider pertama yang konkret sebelum **dikerjakan** |
| 9. Budget Controller & analytics | EG-901 – EG-903 | Threshold (#4) sebelum **dikerjakan** |
| 10. Spin & bersih-bersih | EG-1001 – EG-1003 | – |
Fase 0 independen dan bisa jalan paralel dengan semua fase lain, tetapi **harus selesai
sebelum EnakGame rilis** (PRD §3.2).
Fase 1–3 membuat game bisa dimainkan end-to-end dengan Coin. Fase 5 membuat Point bisa
ditukar. Fase 6 membuat Finance bisa melihat biaya.
```
Fase 0 EG-001 ── EG-002
Fondasi EG-101 ── EG-102 ── EG-105
EG-103, EG-104, EG-106, EG-301 (independen)
Game EG-104 + EG-105 ─────────────────── EG-201, EG-203
EG-104 + EG-105 + EG-301 ────────── EG-202
EG-103 + EG-201 + EG-202 + EG-203 ─ EG-204 ── EG-205
EG-204 + EG-205 + EG-301 + EG-302 ─ EG-303
EG-106 ── EG-401
EG-303 + EG-401 ─────────────────── EG-402
Voucher EG-501 ── EG-502
EG-103 + EG-502 + EG-503 ────────── EG-504
EG-303 + EG-504 ─────────────────── EG-505 ── EG-601 ─┬─ EG-602
└─ EG-901 ── EG-902
EG-504 ── EG-801
EG-505 + EG-801 ─────────────────── EG-802 ── EG-803
Event EG-102 ── EG-701
EG-203 + EG-701 ─────────────────── EG-702
EG-402 + EG-702 ─────────────────── EG-703
Akhir EG-402 ── EG-1001 ── EG-1002 ── EG-1003
```
Task kecil yang tidak digambar: EG-206, EG-302 (setelah EG-105), EG-506 (EG-504), EG-507
(EG-501), EG-704 (EG-206 + EG-702), EG-903 (EG-303 + EG-505).
---
## Fase 0 — Matikan Bayar dengan EnakPoint
PRD §3.2: Point hanya bisa ditukar ke voucher. Belum ada order yang dibayar dengan Point,
jadi tidak ada data yang dimigrasi.
### EG-001 · Tutup semua jalur pembayaran EnakPoint · M
- **RFC:** §14
- **Kerjakan:**
- Sudah dikonfirmasi belum ada pembayaran dengan EnakPoint di production (2026-10-07).
- Migrasi: drop trigger yang membuat payment method `point` untuk organisasi baru
(`000094`), lalu nonaktifkan / hapus baris `payment_methods` tipe `point`.
- Tolak pembayaran bertipe `point` di jalur POS (`order_handler`, `order_processor`,
`payment_method_processor`) dan customer app (`customer_order_payment_service`).
- Hapus route `GET /orders/:id/point-payment/preview` dan
`POST /customer/wallet/payment-code`.
- Hapus `PAYMENT` dan `PAYMENT_REFUND` dari `walletTypeRules`, supaya engine menolak
keduanya. CHECK database dibiarkan.
- Hapus setting `loyalty.point.accept_payment` dari descriptor
`LoyaltySettingsProcessor` dan dari response outlet customer.
- **Selesai jika:** pembayaran order dengan method `point` ditolak di POS dan customer app,
outlet baru tidak lagi mendapat payment method EnakPoint, dan test yang ada diperbarui.
- **Bergantung pada:** –
### EG-002 · Hapus kode point payment · S
- **Kerjakan:** hapus `PointPaymentProcessor`, `point_payment_refund.go`,
`PointPaymentRepository`, `PointPaymentService`, `PointPaymentHandler`, wiring di
`app.go` / `router.go`, dan test-nya (`point_payment_db_test.go`,
`point_payment_method_db_test.go`). Pindahkan dulu helper yang ternyata dipakai di
tempat lain (mis. pola pembuatan lot refund, yang dipakai ulang di EG-205).
- **Selesai jika:** `go build ./...` dan seluruh test lulus, dan
`grep -rn "PointPayment" internal/` kosong.
- **Bergantung pada:** EG-001
---
## Fase 1 — Fondasi
### EG-101 · Migrasi extend `games` + arsipkan game lama · S
- **RFC:** §5.1, §14
- **Kerjakan:** migrasi up & down persis seperti §5.1. Semua baris lama menjadi
`ARCHIVED`. **Tidak ada `DELETE`** (FK `game_plays` CASCADE).
- **Selesai jika:**
- Up/down bersih di database kosong dan di salinan staging.
- Jumlah baris `game_plays` sebelum = sesudah.
- Ditolak database: game `ACTIVE` tanpa `organization_id` atau `slug`;
`entry_cost = 0`; dua game dengan slug sama di satu org.
- **Bergantung pada:** –
### EG-102 · Migrasi budget, reward config, session · M
- **RFC:** §5.2, §5.3, §5.4, §5.6
- **Kerjakan:** `game_budgets`, `game_reward_configs`, `game_sessions`,
`game_session_rewards`, dalam urutan foreign key.
- **Selesai jika:** up/down bersih. Ditolak database: dua config `ACTIVE` untuk satu game;
session `REFUNDED` tanpa `refund_transaction_id`; dua budget `GLOBAL` dengan
`period_start` sama di satu org; `game_session_rewards` dengan
`wallet_transaction_id` yang sama dua kali.
- **Bergantung pada:** EG-101
### EG-103 · Tipe ledger baru · M
- **RFC:** §6
- **Kerjakan:**
- Konstanta `GAME_SPEND_REFUND`, `GAME_REWARD`, `REWARD_REDEEM_REFUND`, ref
`GAME_SESSION` di `constants/wallet.go`.
- `walletTypeRules`: tambah tiga tipe, dan izinkan ref `GAME_SESSION` pada
`GAME_SPEND`.
- Migrasi: drop + create ulang tiga CHECK di `wallet_transactions` (§6.2).
- Periksa invariant di `wallet_reconciliation_repository.go` terhadap tipe baru.
- **Selesai jika:** test validasi di `wallet_processor_test.go` per tipe baru (currency
salah, ref salah, reversal tanpa `reverses_transaction_id` → ditolak), dan test DB yang
benar-benar insert ke `wallet_transactions` untuk setiap tipe (lolos engine **dan**
CHECK).
- **Bergantung pada:** –
### EG-104 · `audit_logs` + helper · S
- **RFC:** §5.9, §13
- **Kerjakan:** migrasi, entity, dan `AuditLogger.Record(ctx, entry)` yang menulis lewat
`DBFromContext`, sehingga audit ikut commit/rollback bersama perubahannya.
- **Selesai jika:** perubahan yang di-rollback tidak meninggalkan baris audit.
- **Bergantung pada:** –
### EG-105 · Entities & repository EnakGame · M
- **RFC:** §5.1–§5.6
- **Kerjakan:** entities dan repository untuk kolom baru `games`, `game_reward_configs`,
`game_sessions`, `game_session_rewards`, `game_budgets`. Transisi status session
sebagai `UPDATE ... WHERE status = 'STARTED'` yang mengembalikan jumlah baris
ter-update (D4). Semua query game/budget memfilter `organization_id`.
- **Selesai jika:** test repository: dua transisi bersamaan pada session yang sama, hanya
satu yang mendapat 1 baris.
- **Bergantung pada:** EG-101, EG-102
### EG-106 · Setting limit EnakGame · S
- **RFC:** §5.10
- **Kerjakan:** key `enakgame.limit.user_daily` dan `enakgame.limit.global_daily` sebagai
field descriptor di `LoyaltySettingsProcessor` (default 0 = tanpa batas, min 0).
- **Selesai jika:** setting terbaca dengan default, bisa diubah lewat endpoint loyalty
settings yang ada, dan perubahan tercatat di `loyalty_setting_changes`.
- **Bergantung pada:** –
---
## Fase 2 — Admin Game & Session
### EG-201 · Admin game CRUD · M
- **RFC:** §11 (admin)
- **Kerjakan:** `/marketing/enakgame/games` CRUD + `PUT /:id/status`, org dari user admin.
Validasi `slug`, `entry_cost ≥ 1`, `result_rules`. Game `ARCHIVED` tidak bisa diubah.
Perubahan status masuk `audit_logs`.
- **Selesai jika:** admin org A tidak bisa melihat atau mengubah game org B; game lama
(arsip) tidak muncul.
- **Bergantung pada:** EG-104, EG-105
### EG-202 · Admin reward config versioning · M
- **RFC:** §5.2, D7
- **Kerjakan:** `POST /games/:id/reward-configs` (versi baru, `rules` divalidasi
`RewardCalculator.Validate`), `POST /reward-configs/:id/activate` (dalam satu transaksi:
config lama → `RETIRED`, yang baru → `ACTIVE`), `GET` daftar versi. Tidak ada endpoint
update atau delete. Wajib `RequireLoyaltyManager`. Semua aksi masuk `audit_logs`.
- **Selesai jika:** config yang sudah dipakai session tidak bisa diubah lewat jalur apa
pun; aktivasi bersamaan dua versi berakhir dengan tepat satu `ACTIVE`.
- **Bergantung pada:** EG-104, EG-105, EG-301
### EG-203 · Admin budget + `GameBudgetPeriodJob` · M
- **RFC:** §5.6, §12
- **Kerjakan:** CRUD `/marketing/enakgame/budgets` (scope `GLOBAL` / `EVENT`), wajib
`RequireLoyaltyManager`, audit. Job harian yang membuat budget global bulan berikutnya
dari bulan berjalan bila belum ada.
- **Selesai jika:** job aman dijalankan berulang (unique index mencegah duplikat), dan
perubahan `amount` tercatat sebelum/sesudah di audit.
- **Bergantung pada:** EG-104, EG-105
### EG-204 · Start Game · M
- **RFC:** §7.1
- **Kerjakan:** `POST /customer/enakgame/sessions` dengan `Idempotency-Key` wajib (≤ 50
karakter, pola `customer_wallet_handler.go`). Langkah persis §7.1.
- **Selesai jika:** test untuk:
- Key yang sama dua kali → satu debit, session yang sama dikembalikan.
- Coin kurang → ditolak, tidak ada ledger maupun session.
- Game org lain, game tidak `ACTIVE`, tanpa config aktif, tanpa budget global → ditolak.
- `entry_cost` dan `reward_config_id` tersimpan sebagai snapshot: mengubah game setelah
start tidak mengubah session.
- **Bergantung pada:** EG-103, EG-105, EG-201, EG-202, EG-203
### EG-205 · Refund otomatis + `GameSessionJob` · M
- **RFC:** §7.3, §6.3
- **Kerjakan:**
- `RefundSession(ctx, sessionID, reason)`: lock wallet → transisi bersyarat ke
`REFUNDED` → `Credit` `GAME_SPEND_REFUND` dengan lot per alokasi asal
(`RefundExpiry`, `origin_lot_id`) → audit `SYSTEM`.
- Job tiap 1 menit, per batch, satu transaksi per session, sesuai tabel §7.3.
- Wiring start/stop di `app.go` seperti `WalletExpiryJob`.
- **Selesai jika:** test untuk:
- Game dinonaktifkan → session `STARTED` di-refund tanpa menunggu kedaluwarsa.
- Expired dengan `completion_failed_at` → refund. Expired tanpa → `EXPIRED`, saldo tetap.
- Lot refund punya `origin_lot_id` ke lot asal dan `expires_at` minimal 7 hari.
- Complete dan refund bersamaan pada session yang sama → tepat satu yang berhasil.
- Job dijalankan dua kali → tidak ada refund ganda.
- **Bergantung pada:** EG-204
### EG-206 · Customer: daftar game & riwayat session · S
- **RFC:** §11 (customer)
- **Kerjakan:** `GET /customer/enakgame/games`, `GET /sessions`, `GET /sessions/:id`. Hanya
game `ACTIVE` milik org customer. Rincian internal (`reward_breakdown` lengkap, angka
RNG) tidak ditampilkan.
- **Bergantung pada:** EG-105
---
## Fase 3 — Reward Engine & Complete
### EG-301 · `RewardCalculator` + empat tipe · M
- **RFC:** §8
- **Kerjakan:** interface `Validate` / `Calculate`, implementasi `FIXED`, `SCORE_BASED`,
`OUTCOME_BASED`, `PROBABILITY`. RNG lewat interface yang diisi `crypto/rand` di
production dan RNG tetap di test. Pembulatan ke bawah.
- **Selesai jika:** unit test: contoh tabel PRD §12 untuk setiap tipe; band skor
tumpang tindih / berlubang ditolak `Validate`; bobot `PROBABILITY` nol atau negatif
ditolak; distribusi `PROBABILITY` pada 100.000 undian berada dalam toleransi bobotnya.
- **Bergantung pada:** –
### EG-302 · Result Validator · S
- **RFC:** §7.2 langkah 5
- **Kerjakan:** validasi `result` terhadap `games.result_rules`: durasi minimal, skor
maksimum, skor per detik, outcome yang dikenal. Mengembalikan alasan, bukan error.
- **Selesai jika:** unit test per aturan, termasuk game tanpa `result_rules` (semua lolos).
- **Bergantung pada:** EG-105
### EG-303 · Complete Game · L
- **RFC:** §7.2, §7.3 (bagian `completion_failed_at`)
- **Kerjakan:**
- `POST /customer/enakgame/sessions/:id/complete`, langkah §7.2 dengan base reward dari
budget global saja. Event menyusul di EG-703, guard di EG-402.
- Satu baris `GAME_REWARD` + `game_session_rewards` per budget, key
`game-reward:{session}:{budget}`. Lot dengan `ComputeExpiry(CoinExpiry)`.
- Game tidak `ACTIVE` saat complete → `RefundSession(GAME_DEACTIVATED)`.
- Error non-bisnis → tulis `completion_failed_at` di transaksi terpisah
(`DetachTransaction`), lalu kembalikan 5xx.
- **Selesai jika:** test untuk:
- Complete dua kali → satu reward, response kedua sama dengan yang pertama.
- Hasil tidak valid → reward 0, `flagged`, session `COMPLETED`, tanpa refund.
- Session milik customer lain → 404. Session expired → ditolak.
- Error yang disuntikkan setelah kredit → rollback total, `completion_failed_at` terisi.
- Request yang membawa field `reward` diabaikan (P1).
- **Bergantung pada:** EG-204, EG-205, EG-301, EG-302
---
## Fase 4 — Economy Guard
### EG-401 · Counter reward + increment bersyarat · M
- **RFC:** §5.8, §9
- **Kerjakan:** migrasi `game_reward_counters`; repository `Consume(scope, id, day, x,
limit)` yang mengembalikan jumlah yang **benar-benar** diterima (dipotong ke sisa
limit, 0 bila habis). Hari dihitung di Asia/Jakarta.
- **Selesai jika:** test DB: 20 goroutine menambah counter yang sama dengan limit 100 →
total tepat 100, tidak pernah lewat.
- **Bergantung pada:** EG-106
### EG-402 · Guard di complete · S
- **RFC:** §9
- **Kerjakan:** panggil `Consume` untuk `USER` dan `GLOBAL` harian (dari setting) dan
`GAME` harian (dari `result_rules`) sebelum kredit. Batas yang memotong dicatat di
`reward_breakdown` dan dikembalikan di response.
- **Selesai jika:** reward 30 dengan sisa limit user 10 → kredit 10, breakdown menyebut
limit user. Sisa 0 → session `COMPLETED` dengan reward 0.
- **Bergantung pada:** EG-303, EG-401
---
## Fase 5 — Voucher & Redemption
### EG-501 · Migrasi voucher · M
- **RFC:** §5.7
- **Kerjakan:** `vouchers`, `voucher_codes`, `voucher_redemptions`,
`voucher_redemption_costs`.
- **Selesai jika:** up/down bersih. Ditolak database: voucher `STATIC` tanpa `stock`;
`EXTERNAL` tanpa `provider`; kode `REDEEMED` tanpa `redemption_id`; dua redemption dengan
`(customer_id, idempotency_key)` yang sama.
- **Bergantung pada:** –
### EG-502 · Admin voucher + impor kode · M
- **RFC:** §11 (admin)
- **Kerjakan:** CRUD `/marketing/enakgame/vouchers`, `POST /:id/codes` (CSV, kode duplikat
dilewati dan dilaporkan), `GET /:id/codes` dengan jumlah per status. `RequireLoyaltyManager`
dan audit.
- **Selesai jika:** impor CSV yang sama dua kali tidak menggandakan kode.
- **Bergantung pada:** EG-104, EG-501
### EG-503 · Aksi PIN `REDEEM` · S
- **RFC:** §7.4 langkah 2
- **Kerjakan:** `PinActionRedeem` di `customer_pin_processor.go`, ikut aturan kunci yang
ada (5 salah, 30 menit).
- **Bergantung pada:** –
### EG-504 · Redemption internal · L
- **RFC:** §7.4
- **Kerjakan:** `POST /customer/enakgame/vouchers/:id/redeem` untuk `STATIC` dan
`CODE_POOL`, satu transaksi, langkah persis §7.4. Debit `REWARD_REDEEM` dengan key
`redeem:{redemption}`.
- **Selesai jika:** test untuk:
- Stok tersisa 1, dua customer menukar bersamaan → tepat satu berhasil.
- `CODE_POOL`: dua redemption bersamaan mendapat kode berbeda (`SKIP LOCKED`).
- Key yang sama dua kali → satu debit, satu kode.
- Point kurang, PIN salah, `max_per_customer` tercapai, voucher di luar masa berlaku →
ditolak tanpa apa pun tercatat.
- **Bergantung pada:** EG-103, EG-501, EG-502, EG-503
### EG-505 · Atribusi realized cost · M
- **RFC:** §7.6, D5
- **Kerjakan:** query rekursif §7.6 di dalam transaksi redemption, pembagian
`face_value` proporsional dengan sisa pembulatan ke bagian terbesar, insert
`voucher_redemption_costs`.
- **Selesai jika:** test DB untuk rantai: `GAME_REWARD` → exchange → redeem;
`GAME_REWARD` → transfer → exchange → redeem; campuran `GAME_REWARD` + `EARN` (contoh
§7.6: Rp7.500 ke budget, Rp2.500 tanpa budget); `Σ cost = face_value` untuk kombinasi
angka yang tidak habis dibagi.
- **Bergantung pada:** EG-303, EG-504
### EG-506 · Customer: katalog & voucher saya · S
- **RFC:** §11 (customer)
- **Kerjakan:** `GET /customer/enakgame/vouchers` (stok tersedia, tanpa membocorkan
jumlah kode per status) dan `GET /redemptions` (dengan kode voucher).
- **Bergantung pada:** EG-504
### EG-507 · `VoucherCodeExpiryJob` · S
- **RFC:** §12
- **Kerjakan:** `AVAILABLE → EXPIRED` untuk kode lewat `expires_at`, per batch.
- **Bergantung pada:** EG-501
---
## Fase 6 — Budget Metrics
### EG-601 · Perhitungan metrik & status budget · M
- **RFC:** §10
- **Kerjakan:** realized cost, Coin issued, remaining, utilization, forecast, exposure,
dan status per budget. Global dibatasi periode; event tanpa batas waktu. Threshold dari
`game_budgets.thresholds`.
- **Selesai jika:** test dengan data tetap menghasilkan angka contoh PRD §30 (budget
Rp100M, realized Rp60M, sisa 10 hari); Point dari `EARN` tidak ikut dihitung.
- **Bergantung pada:** EG-505
### EG-602 · Endpoint metrik budget · S
- **Kerjakan:** `GET /marketing/enakgame/budgets/:id/metrics`.
- **Bergantung pada:** EG-601
---
## Fase 7 — Event / Campaign
### EG-701 · Migrasi event · S
- **RFC:** §5.5
- **Kerjakan:** `game_events`, `game_event_games`.
- **Selesai jika:** up/down bersih; event tanpa `budget_id` atau dengan
`end_at ≤ start_at` ditolak.
- **Bergantung pada:** EG-102
### EG-702 · Admin event · M
- **RFC:** §5.5, §11
- **Kerjakan:** CRUD + status. Budget yang dipasang harus milik org yang sama dan ber-scope
`EVENT`. Audit.
- **Bergantung pada:** EG-203, EG-701
### EG-703 · Modifier event di complete · M
- **RFC:** §7.2 langkah 7, §8 (modifier), D6
- **Kerjakan:** cari event aktif untuk game, hitung tambahan dari multiplier dan bonus,
pisahkan menjadi komponen per budget, terapkan `reward_limit` dan `user_daily_limit`
event lewat counter `EVENT`, cap `max_reward`. Aturan tumpuk mengikuti default PRD §16.
- **Selesai jika:** contoh PRD §7 (10 Coin + Ramadan 2x) menghasilkan dua baris
`GAME_REWARD`: 10 ke budget global, 10 ke budget event; event di luar jam aktif tidak
berpengaruh.
- **Bergantung pada:** EG-402, EG-702
### EG-704 · Event di daftar game customer · S
- **Kerjakan:** `GET /customer/enakgame/games` menyertakan event aktif per game (nama,
banner, multiplier/bonus, berakhir kapan).
- **Bergantung pada:** EG-206, EG-702
---
## Fase 8 — Voucher Eksternal
Terblokir sampai provider pertama ditentukan: API-nya menentukan bentuk adapter dan
apakah provider menerima idempotency key (RFC §18).
### EG-801 · Interface provider + adapter pertama · M
- **Kerjakan:** `VoucherProvider` (`Issue(ctx, redemptionID, voucher)`,
`Lookup(ctx, redemptionID)`), adapter provider pertama, dan klasifikasi hasil: sukses,
gagal pasti, tidak jelas.
- **Bergantung pada:** EG-504
### EG-802 · Redemption dua tahap · M
- **RFC:** §7.5
- **Kerjakan:** jalur `EXTERNAL` di endpoint redeem: transaksi 1 (`PENDING` + debit),
panggil provider di luar transaksi, transaksi 2 (`COMPLETED` + atribusi, atau `FAILED` +
`REWARD_REDEEM_REFUND`).
- **Selesai jika:** test dengan provider palsu untuk ketiga hasil; timeout meninggalkan
`PENDING` dengan Point terpotong dan response "sedang diproses".
- **Bergantung pada:** EG-505, EG-801
### EG-803 · `VoucherRedemptionRecoveryJob` · M
- **RFC:** §7.5 langkah 4, §12
- **Kerjakan:** ambil `PENDING` lewat N menit, `Lookup` ke provider, selesaikan transaksi 2.
Setelah batas percobaan → refund + `FAILED`.
- **Selesai jika:** tidak ada redemption yang tertinggal `PENDING` melewati batas
percobaan; job berjalan dua kali tidak merefund dua kali.
- **Bergantung pada:** EG-802
---
## Fase 9 — Budget Controller & Analytics
### EG-901 · Rekomendasi multiplier · M
- **RFC:** §10 (rekomendasi), PRD §30–§31
- **Kerjakan:** hitung multiplier yang membuat forecast = budget, dibatasi step maksimum dan
min/max multiplier. `GET /budgets/:id/recommendation`.
- **Bergantung pada:** EG-601
### EG-902 · Terima rekomendasi · S
- **Kerjakan:** admin menerima rekomendasi → reward config versi baru dibuat dari config
aktif dengan angka yang disesuaikan, tercatat di audit dengan `source = budget_controller`.
- **Bergantung pada:** EG-202, EG-901
### EG-903 · Analytics · M
- **RFC:** PRD §36
- **Kerjakan:** endpoint dashboard game (plays, completed, rata-rata skor & reward, Coin
issued, entry cost dibayar, Coin di-refund) dan economy (Coin generated / spent /
expired / outstanding, Point redeemed), per org dan rentang tanggal.
- **Bergantung pada:** EG-303, EG-505
---
## Fase 10 — Spin & Bersih-bersih
### EG-1001 · Spin sebagai game EnakGame · S
- **RFC:** §14
- **Kerjakan:** seeder / langkah admin untuk game spin baru dengan config `PROBABILITY` dan
reward Coin. Client Phaser di luar repo ini.
- **Selesai jika:** spin bisa dimainkan lewat `/customer/enakgame/sessions` end-to-end.
- **Bergantung pada:** EG-402
### EG-1002 · Hapus alur game lama · M
- **RFC:** §14, §15
- **Kerjakan:** hapus route `POST /customer/spin`, `GET /customer/games`,
`GET /customer/ferris-wheel`, admin `/marketing/games`, `/marketing/game-prizes`, dan
`/marketing/rewards`, beserta handler, service, processor, dan test-nya
(`GamePlayProcessor`, `SpinGameService`, dan seterusnya). **Tabel `games`,
`game_prizes`, `game_plays` tetap ada** untuk riwayat ledger.
- **Selesai jika:** build dan test lulus; aplikasi customer sudah tidak memanggil endpoint
lama (konfirmasi tim aplikasi).
- **Bergantung pada:** EG-1001. Tabel `rewards` tidak punya data produksi (dikonfirmasi
2026-10-07), jadi tidak ada yang dipindah ke `vouchers`.
- **Catatan:** endpoint customer lama boleh dimatikan **lebih awal**, kapan pun, untuk
menutup temuan RFC §15 nomor 1 dan 2.
### EG-1003 · Bersihkan kolom lama `games` · S
- **Kerjakan:** drop `games.is_active` dan berhenti membaca `metadata.coin_cost`.
- **Bergantung pada:** EG-1002
---
## Yang Bisa Dimulai Sekarang
Bisa dikerjakan paralel tanpa menunggu apa pun:
- **EG-001** (matikan bayar dengan EnakPoint) → **EG-002**
- **EG-101** → **EG-102** → **EG-105** (skema & repository)
- **EG-103** (tipe ledger)
- **EG-104** (audit), **EG-106** (setting limit)
- **EG-301** (Reward Engine, kode murni tanpa database)
- **EG-501**, **EG-503** (fondasi voucher)
Jalur kritis: **EG-105 → EG-204 → EG-303 → EG-402**. Hampir semua fase setelahnya
menunggu complete game selesai.
+7
View File
@@ -3,6 +3,13 @@
**Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md)
**Tanggal:** 2026-09-29
> **Catatan 2026-10-07:** fase 3 (Pembayaran EnakPoint) dihapus sesuai
> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher,
> bukan alat bayar. Payment method EnakPoint, kode bayar, bayar di kasir & app, refund
> EnakPoint, dan EnakPoint di laporan (PC-303 – PC-308) sudah dihapus dari backend
> (migrasi `000102`). PIN customer (PC-301) dan pengaturan loyalitas organisasi (PC-302)
> tetap dipakai. Sisa dokumen ini dibiarkan sebagai riwayat.
Setiap task menyebut bagian PRD yang dikerjakan, lapisan kode yang disentuh, task yang
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
**L** 4–5 hari.
-2
View File
@@ -76,7 +76,6 @@ require (
github.com/subosito/gotenv v1.4.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/zeebo/errs v1.4.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
@@ -108,7 +107,6 @@ require (
require (
firebase.google.com/go/v4 v4.19.0
github.com/alicebob/miniredis/v2 v2.39.0
github.com/aws/aws-sdk-go v1.55.7
github.com/boombuler/barcode v1.1.0
github.com/golang-jwt/jwt/v5 v5.2.3
-4
View File
@@ -74,8 +74,6 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
@@ -351,8 +349,6 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+2 -16
View File
@@ -161,8 +161,6 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.walletValidator,
services.loyaltySettingsService,
services.customerPinService,
services.pointPaymentService,
services.customerOrderPaymentService,
services.customerWalletService,
services.customerDeviceService,
services.customerOutletService,
@@ -399,8 +397,6 @@ type processors struct {
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
earningProcessor *processor.EarningProcessor
customerPinProcessor *processor.CustomerPinProcessor
paymentCodeProcessor *processor.PaymentCodeProcessor
pointPaymentProcessor *processor.PointPaymentProcessor
walletExchangeProcessor *processor.WalletExchangeProcessor
walletTransferProcessor *processor.WalletTransferProcessor
walletTraceProcessor *processor.WalletTraceProcessor
@@ -418,7 +414,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
// Customer PIN (docs/prd-point-coin.md F11)
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor)
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
@@ -426,9 +421,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
orderProcessor.SetLoyalty(earningProcessor)
// Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9)
pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
// Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4)
walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
// Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5)
@@ -444,7 +436,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
orderProcessor: orderProcessor,
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
@@ -482,8 +474,6 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
loyaltySettingsProcessor: loyaltySettingsProcessor,
earningProcessor: earningProcessor,
customerPinProcessor: customerPinProcessor,
paymentCodeProcessor: paymentCodeProcessor,
pointPaymentProcessor: pointPaymentProcessor,
walletExchangeProcessor: walletExchangeProcessor,
walletTransferProcessor: walletTransferProcessor,
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
@@ -536,8 +526,6 @@ type services struct {
walletAdminService *service.WalletAdminServiceImpl
loyaltySettingsService *service.LoyaltySettingsServiceImpl
customerPinService *service.CustomerPinServiceImpl
pointPaymentService *service.PointPaymentServiceImpl
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
customerWalletService *service.CustomerWalletServiceImpl
customerDeviceService *service.CustomerDeviceServiceImpl
customerOutletService *service.CustomerOutletServiceImpl
@@ -625,9 +613,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor),
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor),
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor),
+1 -8
View File
@@ -4,7 +4,7 @@ package constants
// outlet_settings and organization keys in organization_settings. A key that was
// never set takes the default in the PRD.
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
// Per outlet (F1): what an order earns.
const (
LoyaltyPointEnabledKey = "loyalty.point.enabled"
LoyaltyPointEarnModeKey = "loyalty.point.earn_mode"
@@ -21,10 +21,6 @@ const (
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order"
LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment"
LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points"
LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent"
)
// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and
@@ -81,9 +77,6 @@ const (
LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount
LoyaltyEarnPercentDefault = float64(1)
LoyaltyMinPaymentPointsDefault = int64(1)
LoyaltyMaxPaymentPercentDefault = int64(100)
LoyaltyPointValueDefault = int64(1)
LoyaltyExchangeAmountDefault = int64(1)
-3
View File
@@ -8,9 +8,6 @@ const (
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
PaymentMethodTypeQR PaymentMethodType = "qr"
PaymentMethodTypeEDC PaymentMethodType = "edc"
// Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method
// type until that phase ships.
PaymentMethodTypePoint PaymentMethodType = "point"
)
type PaymentStatus string
+11 -14
View File
@@ -14,26 +14,23 @@ func IsValidWalletCurrency(currency string) bool {
// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which
// way it moves the balance, and which reference it must carry.
const (
WalletTxTypeEarn = "EARN"
WalletTxTypeEarnReversal = "EARN_REVERSAL"
WalletTxTypePayment = "PAYMENT"
WalletTxTypePaymentRefund = "PAYMENT_REFUND"
WalletTxTypeExchangeOut = "EXCHANGE_OUT"
WalletTxTypeExchangeIn = "EXCHANGE_IN"
WalletTxTypeTransferOut = "TRANSFER_OUT"
WalletTxTypeTransferIn = "TRANSFER_IN"
WalletTxTypeGameSpend = "GAME_SPEND"
WalletTxTypeExpire = "EXPIRE"
WalletTxTypeAdjustment = "ADJUSTMENT"
WalletTxTypeMigration = "MIGRATION"
WalletTxTypeRewardRedeem = "REWARD_REDEEM"
WalletTxTypeEarn = "EARN"
WalletTxTypeEarnReversal = "EARN_REVERSAL"
WalletTxTypeExchangeOut = "EXCHANGE_OUT"
WalletTxTypeExchangeIn = "EXCHANGE_IN"
WalletTxTypeTransferOut = "TRANSFER_OUT"
WalletTxTypeTransferIn = "TRANSFER_IN"
WalletTxTypeGameSpend = "GAME_SPEND"
WalletTxTypeExpire = "EXPIRE"
WalletTxTypeAdjustment = "ADJUSTMENT"
WalletTxTypeMigration = "MIGRATION"
WalletTxTypeRewardRedeem = "REWARD_REDEEM"
)
// What a ledger row's reference_id points at: where the value came from for a
// credit, or where it went for a debit.
const (
WalletRefTypeOrder = "ORDER"
WalletRefTypePayment = "PAYMENT"
WalletRefTypeWalletTx = "WALLET_TX"
WalletRefTypeGamePlay = "GAME_PLAY"
WalletRefTypeLot = "LOT"
-6
View File
@@ -28,11 +28,7 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct {
// Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9).
TotalAmount float64 `json:"total_amount"`
PointAmount float64 `json:"point_amount"`
PointsUsed int64 `json:"points_used"`
TotalWithPoints float64 `json:"total_with_points"`
TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"`
@@ -46,8 +42,6 @@ type PaymentMethodAnalyticsData struct {
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
Percentage float64 `json:"percentage"`
PointsUsed int64 `json:"points_used"`
CountsAsCashIn bool `json:"counts_as_cash_in"`
}
type SalesAnalyticsRequest struct {
@@ -26,14 +26,3 @@ type ResetCustomerPinRequest = CreateCustomerPinRequest
type RemoveCustomerPinRequest struct {
Reason string `json:"reason" binding:"required"`
}
// IssuePaymentCodeRequest is POST /customer/wallet/payment-code.
type IssuePaymentCodeRequest struct {
Pin string `json:"pin" binding:"required"`
}
// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points.
type PayWithPointsRequest struct {
Points int64 `json:"points" binding:"required,min=1"`
Pin string `json:"pin" binding:"required"`
}
+16 -24
View File
@@ -186,13 +186,9 @@ type SetOrderCustomerResponse struct {
}
type CreatePaymentRequest struct {
OrderID uuid.UUID `json:"order_id" validate:"required"`
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
// For the EnakPoint method: how many to use and the code the customer shows. The
// amount is then computed from them.
Points *int64 `json:"points,omitempty"`
PaymentCode *string `json:"payment_code,omitempty"`
Amount float64 `json:"amount" validate:"min=0"`
OrderID uuid.UUID `json:"order_id" validate:"required"`
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,min=0"`
TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"`
SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"`
SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"`
@@ -208,23 +204,19 @@ type CreatePaymentOrderItemRequest struct {
}
type PaymentResponse struct {
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
PaymentMethodID uuid.UUID `json:"payment_method_id"`
PaymentMethodName string `json:"payment_method_name"`
PaymentMethodType string `json:"payment_method_type"`
Amount float64 `json:"amount"`
Status string `json:"status"`
TransactionID *string `json:"transaction_id,omitempty"`
SplitNumber int `json:"split_number"`
SplitTotal int `json:"split_total"`
SplitType *string `json:"split_type,omitempty"`
SplitDescription *string `json:"split_description,omitempty"`
RefundAmount float64 `json:"refund_amount"`
// Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the
// receipt (docs/prd-point-coin.md F9).
PointsUsed *int64 `json:"points_used,omitempty"`
PointValue *float64 `json:"point_value,omitempty"`
ID uuid.UUID `json:"id"`
OrderID uuid.UUID `json:"order_id"`
PaymentMethodID uuid.UUID `json:"payment_method_id"`
PaymentMethodName string `json:"payment_method_name"`
PaymentMethodType string `json:"payment_method_type"`
Amount float64 `json:"amount"`
Status string `json:"status"`
TransactionID *string `json:"transaction_id,omitempty"`
SplitNumber int `json:"split_number"`
SplitTotal int `json:"split_total"`
SplitType *string `json:"split_type,omitempty"`
SplitDescription *string `json:"split_description,omitempty"`
RefundAmount float64 `json:"refund_amount"`
RefundReason *string `json:"refund_reason,omitempty"`
RefundedAt *time.Time `json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
+6 -8
View File
@@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct {
type UpdatePaymentMethodRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
Configuration map[string]interface{} `json:"configuration,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
@@ -38,13 +38,11 @@ type PaymentMethodResponse struct {
type ListPaymentMethodsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
// When set, EnakPoint is left out unless the outlet accepts it (F9).
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
IsActive *bool `json:"is_active,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
IsActive *bool `json:"is_active,omitempty"`
Search string `json:"search,omitempty"`
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
}
type ListPaymentMethodsResponse struct {
-2
View File
@@ -14,8 +14,6 @@ type PaymentMethodAnalytics struct {
TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
// EnakPoint used, for the EnakPoint method.
PointsUsed int64 `json:"points_used"`
}
// SalesAnalytics represents sales analytics data
+7 -14
View File
@@ -13,16 +13,13 @@ const (
PaymentMethodTypeCash PaymentMethodType = "cash"
PaymentMethodTypeCard PaymentMethodType = "card"
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
// The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per
// organization; it cannot be created, deleted or retyped through the API.
PaymentMethodTypePoint PaymentMethodType = "point"
)
type PaymentMethod struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
Processor *string `gorm:"size:100" json:"processor"`
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
IsActive bool `gorm:"default:true" json:"is_active"`
@@ -72,16 +69,12 @@ type Payment struct {
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
// one then, frozen so a refund returns exactly what was used.
PointsUsed *int64 `json:"points_used,omitempty"`
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
@@ -1,35 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points
// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged.
type CustomerOrderPaymentHandler struct {
payments service.CustomerOrderPaymentService
}
func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler {
return &CustomerOrderPaymentHandler{payments: payments}
}
func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints")
if !ok {
return
}
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints")
if !ok {
return
}
var req contract.PayWithPointsRequest
if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") {
return
}
util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints")
}
-13
View File
@@ -136,16 +136,3 @@ func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
}
// IssuePaymentCode is POST /customer/wallet/payment-code.
func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) {
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode")
if !ok {
return
}
var req contract.IssuePaymentCodeRequest
if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") {
return
}
util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode")
}
+4 -4
View File
@@ -82,9 +82,9 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
return err
}
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
Description: "Bayar #ORD-2"}}); err != nil {
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem,
Amount: 50, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: payment, OutletID: &outlet,
Description: "Tukar voucher"}}); err != nil {
return err
}
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
@@ -162,7 +162,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
assert.Contains(t, tx, "source", tx["type"])
assert.NotContains(t, tx, "destination")
} else {
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
assert.Equal(t, map[string]any{"type": "REWARD_REDEMPTION", "id": payment.String()}, tx["destination"])
assert.NotContains(t, tx, "source")
}
}
+11 -11
View File
@@ -93,7 +93,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
got := data(body)
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
assert.NotContains(t, got, "point_payment", "EnakPoint cannot pay (docs/enakgame-prd.md §3.2)")
assert.EqualValues(t, 1, got["point_value"])
assert.EqualValues(t, 1, got["point_cashback_percent"])
@@ -133,16 +133,16 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
for name, bad := range map[string]string{
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
"negative earn_value": `{"coin": {"earn_value": -1}}`,
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
"negative min_order": `{"point": {"min_order_amount": -5}}`,
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
"unknown field": `{"point": {"earn_per_amout": 50}}`,
"wrong type": `{"point": {"enabled": "yes"}}`,
"not json": `enabled=true`,
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
"negative earn_value": `{"coin": {"earn_value": -1}}`,
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
"negative min_order": `{"point": {"min_order_amount": -5}}`,
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
"point_payment removed": `{"point_payment": {"accept_payment": true}}`,
"unknown field": `{"point": {"earn_per_amout": 50}}`,
"wrong type": `{"point": {"enabled": "yes"}}`,
"not json": `enabled=true`,
} {
status, _ = call(http.MethodPut, "/manager"+path, bad)
assert.Equal(t, http.StatusBadRequest, status, name)
-13
View File
@@ -1,11 +1,8 @@
package handler
import (
"errors"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/transformer"
"apskel-pos-be/internal/util"
@@ -205,11 +202,6 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
}
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
// Refusing to hand EnakPoint back as cash is a bad request, not a server fault.
if errors.Is(err, processor.ErrPointPaymentRejected) {
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
return
}
@@ -233,11 +225,6 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) {
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq)
if err != nil {
// A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault.
if errors.Is(err, processor.ErrPointPaymentRejected) {
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
return
}
@@ -113,16 +113,6 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) {
req.OrganizationID = &contextInfo.OrganizationID
// At the cashier, EnakPoint is listed only where the outlet accepts it (F9).
if outletStr := c.Query("outlet_id"); outletStr != "" {
if outletID, err := uuid.Parse(outletStr); err == nil {
req.OutletID = &outletID
}
} else if contextInfo.OutletID != uuid.Nil {
outletID := contextInfo.OutletID
req.OutletID = &outletID
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
-28
View File
@@ -1,28 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
)
// PointPaymentHandler serves GET /orders/:id/point-payment/preview
// (docs/prd-point-coin.md F9).
type PointPaymentHandler struct {
pointPaymentService service.PointPaymentService
}
func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler {
return &PointPaymentHandler{pointPaymentService: pointPaymentService}
}
func (h *PointPaymentHandler) Preview(c *gin.Context) {
orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview")
if !ok {
return
}
ctx := c.Request.Context()
util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview")
}
-2
View File
@@ -189,8 +189,6 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse
SplitType: (*string)(payment.SplitType),
SplitDescription: payment.SplitDescription,
RefundAmount: payment.RefundAmount,
PointsUsed: payment.PointsUsed,
PointValue: payment.PointValue,
RefundReason: payment.RefundReason,
RefundedAt: payment.RefundedAt,
RefundedBy: payment.RefundedBy,
@@ -134,7 +134,6 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest)
return &models.ListPaymentMethodsRequest{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
Type: paymentMethodType,
IsActive: req.IsActive,
Search: req.Search,
+2 -13
View File
@@ -33,14 +33,7 @@ type PaymentMethodAnalyticsResponse struct {
// PaymentMethodSummary represents the summary of payment method analytics
type PaymentMethodSummary struct {
// Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9,
// K7) and is reported apart; its accounting treatment waits on note N2.
TotalAmount float64 `json:"total_amount"`
// Rupiah paid with EnakPoint, and how many EnakPoint that was.
PointAmount float64 `json:"point_amount"`
PointsUsed int64 `json:"points_used"`
// TotalAmount plus PointAmount: the value of the orders paid.
TotalWithPoints float64 `json:"total_with_points"`
TotalAmount float64 `json:"total_amount"`
TotalOrders int64 `json:"total_orders"`
TotalPayments int64 `json:"total_payments"`
AverageOrderValue float64 `json:"average_order_value"`
@@ -54,11 +47,7 @@ type PaymentMethodAnalyticsData struct {
TotalAmount float64 `json:"total_amount"`
OrderCount int64 `json:"order_count"`
PaymentCount int64 `json:"payment_count"`
// Share of the money received; 0 for EnakPoint, which is not money in.
Percentage float64 `json:"percentage"`
PointsUsed int64 `json:"points_used"`
// False for EnakPoint.
CountsAsCashIn bool `json:"counts_as_cash_in"`
Percentage float64 `json:"percentage"`
}
// SalesAnalyticsRequest represents the request for sales analytics
+1 -4
View File
@@ -61,10 +61,7 @@ type CustomerOrderPayment struct {
Amount float64 `json:"amount"`
Status string `json:"status"`
RefundAmount float64 `json:"refund_amount"`
// Set for a payment with EnakPoint.
PointsUsed *int64 `json:"points_used,omitempty"`
PointValue *float64 `json:"point_value,omitempty"`
CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"`
}
// ListCustomerOrdersQuery is GET /customer/orders.
-2
View File
@@ -8,8 +8,6 @@ type CustomerOutlet struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Address *string `json:"address"`
// The cashier accepts EnakPoint as payment here.
AcceptsPointPayment bool `json:"accepts_point_payment"`
// Orders here earn EnakPoint / EnakCoin.
EarnsPoints bool `json:"earns_points"`
EarnsCoins bool `json:"earns_coins"`
-8
View File
@@ -37,11 +37,3 @@ type CustomerPinRequestInfo struct {
IPAddress string
UserAgent string
}
// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the
// customer shows the cashier, as digits or as a QR of QRPayload.
type PaymentCode struct {
Code string `json:"code"`
QRPayload string `json:"qr_payload"`
ExpiresAt time.Time `json:"expires_at"`
}
-9
View File
@@ -13,8 +13,6 @@ import (
type OutletLoyaltySettings struct {
Point LoyaltyEarnSettings `json:"point"`
Coin LoyaltyEarnSettings `json:"coin"`
// Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart.
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
}
// LoyaltyEarnSettings is how much of one currency an order earns, nothing below
@@ -36,13 +34,6 @@ type LoyaltyEarnSettings struct {
MaxPerOrder *int64 `json:"max_per_order"`
}
type LoyaltyPointPaymentSettings struct {
AcceptPayment bool `json:"accept_payment"`
MinPaymentPoints int64 `json:"min_payment_points"`
// Largest share of the order total, 0–100, that EnakPoint may pay.
MaxPaymentPercent int64 `json:"max_payment_percent"`
}
// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an
// organization (docs/prd-point-coin.md F2, F12).
type OrganizationLoyaltySettings struct {
+2 -7
View File
@@ -28,10 +28,8 @@ type Payment struct {
}
type CreatePaymentRequest struct {
OrderID uuid.UUID `validate:"required"`
PaymentMethodID uuid.UUID `validate:"required"`
Points *int64
PaymentCode *string
OrderID uuid.UUID `validate:"required"`
PaymentMethodID uuid.UUID `validate:"required"`
Amount float64 `validate:"required,min=0"`
TransactionID *string `validate:"omitempty"`
SplitNumber int `validate:"omitempty,min=1"`
@@ -62,9 +60,6 @@ type PaymentResponse struct {
SplitType *string
SplitDescription *string
RefundAmount float64
// Set for a payment with EnakPoint.
PointsUsed *int64
PointValue *float64
RefundReason *string
RefundedAt *time.Time
RefundedBy *uuid.UUID
+5 -7
View File
@@ -51,13 +51,11 @@ type PaymentMethodResponse struct {
type ListPaymentMethodsRequest struct {
OrganizationID *uuid.UUID
// When set, EnakPoint is left out unless the outlet accepts it (F9).
OutletID *uuid.UUID
Type *constants.PaymentMethodType
IsActive *bool
Search string
Page int `validate:"min=1"`
Limit int `validate:"min=1,max=100"`
Type *constants.PaymentMethodType
IsActive *bool
Search string
Page int `validate:"min=1"`
Limit int `validate:"min=1,max=100"`
}
type ListPaymentMethodsResponse struct {
-19
View File
@@ -139,25 +139,6 @@ type AdminWalletAdjustmentResult struct {
Replayed bool `json:"replayed"`
}
// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md
// F9): whether the order can be paid with EnakPoint and at most how much, for the
// cashier's "use maximum" button.
type PointPaymentPreview struct {
OrderID uuid.UUID `json:"order_id"`
CustomerID *uuid.UUID `json:"customer_id"`
Eligible bool `json:"eligible"`
// Why not, when not eligible.
Reason string `json:"reason,omitempty"`
PointBalance int64 `json:"point_balance"`
PointValue int64 `json:"point_value"`
RemainingAmount float64 `json:"remaining_amount"`
MinPaymentPoints int64 `json:"min_payment_points"`
MaxPaymentPercent int64 `json:"max_payment_percent"`
MaxPoints int64 `json:"max_points"`
// Rupiah covered by MaxPoints.
MaxAmount int64 `json:"max_amount"`
}
// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md
// F6): everything that will expire, per currency and day, soonest first.
type CustomerWalletExpiringList struct {
+7 -22
View File
@@ -63,37 +63,27 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return nil, fmt.Errorf("failed to get payment method analytics: %w", err)
}
// EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own
// method but left out of the money received and of the shares. How it is booked
// waits on note N2.
var cashAmount, pointAmount float64
var pointsUsed int64
var totalAmount float64
var totalOrders int64
var totalPayments int64
for _, data := range analyticsData {
if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) {
pointAmount += data.TotalAmount
pointsUsed += data.PointsUsed
} else {
cashAmount += data.TotalAmount
}
totalAmount += data.TotalAmount
totalOrders += data.OrderCount
totalPayments += data.PaymentCount
}
// The value of an order includes what EnakPoint paid, so the average does too.
var averageOrderValue float64
if totalOrders > 0 {
averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders)
averageOrderValue = totalAmount / float64(totalOrders)
}
// Calculate percentages
var resultData []models.PaymentMethodAnalyticsData
for _, data := range analyticsData {
cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint)
var percentage float64
if cashIn && cashAmount > 0 {
percentage = (data.TotalAmount / cashAmount) * 100
if totalAmount > 0 {
percentage = (data.TotalAmount / totalAmount) * 100
}
resultData = append(resultData, models.PaymentMethodAnalyticsData{
@@ -104,16 +94,11 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
OrderCount: data.OrderCount,
PaymentCount: data.PaymentCount,
Percentage: percentage,
PointsUsed: data.PointsUsed,
CountsAsCashIn: cashIn,
})
}
summary := models.PaymentMethodSummary{
TotalAmount: cashAmount,
PointAmount: pointAmount,
PointsUsed: pointsUsed,
TotalWithPoints: cashAmount + pointAmount,
TotalAmount: totalAmount,
TotalOrders: totalOrders,
TotalPayments: totalPayments,
AverageOrderValue: averageOrderValue,
@@ -10,7 +10,6 @@ import (
"apskel-pos-be/internal/models"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -574,38 +573,6 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t
require.Len(t, result.DailyTransactions, 2)
}
// EnakPoint is listed as its own method but is not money in (F9, K7).
func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{
{PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2},
{PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1},
{PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000},
}}
p := NewAnalyticsProcessorImpl(repo, nil)
got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{
OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(),
})
require.NoError(t, err)
assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out")
assert.Equal(t, 30000.0, got.Summary.PointAmount)
assert.Equal(t, int64(30000), got.Summary.PointsUsed)
assert.Equal(t, 120000.0, got.Summary.TotalWithPoints)
assert.Equal(t, int64(4), got.Summary.TotalOrders)
assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid")
byType := map[string]models.PaymentMethodAnalyticsData{}
for _, d := range got.Data {
byType[d.PaymentMethodType] = d
}
assert.True(t, byType["cash"].CountsAsCashIn)
assert.False(t, byType["point"].CountsAsCashIn)
assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received")
assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01)
assert.Zero(t, byType["point"].Percentage)
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
}
// A parent category with its own owner fee percent moves the owner limit away from the
// default share, and the team limit shrinks by the same amount.
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
@@ -129,8 +129,6 @@ func (p *CustomerOrderProcessor) Detail(ctx context.Context, customerID, orderID
Amount: pay.Amount,
Status: pay.Status,
RefundAmount: pay.RefundAmount,
PointsUsed: pay.PointsUsed,
PointValue: pay.PointValue,
CreatedAt: pay.CreatedAt,
})
}
@@ -110,7 +110,6 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
repo, customer, mine, _ := newCustomerOrderTest()
variant, unit := "Large", "ons"
weight := 4.2
points, value := int64(12500), 1.0
repo.items = map[uuid.UUID][]repository.CustomerOrderItemRow{
mine: {
{ProductName: "Kopi Susu", VariantName: &variant, Quantity: 2, UnitPrice: 25000, TotalPrice: 50000, Status: "completed"},
@@ -119,7 +118,7 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
}
repo.payments = map[uuid.UUID][]repository.CustomerOrderPaymentRow{
mine: {
{MethodName: "EnakPoint", MethodType: "point", Amount: 12500, Status: "completed", PointsUsed: &points, PointValue: &value},
{MethodName: "Card", MethodType: "card", Amount: 12500, Status: "completed"},
{MethodName: "Cash", MethodType: "cash", Amount: 86500, Status: "completed"},
},
}
@@ -135,7 +134,8 @@ func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
assert.Equal(t, []map[string]interface{}{}, got.Items[0].Modifiers, "no modifiers is an empty list, not null")
assert.Equal(t, 4.2, *got.Items[1].Weight)
require.Len(t, got.Payments, 2)
assert.Equal(t, int64(12500), *got.Payments[0].PointsUsed)
assert.Equal(t, "Card", got.Payments[0].MethodName)
assert.Equal(t, float64(12500), got.Payments[0].Amount)
}
func TestCustomerOrders_AnotherCustomersOrderIsNotFound(t *testing.T) {
@@ -37,12 +37,11 @@ func (p *CustomerOutletProcessor) List(ctx context.Context, customerID uuid.UUID
return nil, err
}
list = append(list, models.CustomerOutlet{
ID: o.ID,
Name: o.Name,
Address: o.Address,
AcceptsPointPayment: settings.PointPayment.AcceptPayment,
EarnsPoints: settings.Point.Enabled,
EarnsCoins: settings.Coin.Enabled,
ID: o.ID,
Name: o.Name,
Address: o.Address,
EarnsPoints: settings.Point.Enabled,
EarnsCoins: settings.Coin.Enabled,
})
}
return list, nil
@@ -48,17 +48,14 @@ func TestCustomerOutlets_ListsTheCustomersOrganizationWithLoyaltyFlags(t *testin
},
}
settings := outletSettingsFake{
kemang: {
Point: models.LoyaltyEarnSettings{Enabled: true},
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: true},
},
kemang: {Point: models.LoyaltyEarnSettings{Enabled: true}},
}
got, err := NewCustomerOutletProcessor(repo, settings).List(context.Background(), customer)
require.NoError(t, err)
assert.Equal(t, []models.CustomerOutlet{
{ID: blokm, Name: "Blok M"},
{ID: kemang, Name: "Kemang", Address: &addr, AcceptsPointPayment: true, EarnsPoints: true},
{ID: kemang, Name: "Kemang", Address: &addr, EarnsPoints: true},
}, got)
}
@@ -47,6 +47,10 @@ const (
PinActionTransfer PinAction = "TRANSFER"
)
type pinVerifier interface {
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
}
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
const (
PinErrNotSet = "PIN_NOT_SET"
+6 -7
View File
@@ -21,7 +21,7 @@ type EarningLine struct {
// EarningResult is what an order earns.
type EarningResult struct {
// subtotal − discount − the part paid with EnakPoint, in rupiah, never negative.
// subtotal − discount, in rupiah, never negative.
// Tax and anything else added on top of the subtotal are not part of it (Q1).
Basis float64
Point EarningLine
@@ -49,17 +49,16 @@ func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1:
//
// basis = subtotal − discount_amount − paid with EnakPoint
// basis = subtotal − discount_amount
// amount = 0 if basis < min_order_amount
// amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode
// amount = floor(basis × earn_percent / 100) in PERCENTAGE mode
// amount = min(amount, max_per_order) if max_per_order is set
//
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
// floor never lands one short on a value like 87500.00 that float64 cannot hold
// exactly. It has no side effects.
func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult {
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount)
// Money is handled in whole cents so floor never lands one short on a value like
// 87500.00 that float64 cannot hold exactly. It has no side effects.
func CalculateEarning(order *entities.Order, settings models.OutletLoyaltySettings) EarningResult {
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount)
if basisCents < 0 {
basisCents = 0
}
+28 -36
View File
@@ -22,35 +22,29 @@ func TestCalculateEarning_PRDExample(t *testing.T) {
// Subtotal after discount Rp 87.500, paid in full in cash.
order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125}
got := CalculateEarning(order, 0, prdEarningSettings())
got := CalculateEarning(order, prdEarningSettings())
assert.Equal(t, 87500.0, got.Basis)
assert.Equal(t, int64(875), got.Point.Amount)
assert.Equal(t, int64(3), got.Coin.Amount)
// Rp 20.000 of it paid with EnakPoint earns nothing.
got = CalculateEarning(order, 20000, prdEarningSettings())
assert.Equal(t, 67500.0, got.Basis)
assert.Equal(t, int64(675), got.Point.Amount)
assert.Equal(t, int64(2), got.Coin.Amount)
}
func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) {
withoutTax := &entities.Order{Subtotal: 50000}
withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500}
assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings()))
assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount)
assert.Equal(t, CalculateEarning(withoutTax, prdEarningSettings()), CalculateEarning(withTax, prdEarningSettings()))
assert.Equal(t, int64(500), CalculateEarning(withTax, prdEarningSettings()).Point.Amount)
}
func TestCalculateEarning_BelowMinimum(t *testing.T) {
s := prdEarningSettings()
s.Point.MinOrderAmount = 50000
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns")
// The minimum applies to the basis, after discount and EnakPoint.
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount)
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount)
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount, "the minimum itself earns")
// The minimum applies to the basis, after discount.
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000, DiscountAmount: 15000}, s).Point.Amount)
// Coin has its own minimum.
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount)
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Coin.Amount)
}
func TestCalculateEarning_MaxPerOrder(t *testing.T) {
@@ -58,36 +52,36 @@ func TestCalculateEarning_MaxPerOrder(t *testing.T) {
max := int64(300)
s.Point.MaxPerOrder = &max
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
got := CalculateEarning(&entities.Order{Subtotal: 87500}, s)
assert.Equal(t, int64(300), got.Point.Amount)
assert.True(t, got.Point.Capped)
assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"])
got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s)
got = CalculateEarning(&entities.Order{Subtotal: 20000}, s)
assert.Equal(t, int64(200), got.Point.Amount)
assert.False(t, got.Point.Capped)
zero := int64(0)
s.Point.MaxPerOrder = &zero
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
}
func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) {
s := prdEarningSettings()
s.Point.Enabled = false
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
got := CalculateEarning(&entities.Order{Subtotal: 87500}, s)
assert.Equal(t, int64(0), got.Point.Amount)
assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own")
s.Coin.Enabled = false
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
got = CalculateEarning(&entities.Order{Subtotal: 87500}, s)
assert.Equal(t, int64(0), got.Coin.Amount)
assert.Equal(t, 87500.0, got.Basis, "the basis is still reported")
// The defaults of an outlet that never set anything earn nothing.
var defaults models.OutletLoyaltySettings
loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test")
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults)
got = CalculateEarning(&entities.Order{Subtotal: 87500}, defaults)
assert.Equal(t, int64(0), got.Point.Amount)
assert.Equal(t, int64(0), got.Coin.Amount)
}
@@ -96,22 +90,22 @@ func TestCalculateEarning_EdgeCases(t *testing.T) {
s := prdEarningSettings()
// floor, not round.
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount)
// Values float64 cannot hold exactly do not lose a point: computed in float64 this
// basis divides to 4956.999…, which a naive floor turns into 4956.
per250 := prdEarningSettings()
per250.Point.EarnPerAmount = 250
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount)
// Paying more with EnakPoint than the basis leaves nothing, never a negative amount.
got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s)
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 905.48}, per250).Point.Amount)
// A discount larger than the subtotal leaves nothing, never a negative amount.
got := CalculateEarning(&entities.Order{Subtotal: 10000, DiscountAmount: 15000}, s)
assert.Equal(t, 0.0, got.Basis)
assert.Equal(t, int64(0), got.Point.Amount)
// earn_value multiplies.
s.Point.EarnValue = 5
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
// A zero earn_value earns nothing even when enabled.
s.Point.EarnValue = 0
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
}
func TestCalculateEarning_Percentage(t *testing.T) {
@@ -120,26 +114,24 @@ func TestCalculateEarning_Percentage(t *testing.T) {
s.Point.EarnPercent = 1
// 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode.
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 0, s)
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, s)
assert.Equal(t, int64(875), got.Point.Amount)
assert.Equal(t, int64(3), got.Coin.Amount)
// The part paid with EnakPoint earns nothing in this mode either.
assert.Equal(t, int64(675), CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, 20000, s).Point.Amount)
// Decimals, and floor, not round: 2.5% of 87.500 is 2187.5.
s.Point.EarnPercent = 2.5
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
s.Point.EarnPercent = 0.01
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount)
// The minimum and the cap apply as in the other mode.
s.Point.EarnPercent = 10
s.Point.MinOrderAmount = 50000
max := int64(6000)
s.Point.MaxPerOrder = &max
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount)
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount)
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount)
got = CalculateEarning(&entities.Order{Subtotal: 87500}, s)
assert.Equal(t, int64(6000), got.Point.Amount)
assert.True(t, got.Point.Capped)
@@ -149,11 +141,11 @@ func TestCalculateEarning_Percentage(t *testing.T) {
// A zero percent earns nothing even when enabled.
s.Point.EarnPercent = 0
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
}
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
got := CalculateEarning(&entities.Order{Subtotal: 87500}, prdEarningSettings())
assert.Equal(t, entities.Metadata{
"basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false,
}, got.Metadata(got.Point))
+1 -5
View File
@@ -86,11 +86,7 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
if err != nil {
return nil, err
}
pointPaid, err := p.orders.PointPaidAmount(ctx, orderID)
if err != nil {
return nil, err
}
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings)
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, *settings)
if result.Point.Amount == 0 && result.Coin.Amount == 0 {
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
}
@@ -140,8 +140,8 @@ func TestEarningReversal_AgainstPostgres(t *testing.T) {
spent := paidOrder(spender)
committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem,
Amount: 800, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Bayar"}})
})
exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent)
@@ -257,10 +257,6 @@ func outletLoyaltyFields(s *models.OutletLoyaltySettings) []loyaltyField {
percentLoyaltyField(constants.LoyaltyCoinEarnPercentKey, &s.Coin.EarnPercent, constants.LoyaltyEarnPercentDefault),
intLoyaltyField(constants.LoyaltyCoinMinOrderAmountKey, &s.Coin.MinOrderAmount, 0, 0, noLoyaltyMax),
optionalIntLoyaltyField(constants.LoyaltyCoinMaxPerOrderKey, &s.Coin.MaxPerOrder, 0),
boolLoyaltyField(constants.LoyaltyPointAcceptPaymentKey, &s.PointPayment.AcceptPayment, false),
intLoyaltyField(constants.LoyaltyPointMinPaymentPointsKey, &s.PointPayment.MinPaymentPoints, constants.LoyaltyMinPaymentPointsDefault, 1, noLoyaltyMax),
intLoyaltyField(constants.LoyaltyPointMaxPaymentPercentKey, &s.PointPayment.MaxPaymentPercent, constants.LoyaltyMaxPaymentPercentDefault, 0, 100),
}
}
@@ -107,9 +107,8 @@ func TestLoyaltySettings_OutletWithoutSettingsGetsEveryDefault(t *testing.T) {
s, err := p.Outlet(context.Background(), uuid.New())
require.NoError(t, err)
assert.Equal(t, models.OutletLoyaltySettings{
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
PointPayment: models.LoyaltyPointPaymentSettings{AcceptPayment: false, MinPaymentPoints: 1, MaxPaymentPercent: 100},
Point: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
Coin: models.LoyaltyEarnSettings{Enabled: false, EarnMode: "PER_AMOUNT", EarnPerAmount: 25000, EarnValue: 1, EarnPercent: 1, MinOrderAmount: 0, MaxPerOrder: nil},
}, *s)
}
@@ -130,11 +129,10 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.
func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "true",
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
constants.LoyaltyPointMaxPerOrderKey: "500",
constants.LoyaltyPointMaxPaymentPercentKey: "50",
"loyalty.unknown": "ignored",
constants.LoyaltyPointEnabledKey: "true",
constants.LoyaltyPointEarnPerAmountKey: " 1000 ",
constants.LoyaltyPointMaxPerOrderKey: "500",
"loyalty.unknown": "ignored",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "100",
@@ -151,7 +149,6 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
assert.True(t, outlet.Point.Enabled)
assert.Equal(t, int64(1000), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(500), *outlet.Point.MaxPerOrder)
assert.Equal(t, int64(50), outlet.PointPayment.MaxPaymentPercent)
org, err := p.Organization(context.Background(), uuid.New())
require.NoError(t, err)
@@ -168,11 +165,10 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
repo := &loyaltyRepoFake{
outletValues: map[string]string{
constants.LoyaltyPointEnabledKey: "yes please",
constants.LoyaltyPointEarnPerAmountKey: "0",
constants.LoyaltyCoinEarnValueKey: "-1",
constants.LoyaltyPointMaxPerOrderKey: "abc",
constants.LoyaltyPointMaxPaymentPercentKey: "150",
constants.LoyaltyPointEnabledKey: "yes please",
constants.LoyaltyPointEarnPerAmountKey: "0",
constants.LoyaltyCoinEarnValueKey: "-1",
constants.LoyaltyPointMaxPerOrderKey: "abc",
},
orgValues: map[string]string{
constants.LoyaltyPointValueKey: "0",
@@ -188,7 +184,6 @@ func TestLoyaltySettings_UnusableStoredValuesFallBackToDefault(t *testing.T) {
assert.Equal(t, int64(100), outlet.Point.EarnPerAmount)
assert.Equal(t, int64(1), outlet.Coin.EarnValue)
assert.Nil(t, outlet.Point.MaxPerOrder)
assert.Equal(t, int64(100), outlet.PointPayment.MaxPaymentPercent)
for name, raw := range map[string]string{"not set": "", "garbage": "abc", "zero": "0", "negative": "-5"} {
repo.orgValues = map[string]string{}
@@ -288,17 +283,14 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
ctx := context.Background()
for name, mutate := range map[string]func(*models.OutletLoyaltySettings){
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
"payment percent over 100": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = 101 },
"negative payment percent": func(s *models.OutletLoyaltySettings) { s.PointPayment.MaxPaymentPercent = -1 },
"min payment points 0": func(s *models.OutletLoyaltySettings) { s.PointPayment.MinPaymentPoints = 0 },
"earn_per_amount 0": func(s *models.OutletLoyaltySettings) { s.Point.EarnPerAmount = 0 },
"negative earn_value": func(s *models.OutletLoyaltySettings) { s.Coin.EarnValue = -1 },
"earn mode unknown": func(s *models.OutletLoyaltySettings) { s.Point.EarnMode = "PERCENT" },
"negative earn_percent": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = -1 },
"earn_percent over 100": func(s *models.OutletLoyaltySettings) { s.Coin.EarnPercent = 100.5 },
"earn_percent 3 decimals": func(s *models.OutletLoyaltySettings) { s.Point.EarnPercent = 1.125 },
"negative min_order": func(s *models.OutletLoyaltySettings) { s.Point.MinOrderAmount = -1 },
"negative max_per_order": func(s *models.OutletLoyaltySettings) { s.Coin.MaxPerOrder = ptr(int64(-1)) },
} {
s, err := p.Outlet(ctx, outlet)
require.NoError(t, err)
+1 -154
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
@@ -21,9 +20,6 @@ type OrderProcessor interface {
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
// PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a
// self-order, approved by their PIN (docs/prd-point-coin.md F9).
PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error)
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
@@ -115,9 +111,6 @@ type OrderProcessorImpl struct {
inventoryMovementService InventoryMovementService
productOutletPriceRepo repository.ProductOutletPriceRepository
loyalty OrderLoyalty
pointPayments *PointPaymentProcessor
paymentCodes paymentCodeRedeemer
pins pinVerifier
}
// OrderLoyalty is what the order flow tells and asks the loyalty program
@@ -144,58 +137,6 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
p.loyalty = loyalty
}
type paymentCodeRedeemer interface {
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
}
// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such
// payments to pointPayments approved by the code the customer shows at the cashier,
// and PayWithPointsInApp approved by the customer's PIN (F9).
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) {
p.pointPayments = pointPayments
p.paymentCodes = codes
p.pins = pins
}
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
// generic payment path, which would record the payment without taking any balance.
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.paymentCodes == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
}
var cashier *uuid.UUID
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
cashier = &id
}
code := *req.PaymentCode
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: req.OrderID,
PaymentMethodID: req.PaymentMethodID,
Points: *req.Points,
CashierID: cashier,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
}
return nil
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, req.OrderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
// onOrderPaid is the single place every path that completes an order's payment goes
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
// after the payment has committed. The hook runs detached from the caller's
@@ -213,12 +154,6 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID)
// never block or fail the void or refund.
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
if p.pointPayments != nil {
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
}
}
if p.loyalty != nil {
p.loyalty.OnOrderRefunded(ctx, orderID)
}
@@ -896,18 +831,6 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
reason = *req.Reason
}
// An order refund is handed back in cash or another method, so it cannot cover what
// was paid with EnakPoint (K7). Checked before anything is written.
if p.pointPayments != nil {
planned, err := p.plannedOrderRefund(ctx, id, req)
if err != nil {
return err
}
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
return err
}
}
// Process refund based on request type
if req.RefundAmount != nil {
// Full or partial refund by amount
@@ -999,13 +922,10 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
return nil, fmt.Errorf("order is already fully paid")
}
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
if method.Type == entities.PaymentMethodTypePoint {
return p.createPointPayment(ctx, req)
}
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
if err != nil {
@@ -1390,11 +1310,6 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
if err != nil {
return nil, fmt.Errorf("payment method not found: %w", err)
}
// Splitting with EnakPoint would record a payment without taking any balance; an
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
if payment.Type == entities.PaymentMethodTypePoint {
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
}
customer := &entities.Customer{}
if req.CustomerID != uuid.Nil {
@@ -1841,71 +1756,3 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con
func stringPtr(s string) *string {
return &s
}
// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the
// app or a self-order. The session alone is not enough: the customer's PIN approves
// it (K8). An order that is not the customer's own is reported as not found, so the
// endpoint does not reveal other customers' orders.
func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
if p.pointPayments == nil || p.pins == nil {
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
}
organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID)
if err != nil {
return nil, err
}
if owner == nil || *owner != customerID {
return nil, repository.ErrPointPaymentOrderNotFound
}
methodID, err := p.pointPayments.PointMethodID(ctx, organizationID)
if err != nil {
return nil, err
}
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
OrderID: orderID,
PaymentMethodID: methodID,
Points: points,
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info)
},
})
if err != nil {
return nil, err
}
if result.Completed {
p.onOrderPaid(ctx, orderID)
}
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
}
return mappers.PaymentEntityToResponse(payment), nil
}
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
// way it will be, without writing anything.
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
if req.RefundAmount != nil {
return *req.RefundAmount, nil
}
var total float64
for _, itemRefund := range req.OrderItems {
if itemRefund.RefundAmount != nil {
total += *itemRefund.RefundAmount
continue
}
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
if err != nil {
return 0, fmt.Errorf("order item not found: %w", err)
}
if orderItem.OrderID != orderID {
return 0, fmt.Errorf("order item does not belong to this order")
}
quantity := itemRefund.RefundQuantity
if quantity == 0 {
quantity = orderItem.Quantity
}
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
}
return total, nil
}
@@ -1,101 +0,0 @@
package processor
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
paymentCodeDigits = 6
paymentCodeTTL = 2 * time.Minute
paymentCodeAttempts = 5
// PaymentCodeQRPrefix marks a scanned QR as an EnakPoint payment code.
PaymentCodeQRPrefix = "enakpoint:"
)
// ErrPaymentCodeInvalid means the code was never issued, has expired, has been used,
// or belongs to another customer.
var ErrPaymentCodeInvalid = errors.New("payment code is invalid or expired")
type pinVerifier interface {
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
}
// PaymentCodeProcessor issues and redeems the one-time codes that let a cashier take a
// customer's EnakPoint (docs/prd-point-coin.md F9, K8). The customer approves with
// their PIN on their own phone and shows the code; the PIN is never typed on the
// cashier's device.
type PaymentCodeProcessor struct {
codes repository.PaymentCodeRepository
pins pinVerifier
now func() time.Time
}
func NewPaymentCodeProcessor(codes repository.PaymentCodeRepository, pins pinVerifier) *PaymentCodeProcessor {
return &PaymentCodeProcessor{codes: codes, pins: pins, now: time.Now}
}
// Issue checks the customer's PIN and returns a fresh 6-digit code, valid for two
// minutes and bound to the customer. A new code retires the previous one.
func (p *PaymentCodeProcessor) Issue(ctx context.Context, customerID uuid.UUID, pin string, info models.CustomerPinRequestInfo) (*models.PaymentCode, error) {
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info); err != nil {
return nil, err
}
for attempt := 0; attempt < paymentCodeAttempts; attempt++ {
code, err := randomDigits(paymentCodeDigits)
if err != nil {
return nil, err
}
err = p.codes.Save(ctx, code, customerID, paymentCodeTTL)
if errors.Is(err, repository.ErrPaymentCodeTaken) {
continue
}
if err != nil {
return nil, err
}
return &models.PaymentCode{
Code: code,
QRPayload: PaymentCodeQRPrefix + code,
ExpiresAt: p.now().Add(paymentCodeTTL),
}, nil
}
return nil, fmt.Errorf("could not draw a free payment code after %d attempts", paymentCodeAttempts)
}
// Redeem uses a code up for a payment by the given customer. It accepts the code as
// typed or as scanned from the QR. Every failure is ErrPaymentCodeInvalid.
func (p *PaymentCodeProcessor) Redeem(ctx context.Context, code string, customerID uuid.UUID) error {
code = strings.TrimPrefix(strings.TrimSpace(code), PaymentCodeQRPrefix)
if len(code) != paymentCodeDigits {
return ErrPaymentCodeInvalid
}
err := p.codes.Consume(ctx, code, customerID)
if errors.Is(err, repository.ErrPaymentCodeNotFound) || errors.Is(err, repository.ErrPaymentCodeWrongCustomer) {
return ErrPaymentCodeInvalid
}
return err
}
// randomDigits draws n decimal digits from a cryptographic source, so codes cannot be
// predicted.
func randomDigits(n int) (string, error) {
var b strings.Builder
for i := 0; i < n; i++ {
d, err := rand.Int(rand.Reader, big.NewInt(10))
if err != nil {
return "", fmt.Errorf("failed to draw a payment code: %w", err)
}
b.WriteByte(byte('0' + d.Int64()))
}
return b.String(), nil
}
@@ -1,136 +0,0 @@
package processor
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
type pinVerifierFake struct{ good string }
func (f pinVerifierFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
if action != PinActionPay {
return &PinError{Code: "UNEXPECTED_ACTION"}
}
if pin != f.good {
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
}
return nil
}
func newPaymentCodeTest(t *testing.T) (*PaymentCodeProcessor, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { client.Close() })
return NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"}), mr
}
func TestPaymentCode_IssueNeedsThePin(t *testing.T) {
p, mr := newPaymentCodeTest(t)
_, err := p.Issue(context.Background(), uuid.New(), "000000", models.CustomerPinRequestInfo{})
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Empty(t, mr.Keys(), "nothing is issued without the PIN")
}
func TestPaymentCode_Lifecycle(t *testing.T) {
p, mr := newPaymentCodeTest(t)
ctx := context.Background()
customer, other := uuid.New(), uuid.New()
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
assert.Len(t, code.Code, 6)
assert.Equal(t, "enakpoint:"+code.Code, code.QRPayload)
assert.WithinDuration(t, time.Now().Add(2*time.Minute), code.ExpiresAt, 2*time.Second)
assert.InDelta(t, 120, mr.TTL("wallet:paycode:"+code.Code).Seconds(), 1, "Redis expires it by itself")
// A code of another customer is refused, and stays usable by its owner.
assert.ErrorIs(t, p.Redeem(ctx, code.Code, other), ErrPaymentCodeInvalid)
// Scanned from the QR it works; used once, it is gone.
require.NoError(t, p.Redeem(ctx, code.QRPayload, customer))
assert.ErrorIs(t, p.Redeem(ctx, code.Code, customer), ErrPaymentCodeInvalid)
// An expired code is refused.
late, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
mr.FastForward(2*time.Minute + time.Second)
assert.ErrorIs(t, p.Redeem(ctx, late.Code, customer), ErrPaymentCodeInvalid)
// A new code retires the previous one.
first, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
second, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
if first.Code != second.Code {
assert.ErrorIs(t, p.Redeem(ctx, first.Code, customer), ErrPaymentCodeInvalid)
}
require.NoError(t, p.Redeem(ctx, second.Code, customer))
// Garbage is refused without touching Redis.
for _, bad := range []string{"", "12345", "1234567", "enakpoint:"} {
assert.ErrorIs(t, p.Redeem(ctx, bad, customer), ErrPaymentCodeInvalid, bad)
}
}
// Two cashiers scanning the same code at once: exactly one gets it.
func TestPaymentCode_UsedOnceUnderRace(t *testing.T) {
p, _ := newPaymentCodeTest(t)
ctx := context.Background()
customer := uuid.New()
code, err := p.Issue(ctx, customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(t, err)
var wins int32
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if p.Redeem(ctx, code.Code, customer) == nil {
atomic.AddInt32(&wins, 1)
}
}()
}
wg.Wait()
assert.Equal(t, int32(1), wins)
}
func TestPaymentCode_SaveRefusesALiveCode(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
defer client.Close()
repo := repository.NewPaymentCodeRepository(client)
ctx := context.Background()
require.NoError(t, repo.Save(ctx, "123456", uuid.New(), time.Minute))
assert.ErrorIs(t, repo.Save(ctx, "123456", uuid.New(), time.Minute), repository.ErrPaymentCodeTaken,
"a live code is never handed to a second customer")
}
func TestRandomDigits(t *testing.T) {
seen := map[string]bool{}
for i := 0; i < 200; i++ {
d, err := randomDigits(6)
require.NoError(t, err)
require.Len(t, d, 6)
for _, r := range d {
require.True(t, r >= '0' && r <= '9')
}
seen[d] = true
}
assert.Greater(t, len(seen), 190, "codes do not repeat")
}
+2 -37
View File
@@ -1,10 +1,7 @@
package processor
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"context"
"errors"
"fmt"
"apskel-pos-be/internal/mappers"
@@ -23,26 +20,17 @@ type PaymentMethodProcessor interface {
GetActivePaymentMethodsByOrganization(ctx context.Context, organizationID uuid.UUID) ([]models.PaymentMethodResponse, error)
}
// ErrSystemPaymentMethod means an attempt to create, delete or retype the EnakPoint
// method, which the system owns (docs/prd-point-coin.md F9).
var ErrSystemPaymentMethod = errors.New("the EnakPoint payment method is managed by the system: it cannot be created, deleted or change type")
type PaymentMethodProcessorImpl struct {
paymentMethodRepo repository.PaymentMethodRepository
outletSettings outletSettingsReader
}
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository, outletSettings outletSettingsReader) *PaymentMethodProcessorImpl {
func NewPaymentMethodProcessorImpl(paymentMethodRepo repository.PaymentMethodRepository) *PaymentMethodProcessorImpl {
return &PaymentMethodProcessorImpl{
paymentMethodRepo: paymentMethodRepo,
outletSettings: outletSettings,
}
}
func (p *PaymentMethodProcessorImpl) CreatePaymentMethod(ctx context.Context, req *models.CreatePaymentMethodRequest) (*models.PaymentMethodResponse, error) {
if req.Type == constants.PaymentMethodTypePoint {
return nil, ErrSystemPaymentMethod
}
exists, err := p.paymentMethodRepo.ExistsByName(ctx, req.OrganizationID, req.Name, nil)
if err != nil {
return nil, fmt.Errorf("failed to check payment method name uniqueness: %w", err)
@@ -89,17 +77,6 @@ func (p *PaymentMethodProcessorImpl) ListPaymentMethods(ctx context.Context, req
if req.Search != "" {
filters["search"] = req.Search
}
// At the cashier EnakPoint only shows where the outlet accepts it (F9). Filtered in
// the query so paging stays right.
if req.OutletID != nil && p.outletSettings != nil {
settings, err := p.outletSettings.Outlet(ctx, *req.OutletID)
if err != nil {
return nil, fmt.Errorf("failed to read outlet loyalty settings: %w", err)
}
if !settings.PointPayment.AcceptPayment {
filters["exclude_type"] = string(constants.PaymentMethodTypePoint)
}
}
offset := (req.Page - 1) * req.Limit
@@ -139,15 +116,6 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
return nil, fmt.Errorf("payment method not found: %w", err)
}
// The EnakPoint method keeps its type, and no other method can become one.
if req.Type != nil {
wasPoint := existingPaymentMethod.Type == entities.PaymentMethodTypePoint
isPoint := *req.Type == constants.PaymentMethodTypePoint
if wasPoint != isPoint {
return nil, ErrSystemPaymentMethod
}
}
// Check name uniqueness if name is being updated
if req.Name != nil && *req.Name != existingPaymentMethod.Name {
exists, err := p.paymentMethodRepo.ExistsByName(ctx, existingPaymentMethod.OrganizationID, *req.Name, &id)
@@ -179,13 +147,10 @@ func (p *PaymentMethodProcessorImpl) UpdatePaymentMethod(ctx context.Context, id
func (p *PaymentMethodProcessorImpl) DeletePaymentMethod(ctx context.Context, id uuid.UUID) error {
// Check if payment method exists
existing, err := p.paymentMethodRepo.GetByID(ctx, id)
_, err := p.paymentMethodRepo.GetByID(ctx, id)
if err != nil {
return fmt.Errorf("payment method not found: %w", err)
}
if existing.Type == entities.PaymentMethodTypePoint {
return ErrSystemPaymentMethod
}
// TODO: Check if payment method is being used in any payments
// For now, allow deletion
-401
View File
@@ -1,401 +0,0 @@
package processor
import (
"context"
"os"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// pointPaymentEnv is an order flow wired as in the app, against Postgres and a
// miniredis for payment codes.
type pointPaymentEnv struct {
t *testing.T
db *gorm.DB
orders *OrderProcessorImpl
payments *PointPaymentProcessor
codes *PaymentCodeProcessor
org uuid.UUID
cashier uuid.UUID
outlet uuid.UUID
point uuid.UUID
cash uuid.UUID
walkIn uuid.UUID
ctx context.Context
}
func newPointPaymentEnv(t *testing.T) *pointPaymentEnv {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()}
e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org)
e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t")
e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org)
e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org)
var ids []string
require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error)
require.Len(t, ids, 1)
e.point = uuid.MustParse(ids[0])
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error)
e.walkIn = uuid.MustParse(ids[0])
t.Cleanup(func() {
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org)
db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org)
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet)
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org)
db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet)
db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier)
db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org)
})
txm := repository.NewTxManager(db)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm)
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { client.Close() })
e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"})
e.orders = &OrderProcessorImpl{
orderRepo: repository.NewOrderRepositoryImpl(db),
orderItemRepo: repository.NewOrderItemRepositoryImpl(db),
paymentRepo: repository.NewPaymentRepositoryImpl(db),
paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db),
splitBillProcessor: nil,
txManager: txm,
}
e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm))
e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"})
// The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint.
s, err := settings.Outlet(context.Background(), e.outlet)
require.NoError(t, err)
s.Point.Enabled = true
s.PointPayment.AcceptPayment = true
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
require.NoError(t, err)
e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String())
return e
}
func (e *pointPaymentEnv) exec(q string, args ...any) {
e.t.Helper()
require.NoError(e.t, e.db.Exec(q, args...).Error)
}
// customerWith creates a customer holding the given EnakPoint.
func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID {
e.t.Helper()
id := uuid.New()
e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org)
if points > 0 {
require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points,
ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
return err
}))
}
return id
}
func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID {
e.t.Helper()
id := uuid.New()
e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
subtotal, tax_amount, total_amount, remaining_amount, payment_status)
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`,
id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal)
return id
}
func (e *pointPaymentEnv) code(customer uuid.UUID) string {
e.t.Helper()
c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{})
require.NoError(e.t, err)
return c.Code
}
func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) {
return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code})
}
func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 {
e.t.Helper()
var b int64
require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
return b
}
func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) {
e.t.Helper()
var row struct {
PaymentStatus string
RemainingAmount float64
}
require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error)
return row.PaymentStatus, row.RemainingAmount
}
func TestPointPayment_FullPayment(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 50000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, 50000.0, payment.Amount)
require.NotNil(t, payment.PointsUsed)
assert.Equal(t, int64(50000), *payment.PointsUsed)
assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment")
status, remaining := e.orderState(order)
assert.Equal(t, "completed", status)
assert.Zero(t, remaining)
assert.Equal(t, int64(50000), e.balance(customer))
var ledger struct {
Amount int64
ReferenceType string
ReferenceID string
OutletID string
CreatedByUser string
}
require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id,
created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error)
assert.Equal(t, int64(-50000), ledger.Amount)
assert.Equal(t, "PAYMENT", ledger.ReferenceType)
assert.Equal(t, payment.ID.String(), ledger.ReferenceID)
assert.Equal(t, e.outlet.String(), ledger.OutletID)
assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it")
// Paid entirely with EnakPoint, so nothing earns (Q10).
var earned int64
require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
assert.Zero(t, earned)
}
func TestPointPayment_PartialThenCash(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
status, remaining := e.orderState(order)
assert.Equal(t, "partial", status)
assert.Equal(t, 67500.0, remaining)
// The rest in cash settles it; earning counts only the part not paid with
// EnakPoint: floor(67.500 / 100) = 675.
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
status, _ = e.orderState(order)
assert.Equal(t, "completed", status)
var earned int64
require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
assert.Equal(t, int64(675), earned)
assert.Equal(t, int64(100000-20000+675), e.balance(customer))
}
func TestPointPayment_PercentCap(t *testing.T) {
e := newPointPaymentEnv(t)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
s, err := settings.Outlet(context.Background(), e.outlet)
require.NoError(t, err)
s.PointPayment.MaxPaymentPercent = 50
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
require.NoError(t, err)
customer := e.customerWith(100000)
order := e.order(customer, 100000)
preview, err := e.payments.Preview(context.Background(), e.org, order)
require.NoError(t, err)
assert.True(t, preview.Eligible)
assert.Equal(t, int64(50000), preview.MaxPoints)
assert.Equal(t, int64(100000), preview.PointBalance)
_, err = e.payPoints(order, 50001, e.code(customer))
assert.ErrorIs(t, err, ErrPointPaymentRejected)
_, err = e.payPoints(order, 30000, e.code(customer))
require.NoError(t, err)
_, err = e.payPoints(order, 20001, e.code(customer))
assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap")
_, err = e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, int64(50000), e.balance(customer))
}
func TestPointPayment_Refusals(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
other := e.customerWith(100000)
// A walk-in order cannot be paid with EnakPoint.
walkInOrder := e.order(e.walkIn, 10000)
preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder)
require.NoError(t, err)
assert.False(t, preview.Eligible)
assert.Contains(t, preview.Reason, "walk-in")
_, err = e.payPoints(walkInOrder, 1000, "123456")
assert.ErrorIs(t, err, ErrPointPaymentRejected)
order := e.order(customer, 10000)
_, err = e.payPoints(order, 1000, "000000")
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code")
_, err = e.payPoints(order, 1000, e.code(other))
assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code")
missing := int64(1000)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing})
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all")
code := e.code(customer)
_, err = e.payPoints(order, 1000, code)
require.NoError(t, err)
_, err = e.payPoints(order, 1000, code)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once")
_, err = e.payPoints(order, 9001, e.code(customer))
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left")
// Splitting with the EnakPoint method would skip the balance, so it is refused.
e.orders.splitBillProcessor = splitFake{}
_, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000})
assert.ErrorIs(t, err, ErrPointPaymentRejected)
assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything")
assert.Equal(t, int64(100000), e.balance(other))
}
// Two payments for the same customer at once, on two orders: the balance is taken
// once, never twice. Authorization is taken as given so only the balance decides.
func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(30000)
orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)}
var wg sync.WaitGroup
results := make([]error, len(orders))
for i, order := range orders {
wg.Add(1)
go func(i int, order uuid.UUID) {
defer wg.Done()
_, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{
OrderID: order, PaymentMethodID: e.point, Points: 20000,
Authorize: func(context.Context, uuid.UUID) error { return nil },
})
}(i, order)
}
wg.Wait()
succeeded := 0
for _, err := range results {
if err == nil {
succeeded++
} else {
assert.ErrorIs(t, err, ErrPointPaymentRejected)
}
}
assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two")
assert.Equal(t, int64(10000), e.balance(customer))
// And the wallet still reconciles.
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
require.NoError(t, err)
for _, d := range found {
assert.NotEqual(t, customer, d.CustomerID, d.Check)
}
}
func TestPointPayment_InApp(t *testing.T) {
e := newPointPaymentEnv(t)
owner := e.customerWith(100000)
stranger := e.customerWith(100000)
order := e.order(owner, 60000)
info := models.CustomerPinRequestInfo{}
// Another customer cannot pay it, and is not told it exists.
_, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info)
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
// The session alone is not enough: a wrong PIN takes nothing.
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info)
var pe *PinError
require.ErrorAs(t, err, &pe)
assert.Equal(t, PinErrInvalid, pe.Code)
assert.Equal(t, int64(100000), e.balance(owner))
// The owner pays part, then the rest, with the same rules as at the cashier.
payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info)
require.NoError(t, err)
assert.Equal(t, int64(10000), *payment.PointsUsed)
status, remaining := e.orderState(order)
assert.Equal(t, "partial", status)
assert.Equal(t, 50000.0, remaining)
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left")
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info)
require.NoError(t, err)
status, _ = e.orderState(order)
assert.Equal(t, "completed", status)
assert.Equal(t, int64(40000), e.balance(owner))
assert.Equal(t, int64(100000), e.balance(stranger))
var createdBy *string
require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error)
assert.Nil(t, createdBy, "no cashier took an in-app payment")
}
// The payment method report counts only money actually received as money in; the
// EnakPoint part is listed apart (F9).
func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(),
&models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)})
require.NoError(t, err)
assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total")
assert.Equal(t, 20000.0, report.Summary.PointAmount)
assert.Equal(t, int64(20000), report.Summary.PointsUsed)
assert.Equal(t, 87500.0, report.Summary.TotalWithPoints)
require.Len(t, report.Data, 2)
for _, d := range report.Data {
assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName)
}
}
@@ -1,118 +0,0 @@
package processor
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// Needs TEST_DATABASE_URL pointing at a migrated database; see
// internal/repository/wallet_repository_test.go.
func TestPointPaymentMethod_AgainstPostgres(t *testing.T) {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
ctx := context.Background()
org, user, accepting, refusing := uuid.New(), uuid.New(), uuid.New(), uuid.New()
exec := func(q string, args ...any) error { return db.Exec(q, args...).Error }
require.NoError(t, exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pm test', 'basic')`, org))
require.NoError(t, exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Terima'), (?, ?, 'Tolak')`, accepting, org, refusing, org))
t.Cleanup(func() {
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{accepting, refusing})
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{accepting, refusing})
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, org)
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
})
// A new organization gets exactly one EnakPoint method from the trigger.
var methods []struct {
ID string
Name string
Type string
}
require.NoError(t, db.Raw(`SELECT id::text AS id, name, type FROM payment_methods WHERE organization_id = ?`, org).Scan(&methods).Error)
require.Len(t, methods, 1)
assert.Equal(t, "EnakPoint", methods[0].Name)
assert.Equal(t, "point", methods[0].Type)
pointID := uuid.MustParse(methods[0].ID)
// The database refuses a second one.
assert.Error(t, exec(`INSERT INTO payment_methods (organization_id, name, type) VALUES (?, 'EnakPoint 2', 'point')`, org))
txm := repository.NewTxManager(db)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
s, err := settings.Outlet(ctx, accepting)
require.NoError(t, err)
s.PointPayment.AcceptPayment = true
_, err = settings.UpdateOutlet(ctx, org, accepting, user, *s)
require.NoError(t, err)
p := NewPaymentMethodProcessorImpl(repository.NewPaymentMethodRepositoryImpl(db), settings)
cash, err := p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Tunai", Type: constants.PaymentMethodTypeCash, IsActive: ptr(true)})
require.NoError(t, err)
// The API cannot make, retype or delete an EnakPoint method.
_, err = p.CreatePaymentMethod(ctx, &models.CreatePaymentMethodRequest{OrganizationID: org, Name: "Poin Lain", Type: constants.PaymentMethodTypePoint, IsActive: ptr(true)})
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
toCash := constants.PaymentMethodTypeCash
_, err = p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Type: &toCash})
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
toPoint := constants.PaymentMethodTypePoint
_, err = p.UpdatePaymentMethod(ctx, cash.ID, &models.UpdatePaymentMethodRequest{Type: &toPoint})
assert.ErrorIs(t, err, ErrSystemPaymentMethod)
assert.ErrorIs(t, p.DeletePaymentMethod(ctx, pointID), ErrSystemPaymentMethod)
// Renaming it is fine; its type stays.
name := "Bayar pakai EnakPoint"
renamed, err := p.UpdatePaymentMethod(ctx, pointID, &models.UpdatePaymentMethodRequest{Name: &name, Type: &toPoint})
require.NoError(t, err)
assert.Equal(t, name, renamed.Name)
// At the cashier it shows only where the outlet accepts EnakPoint.
types := func(outlet *uuid.UUID) []constants.PaymentMethodType {
t.Helper()
list, err := p.ListPaymentMethods(ctx, &models.ListPaymentMethodsRequest{OrganizationID: &org, OutletID: outlet, Page: 1, Limit: 50})
require.NoError(t, err)
var out []constants.PaymentMethodType
for _, m := range list.PaymentMethods {
out = append(out, m.Type)
}
assert.Equal(t, len(out), list.TotalCount, "the count matches what is listed")
return out
}
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(&accepting))
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash"}, types(&refusing))
assert.ElementsMatch(t, []constants.PaymentMethodType{"cash", "point"}, types(nil), "the dashboard, without an outlet, sees it")
// A payment either records both points_used and point_value, or neither.
var orderID uuid.UUID
require.NoError(t, exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'K', ?, 'x', 'cashier')`, user, org, user.String()+"@t"))
orderID = uuid.New()
require.NoError(t, exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, order_number, order_type, subtotal, tax_amount, total_amount)
VALUES (?, ?, ?, ?, ?, 'dine_in', 1000, 0, 1000)`, orderID, org, accepting, user, "PM-"+orderID.String()[:8]))
t.Cleanup(func() {
db.Exec(`DELETE FROM payments WHERE order_id = ?`, orderID)
db.Exec(`DELETE FROM orders WHERE id = ?`, orderID)
db.Exec(`DELETE FROM users WHERE id = ?`, user)
})
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used) VALUES (?, ?, 1000, 1000)`, orderID, pointID))
assert.Error(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 0, 1)`, orderID, pointID))
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount, points_used, point_value) VALUES (?, ?, 1000, 1000, 1)`, orderID, pointID))
assert.NoError(t, exec(`INSERT INTO payments (order_id, payment_method_id, amount) VALUES (?, ?, 1000)`, orderID, cash.ID))
}
@@ -1,336 +0,0 @@
package processor
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the
// order, the customer, the outlet or the amount. The message says which.
var ErrPointPaymentRejected = errors.New("EnakPoint payment refused")
type pointPaymentSettings interface {
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
}
type spendableReader interface {
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
}
// PointPaymentInput is one payment of an order with EnakPoint.
type PointPaymentInput struct {
OrderID uuid.UUID
PaymentMethodID uuid.UUID
Points int64
// The cashier taking the payment at the POS; nil when the customer pays in the app.
CashierID *uuid.UUID
// Authorize proves the customer agreed, before anything is taken: at the POS it
// redeems the payment code, in the app it checks the PIN (K8).
Authorize func(ctx context.Context, customerID uuid.UUID) error
}
// PointPaymentResult is the payment made and where it left the order.
type PointPaymentResult struct {
Payment *entities.Payment
// True when this payment settled the order.
Completed bool
// Rupiah still to pay with another method.
Remaining float64
}
// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents:
//
// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint)
// max_points = min(balance, floor(cap / point_value))
type pointPaymentLimits struct {
RemainingCents int64
CapCents int64
MaxPoints int64
}
func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits {
remaining := toCents(total) - toCents(totalPaid)
if remaining < 0 {
remaining = 0
}
byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints)
capCents := min(remaining, byPercent)
if capCents < 0 {
capCents = 0
}
maxPoints := int64(0)
if pointValue > 0 {
maxPoints = min(balance, capCents/(pointValue*100))
}
if maxPoints < 0 {
maxPoints = 0
}
return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints}
}
// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9).
type PointPaymentProcessor struct {
repo repository.PointPaymentRepository
settings pointPaymentSettings
spendable spendableReader
wallet *WalletProcessor
tx TxRunner
now func() time.Time
}
func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor {
return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now}
}
// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with
// EnakPoint, and at most how much.
func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
return nil, err
}
if order.OrganizationID != organizationID {
return nil, repository.ErrPointPaymentOrderNotFound
}
preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID}
settings, err := p.settings.Outlet(ctx, order.OutletID)
if err != nil {
return nil, err
}
value, err := p.settings.PointValue(ctx, order.OrganizationID)
if err != nil {
return nil, err
}
preview.PointValue = value
preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints
preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
preview.Reason = reason
return preview, nil
}
limits, balance, err := p.limits(ctx, order, settings, value)
if err != nil {
return nil, err
}
preview.PointBalance = balance
preview.RemainingAmount = float64(limits.RemainingCents) / 100
preview.MaxPoints = limits.MaxPoints
preview.MaxAmount = limits.MaxPoints * value
if limits.MaxPoints < settings.PointPayment.MinPaymentPoints {
preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order"
return preview, nil
}
preview.Eligible = true
return preview, nil
}
// Pay takes EnakPoint from the order's customer and records the payment. The payment
// row, the ledger PAYMENT row, the balance and the order change in one transaction
// with the order row and the wallet locked (F9 steps 1–6).
func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) {
reject := func(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...))
}
if in.Points <= 0 {
return nil, reject("the number of EnakPoint must be positive")
}
order, err := p.repo.GetOrder(ctx, in.OrderID, false)
if err != nil {
return nil, err
}
methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID)
if err != nil {
return nil, err
}
if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID {
return nil, reject("the payment method is not this organization's EnakPoint method")
}
settings, err := p.settings.Outlet(ctx, order.OutletID)
if err != nil {
return nil, err
}
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
return nil, reject("%s", reason)
}
if in.Points < settings.PointPayment.MinPaymentPoints {
return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints)
}
customerID := *order.CustomerID
// The customer agrees before anything is taken. A code is used up here even if the
// payment then fails, and the customer shows a new one.
if in.Authorize == nil {
return nil, reject("the customer has not approved the payment")
}
if err := in.Authorize(ctx, customerID); err != nil {
return nil, err
}
result := &PointPaymentResult{}
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
// Lock the order, then the wallet, and read everything again: another payment
// may have landed since the checks above.
order, err := p.repo.GetOrder(ctx, in.OrderID, true)
if err != nil {
return err
}
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
return reject("%s", reason)
}
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
return err
}
value, err := p.settings.PointValue(ctx, order.OrganizationID)
if err != nil {
return err
}
limits, _, err := p.limits(ctx, order, settings, value)
if err != nil {
return err
}
if in.Points > limits.MaxPoints {
return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints)
}
amountCents := in.Points * value * 100
// No change is ever given for EnakPoint (K7); the limits already keep it
// within what is left, this only guards that.
if amountCents > limits.RemainingCents {
return reject("EnakPoint cannot pay more than what is left on the order")
}
pointsUsed := in.Points
frozenValue := float64(value)
payment := &entities.Payment{
ID: uuid.New(),
OrderID: order.ID,
PaymentMethodID: in.PaymentMethodID,
Amount: float64(amountCents) / 100,
Status: entities.PaymentTransactionStatusCompleted,
PointsUsed: &pointsUsed,
PointValue: &frozenValue,
Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value},
}
if err := p.repo.InsertPayment(ctx, payment); err != nil {
return err
}
outletID := order.OutletID
if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypePayment,
Amount: in.Points,
ReferenceType: constants.WalletRefTypePayment,
ReferenceID: payment.ID,
OutletID: &outletID,
CreatedByUser: in.CashierID,
Description: pointPaymentDescription(order, amountCents),
Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount},
IdempotencyKey: "payment:" + payment.ID.String(),
}}); err != nil {
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
return reject("the customer does not have enough EnakPoint")
}
return err
}
remainingCents := limits.RemainingCents - amountCents
completed := remainingCents == 0
if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil {
return err
}
result.Payment = payment
result.Completed = completed
result.Remaining = float64(remainingCents) / 100
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) {
totalPaid, err := p.repo.TotalPaid(ctx, order.ID)
if err != nil {
return pointPaymentLimits{}, 0, err
}
paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID)
if err != nil {
return pointPaymentLimits{}, 0, err
}
balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now())
if err != nil {
return pointPaymentLimits{}, 0, err
}
balance := balances[constants.WalletCurrencyPoint]
return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil
}
// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or
// "" when it can.
func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string {
switch {
case order.IsVoid:
return "the order is void"
case order.PaymentStatus == string(entities.PaymentStatusCompleted):
return "the order is already paid"
case !settings.PointPayment.AcceptPayment:
return "this outlet does not accept EnakPoint"
case order.CustomerID == nil || order.CustomerIsDefault == nil:
return "the order has no customer"
case *order.CustomerIsDefault:
return "a walk-in order cannot be paid with EnakPoint"
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
return "the customer is not active"
}
return ""
}
func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string {
description := "Bayar #" + order.OrderNumber
if order.OutletName != "" {
description += " di " + order.OutletName
}
description += " (Rp " + formatRupiah(amountCents/100) + ")"
return truncateRunes(description, walletDescriptionLimit)
}
// formatRupiah writes 50000 as 50.000.
func formatRupiah(n int64) string {
s := fmt.Sprintf("%d", n)
out := make([]byte, 0, len(s)+len(s)/3)
for i, c := range []byte(s) {
if i > 0 && (len(s)-i)%3 == 0 {
out = append(out, '.')
}
out = append(out, c)
}
return string(out)
}
// PointMethodID returns the organization's EnakPoint payment method.
func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
return p.repo.PointMethodID(ctx, organizationID)
}
// OrderOwner returns the organization and customer of an order, for checking that a
// customer pays only their own order.
func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
return uuid.Nil, nil, err
}
return order.OrganizationID, order.CustomerID, nil
}
@@ -1,39 +0,0 @@
package processor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestComputePointPaymentLimits(t *testing.T) {
for name, c := range map[string]struct {
total, paid, paidWithPoints float64
percent, value, balance int64
wantRemaining, wantMax int64
}{
// The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point.
"PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000},
"balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550},
"part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000},
"capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000},
"percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000},
"percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0},
"point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875},
"nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0},
"overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0},
"no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0},
"cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000},
} {
got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance)
assert.Equal(t, c.wantRemaining, got.RemainingCents, name)
assert.Equal(t, c.wantMax, got.MaxPoints, name)
assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name)
}
}
func TestFormatRupiah(t *testing.T) {
for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} {
assert.Equal(t, want, formatRupiah(n))
}
}
-157
View File
@@ -1,157 +0,0 @@
package processor
import (
"context"
"fmt"
"github.com/google/uuid"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/repository"
)
// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the
// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7):
//
// - void: every EnakPoint used on the order;
// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value),
// so a later change of the point value does not change how many come back, and a
// rupiah remainder below one EnakPoint is lost (Q13).
//
// Never more than the payment used, and only what has not come back yet, so it can be
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
// they were taken from, but at least seven days from the refund (note N4, decided). It
// returns how many came back in total.
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
order, err := p.repo.GetOrder(ctx, orderID, false)
if err != nil {
return 0, err
}
payments, err := p.repo.ListPointPayments(ctx, orderID)
if err != nil {
return 0, err
}
var returned int64
for _, payment := range payments {
if payment.LedgerID == nil || payment.CustomerID == nil {
continue
}
target := pointRefundTarget(order.IsVoid, payment)
if target == 0 {
continue
}
var n int64
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil {
return err
}
allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID)
if err != nil {
return err
}
refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID)
if err != nil {
return err
}
var already int64
for _, amount := range refunded {
already += amount
}
toReturn := target - already
if toReturn <= 0 {
return nil
}
// Fill the lots the payment took from, each up to what it gave.
var lots []WalletLotInput
left := toReturn
for _, a := range allocations {
if left == 0 {
break
}
room := a.Amount - refunded[a.LotID]
if room <= 0 {
continue
}
take := min(room, left)
left -= take
lotID := a.LotID
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID})
}
toReturn -= left
ledgerID := *payment.LedgerID
if _, err := p.wallet.Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{
CustomerID: *payment.CustomerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypePaymentRefund,
Amount: toReturn,
ReferenceType: constants.WalletRefTypePayment,
ReferenceID: payment.PaymentID,
ReversesTransactionID: &ledgerID,
OutletID: payment.OutletID,
Description: pointRefundDescription(order),
Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid},
IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target),
},
Lots: lots,
}); err != nil {
return err
}
n = toReturn
return nil
})
if err != nil {
return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err)
}
returned += n
}
return returned, nil
}
// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash
// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded
// through its own payment, and comes back as EnakPoint.
func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error {
refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID)
if err != nil {
return err
}
paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID)
if err != nil {
return err
}
if paidWithPoints == 0 {
return nil
}
if toCents(amount) > toCents(refundable) {
return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint",
ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100))
}
return nil
}
func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 {
if orderVoid {
return payment.PointsUsed
}
if payment.Status != string(entities.PaymentTransactionStatusRefunded) {
return 0
}
valueCents := toCents(payment.PointValue)
if valueCents <= 0 {
return 0
}
return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents)
}
func pointRefundDescription(order *repository.PointPaymentOrder) string {
description := "Pengembalian #" + order.OrderNumber
if order.OutletName != "" {
description += " di " + order.OutletName
}
return truncateRunes(description, walletDescriptionLimit)
}
-151
View File
@@ -1,151 +0,0 @@
package processor
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
func (e *pointPaymentEnv) sum(query string, args ...any) int64 {
e.t.Helper()
var n int64
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
return n
}
func (e *pointPaymentEnv) returned(order uuid.UUID) int64 {
return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t
JOIN payments p ON p.id = t.reference_id
WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order)
}
func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) {
e.t.Helper()
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
require.NoError(e.t, err)
for _, d := range found {
for _, c := range customers {
assert.NotEqual(e.t, c, d.CustomerID, d.Check)
}
}
}
func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(0)
expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{
WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"},
Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}},
})
return err
}))
// Part of the order paid with EnakPoint, then the order is voided.
order := e.order(customer, 50000)
_, err := e.payPoints(order, 30000, e.code(customer))
require.NoError(t, err)
assert.Equal(t, int64(10000), e.balance(customer))
require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier))
assert.Equal(t, int64(30000), e.returned(order))
assert.Equal(t, int64(40000), e.balance(customer))
var expiry time.Time
require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id
WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error)
assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had")
// Calling it again returns nothing more.
e.orders.onOrderRefunded(e.ctx, order)
assert.Equal(t, int64(30000), e.returned(order))
e.assertReconciled(customer)
}
func TestPointRefund_PartialRefundFloors(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 50000, e.code(customer))
require.NoError(t, err)
// Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13).
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier))
assert.Equal(t, int64(12345), e.returned(order))
assert.Equal(t, int64(50000+12345), e.balance(customer))
e.assertReconciled(customer)
}
func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
e := newPointPaymentEnv(t)
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
setValue := func(v int64) {
s, err := settings.Organization(context.Background(), e.org)
require.NoError(t, err)
s.PointValue = v
_, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
require.NoError(t, err)
}
setValue(100)
customer := e.customerWith(1000)
order := e.order(customer, 50000)
payment, err := e.payPoints(order, 500, e.code(customer))
require.NoError(t, err)
assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100")
// The value changes before the refund; the customer still gets back what they used.
setValue(250)
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier))
assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250")
assert.Equal(t, int64(1000), e.balance(customer))
}
func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 50000)
_, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000})
require.NoError(t, err)
amount := 40000.0
err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier)
assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint")
var refunded float64
require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error)
assert.Zero(t, refunded, "nothing was written")
amount = 30000
require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded")
assert.Zero(t, e.returned(order), "and no EnakPoint came back for it")
}
// Giving back the EnakPoint part does not take earning back: that part never earned.
func TestPointRefund_DoesNotReverseEarning(t *testing.T) {
e := newPointPaymentEnv(t)
customer := e.customerWith(100000)
order := e.order(customer, 87500)
pointPayment, err := e.payPoints(order, 20000, e.code(customer))
require.NoError(t, err)
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
require.NoError(t, err)
earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order)
require.Equal(t, int64(675), earned)
require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier))
assert.Equal(t, int64(20000), e.returned(order))
reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order)
assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing")
e.assertReconciled(customer)
}
@@ -28,6 +28,10 @@ type organizationSettingsReader interface {
Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error)
}
type spendableReader interface {
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
}
// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md
// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin.
type WalletExchangeProcessor struct {
@@ -63,7 +63,7 @@ func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
e.credit(t, earn(a, 30, nil)) // never
e.earnCoins(t, a, 4, e.at(-time.Minute))
// A payment already used part of the first lot; only the rest expires.
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: redeem(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
require.NoError(t, err)
notifier := &notifierFake{}
+1 -1
View File
@@ -180,7 +180,7 @@ func TestWalletTrace_AgainstPostgres(t *testing.T) {
require.NoError(t, err)
var payment *WalletResult
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
payment, err = wallet.Debit(ctx, pay(b, 30))
payment, err = wallet.Debit(ctx, redeem(b, 30))
return err
}))
+11 -13
View File
@@ -483,19 +483,17 @@ type walletTypeRule struct {
}
var walletTypeRules = map[string]walletTypeRule{
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypePayment: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true},
constants.WalletTxTypePaymentRefund: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypePayment}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
constants.WalletTxTypeEarn: {credit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true},
constants.WalletTxTypeEarnReversal: {debit: true, referenceTypes: []string{constants.WalletRefTypeOrder}, needsOutlet: true, needsReverses: true},
constants.WalletTxTypeExchangeOut: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeExchangeIn: {credit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true},
constants.WalletTxTypeTransferOut: {debit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeTransferIn: {credit: true, referenceTypes: []string{constants.WalletRefTypeWalletTx}, needsGroup: true, needsCounter: true},
constants.WalletTxTypeGameSpend: {debit: true, currency: constants.WalletCurrencyCoin, referenceTypes: []string{constants.WalletRefTypeGamePlay}},
constants.WalletTxTypeExpire: {debit: true, referenceTypes: []string{constants.WalletRefTypeLot}},
constants.WalletTxTypeAdjustment: {credit: true, debit: true, referenceTypes: []string{constants.WalletRefTypeUser}, needsActor: true},
constants.WalletTxTypeMigration: {credit: true, referenceTypes: []string{constants.WalletRefTypeLegacyPoints, constants.WalletRefTypeLegacyTokens}},
constants.WalletTxTypeRewardRedeem: {debit: true, currency: constants.WalletCurrencyPoint, referenceTypes: []string{constants.WalletRefTypeRewardRedemption}},
}
func validateWalletEntry(in *WalletEntry, credit bool) error {
@@ -106,7 +106,7 @@ func TestWalletProcessor_AgainstPostgres(t *testing.T) {
// Overdraw fails and rolls back cleanly.
err = txm.WithTransaction(context.Background(), func(ctx context.Context) error {
_, err := p.Debit(ctx, pay(b, 121))
_, err := p.Debit(ctx, redeem(b, 121))
return err
})
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
+18 -22
View File
@@ -300,16 +300,16 @@ func earn(customerID uuid.UUID, amount int64, expiresAt *time.Time) WalletCredit
}
}
func pay(customerID uuid.UUID, amount int64) WalletDebitInput {
func redeem(customerID uuid.UUID, amount int64) WalletDebitInput {
return WalletDebitInput{WalletEntry: WalletEntry{
CustomerID: customerID,
Currency: constants.WalletCurrencyPoint,
Type: constants.WalletTxTypePayment,
Type: constants.WalletTxTypeRewardRedeem,
Amount: amount,
ReferenceType: constants.WalletRefTypePayment,
ReferenceType: constants.WalletRefTypeRewardRedemption,
ReferenceID: uuid.New(),
OutletID: ptr(uuid.New()),
Description: "Bayar",
Description: "Tukar voucher",
}}
}
@@ -385,7 +385,7 @@ func TestWalletProcessor_DebitAcrossSeveralLots(t *testing.T) {
second := e.credit(t, earn(c, 50, e.at(2*time.Hour))).Lots[0]
third := e.credit(t, earn(c, 40, e.at(3*time.Hour))).Lots[0]
res, err := e.p.Debit(e.ctx, pay(c, 70))
res, err := e.p.Debit(e.ctx, redeem(c, 70))
require.NoError(t, err)
assert.Equal(t, int64(-70), res.Transaction.Amount)
@@ -415,7 +415,7 @@ func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
var order []uuid.UUID
for i := 0; i < 4; i++ {
res, err := e.p.Debit(e.ctx, pay(c, 10))
res, err := e.p.Debit(e.ctx, redeem(c, 10))
require.NoError(t, err)
require.Len(t, res.Allocations, 1)
order = append(order, res.Allocations[0].LotID)
@@ -425,7 +425,7 @@ func TestWalletProcessor_DebitFollowsLotOrder(t *testing.T) {
// The expired lot still counts in the balance until the expiry job removes it,
// but it cannot be spent (§7.3).
assert.Equal(t, int64(10), e.balance(t, c))
_, err := e.p.Debit(e.ctx, pay(c, 10))
_, err := e.p.Debit(e.ctx, redeem(c, 10))
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
}
@@ -434,14 +434,14 @@ func TestWalletProcessor_DebitOverBalanceChangesNothing(t *testing.T) {
c := e.customer()
e.credit(t, earn(c, 50, nil))
_, err := e.p.Debit(e.ctx, pay(c, 51))
_, err := e.p.Debit(e.ctx, redeem(c, 51))
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
assert.Equal(t, int64(50), e.balance(t, c))
assert.Len(t, e.repo.transactions, 1)
assert.Empty(t, e.repo.allocations)
// A customer who never had a wallet has nothing to spend.
_, err = e.p.Debit(e.ctx, pay(e.customer(), 1))
_, err = e.p.Debit(e.ctx, redeem(e.customer(), 1))
assert.ErrorIs(t, err, repository.ErrWalletInsufficientBalance)
}
@@ -467,7 +467,7 @@ func TestWalletProcessor_DebitUpToWithShortfall(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
earned := e.credit(t, earn(c, 100, nil))
_, err := e.p.Debit(e.ctx, pay(c, 70))
_, err := e.p.Debit(e.ctx, redeem(c, 70))
require.NoError(t, err)
res, err := e.p.DebitUpTo(e.ctx, reversal(c, 100, earned))
@@ -510,7 +510,7 @@ func TestWalletProcessor_DebitRejectsSomeoneElsesLot(t *testing.T) {
e.credit(t, earn(a, 10, nil))
other := e.credit(t, earn(b, 10, nil))
in := pay(a, 5)
in := redeem(a, 5)
in.PreferredLotIDs = []uuid.UUID{other.Lots[0].ID}
_, err := e.p.Debit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
@@ -567,7 +567,7 @@ func TestWalletProcessor_IdempotentDebit(t *testing.T) {
c := e.customer()
e.credit(t, earn(c, 30, e.at(time.Hour)))
e.credit(t, earn(c, 30, nil))
in := pay(c, 40)
in := redeem(c, 40)
in.IdempotencyKey = "pay:1"
first, err := e.p.Debit(e.ctx, in)
@@ -585,7 +585,7 @@ func TestWalletProcessor_IdempotentDebitUpToKeepsShortfall(t *testing.T) {
e := newWalletTestEnv(t)
c := e.customer()
earned := e.credit(t, earn(c, 100, nil))
_, err := e.p.Debit(e.ctx, pay(c, 60))
_, err := e.p.Debit(e.ctx, redeem(c, 60))
require.NoError(t, err)
in := reversal(c, 100, earned)
in.IdempotencyKey = "reverse:order-1"
@@ -614,7 +614,7 @@ func TestWalletProcessor_IdempotencyKeyReusedForAnotherOperation(t *testing.T) {
_, err := e.p.Credit(e.ctx, other)
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
debit := pay(c, 100)
debit := redeem(c, 100)
debit.IdempotencyKey = "k"
_, err = e.p.Debit(e.ctx, debit)
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
@@ -673,8 +673,8 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
credits := map[string]func(*WalletCreditInput){
"unknown type": func(in *WalletCreditInput) { in.Type = "BONUS" },
"debit-only type as credit": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypePayment
in.ReferenceType = constants.WalletRefTypePayment
in.Type = constants.WalletTxTypeRewardRedeem
in.ReferenceType = constants.WalletRefTypeRewardRedemption
},
"unknown currency": func(in *WalletCreditInput) { in.Currency = "GOLD" },
"zero amount": func(in *WalletCreditInput) { in.Amount = 0; in.Lots = nil },
@@ -701,10 +701,6 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
in.ReferenceType = constants.WalletRefTypeWalletTx
in.CounterpartyCustomerID = ptr(uuid.New())
},
"PAYMENT_REFUND without source": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypePaymentRefund
in.ReferenceType = constants.WalletRefTypePayment
},
"ADJUSTMENT without reason": func(in *WalletCreditInput) {
in.Type = constants.WalletTxTypeAdjustment
in.ReferenceType = constants.WalletRefTypeUser
@@ -744,7 +740,7 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
in.Type = constants.WalletTxTypeMigration
in.ReferenceType = constants.WalletRefTypeLegacyPoints
},
"PAYMENT in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
"REWARD_REDEEM in COIN": func(in *WalletDebitInput) { in.Currency = constants.WalletCurrencyCoin },
"GAME_SPEND in POINT": func(in *WalletDebitInput) {
in.Type = constants.WalletTxTypeGameSpend
in.ReferenceType = constants.WalletRefTypeGamePlay
@@ -772,7 +768,7 @@ func TestWalletProcessor_RejectsEntriesThatBreakTheTypeRules(t *testing.T) {
e := newWalletTestEnv(t)
e.repo.customers[c] = e.org
e.credit(t, earn(c, 100, nil))
in := pay(c, 10)
in := redeem(c, 10)
mutate(&in)
_, err := e.p.Debit(e.ctx, in)
assert.ErrorIs(t, err, ErrWalletInvalidEntry)
@@ -87,8 +87,8 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
{Currency: constants.WalletCurrencyPoint, Date: "2026-07-01", Amount: 100},
},
transactions: []entities.WalletTransaction{
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment, Amount: -50, BalanceAfter: 250,
ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, Description: "Bayar #ORD-1", CreatedAt: created.Add(time.Hour),
{ID: payID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem, Amount: -50, BalanceAfter: 250,
ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: payment, Description: "Tukar voucher", CreatedAt: created.Add(time.Hour),
CounterpartyCustomerID: ptr(uuid.New()), Metadata: entities.Metadata{"point_value": 100}},
{ID: earnID, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn, Amount: 300, BalanceAfter: 300,
ReferenceType: constants.WalletRefTypeOrder, ReferenceID: order, Description: "Belanja #ORD-1", CreatedAt: created},
@@ -112,7 +112,7 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
require.Len(t, data.RecentTransactions, 2)
pay, earn := data.RecentTransactions[0], data.RecentTransactions[1]
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypePayment, ID: payment}, pay.Destination)
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeRewardRedemption, ID: payment}, pay.Destination)
assert.Nil(t, pay.Source)
assert.Empty(t, pay.Lots)
assert.Equal(t, &models.CustomerWalletTransactionRef{Type: constants.WalletRefTypeOrder, ID: order}, earn.Source)
@@ -124,7 +124,7 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
assert.Equal(t, int64(250), data.TotalPoints)
require.Len(t, data.PointsHistory, 2)
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
assert.Equal(t, constants.WalletTxTypeRewardRedeem, data.PointsHistory[0].Type)
assert.Equal(t, created.Add(time.Hour), data.LastUpdated)
}
@@ -181,7 +181,7 @@ func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
repo := &walletQueryRepoFake{org: uuid.New(), total: 45}
page, err := newWalletQueryTest(repo, orgSettingsFake{}).Transactions(context.Background(), customer, models.ListCustomerWalletTransactionsQuery{
Page: 3, Limit: 10, Currency: "point", Type: "earn, PAYMENT", From: "2026-05-01", To: "2026-05-31",
Page: 3, Limit: 10, Currency: "point", Type: "earn, REWARD_REDEEM", From: "2026-05-01", To: "2026-05-31",
})
require.NoError(t, err)
assert.Equal(t, models.Pagination{Page: 3, Limit: 10, Total: 45, TotalPages: 5}, page.Pagination)
@@ -192,7 +192,7 @@ func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
assert.Equal(t, 20, f.Offset)
assert.Equal(t, 10, f.Limit)
assert.Equal(t, constants.WalletCurrencyPoint, f.Currency)
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypePayment}, f.Types)
assert.Equal(t, []string{constants.WalletTxTypeEarn, constants.WalletTxTypeRewardRedeem}, f.Types)
assert.True(t, f.From.Equal(time.Date(2026, 5, 1, 0, 0, 0, 0, jakarta)))
assert.True(t, f.To.Equal(time.Date(2026, 6, 1, 0, 0, 0, 0, jakarta)), "to covers the whole last day")
@@ -82,8 +82,8 @@ func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string
}
// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B
// pays 30. Tracing B's payment leads to A's order #ORD-1.
func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
// redeems 30. Tracing B's redemption leads to A's order #ORD-1.
func TestWalletTrace_RedemptionLeadsBackToTheSendersOrder(t *testing.T) {
e := newWalletMoveEnv(t)
a := e.member("Anita", "081200005678")
b := e.member("Budi Santoso", "081234561234")
@@ -95,14 +95,14 @@ func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
e.credit(t, ord2)
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
require.NoError(t, err)
payment, err := e.p.Debit(e.ctx, pay(b, 30))
payment, err := e.p.Debit(e.ctx, redeem(b, 30))
require.NoError(t, err)
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID)
require.NoError(t, err)
assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type)
assert.Equal(t, constants.WalletTxTypeRewardRedeem, trace.Transaction.Type)
assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name)
require.Len(t, trace.Lots, 1)
assert.Equal(t, int64(30), trace.Lots[0].Amount)
+1 -2
View File
@@ -99,8 +99,7 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
pm.type as payment_method_type,
COALESCE(SUM(p.amount), 0) as total_amount,
COUNT(DISTINCT p.order_id) as order_count,
COUNT(p.id) as payment_count,
COALESCE(SUM(p.points_used), 0) as points_used
COUNT(p.id) as payment_count
`).
Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id").
Joins("JOIN orders o ON p.order_id = o.id").
@@ -62,8 +62,6 @@ type CustomerOrderPaymentRow struct {
Amount float64
Status string
RefundAmount float64
PointsUsed *int64
PointValue *float64
CreatedAt time.Time
}
@@ -145,7 +143,7 @@ func (r *customerOrderRepository) ListPayments(ctx context.Context, orderID uuid
var rows []CustomerOrderPaymentRow
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT pay.id, COALESCE(pm.name, '') AS method_name, COALESCE(pm.type, '') AS method_type,
pay.amount, pay.status, pay.refund_amount, pay.points_used, pay.point_value, pay.created_at
pay.amount, pay.status, pay.refund_amount, pay.created_at
FROM payments pay
LEFT JOIN payment_methods pm ON pm.id = pay.payment_method_id
WHERE pay.order_id = ?
+1 -25
View File
@@ -44,9 +44,6 @@ type EarningCursor struct {
// EarningRepository reads orders for loyalty earning (docs/prd-point-coin.md F3).
type EarningRepository interface {
GetOrderForEarning(ctx context.Context, orderID uuid.UUID) (*EarningOrder, error)
// PointPaidAmount is the rupiah part of the order paid with EnakPoint, which earns
// nothing (Q10). Zero until EnakPoint payment exists (phase 3).
PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error)
// ListPaidOrdersWithoutEarning pages, oldest first, through orders updated since
// the given time that are paid, not void, have an eligible customer, belong to an
// outlet that earns something, and have no EARN row yet. Pass the previous page's
@@ -92,13 +89,7 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
COALESCE(o.is_void, false) AS is_void,
-- Refunds of EnakPoint payments are left out: that part never earned (Q10),
-- so giving it back must not take earning back.
COALESCE(o.refund_amount, 0) - COALESCE((
SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount,
COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount,
c.is_default AS customer_is_default, c.is_active AS customer_is_active
FROM orders o
LEFT JOIN outlets ou ON ou.id = o.outlet_id
@@ -134,21 +125,6 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
return order, nil
}
func (r *earningRepository) PointPaidAmount(ctx context.Context, orderID uuid.UUID) (float64, error) {
var total float64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(p.amount), 0)
FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = ? AND pm.type = ? AND p.status = ?`,
orderID, constants.PaymentMethodTypePoint, entities.PaymentTransactionStatusCompleted).
Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
}
return total, nil
}
func (r *earningRepository) ListPaidOrdersWithoutEarning(ctx context.Context, since time.Time, after *EarningCursor, limit int) ([]EarningCursor, error) {
cursorAt, cursorID := since, uuid.Nil
if after != nil {
@@ -58,7 +58,6 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
assert.Equal(t, int64(100), settings.Point.EarnPerAmount)
assert.Equal(t, int64(25000), settings.Coin.EarnPerAmount)
assert.Nil(t, settings.Point.MaxPerOrder)
assert.Equal(t, int64(100), settings.PointPayment.MaxPaymentPercent)
// Change three keys.
settings.Point.Enabled = true
@@ -1,103 +0,0 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
)
var (
// ErrPaymentCodeTaken means the code is already live for someone; draw another.
ErrPaymentCodeTaken = errors.New("payment code already in use")
// ErrPaymentCodeNotFound means the code does not exist: never issued, expired, or
// already used.
ErrPaymentCodeNotFound = errors.New("payment code not found")
// ErrPaymentCodeWrongCustomer means the code belongs to another customer.
ErrPaymentCodeWrongCustomer = errors.New("payment code belongs to another customer")
)
// PaymentCodeRepository keeps one-time EnakPoint payment codes in Redis
// (docs/prd-point-coin.md F9). A code expires by TTL and is removed when used.
type PaymentCodeRepository interface {
// Save stores a code for a customer for ttl, and retires the customer's previous
// code so only the newest one works. ErrPaymentCodeTaken if the code is live.
Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error
// Consume uses a code up if it belongs to the customer. A code of another customer
// is left in place, so a cashier scanning it against the wrong order does not burn
// it for its owner.
Consume(ctx context.Context, code string, customerID uuid.UUID) error
}
type paymentCodeRepository struct {
client *redis.Client
}
func NewPaymentCodeRepository(client *redis.Client) PaymentCodeRepository {
return &paymentCodeRepository{client: client}
}
func paymentCodeKey(code string) string { return "wallet:paycode:" + code }
func paymentCodeCustomerKey(customerID uuid.UUID) string {
return "wallet:paycode:customer:" + customerID.String()
}
func (r *paymentCodeRepository) Save(ctx context.Context, code string, customerID uuid.UUID, ttl time.Duration) error {
ok, err := r.client.SetNX(ctx, paymentCodeKey(code), customerID.String(), ttl).Result()
if err != nil {
return fmt.Errorf("failed to store payment code: %w", err)
}
if !ok {
return ErrPaymentCodeTaken
}
previous, err := r.client.GetSet(ctx, paymentCodeCustomerKey(customerID), code).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("failed to track payment code: %w", err)
}
r.client.Expire(ctx, paymentCodeCustomerKey(customerID), ttl)
if previous != "" && previous != code {
// Only if it is still that customer's: the number may have been reissued.
if err := r.compareAndDelete(ctx, previous, customerID); err != nil && !errors.Is(err, ErrPaymentCodeNotFound) && !errors.Is(err, ErrPaymentCodeWrongCustomer) {
return err
}
}
return nil
}
// consumeScript deletes a code only if it belongs to the given customer, in one step.
// Returns 1 when used up, 0 when missing, -1 when it belongs to someone else.
var consumeScript = redis.NewScript(`
local owner = redis.call('GET', KEYS[1])
if not owner then return 0 end
if owner ~= ARGV[1] then return -1 end
redis.call('DEL', KEYS[1])
return 1
`)
func (r *paymentCodeRepository) Consume(ctx context.Context, code string, customerID uuid.UUID) error {
code = strings.TrimSpace(code)
if code == "" {
return ErrPaymentCodeNotFound
}
return r.compareAndDelete(ctx, code, customerID)
}
func (r *paymentCodeRepository) compareAndDelete(ctx context.Context, code string, customerID uuid.UUID) error {
result, err := consumeScript.Run(ctx, r.client, []string{paymentCodeKey(code)}, customerID.String()).Int()
if err != nil {
return fmt.Errorf("failed to use payment code: %w", err)
}
switch result {
case 1:
return nil
case -1:
return ErrPaymentCodeWrongCustomer
default:
return ErrPaymentCodeNotFound
}
}
@@ -75,8 +75,6 @@ func (r *PaymentMethodRepositoryImpl) List(ctx context.Context, filters map[stri
case "search":
searchValue := "%" + value.(string) + "%"
query = query.Where("name ILIKE ? OR processor ILIKE ?", searchValue, searchValue)
case "exclude_type":
query = query.Where("type <> ?", value)
default:
query = query.Where(key+" = ?", value)
}
@@ -1,339 +0,0 @@
package repository
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/entities"
)
// ErrPointPaymentOrderNotFound means the order does not exist.
var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found")
// PointPaymentOrder is what paying with EnakPoint needs to know about an order.
type PointPaymentOrder struct {
ID uuid.UUID
OrganizationID uuid.UUID
OutletID uuid.UUID
OrderNumber string
OutletName string
CustomerID *uuid.UUID
TotalAmount float64
PaymentStatus string
IsVoid bool
CustomerIsDefault *bool
CustomerIsActive *bool
}
// PointPaymentRepository reads and writes what paying an order with EnakPoint touches
// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method
// joins the caller's transaction, since the payment row, the balance and the order
// must change together.
type PointPaymentRepository interface {
// GetOrder reads the order; with lock it also locks the order row for the rest of
// the transaction, so two payments of the same order queue up.
GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error)
// GetPaymentMethod returns a method's organization and type.
GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error)
// TotalPaid sums the order's completed payments, as the rest of the order flow does.
TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error)
// PaidWithPoints sums the rupiah of the order's completed EnakPoint payments.
PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error)
// PointMethodID returns the organization's EnakPoint payment method.
PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)
InsertPayment(ctx context.Context, payment *entities.Payment) error
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
// nothing is.
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
// ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows.
ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error)
// PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first.
PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error)
// RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already
// returned for a PAYMENT row.
RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error)
// RefundableByOtherMethods is what the order's non-EnakPoint payments can still give
// back: paid minus already refunded.
RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error)
}
type pointPaymentRepository struct {
db *gorm.DB
}
func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository {
return &pointPaymentRepository{db: db}
}
func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) {
lockClause := ""
if lock {
lockClause = "FOR UPDATE OF o"
}
var rows []struct {
ID string
OrganizationID string
OutletID string
OrderNumber string
OutletName string
CustomerID *string
TotalAmount float64
PaymentStatus string
IsVoid bool
CustomerIsDefault *bool
CustomerIsActive *bool
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void,
c.is_default AS customer_is_default, c.is_active AS customer_is_active
FROM orders o
LEFT JOIN outlets ou ON ou.id = o.outlet_id
LEFT JOIN customers c ON c.id = o.customer_id
WHERE o.id = ?
`+lockClause, orderID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read order: %w", err)
}
if len(rows) == 0 {
return nil, ErrPointPaymentOrderNotFound
}
row := rows[0]
order := &PointPaymentOrder{
OrderNumber: row.OrderNumber,
OutletName: row.OutletName,
TotalAmount: row.TotalAmount,
PaymentStatus: row.PaymentStatus,
IsVoid: row.IsVoid,
CustomerIsDefault: row.CustomerIsDefault,
CustomerIsActive: row.CustomerIsActive,
}
order.ID, _ = uuid.Parse(row.ID)
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
order.OutletID, _ = uuid.Parse(row.OutletID)
if row.CustomerID != nil {
if id, err := uuid.Parse(*row.CustomerID); err == nil {
order.CustomerID = &id
}
}
return order, nil
}
func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) {
var rows []struct {
OrganizationID string
Type string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error
if err != nil {
return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err)
}
if len(rows) == 0 {
return uuid.Nil, "", fmt.Errorf("payment method not found")
}
org, _ := uuid.Parse(rows[0].OrganizationID)
return org, rows[0].Type, nil
}
func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) {
var total float64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`,
orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum payments: %w", err)
}
return total, nil
}
func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) {
var total float64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(p.amount), 0) FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`,
orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
}
return total, nil
}
func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error {
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil {
return fmt.Errorf("failed to create payment: %w", err)
}
return nil
}
func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error {
paymentStatus := entities.PaymentStatusPartial
if completed {
paymentStatus = entities.PaymentStatusCompleted
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
UPDATE orders SET remaining_amount = ?, payment_status = ?,
status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW()
WHERE id = ?`,
remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error
if err != nil {
return fmt.Errorf("failed to update order after payment: %w", err)
}
return nil
}
func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
var ids []string
err := DBFromContext(ctx, r.db).WithContext(ctx).
Table("payment_methods").
Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint).
Limit(1).
Pluck("id::text", &ids).Error
if err != nil {
return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err)
}
if len(ids) == 0 {
return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method")
}
return uuid.Parse(ids[0])
}
// PointPaymentRow is one EnakPoint payment of an order, for refunding it.
type PointPaymentRow struct {
PaymentID uuid.UUID
Status string
PointsUsed int64
PointValue float64
RefundAmount float64
// The PAYMENT ledger row that took the balance; nil if none was written.
LedgerID *uuid.UUID
CustomerID *uuid.UUID
OutletID *uuid.UUID
}
// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's
// expiry, so a refund can return it to the same expiry.
type PointPaymentAllocation struct {
LotID uuid.UUID
Amount int64
ExpiresAt *time.Time
}
func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) {
var rows []struct {
PaymentID string
Status string
PointsUsed int64
PointValue float64
RefundAmount float64
LedgerID *string
CustomerID *string
OutletID *string
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value,
COALESCE(p.refund_amount, 0) AS refund_amount,
t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id
FROM payments p
JOIN payment_methods pm ON pm.id = p.payment_method_id
LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ?
WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL
ORDER BY p.created_at, p.id`,
constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint).
Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err)
}
out := make([]PointPaymentRow, 0, len(rows))
for _, row := range rows {
p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount}
p.PaymentID, _ = uuid.Parse(row.PaymentID)
p.LedgerID = parseOptionalUUID(row.LedgerID)
p.CustomerID = parseOptionalUUID(row.CustomerID)
p.OutletID = parseOptionalUUID(row.OutletID)
out = append(out, p)
}
return out, nil
}
func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) {
var rows []struct {
LotID string
Amount int64
ExpiresAt *time.Time
}
// Longest-lasting first: a partial refund gives back the balance that keeps longest.
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at
FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id
WHERE a.transaction_id = ?
ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to list payment allocations: %w", err)
}
out := make([]PointPaymentAllocation, 0, len(rows))
for _, row := range rows {
id, err := uuid.Parse(row.LotID)
if err != nil {
continue
}
out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt})
}
return out, nil
}
func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) {
var rows []struct {
OriginLotID string
Amount int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount
FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id
WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL
GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to sum payment refunds: %w", err)
}
out := make(map[uuid.UUID]int64, len(rows))
for _, row := range rows {
if id, err := uuid.Parse(row.OriginLotID); err == nil {
out[id] = row.Amount
}
}
return out, nil
}
func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) {
var total float64
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0)
FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id
WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`,
orderID, constants.PaymentMethodTypePoint,
entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error
if err != nil {
return 0, fmt.Errorf("failed to sum refundable payments: %w", err)
}
return total, nil
}
func parseOptionalUUID(s *string) *uuid.UUID {
if s == nil {
return nil
}
id, err := uuid.Parse(*s)
if err != nil {
return nil
}
return &id
}
@@ -71,8 +71,8 @@ func TestWalletReconciliation_AgainstPostgres(t *testing.T) {
}
outlet := uuid.New()
_, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
Amount: 70, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeRewardRedeem,
Amount: 70, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: uuid.New(), OutletID: &outlet,
Description: "Bayar"}})
return err
}))
+1 -8
View File
@@ -57,8 +57,6 @@ type Router struct {
walletAdminHandler *handler.WalletAdminHandler
loyaltySettingsHandler *handler.LoyaltySettingsHandler
customerPinHandler *handler.CustomerPinHandler
pointPaymentHandler *handler.PointPaymentHandler
customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler
customerWalletHandler *handler.CustomerWalletHandler
customerDeviceHandler *handler.CustomerDeviceHandler
customerOutletHandler *handler.CustomerOutletHandler
@@ -68,7 +66,7 @@ type Router struct {
redisClient *redis.Client
}
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, redisClient *redis.Client) *Router {
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, redisClient *redis.Client) *Router {
return &Router{
config: cfg,
@@ -117,8 +115,6 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService),
customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService),
customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService),
customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService),
customerOutletHandler: handler.NewCustomerOutletHandler(customerOutletService),
@@ -176,7 +172,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring)
customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode)
customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange)
customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange)
customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient)
@@ -186,7 +181,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
customer.GET("/outlets", r.customerOutletHandler.List)
customer.GET("/orders", r.customerOrderHandler.List)
customer.GET("/orders/:id", r.customerOrderHandler.Detail)
customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints)
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
customer.GET("/pin/status", r.customerPinHandler.Status)
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
@@ -316,7 +310,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{
orders.GET("", r.orderHandler.ListOrders)
orders.GET("/:id", r.orderHandler.GetOrderByID)
orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview)
orders.POST("", r.orderHandler.CreateOrder)
orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
orders.PUT("/:id", r.orderHandler.UpdateOrder)
+9 -3
View File
@@ -37,7 +37,6 @@ func TestAllRoutesRegister(t *testing.T) {
"GET /api/v1/marketing/loyalty-settings",
"PUT /api/v1/marketing/loyalty-settings",
"GET /api/v1/marketing/loyalty-settings/history",
"POST /api/v1/customer/wallet/payment-code",
"GET /api/v1/customer/wallet/exchange/preview",
"POST /api/v1/customer/wallet/exchange",
"GET /api/v1/customer/wallet/transfer/recipient",
@@ -47,8 +46,6 @@ func TestAllRoutesRegister(t *testing.T) {
"GET /api/v1/customer/outlets",
"GET /api/v1/customer/orders",
"GET /api/v1/customer/orders/:id",
"GET /api/v1/orders/:id/point-payment/preview",
"POST /api/v1/customer/orders/:id/pay-with-points",
"GET /api/v1/customer/pin/status",
"POST /api/v1/customer/pin/otp",
"POST /api/v1/customer/pin",
@@ -59,4 +56,13 @@ func TestAllRoutesRegister(t *testing.T) {
} {
assert.True(t, registered[want], want)
}
// EnakPoint cannot pay for orders (docs/enakgame-prd.md §3.2).
for _, gone := range []string{
"POST /api/v1/customer/wallet/payment-code",
"GET /api/v1/orders/:id/point-payment/preview",
"POST /api/v1/customer/orders/:id/pay-with-points",
} {
assert.False(t, registered[gone], gone)
}
}
@@ -1,34 +0,0 @@
package service
import (
"context"
"github.com/google/uuid"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/transformer"
)
// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the
// app or a self-order (docs/prd-point-coin.md F9).
type CustomerOrderPaymentService interface {
PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerOrderPaymentServiceImpl struct {
orders processor.OrderProcessor
}
func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl {
return &CustomerOrderPaymentServiceImpl{orders: orders}
}
func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response {
payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment))
}
+3 -15
View File
@@ -25,18 +25,14 @@ type CustomerPinService interface {
RemovePin(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, req *contract.RemoveCustomerPinRequest, info models.CustomerPinRequestInfo) *contract.Response
ListSecurityEvents(ctx context.Context, apctx *appcontext.ContextInfo, customerID uuid.UUID, page, limit int) *contract.Response
// IssuePaymentCode checks the PIN and returns a one-time code for the cashier (F9).
IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response
}
type CustomerPinServiceImpl struct {
pins *processor.CustomerPinProcessor
codes *processor.PaymentCodeProcessor
pins *processor.CustomerPinProcessor
}
func NewCustomerPinService(pins *processor.CustomerPinProcessor, codes *processor.PaymentCodeProcessor) *CustomerPinServiceImpl {
return &CustomerPinServiceImpl{pins: pins, codes: codes}
func NewCustomerPinService(pins *processor.CustomerPinProcessor) *CustomerPinServiceImpl {
return &CustomerPinServiceImpl{pins: pins}
}
func (s *CustomerPinServiceImpl) Status(ctx context.Context, customerID uuid.UUID) *contract.Response {
@@ -130,11 +126,3 @@ func PinErrorResponse(err error) *contract.Response {
contract.NewResponseError(code, constants.CustomerPinServiceEntity, err.Error()),
})
}
func (s *CustomerPinServiceImpl) IssuePaymentCode(ctx context.Context, customerID uuid.UUID, req *contract.IssuePaymentCodeRequest, info models.CustomerPinRequestInfo) *contract.Response {
code, err := s.codes.Issue(ctx, customerID, req.Pin, info)
if err != nil {
return PinErrorResponse(err)
}
return contract.BuildSuccessResponse(code)
}
+1 -2
View File
@@ -557,8 +557,7 @@ func (s *OrderServiceImpl) validateCreatePaymentRequest(req *models.CreatePaymen
return fmt.Errorf("payment method ID is required")
}
// A payment with EnakPoint gives points instead; its amount is computed from them.
if req.Points == nil && req.Amount <= 0 {
if req.Amount <= 0 {
return fmt.Errorf("payment amount must be greater than zero")
}
@@ -42,14 +42,6 @@ func (m *MockOrderProcessor) UpdateOrder(ctx context.Context, id uuid.UUID, req
return args.Get(0).(*models.OrderResponse), args.Error(1)
}
func (m *MockOrderProcessor) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
args := m.Called(ctx, customerID, orderID, points, pin, info)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*models.PaymentResponse), args.Error(1)
}
func (m *MockOrderProcessor) GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
+3 -14
View File
@@ -2,10 +2,8 @@ package service
import (
"context"
"errors"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/processor"
@@ -41,7 +39,7 @@ func (s *PaymentMethodServiceImpl) CreatePaymentMethod(ctx context.Context, cont
response, err := s.paymentMethodProcessor.CreatePaymentMethod(ctx, modelReq)
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_CREATE_ERROR"), "payment_method", err.Error()),
contract.NewResponseError("PAYMENT_METHOD_CREATE_ERROR", "payment_method", err.Error()),
})
}
@@ -86,7 +84,7 @@ func (s *PaymentMethodServiceImpl) UpdatePaymentMethod(ctx context.Context, id u
response, err := s.paymentMethodProcessor.UpdatePaymentMethod(ctx, id, modelReq)
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_UPDATE_ERROR"), "payment_method", err.Error()),
contract.NewResponseError("PAYMENT_METHOD_UPDATE_ERROR", "payment_method", err.Error()),
})
}
@@ -99,7 +97,7 @@ func (s *PaymentMethodServiceImpl) DeletePaymentMethod(ctx context.Context, id u
err := s.paymentMethodProcessor.DeletePaymentMethod(ctx, id)
if err != nil {
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(paymentMethodErrorCode(err, "PAYMENT_METHOD_DELETE_ERROR"), "payment_method", err.Error()),
contract.NewResponseError("PAYMENT_METHOD_DELETE_ERROR", "payment_method", err.Error()),
})
}
@@ -125,12 +123,3 @@ func (s *PaymentMethodServiceImpl) GetActivePaymentMethodsByOrganization(ctx con
return contract.BuildSuccessResponse(contractResponses)
}
// paymentMethodErrorCode answers a request to create, delete or retype the EnakPoint
// method as a bad request instead of a server error.
func paymentMethodErrorCode(err error, fallback string) string {
if errors.Is(err, processor.ErrSystemPaymentMethod) {
return constants.ValidationErrorCode
}
return fallback
}
-55
View File
@@ -1,55 +0,0 @@
package service
import (
"context"
"errors"
"github.com/google/uuid"
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/processor"
"apskel-pos-be/internal/repository"
)
// PointPaymentService serves what the cashier needs before taking EnakPoint
// (docs/prd-point-coin.md F9).
type PointPaymentService interface {
Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response
}
type PointPaymentServiceImpl struct {
payments *processor.PointPaymentProcessor
}
func NewPointPaymentService(payments *processor.PointPaymentProcessor) *PointPaymentServiceImpl {
return &PointPaymentServiceImpl{payments: payments}
}
func (s *PointPaymentServiceImpl) Preview(ctx context.Context, apctx *appcontext.ContextInfo, orderID uuid.UUID) *contract.Response {
preview, err := s.payments.Preview(ctx, apctx.OrganizationID, orderID)
if err != nil {
return PointPaymentErrorResponse(err)
}
return contract.BuildSuccessResponse(preview)
}
// PointPaymentErrorResponse answers a refused EnakPoint payment as a bad request, PIN
// problems with their own codes, and anything else as a server error.
func PointPaymentErrorResponse(err error) *contract.Response {
var pinErr *processor.PinError
if errors.As(err, &pinErr) {
return PinErrorResponse(err)
}
code := constants.InternalServerErrorCode
switch {
case errors.Is(err, repository.ErrPointPaymentOrderNotFound):
code = constants.NotFoundErrorCode
case errors.Is(err, processor.ErrPointPaymentRejected):
code = constants.ValidationErrorCode
}
return contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(code, constants.WalletServiceEntity, err.Error()),
})
}
@@ -60,8 +60,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
OrderCount: item.OrderCount,
PaymentCount: item.PaymentCount,
Percentage: item.Percentage,
PointsUsed: item.PointsUsed,
CountsAsCashIn: item.CountsAsCashIn,
})
}
@@ -74,9 +72,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
GroupBy: resp.GroupBy,
Summary: contract.PaymentMethodSummary{
TotalAmount: resp.Summary.TotalAmount,
PointAmount: resp.Summary.PointAmount,
PointsUsed: resp.Summary.PointsUsed,
TotalWithPoints: resp.Summary.TotalWithPoints,
TotalOrders: resp.Summary.TotalOrders,
TotalPayments: resp.Summary.TotalPayments,
AverageOrderValue: resp.Summary.AverageOrderValue,
@@ -642,8 +637,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
OrderCount: item.OrderCount,
PaymentCount: item.PaymentCount,
Percentage: item.Percentage,
PointsUsed: item.PointsUsed,
CountsAsCashIn: item.CountsAsCashIn,
})
}
@@ -326,8 +326,6 @@ func CreatePaymentContractToModel(req *contract.CreatePaymentRequest) *models.Cr
return &models.CreatePaymentRequest{
OrderID: req.OrderID,
PaymentMethodID: req.PaymentMethodID,
Points: req.Points,
PaymentCode: req.PaymentCode,
Amount: req.Amount,
TransactionID: req.TransactionID,
SplitNumber: req.SplitNumber,
@@ -369,8 +367,6 @@ func PaymentModelToContract(resp *models.PaymentResponse) *contract.PaymentRespo
SplitType: resp.SplitType,
SplitDescription: resp.SplitDescription,
RefundAmount: resp.RefundAmount,
PointsUsed: resp.PointsUsed,
PointValue: resp.PointValue,
RefundReason: resp.RefundReason,
RefundedAt: resp.RefundedAt,
RefundedBy: resp.RefundedBy,
@@ -95,7 +95,6 @@ func (v *PaymentMethodValidatorImpl) isValidPaymentMethodType(paymentMethodType
string(constants.PaymentMethodTypeDigitalWallet),
string(constants.PaymentMethodTypeQR),
string(constants.PaymentMethodTypeEDC),
string(constants.PaymentMethodTypePoint),
}
for _, validType := range validTypes {
@@ -0,0 +1,35 @@
-- Brings back what 000094 and 000098 set up. The outlet settings removed by the up
-- migration are not restored: every outlet falls back to not accepting EnakPoint.
ALTER TABLE payments
ADD COLUMN IF NOT EXISTS points_used BIGINT,
ADD COLUMN IF NOT EXISTS point_value DECIMAL(10,2),
ADD CONSTRAINT chk_payments_point_pair CHECK (
(points_used IS NULL) = (point_value IS NULL)
AND (points_used IS NULL OR (points_used > 0 AND point_value > 0)));
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));
CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id)
WHERE type = 'point';
INSERT INTO payment_methods (organization_id, name, type, is_active)
SELECT id, 'EnakPoint', 'point', TRUE FROM organizations
ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING;
CREATE OR REPLACE FUNCTION create_point_payment_method()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO payment_methods (organization_id, name, type, is_active)
VALUES (NEW.id, 'EnakPoint', 'point', TRUE)
ON CONFLICT (organization_id) WHERE type = 'point' DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_create_point_payment_method
AFTER INSERT ON organizations
FOR EACH ROW
EXECUTE FUNCTION create_point_payment_method();
@@ -0,0 +1,25 @@
-- EnakPoint can no longer pay for orders: it can only be redeemed for vouchers
-- (docs/enakgame-prd.md §3.2). Undoes 000094 and the point type 000098 kept.
DROP TRIGGER IF EXISTS trigger_create_point_payment_method ON organizations;
DROP FUNCTION IF EXISTS create_point_payment_method();
-- payments.payment_method_id is ON DELETE RESTRICT, so this fails if any payment was
-- made with EnakPoint instead of silently losing it. None was (2026-10-07).
DELETE FROM payment_methods WHERE type = 'point';
DROP INDEX IF EXISTS uq_payment_methods_point_per_organization;
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery'));
ALTER TABLE payments
DROP CONSTRAINT IF EXISTS chk_payments_point_pair,
DROP COLUMN IF EXISTS points_used,
DROP COLUMN IF EXISTS point_value;
-- The outlet settings that switched it on. Their history in loyalty_setting_changes stays.
DELETE FROM outlet_settings WHERE key IN (
'loyalty.point.accept_payment',
'loyalty.point.min_payment_points',
'loyalty.point.max_payment_percent');