Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325ad8fc5a | ||
|
|
a320e66705 | ||
|
|
d6114a9fa9 | ||
|
|
18e87398bb | ||
|
|
296708244e | ||
|
|
798a36bd6c | ||
|
|
2c9753fae7 | ||
|
|
3ebc09f818 | ||
|
|
117e3529aa | ||
|
|
b59b7b4ddd | ||
|
|
fbe7e97dc7 | ||
|
|
b372821b23 | ||
|
|
d3e3ea6b08 | ||
|
|
b0ef226af1 | ||
|
|
79226bd891 | ||
|
|
306eabb1d9 | ||
|
|
892575202b | ||
|
|
c9654a387a | ||
|
|
e94eb2c26d | ||
|
|
e8b31035f7 | ||
|
|
f16a5da951 | ||
|
|
a24d5f0561 | ||
|
|
c1ec6a3dfd | ||
|
|
8bf2fe5585 | ||
|
|
a3cb7dd5fd |
+46
-105
@@ -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,6 +43,11 @@ 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 `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/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.
|
||||
|
||||
### GET /customer/wallet
|
||||
|
||||
@@ -69,7 +76,7 @@ Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk
|
||||
| `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
|
||||
@@ -120,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 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -131,36 +138,20 @@ 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.
|
||||
| POST | `/customer/enakgame/sessions` | – | Wajib |
|
||||
|
||||
### GET /customer/wallet/exchange/preview?coins=30
|
||||
|
||||
@@ -219,83 +210,19 @@ Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "
|
||||
|
||||
`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`.
|
||||
|
||||
### POST /customer/spin
|
||||
### Game (EnakGame)
|
||||
|
||||
Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
`POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`:
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb" },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
- `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda).
|
||||
- `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`.
|
||||
- `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar).
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong.
|
||||
Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -303,7 +230,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 |
|
||||
@@ -318,13 +245,12 @@ Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang;
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
"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 }
|
||||
}
|
||||
```
|
||||
|
||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `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
|
||||
|
||||
@@ -343,7 +269,8 @@ Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (defau
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { "…": "sama dengan point_expiry" }
|
||||
"coin_expiry": { "…": "sama dengan point_expiry" },
|
||||
"enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -375,7 +302,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,
|
||||
@@ -388,7 +315,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
|
||||
|
||||
@@ -402,8 +329,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`) |
|
||||
@@ -433,4 +358,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).
|
||||
|
||||
@@ -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`.
|
||||
@@ -16,44 +18,44 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a
|
||||
| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet |
|
||||
| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet |
|
||||
| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan |
|
||||
| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game |
|
||||
| Biaya main game | `entry_cost` di `/marketing/enakgame/games` | Marketing → EnakGame → game |
|
||||
|
||||
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_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
"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 }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Tipe | Default | Validasi |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
||||
| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat | angka | 1 | ≥ 0 |
|
||||
| `earn_mode` | Cara hitung: per nominal / persentase | pilihan `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya |
|
||||
| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 |
|
||||
| `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`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||
**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).
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
**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".
|
||||
|
||||
## Setting loyalitas organisasi
|
||||
|
||||
@@ -65,7 +67,8 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": { "…": "lihat bagian kedaluwarsa" },
|
||||
"coin_expiry": { "…": "lihat bagian kedaluwarsa" }
|
||||
"coin_expiry": { "…": "lihat bagian kedaluwarsa" },
|
||||
"enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -77,6 +80,8 @@ Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku s
|
||||
| `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 |
|
||||
| `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 |
|
||||
| `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, dihitung per currency, reset tengah malam WIB |
|
||||
| `enakgame.user_daily_limit` | Maksimal EnakCoin dari EnakGame per customer per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB |
|
||||
| `enakgame.global_daily_limit` | Maksimal EnakCoin dari EnakGame seluruh organisasi per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB |
|
||||
|
||||
### Alur simpan
|
||||
|
||||
@@ -98,7 +103,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
|
||||
|
||||
@@ -155,7 +160,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¤cy=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¤cy=POINT&type=TRANSFER_OUT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -185,7 +190,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
|
||||
|
||||
@@ -210,7 +215,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,
|
||||
@@ -225,7 +230,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
|
||||
|
||||
@@ -264,24 +269,14 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks
|
||||
|
||||
### Biaya main game
|
||||
|
||||
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.
|
||||
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama dan dikelola di `/marketing/enakgame/games`. Menu lama `/marketing/games`, `/marketing/game-prizes`, dan `/marketing/rewards` sudah dihapus. Biaya per main adalah `entry_cost` game (bilangan bulat ≥ 1), hadiahnya diatur di reward config game itu. Langkah membuat spin ada di [`enakgame-spin.md`](./enakgame-spin.md). Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
|
||||
|
||||
## 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" |
|
||||
|
||||
@@ -298,8 +293,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`.
|
||||
- [ ] Form game EnakGame punya input `entry_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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
# Spin sebagai Game EnakGame
|
||||
|
||||
**Sumber:** [RFC EnakGame](rfc-enakgame.md) §14, task EG-1001
|
||||
**Pembaca:** admin organisasi dan tim backoffice / aplikasi customer
|
||||
|
||||
Spin lama (`POST /customer/spin`) diganti dengan game EnakGame biasa: tipe `SPIN`,
|
||||
reward `PROBABILITY`, hadiah berupa EnakCoin. Spin dimainkan lewat endpoint session
|
||||
yang sama dengan game lain, sehingga ikut mendapat idempotency, refund otomatis, budget,
|
||||
event, dan Economy Guard.
|
||||
|
||||
Tidak ada seeder: setiap organisasi membuat spin-nya sendiri lewat API admin di bawah.
|
||||
Semua langkah memakai token admin organisasi; langkah 2–4 butuh loyalty manager.
|
||||
|
||||
## Langkah Admin
|
||||
|
||||
### 1. Buat game
|
||||
|
||||
`POST /api/v1/marketing/enakgame/games`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Spin Harian",
|
||||
"slug": "spin",
|
||||
"type": "SPIN",
|
||||
"entry_cost": 5,
|
||||
"status": "ACTIVE",
|
||||
"thumbnail_url": "https://…/spin.png",
|
||||
"game_url": "https://…/spin/index.html"
|
||||
}
|
||||
```
|
||||
|
||||
`entry_cost` adalah EnakCoin yang dipotong setiap kali spin (minimal 1).
|
||||
|
||||
### 2. Buat reward config `PROBABILITY`
|
||||
|
||||
`POST /api/v1/marketing/enakgame/games/:id/reward-configs`
|
||||
|
||||
```json
|
||||
{
|
||||
"reward_type": "PROBABILITY",
|
||||
"max_reward": 50,
|
||||
"rules": {
|
||||
"table": [
|
||||
{ "weight": 50, "amount": 0, "label": "Zonk" },
|
||||
{ "weight": 30, "amount": 3, "label": "3 Coin" },
|
||||
{ "weight": 15, "amount": 10, "label": "10 Coin" },
|
||||
{ "weight": 5, "amount": 50, "label": "Jackpot" }
|
||||
]
|
||||
},
|
||||
"reason": "Spin pertama"
|
||||
}
|
||||
```
|
||||
|
||||
- Satu baris `table` = satu segmen roda, urut searah gambar roda.
|
||||
- `weight` bilangan bulat ≥ 1. Peluang segmen = `weight` ÷ total weight (di atas: 50%,
|
||||
30%, 15%, 5%). Weight tidak pernah dikirim ke customer.
|
||||
- `amount` EnakCoin yang didapat (boleh 0). `label` opsional, maksimal 100 karakter,
|
||||
ditampilkan di roda.
|
||||
- `max_reward` minimal sebesar `amount` terbesar, kalau tidak hadiah besar terpotong.
|
||||
|
||||
### 3. Aktifkan config
|
||||
|
||||
`POST /api/v1/marketing/enakgame/reward-configs/:id/activate`
|
||||
|
||||
Mengganti hadiah nanti berarti membuat versi config baru lalu mengaktifkannya; session
|
||||
yang sedang berjalan tetap memakai versi saat dimulai.
|
||||
|
||||
### 4. Pastikan ada budget global bulan berjalan
|
||||
|
||||
`POST /api/v1/marketing/enakgame/budgets` dengan `scope: "GLOBAL"`, bila belum ada. Tanpa
|
||||
budget global, customer tidak bisa memulai game apa pun.
|
||||
|
||||
## Alur di Aplikasi Customer
|
||||
|
||||
1. `GET /api/v1/customer/enakgame/games`: game spin punya `prizes`, yaitu segmen roda
|
||||
(`entry`, `label`, `amount`) dalam urutan config. Gambar roda dari sini.
|
||||
2. `POST /api/v1/customer/enakgame/sessions` dengan `{"game_id": "…"}` dan header
|
||||
`Idempotency-Key`. Entry cost dipotong di sini.
|
||||
3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan body `{}`. Server yang
|
||||
mengundi. Response berisi `prize` (`entry`, `label`, `amount`): putar roda sampai
|
||||
berhenti di segmen `entry` itu. `reward_total` adalah Coin yang benar-benar masuk,
|
||||
bisa lebih besar dari `prize.amount` karena event, atau lebih kecil karena limit
|
||||
harian (`limited_by`).
|
||||
|
||||
Aplikasi tidak boleh mengundi sendiri atau mengirim hadiah: apa pun yang dikirim selain
|
||||
data hasil diabaikan.
|
||||
+71
-157
@@ -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¤cy=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30`
|
||||
`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=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.
|
||||
|
||||
---
|
||||
|
||||
@@ -467,23 +367,18 @@ Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default
|
||||
|
||||
## 7. Game
|
||||
|
||||
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
|
||||
Game lama (`POST /api/v1/customer/spin`, `GET /customer/games`,
|
||||
`GET /customer/ferris-wheel`, dan admin `/marketing/games`, `/marketing/game-prizes`,
|
||||
`/marketing/rewards`) sudah dihapus. Semua game, termasuk spin, sekarang game EnakGame:
|
||||
|
||||
Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
Response:
|
||||
- Customer: `GET /api/v1/customer/enakgame/games`, lalu
|
||||
`POST /api/v1/customer/enakgame/sessions` (wajib `Idempotency-Key`, memotong
|
||||
`entry_cost` EnakCoin), lalu `POST /api/v1/customer/enakgame/sessions/:id/complete`
|
||||
(server menghitung hadiah EnakCoin). Tanpa PIN.
|
||||
- Dashboard: game dan biaya per main (`entry_cost`, bilangan bulat ≥ 1) diatur di
|
||||
`/marketing/enakgame/games`, hadiahnya di reward config.
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
|
||||
EnakCoin yang terpotong.
|
||||
|
||||
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
|
||||
Langkah admin dan alur aplikasi untuk spin ada di [`enakgame-spin.md`](enakgame-spin.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -504,6 +399,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
|
||||
@@ -516,15 +429,16 @@ Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
"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 }
|
||||
}
|
||||
```
|
||||
|
||||
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`). **Tampilkan persentase ini di
|
||||
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
|
||||
pada `earn_mode` `PERCENTAGE`). **Tampilkan persentase ini di
|
||||
samping setting** supaya owner tidak salah membaca skala: default di atas setara
|
||||
cashback 1%.
|
||||
|
||||
@@ -548,7 +462,8 @@ tanpa menyimpan)
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { … sama … }
|
||||
"coin_expiry": { … sama … },
|
||||
"enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
@@ -596,10 +511,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
|
||||
|
||||
@@ -623,12 +538,11 @@ 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
|
||||
`expiry_activations` sebelum owner menyimpan setting.
|
||||
- [ ] Isi `metadata.coin_cost` untuk setiap game.
|
||||
- [ ] Buat ulang game (termasuk spin) di `/marketing/enakgame/games` dengan `entry_cost`
|
||||
dan reward config ([`enakgame-spin.md`](enakgame-spin.md)).
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Migrasi printer_type ke printer_types
|
||||
|
||||
1 Oktober 2026
|
||||
|
||||
## Ringkasan
|
||||
|
||||
`printer_type` (string) dihapus dan diganti `printer_types` (array string), sehingga satu produk bisa dicetak ke lebih dari satu printer. Contohnya "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di kitchen dan bar sekaligus.
|
||||
|
||||
Ini breaking change. Setelah backend baru dirilis, client yang masih membaca `printer_type` tidak menerima printer apa pun. Karena itu app POS dan dashboard harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||
|
||||
Yang perlu bertindak:
|
||||
|
||||
- **App POS**: baca `printer_types` dan cetak tiap item ke semua printer di dalamnya.
|
||||
- **Dashboard admin**: ganti pilihan printer di form produk jadi multi-select yang mengirim `printer_types`.
|
||||
|
||||
## Perubahan response
|
||||
|
||||
`printer_type` hilang dari semua response dan digantikan `printer_types`, yang nilainya selalu array dan tidak pernah `null`.
|
||||
|
||||
| Endpoint | Letak field |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/products`, `PUT /api/v1/products/:id`, `GET /api/v1/products`, `GET /api/v1/products/all`, `GET /api/v1/products/:id` | objek produk |
|
||||
| `POST /api/v1/orders`, `GET /api/v1/orders`, `GET /api/v1/orders/:id`, `PUT /api/v1/orders/:id` | `order_items[]` |
|
||||
| `POST /api/v1/orders/:id/add-items` | `added_items[]` dan `updated_order.order_items[]` |
|
||||
| `POST /api/v1/self-order/orders`, `GET /api/v1/self-order/orders/:session_id` | `order_items[]` |
|
||||
| `/api/v1/product-recipes` (semua yang mengembalikan recipe) | `product` |
|
||||
| `/api/v1/inventory` | `product`, selalu `[]` karena produk di sini hanya berisi id dan nama |
|
||||
|
||||
Contoh satu item di `order_items[]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Paket Makan Minum",
|
||||
"printer_types": ["kitchen", "bar"],
|
||||
"print_to_checker": true
|
||||
}
|
||||
```
|
||||
|
||||
Aturan nilainya:
|
||||
|
||||
- `printer_types: []` artinya produk tidak dicetak ke printer mana pun.
|
||||
- Urutan printer sesuai yang disimpan admin, tanpa duplikat.
|
||||
- Void, refund, payment, split bill, dan set customer tidak membawa data printer, sama seperti sebelumnya.
|
||||
|
||||
## Perubahan request produk
|
||||
|
||||
`POST /api/v1/products` dan `PUT /api/v1/products/:id` menerima `printer_types` sebagai pengganti `printer_type`. Kalau `printer_type` masih dikirim, field itu diabaikan tanpa error.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Paket Makan Minum",
|
||||
"category_id": "<uuid>",
|
||||
"price": 25000,
|
||||
"printer_types": ["kitchen", "bar"]
|
||||
}
|
||||
```
|
||||
|
||||
| Request | `printer_types` yang dikirim | Hasil |
|
||||
| --- | --- | --- |
|
||||
| Create | tidak dikirim | `["kitchen"]` |
|
||||
| Create | `["kitchen", "bar"]` | `["kitchen", "bar"]` |
|
||||
| Create | `[]` atau hanya string kosong | `["kitchen"]` |
|
||||
| Update | tidak dikirim | tidak berubah |
|
||||
| Update | `["bar"]` | `["bar"]`, seluruh daftar diganti |
|
||||
| Update | `[]` | `[]`, produk tidak dicetak ke mana pun |
|
||||
|
||||
Sebelum disimpan, spasi di awal dan akhir tiap entri dibuang, lalu entri kosong dan duplikat dihapus. Urutan dipertahankan.
|
||||
|
||||
Tiap entri maksimal 50 karakter. Kalau lebih, request ditolak dengan error code `310` dan pesan `each printer_types entry cannot exceed 50 characters`.
|
||||
|
||||
## Migrasi app POS
|
||||
|
||||
App POS harus mengirim tiap item ke semua printer di `printer_types`, sehingga satu item bisa muncul di lebih dari satu tiket.
|
||||
|
||||
1. Ganti `printer_type` dengan `printer_types` (list string) di model order item dan produk.
|
||||
2. Selama backend lama masih jalan, `printer_types` belum ada di response. Pakai `[printer_type]` kalau `printer_types` tidak ada, supaya app baru bisa dirilis sebelum backend.
|
||||
3. Saat mencetak, kelompokkan item per printer dengan mengulang setiap entri `printer_types` milik item.
|
||||
4. Item dengan `printer_types: []` tidak dicetak ke printer station mana pun.
|
||||
5. `print_to_checker` tidak berubah dan tetap diperlakukan terpisah.
|
||||
6. Untuk tambahan pesanan dari `POST /api/v1/orders/:id/add-items`, cetak dari `added_items[]`.
|
||||
|
||||
```text
|
||||
tiket = {}
|
||||
untuk setiap item di order_items:
|
||||
printers = item.printer_types ?? [item.printer_type] // fallback hanya untuk backend lama
|
||||
untuk setiap printer di printers:
|
||||
tiket[printer].tambah(item)
|
||||
untuk setiap (printer, items) di tiket:
|
||||
cetak items ke printer
|
||||
```
|
||||
|
||||
Perbaikan di `added_items[]`: sebelumnya field ini selalu berisi `product_name` dan printer kosong, serta `print_to_checker: true`. Sekarang isinya lengkap seperti item di `updated_order.order_items[]`, dengan urutan sesuai request. Kalau app selama ini mengakali dengan mencari item baru di `updated_order`, cara itu bisa diganti dengan `added_items[]` langsung.
|
||||
|
||||
## Migrasi dashboard
|
||||
|
||||
Form produk di dashboard harus memakai multi-select printer dan selalu mengirim daftar lengkapnya lewat `printer_types`.
|
||||
|
||||
1. Ganti dropdown printer tunggal dengan multi-select, misalnya checkbox, berisi pilihan printer yang sama.
|
||||
2. Saat membuka form edit, isi pilihan dari `printer_types`. Selama backend lama masih jalan, pakai `[printer_type]` kalau `printer_types` tidak ada.
|
||||
3. Saat menyimpan, kirim `printer_types` berisi semua printer yang dipilih.
|
||||
4. Selama backend lama masih jalan, kirim juga `printer_type` berisi printer pertama. Backend lama hanya membaca `printer_type`, dan backend baru mengabaikannya.
|
||||
5. Di daftar produk, tampilkan semua printer dari `printer_types`.
|
||||
|
||||
Backend tidak membatasi nilai printer. Nilainya harus sama persis dengan nama printer yang dikenal app POS, termasuk huruf besar dan kecilnya.
|
||||
|
||||
Create dengan `printer_types: []` tetap menghasilkan `["kitchen"]`. Produk tanpa printer dibuat dulu, lalu di-update dengan `printer_types: []`.
|
||||
|
||||
## Database dan urutan rilis
|
||||
|
||||
Migration `000099_add_printer_types_to_products` menambah kolom `products.printer_types` (JSONB, `NOT NULL`, default `["kitchen"]`), mengisinya dari `printer_type`, lalu menghapus kolom `printer_type` beserta index-nya.
|
||||
|
||||
- Produk dengan `printer_type` berisi nilai menjadi `[printer_type]`.
|
||||
- Produk dengan `printer_type` `NULL` atau kosong menjadi `[]`.
|
||||
|
||||
Urutan rilis:
|
||||
|
||||
1. Rilis app POS baru, yang membaca `printer_types` dengan fallback ke `printer_type`, ke semua outlet.
|
||||
2. Rilis dashboard baru, yang mengirim `printer_types` dan `printer_type`.
|
||||
3. Jalankan migration `000099` tepat sebelum deploy backend baru. Di antara keduanya, backend lama gagal menyimpan produk karena kolom `printer_type` sudah tidak ada.
|
||||
4. Setelah backend baru jalan, dashboard boleh berhenti mengirim `printer_type`, dan fallback di app POS boleh dihapus.
|
||||
5. Atur produk multi-printer, misalnya "Paket Makan Minum" ke `["kitchen", "bar"]`.
|
||||
|
||||
Outlet yang masih memakai app POS lama setelah langkah 3 tidak menerima printer untuk semua item. Pastikan langkah 1 sudah selesai di semua outlet.
|
||||
|
||||
Rollback: jalankan down migration dan deploy backend lama bersamaan. Down migration membuat ulang kolom `printer_type` berisi printer pertama, lalu menghapus `printer_types`, jadi yang hilang hanya printer tambahan.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] App POS baru (dengan fallback) terpasang di semua outlet
|
||||
- [ ] Dashboard baru mengirim `printer_types` dan `printer_type`
|
||||
- [ ] Migration `000099` dan backend baru dirilis di staging
|
||||
- [ ] Uji "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di dua printer, saat order baru dan saat tambah pesanan
|
||||
- [ ] Migration `000099` dan backend baru dirilis di production
|
||||
- [ ] Dashboard berhenti mengirim `printer_type`
|
||||
- [ ] Fallback `printer_type` di app POS dihapus
|
||||
|
||||
## FAQ
|
||||
|
||||
**Kenapa `printer_type` dihapus, bukan dipertahankan?** Supaya hanya ada satu sumber data printer. Dua field yang menyimpan hal yang sama bisa saling berbeda.
|
||||
|
||||
**Apakah kitchen dan bar menerima tiket yang sama?** Ya. Keduanya mencetak "Paket Makan Minum" lengkap dengan varian dan modifier-nya. Memecah isi paket per station butuh fitur bundle, yang di luar rilis ini.
|
||||
|
||||
**Apakah nilai printer dibatasi?** Tidak. Nilainya string bebas sampai 50 karakter, dan harus sama dengan nama printer di app POS.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Migrasi profit sharing
|
||||
|
||||
4 Oktober 2026
|
||||
|
||||
## Ringkasan
|
||||
|
||||
Ada tiga perubahan di backend:
|
||||
|
||||
1. **Parent category bisa ditandai bukan team.** Kategori punya field baru `is_team`. Parent category dengan `is_team: false` tidak bisa dipilih sebagai team di purchase order dan cash advance, dan tidak ikut laporan profit sharing.
|
||||
2. **Endpoint laporan pindah path.** `/api/v1/analytics/parent-categories` menjadi `/api/v1/analytics/profit-sharing`.
|
||||
3. **Pembagian revenue tinggal dua porsi.** Porsi purchase (60%) dihapus. Revenue sekarang dibagi ke owner (SDL) dan team, dengan porsi team = 100% − fee owner.
|
||||
|
||||
Nomor 2 dan 3 adalah breaking change. Setelah backend baru dirilis, client yang masih memanggil path lama mendapat 404, dan `limit_purchase` serta `percentages.purchase` tidak ada lagi di response. Karena itu client harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||
|
||||
Yang perlu bertindak, di dashboard maupun app mobile, mana pun yang punya layarnya:
|
||||
|
||||
- **Form kategori**: tambah toggle team untuk parent category.
|
||||
- **Laporan profit sharing**: ganti path, hapus porsi purchase, tampilkan dua porsi.
|
||||
- **Form purchase order dan cash advance**: picker team tidak perlu diubah, tapi form edit perlu menyesuaikan, lihat [Purchase order dan cash advance](#purchase-order-dan-cash-advance).
|
||||
|
||||
## Kategori: field `is_team`
|
||||
|
||||
### Response
|
||||
|
||||
Semua response kategori membawa `is_team` (boolean, tidak pernah `null`): `POST /api/v1/categories`, `PUT /api/v1/categories/:id`, `GET /api/v1/categories`, dan `GET /api/v1/categories/:id`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"name": "Merchandise",
|
||||
"parent_id": null,
|
||||
"owner_fee_percent": null,
|
||||
"is_team": false
|
||||
}
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
`POST /api/v1/categories` dan `PUT /api/v1/categories/:id` menerima `is_team`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Merchandise",
|
||||
"is_team": false
|
||||
}
|
||||
```
|
||||
|
||||
| Request | `is_team` yang dikirim | Hasil |
|
||||
| --- | --- | --- |
|
||||
| Create | tidak dikirim | `true` |
|
||||
| Create | `false` | `false` |
|
||||
| Update | tidak dikirim atau `null` | tidak berubah |
|
||||
| Update | `true` atau `false` | diganti |
|
||||
|
||||
Update yang hanya berisi `is_team` diterima.
|
||||
|
||||
Aturan nilainya:
|
||||
|
||||
- `is_team` hanya berpengaruh di parent category, yaitu kategori dengan `parent_id: null`. Di sub-category nilainya disimpan tapi tidak dipakai, jadi tampilkan toggle hanya untuk parent category.
|
||||
- Semua kategori yang sudah ada sebelum rilis bernilai `true`. Tidak ada yang berubah sampai admin mematikannya.
|
||||
- Flag dibaca saat request, bukan saat transaksi. Kalau parent category dimatikan, penjualannya di periode lampau juga hilang dari laporan profit sharing. Kalau dinyalakan lagi, semuanya muncul kembali.
|
||||
|
||||
### Efek `is_team: false`
|
||||
|
||||
| Endpoint | Efek |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/purchase-orders/teams`, `GET /api/v1/cash-advances/teams` | Kategori tidak muncul. Pusat tetap ada. |
|
||||
| Create dan update purchase order dan cash advance | `team_scope: "category"` dengan `team_category_id` kategori ini ditolak. |
|
||||
| `GET /api/v1/analytics/profit-sharing` | Kategori tidak muncul di `data`, dan revenue-nya tidak dihitung di `budget`. |
|
||||
| `GET /api/v1/analytics/profit-sharing/:parent_category_id` | Ditolak. |
|
||||
|
||||
Data yang sudah ada tidak diubah. Purchase order dan cash advance yang sudah tercatat ke kategori itu tetap menyimpan team-nya. Laporan purchasing (`GET /api/v1/analytics/purchasing`) masih menampilkannya di `team_data`, dan filter `team=<category_id>` tetap bisa dipakai.
|
||||
|
||||
Laporan lain yang tidak menyaring `is_team`, misalnya `GET /api/v1/analytics/categories`, tetap menampilkan penjualan kategori itu.
|
||||
|
||||
## Purchase order dan cash advance
|
||||
|
||||
Picker team sudah mengambil dari `GET /api/v1/purchase-orders/teams` dan `GET /api/v1/cash-advances/teams`, jadi kategori non-team otomatis tidak muncul tanpa perubahan di client.
|
||||
|
||||
Yang perlu diubah ada di form edit. Purchase order atau cash advance lama bisa tercatat ke kategori yang sekarang sudah non-team. Kalau form edit mengirim ulang `team_scope` dan `team_category_id` yang sama, request ditolak, walaupun user tidak mengubah team-nya.
|
||||
|
||||
1. Kirim `team_scope` dan `team_category_id` hanya kalau user mengganti team. Kalau `team_scope` tidak dikirim, team yang tersimpan tidak berubah.
|
||||
2. Kalau team yang tersimpan tidak ada di daftar `/teams`, tampilkan namanya dari field `team` di response apa adanya, dan jangan memilihkan team lain secara otomatis.
|
||||
|
||||
Contoh error saat team ditolak, dengan HTTP status 500:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "900",
|
||||
"entity": "purchase_order_service",
|
||||
"cause": "category Merchandise is not a team"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk cash advance, `entity` bernilai `cash_advance_service`. Cocokkan dengan teks `is not a team` di `cause` kalau perlu menampilkan pesan khusus.
|
||||
|
||||
## Laporan profit sharing
|
||||
|
||||
### Path baru
|
||||
|
||||
| Lama | Baru |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/analytics/parent-categories` | `GET /api/v1/analytics/profit-sharing` |
|
||||
| `GET /api/v1/analytics/parent-categories/:parent_category_id` | `GET /api/v1/analytics/profit-sharing/:parent_category_id` |
|
||||
|
||||
Query parameter dan role tidak berubah:
|
||||
|
||||
- `date_from` dan `date_to` wajib, dengan format `DD-MM-YYYY`, misalnya `28-09-2026`.
|
||||
- `outlet_id` opsional.
|
||||
- Hanya bisa diakses superadmin, admin, manager, owner, dan purchasing.
|
||||
|
||||
Path lama sudah tidak ada dan mengembalikan 404.
|
||||
|
||||
### Pembagian revenue
|
||||
|
||||
Revenue tiap parent category yang team dibagi dua:
|
||||
|
||||
- **SDL (fee owner)** = revenue × `owner_fee_percent` / 100. Default-nya 20%, dan bisa diganti per parent category lewat `owner_fee_percent` di kategori.
|
||||
- **Team** = revenue − SDL.
|
||||
|
||||
Contoh dengan tiga parent category dalam satu minggu:
|
||||
|
||||
| Parent category | `is_team` | Fee owner | Revenue | SDL | Team |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Food | `true` | 20% (default) | 1.000.000 | 200.000 | 800.000 |
|
||||
| Drink | `true` | 35% | 2.000.000 | 700.000 | 1.300.000 |
|
||||
| Merchandise | `false` | - | 500.000 | tidak dihitung | tidak dihitung |
|
||||
| **Budget minggu ini** | | | **3.000.000** | **900.000** | **2.100.000** |
|
||||
|
||||
### Perubahan field
|
||||
|
||||
| Field | Sebelum | Sesudah |
|
||||
| --- | --- | --- |
|
||||
| `data[]` | semua parent category | hanya parent category team |
|
||||
| `budget.percentages.purchase` | `60` | dihapus |
|
||||
| `budget.percentages.owner` | `20`, atau fee parent itu di endpoint detail | tidak berubah |
|
||||
| `budget.percentages.team` | `20` | `100 − owner`: `80` di list, `100 − fee parent` di detail |
|
||||
| `limit_purchase` di `budget.total`, `budget.weekly[]`, `budget.monthly[]` | 60% revenue | dihapus |
|
||||
| `limit_team` di tempat yang sama | 20% revenue | `revenue − sdl` |
|
||||
| `revenue` dan `sdl` di `budget` | semua parent category | hanya parent category team |
|
||||
|
||||
Di endpoint list, `budget.percentages` selalu berisi default `20` dan `80`, walaupun ada parent dengan fee berbeda. Angka `sdl` dan `limit_team` dihitung per parent dengan fee masing-masing, jadi `limit_team / revenue` bisa tidak persis 80%. Tampilkan angka rupiah dari response, jangan dihitung ulang dari persentase.
|
||||
|
||||
Baris di `data[]` membawa `sdl` tapi tidak membawa porsi team. Kalau porsi team per parent perlu ditampilkan, hitung dari `total_revenue − sdl`.
|
||||
|
||||
Contoh response list, dipotong:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"date_from": "2026-09-28T00:00:00+07:00",
|
||||
"date_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"data": [
|
||||
{
|
||||
"parent_category_id": "<uuid>",
|
||||
"parent_category_name": "Drink",
|
||||
"owner_fee_percent": 35,
|
||||
"sdl": 700000,
|
||||
"total_revenue": 2000000
|
||||
},
|
||||
{
|
||||
"parent_category_id": "<uuid>",
|
||||
"parent_category_name": "Food",
|
||||
"owner_fee_percent": 20,
|
||||
"sdl": 200000,
|
||||
"total_revenue": 1000000
|
||||
}
|
||||
],
|
||||
"budget": {
|
||||
"percentages": { "owner": 20, "team": 80 },
|
||||
"cut_off_from": "2026-09-28T00:00:00+07:00",
|
||||
"cut_off_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"total": {
|
||||
"period_start": "2026-09-28T00:00:00+07:00",
|
||||
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"revenue": 3000000,
|
||||
"order_count": 4,
|
||||
"sdl": 900000,
|
||||
"limit_team": 2100000
|
||||
},
|
||||
"weekly": [
|
||||
{
|
||||
"period_start": "2026-09-28T00:00:00+07:00",
|
||||
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"revenue": 3000000,
|
||||
"order_count": 4,
|
||||
"sdl": 900000,
|
||||
"limit_team": 2100000
|
||||
}
|
||||
],
|
||||
"monthly": [
|
||||
{
|
||||
"month": "2026-09",
|
||||
"week_count": 1,
|
||||
"period_start": "2026-09-28T00:00:00+07:00",
|
||||
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"revenue": 3000000,
|
||||
"order_count": 4,
|
||||
"sdl": 900000,
|
||||
"limit_team": 2100000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
Di endpoint detail, `budget` bentuknya sama, tapi `percentages` memakai fee parent itu, misalnya `{ "owner": 35, "team": 65 }` untuk Drink.
|
||||
|
||||
### Detail kategori non-team
|
||||
|
||||
Detail untuk parent category non-team ditolak dengan HTTP status 500:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "internal_error",
|
||||
"entity": "AnalyticsHandler::GetParentCategoryAnalyticsDetail",
|
||||
"cause": "failed to get parent category analytics detail: failed to get parent category analytics detail: category Merchandise is not a team"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ini bisa terjadi kalau user membuka link lama atau bookmark ke parent yang baru dimatikan. Cocokkan dengan teks `is not a team` di `cause`, lalu arahkan user kembali ke list.
|
||||
|
||||
## Migrasi client
|
||||
|
||||
### Laporan profit sharing
|
||||
|
||||
1. Ganti path ke `/api/v1/analytics/profit-sharing`. Selama backend lama masih jalan, path baru mengembalikan 404. Kalau dapat 404, panggil path lama `/api/v1/analytics/parent-categories`, supaya client baru bisa dirilis sebelum backend.
|
||||
2. Hapus tampilan limit purchase dan persentase purchase. Jangan menganggap `limit_purchase` atau `percentages.purchase` selalu ada.
|
||||
3. Tampilkan dua porsi dengan label "SDL / Fee owner" dan "Team", ambil angkanya dari `sdl` dan `limit_team`.
|
||||
4. Kalau detail ditolak dengan `is not a team`, arahkan kembali ke list.
|
||||
|
||||
Selama fallback ke backend lama, `limit_team` masih berisi 20% revenue. Angkanya baru jadi `revenue − sdl` setelah backend baru dirilis.
|
||||
|
||||
### Form kategori
|
||||
|
||||
1. Tambah toggle `is_team` untuk parent category, misalnya berlabel "Ikut profit sharing (team)". Untuk kategori baru, toggle menyala secara default.
|
||||
2. Saat membuka form edit, isi toggle dari `is_team`. Selama backend lama masih jalan, field ini tidak ada di response, jadi anggap `true`.
|
||||
3. Saat admin mematikan toggle, tampilkan konfirmasi bahwa kategori itu tidak akan muncul di pilihan team dan di laporan profit sharing, termasuk untuk periode lampau.
|
||||
4. Di daftar kategori, beri penanda untuk parent category dengan `is_team: false`.
|
||||
|
||||
Backend lama mengabaikan `is_team` di request, jadi toggle bisa dirilis lebih dulu, tapi belum berpengaruh sampai backend baru jalan. Pengecualiannya update yang hanya berisi `is_team`: backend lama menolaknya dengan error code `303` dan pesan `at least one field must be provided for update`. Selama masa transisi, kirim `is_team` bersama field form lainnya.
|
||||
|
||||
## Database dan urutan rilis
|
||||
|
||||
Migration `000101_add_is_team_to_categories` menambah kolom `categories.is_team` (`BOOLEAN NOT NULL DEFAULT TRUE`). Semua kategori yang ada otomatis bernilai `true`. Migration ini hanya menambah kolom, jadi backend lama tetap jalan normal setelahnya.
|
||||
|
||||
Urutan rilis:
|
||||
|
||||
1. Rilis client baru: path profit sharing dengan fallback ke path lama, tanpa porsi purchase, dan dengan toggle `is_team`. Untuk app mobile, pastikan versi baru sudah dipakai sebagian besar user sebelum langkah 3, karena versi lama yang memanggil `/parent-categories` mendapat 404 setelah itu.
|
||||
2. Jalankan migration `000101`.
|
||||
3. Deploy backend baru.
|
||||
4. Hapus fallback path lama di client.
|
||||
5. Admin mematikan `is_team` di parent category yang bukan team.
|
||||
|
||||
Rollback: deploy backend lama, lalu jalankan down migration yang menghapus kolom `is_team`. Client dengan fallback tetap jalan di backend lama. Nilai `is_team` yang sudah diatur admin hilang saat kolom dihapus.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Client baru (fallback path, tanpa porsi purchase, toggle `is_team`) dirilis
|
||||
- [ ] Migration `000101` dan backend baru dirilis di staging
|
||||
- [ ] Uji: parent category yang dimatikan hilang dari `/purchase-orders/teams` dan `/cash-advances/teams`
|
||||
- [ ] Uji: purchase order dan cash advance baru dengan kategori itu ditolak, dan edit purchase order lama tanpa mengganti team tetap berhasil
|
||||
- [ ] Uji: kategori itu hilang dari `/analytics/profit-sharing`, dan detailnya ditolak
|
||||
- [ ] Uji: `limit_team = revenue − sdl`, dan `sdl` mengikuti fee masing-masing parent
|
||||
- [ ] Migration `000101` dan backend baru dirilis di production
|
||||
- [ ] Fallback path lama di client dihapus
|
||||
|
||||
## FAQ
|
||||
|
||||
**Kenapa porsi team jadi 100% − fee owner, bukan tetap 20%?** Porsi purchase sudah tidak ada, jadi seluruh revenue dibagi dua. Owner mengambil fee-nya, dan sisanya untuk team. Kalau fee owner sebuah parent dinaikkan, porsi team parent itu turun sebesar yang sama.
|
||||
|
||||
**Apakah sub-category bisa dijadikan non-team sendiri?** Tidak. Team dan profit sharing dihitung per parent category, jadi semua sub-category ikut status parent-nya.
|
||||
|
||||
**Bagaimana dengan kategori top-level yang tidak punya sub-category?** Kategori itu tetap parent category, jadi `is_team` berlaku untuknya.
|
||||
@@ -0,0 +1,583 @@
|
||||
# Prompt: fitur EnakPoint & EnakCoin di Mobile App Customer
|
||||
|
||||
Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice).
|
||||
Tugasmu: membangun fitur loyalitas EnakPoint & EnakCoin di aplikasi, memakai API backend
|
||||
yang sudah jadi dan dijelaskan di dokumen ini. Jangan mengarang endpoint, field, atau
|
||||
aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan dulu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Konteks bisnis
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Belanja (order lunas), koreksi admin, tukar EnakCoin | Belanja, koreksi admin |
|
||||
| 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, 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
|
||||
analytics.
|
||||
5. **Satu akun customer = satu organisasi.** Saldo berlaku di semua outlet organisasi itu.
|
||||
6. **Waktu memakai WIB.** Tanggal kedaluwarsa berarti saldo masih bisa dipakai sampai
|
||||
23:59:59 WIB di tanggal itu.
|
||||
|
||||
---
|
||||
|
||||
## 2. Koneksi ke API
|
||||
|
||||
- Base URL: `/api/v1`
|
||||
- Semua endpoint customer: header `Authorization: Bearer <token login customer>`
|
||||
- Semua jumlah di request dan response berupa integer.
|
||||
|
||||
### Registrasi customer
|
||||
|
||||
`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional):
|
||||
|
||||
```json
|
||||
{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" }
|
||||
```
|
||||
|
||||
- Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu.
|
||||
- Bila `organization_id` tidak dikirim dan backend hanya punya satu organisasi, customer
|
||||
otomatis masuk ke organisasi itu. Bila ada lebih dari satu, registrasi ditolak
|
||||
("organization_id is required"), jadi sebaiknya app selalu mengirimnya dari config per
|
||||
environment/brand.
|
||||
- `organization_id` yang dikirim harus ada; bila tidak, registrasi ditolak sebelum OTP dikirim.
|
||||
- Wallet customer baru belum punya baris sampai saldo pertama kali bergerak;
|
||||
`GET /customer/wallet` tetap menjawab saldo 0.
|
||||
|
||||
### Format response
|
||||
|
||||
Sukses:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { … }, "errors": null }
|
||||
```
|
||||
|
||||
Gagal:
|
||||
|
||||
```json
|
||||
{ "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] }
|
||||
```
|
||||
|
||||
| `errors[0].code` | HTTP | Arti | Yang dilakukan app |
|
||||
|---|---|---|---|
|
||||
| `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum |
|
||||
| `304` | 400 | Ditolak aturan bisnis | Tampilkan pesan yang ramah (lihat tiap fitur); `cause` berbahasa Inggris, jangan tampilkan mentah |
|
||||
| `404` | 404 | Tidak ditemukan | Tampilkan "tidak ditemukan" |
|
||||
| `429` | 429 | Minta OTP terlalu cepat | Tampilkan hitung mundur sebelum boleh minta lagi |
|
||||
| `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) |
|
||||
| `PIN_INVALID` | 400 | PIN salah | §6.5 |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci | §6.5 |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | §6.5 |
|
||||
| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
### Idempotency-Key
|
||||
|
||||
Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik, maks.
|
||||
50 karakter, mis. UUID v4).
|
||||
|
||||
- Buat **satu key baru saat customer menekan tombol konfirmasi**.
|
||||
- Bila request gagal karena jaringan/timeout, **kirim ulang dengan key yang sama**.
|
||||
Server mengembalikan hasil pertama dengan `"replayed": true` dan tidak memotong saldo
|
||||
dua kali.
|
||||
- Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Layar yang perlu dibuat
|
||||
|
||||
| Layar | Endpoint utama | Butuh PIN |
|
||||
|---|---|---|
|
||||
| Beranda wallet | `GET /customer/wallet` | – |
|
||||
| Riwayat mutasi | `GET /customer/wallet/transactions` | – |
|
||||
| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – |
|
||||
| Daftar outlet | `GET /customer/outlets` | – |
|
||||
| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – |
|
||||
| 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/*` | – |
|
||||
| Game | `GET /customer/enakgame/games`, `POST /customer/enakgame/sessions`, `POST …/sessions/:id/complete` | – |
|
||||
| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – |
|
||||
|
||||
---
|
||||
|
||||
## 4. Beranda wallet, riwayat, kedaluwarsa
|
||||
|
||||
### 4.1 Beranda — `GET /customer/wallet`
|
||||
|
||||
```json
|
||||
{
|
||||
"point_balance": 12500,
|
||||
"coin_balance": 8,
|
||||
"point_value": 1,
|
||||
"point_discount_value": 12500,
|
||||
"nearest_expiring": {
|
||||
"point": { "amount": 150, "date": "2026-12-31" },
|
||||
"coin": null
|
||||
},
|
||||
"recent_transactions": [ /* sama dengan item riwayat §4.2, maksimal 5 */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Tampilkan:
|
||||
- Saldo EnakPoint (`point_balance`) dengan keterangan "setara potongan Rp
|
||||
{point_discount_value}" (format ribuan Indonesia: `Rp 12.500`).
|
||||
- Saldo EnakCoin (`coin_balance`).
|
||||
- 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: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8).
|
||||
|
||||
Muat ulang beranda setelah setiap transaksi dan saat menerima push (§5).
|
||||
|
||||
Field `total_points`, `points_history`, `last_updated` di response ini **deprecated**;
|
||||
jangan dipakai.
|
||||
|
||||
### 4.2 Riwayat — `GET /customer/wallet/transactions`
|
||||
|
||||
Query (semua opsional):
|
||||
|
||||
| Query | Contoh | Keterangan |
|
||||
|---|---|---|
|
||||
| `page` | `1` | Mulai dari 1 |
|
||||
| `limit` | `20` | 1–100, default 20 |
|
||||
| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin |
|
||||
| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter |
|
||||
| `from`, `to` | `2026-09-01` | Tanggal WIB, inklusif |
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"currency": "POINT",
|
||||
"type": "EARN",
|
||||
"amount": 875,
|
||||
"balance_after": 12500,
|
||||
"description": "Belanja #ORD-0123 di Outlet Kemang",
|
||||
"source": { "type": "ORDER", "id": "…" },
|
||||
"outlet_id": "…",
|
||||
"group_id": null,
|
||||
"expires_at": "2026-12-31T23:59:59+07:00",
|
||||
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
Aturan tampilan:
|
||||
- `amount` bertanda: positif tampil hijau dengan `+`, negatif merah dengan `−`.
|
||||
- `description` sudah siap tampil (nama lawan transfer sudah disamarkan). Tampilkan apa
|
||||
adanya.
|
||||
- Mutasi masuk yang punya `expires_at` menampilkan "Berlaku sampai {tanggal}".
|
||||
- Infinite scroll memakai `pagination.total_pages`.
|
||||
- Riwayat tidak pernah berubah atau hilang; koreksi muncul sebagai baris baru.
|
||||
|
||||
Label tipe:
|
||||
|
||||
| `type` | Label | Arah |
|
||||
|---|---|---|
|
||||
| `EARN` | Dari belanja | + |
|
||||
| `EARN_REVERSAL` | Dibatalkan (order di-void/refund) | − |
|
||||
| `EXCHANGE_OUT` | Ditukar ke EnakPoint | − |
|
||||
| `EXCHANGE_IN` | Hasil tukar EnakCoin | + |
|
||||
| `TRANSFER_OUT` | Transfer keluar | − |
|
||||
| `TRANSFER_IN` | Transfer masuk | + |
|
||||
| `GAME_SPEND` | Main game | − |
|
||||
| `EXPIRE` | Kedaluwarsa | − |
|
||||
| `ADJUSTMENT` | Koreksi | + / − |
|
||||
| `MIGRATION` | Saldo awal | + |
|
||||
|
||||
### 4.3 Akan kedaluwarsa — `GET /customer/wallet/expiring`
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Daftar per tanggal, paling dekat di atas. Daftar kosong: tampilkan "Tidak ada saldo
|
||||
yang akan kedaluwarsa". Saldo yang kedaluwarsa hangus tanpa kompensasi.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Daftar outlet — `GET /customer/outlets`
|
||||
|
||||
Outlet aktif di organisasi customer, tempat saldo EnakPoint & EnakCoin berlaku. Urut
|
||||
berdasarkan nama.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "…",
|
||||
"name": "Gokuna Kemang",
|
||||
"address": "Jl. Kemang Raya 10",
|
||||
"earns_points": true,
|
||||
"earns_coins": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `address` bisa `null`.
|
||||
- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin.
|
||||
- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend.
|
||||
|
||||
|
||||
### 4.5 Riwayat order — `GET /customer/orders` dan `GET /customer/orders/:id`
|
||||
|
||||
Order milik customer yang login di semua outlet organisasinya, terbaru di atas. Order
|
||||
hanya masuk ke sini bila kasir mengaitkannya ke customer.
|
||||
|
||||
`GET /api/v1/customer/orders?page=1&limit=20` (`limit` 1–100, default 20):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"order_number": "ORD-0123",
|
||||
"outlet_id": "…",
|
||||
"outlet_name": "Gokuna 1",
|
||||
"order_type": "dine_in",
|
||||
"status": "completed",
|
||||
"payment_status": "completed",
|
||||
"total_amount": 99000,
|
||||
"item_count": 2,
|
||||
"is_void": false,
|
||||
"is_refund": false,
|
||||
"points_earned": 865,
|
||||
"coins_earned": 3,
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/customer/orders/{id}` mengembalikan field yang sama, ditambah:
|
||||
|
||||
```json
|
||||
{
|
||||
"table_number": "A3",
|
||||
"subtotal": 90000,
|
||||
"discount_amount": 0,
|
||||
"tax_amount": 9000,
|
||||
"refund_amount": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": "…",
|
||||
"product_id": "…",
|
||||
"product_name": "Kopi Susu",
|
||||
"variant_name": "Large",
|
||||
"quantity": 2,
|
||||
"unit_price": 25000,
|
||||
"total_price": 50000,
|
||||
"refund_quantity": 0,
|
||||
"modifiers": [],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "…",
|
||||
"product_id": "…",
|
||||
"product_name": "Ikan Tude",
|
||||
"variant_name": null,
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_name": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900,
|
||||
"refund_quantity": 0,
|
||||
"modifiers": [],
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"payments": [
|
||||
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 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".
|
||||
- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" /
|
||||
"Direfund".
|
||||
|
||||
|
||||
## 5. Notifikasi push (FCM)
|
||||
|
||||
### 5.1 Registrasi device
|
||||
|
||||
Setelah login berhasil **dan** setiap kali FCM memberi token baru (`onTokenRefresh`):
|
||||
|
||||
`PUT /api/v1/customer/devices`
|
||||
|
||||
```json
|
||||
{ "device_id": "<id unik & stabil per instalasi>", "fcm_token": "<token FCM>", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
- `device_id` wajib, stabil untuk satu instalasi (simpan di secure storage).
|
||||
- `platform`: `android`, `ios`, atau `web`.
|
||||
- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum
|
||||
menghapus token login, supaya HP itu tidak lagi menerima notifikasi akun ini.
|
||||
|
||||
Tanpa registrasi ini, customer tidak menerima push apa pun.
|
||||
|
||||
### 5.2 Tipe push
|
||||
|
||||
Semua nilai di `data` berupa string.
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lain | Aksi saat di-tap |
|
||||
|---|---|---|---|
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | Buka riwayat, sorot transaksi itu |
|
||||
| `WALLET_EXPIRING` | Beberapa hari sebelum saldo hangus | `currency`, `amount`, `expiry_date` | Buka layar kedaluwarsa (§4.3) |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | Buka riwayat |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339 UTC) | Buka layar lupa PIN (§6.4) |
|
||||
|
||||
Saat app terbuka dan menerima push wallet, muat ulang beranda.
|
||||
|
||||
---
|
||||
|
||||
## 6. PIN
|
||||
|
||||
### 6.1 Kapan diminta
|
||||
|
||||
Jangan minta PIN saat registrasi. Minta saat customer **pertama kali** melakukan aksi
|
||||
yang butuh PIN. Cek dengan:
|
||||
|
||||
`GET /api/v1/customer/pin/status` → `{ "has_pin": false, "locked_until": null, "transfer_blocked_until": null }`
|
||||
|
||||
Bila `has_pin: false`, arahkan ke alur buat PIN, lalu kembali ke aksi semula.
|
||||
|
||||
### 6.2 Buat PIN
|
||||
|
||||
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`.
|
||||
Response: `{ "purpose": "pin_setup", "otp_token": "…", "expires_at": "…" }`.
|
||||
OTP dikirim ke WhatsApp customer.
|
||||
2. Customer memasukkan kode OTP, lalu PIN dua kali.
|
||||
3. `POST /api/v1/customer/pin` dengan
|
||||
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
|
||||
Response: status PIN.
|
||||
|
||||
Validasi di app sebelum kirim (server juga memeriksa, jawab `304`):
|
||||
- Tepat 6 digit angka, dan konfirmasi sama.
|
||||
- Bukan satu digit berulang (`111111`).
|
||||
- Bukan berurutan naik/turun (`123456`, `654321`).
|
||||
- Bukan tanggal lahir customer (`DDMMYY` atau `YYMMDD`).
|
||||
|
||||
Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur.
|
||||
|
||||
### 6.3 Ganti PIN
|
||||
|
||||
`PUT /api/v1/customer/pin` dengan `{ "old_pin": "…", "pin": "…", "confirm_pin": "…" }`.
|
||||
|
||||
### 6.4 Lupa PIN
|
||||
|
||||
1. `POST /customer/pin/otp` dengan `{ "purpose": "pin_reset" }`.
|
||||
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**;
|
||||
tukar tetap bisa. Beri tahu customer hal ini di layar sukses.
|
||||
|
||||
### 6.5 Menangani error PIN
|
||||
|
||||
Semua endpoint yang menerima `pin` bisa menjawab error PIN. Pada error ini **`data`
|
||||
tidak `null`**:
|
||||
|
||||
```json
|
||||
{ "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [ … ] }
|
||||
```
|
||||
|
||||
| `data.code` | Field tambahan | Tampilan |
|
||||
|---|---|---|
|
||||
| `PIN_NOT_SET` | – | Buka alur buat PIN (§6.2) |
|
||||
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa {n} percobaan." Kosongkan input PIN |
|
||||
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai {jam}." Tombol "Lupa PIN" |
|
||||
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada {waktu}." |
|
||||
|
||||
5 kali salah berturut-turut mengunci PIN 30 menit; selama terkunci PIN yang benar pun
|
||||
ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tukar dan transfer
|
||||
|
||||
### 7.1 Tukar EnakCoin → EnakPoint
|
||||
|
||||
1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik):
|
||||
|
||||
`GET /api/v1/customer/wallet/exchange/preview?coins=30`
|
||||
|
||||
```json
|
||||
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
|
||||
```
|
||||
|
||||
- Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint. Tampilkan "10 EnakCoin =
|
||||
3 EnakPoint".
|
||||
- Bila `valid: false`, tampilkan `reason` sebagai alasan dan nonaktifkan tombol. Jumlah
|
||||
harus kelipatan `coin_amount`.
|
||||
- Tampilkan "Kamu akan mendapat {points} EnakPoint".
|
||||
|
||||
2. Konfirmasi (tukar tidak bisa dibatalkan) → minta PIN →
|
||||
|
||||
`POST /api/v1/customer/wallet/exchange` + header `Idempotency-Key`
|
||||
|
||||
```json
|
||||
{ "coins": 30, "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"coins": 30,
|
||||
"points": 9,
|
||||
"coin_amount": 10,
|
||||
"point_amount": 3,
|
||||
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"coin_balance": 5,
|
||||
"point_balance": 9,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku
|
||||
sampai {tanggal}".
|
||||
|
||||
### 7.2 Transfer
|
||||
|
||||
1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah.
|
||||
2. Cek penerima:
|
||||
|
||||
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
| Hasil | Tampilan |
|
||||
|---|---|
|
||||
| Sukses | "Kirim ke Bu*** Sa*** (08**-****-1234)?" |
|
||||
| `404` | "Nomor ini tidak terdaftar" |
|
||||
| `304` | "Tidak bisa mengirim ke nomor ini" (diri sendiri, akun nonaktif) |
|
||||
|
||||
3. Konfirmasi (transfer final, tidak bisa dibatalkan) → minta PIN →
|
||||
|
||||
`POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"currency": "POINT",
|
||||
"amount": 120,
|
||||
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
|
||||
"lots": [
|
||||
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
|
||||
{ "amount": 20, "expires_at": null }
|
||||
],
|
||||
"balance": 30,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
4. Layar sukses: saldo tersisa (`balance`). Bila ada `lots[].expires_at`, tampilkan
|
||||
"Saldo yang dikirim berlaku sampai {tanggal}" (tanggal kedaluwarsa ikut terbawa ke
|
||||
penerima).
|
||||
|
||||
Penolakan `304` yang mungkin: transfer dimatikan owner, di bawah minimal, di atas
|
||||
maksimal per transaksi, melewati batas harian (reset tengah malam WIB), saldo tidak
|
||||
cukup. Tampilkan pesan umum "Transfer tidak bisa diproses" plus alasan yang sesuai
|
||||
bila bisa dikenali. Bila kena `TRANSFER_BLOCKED`, ikuti §6.5.
|
||||
|
||||
Penerima mendapat push `WALLET_TRANSFER_IN`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Game (memakai EnakCoin)
|
||||
|
||||
`POST /api/v1/customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel`
|
||||
sudah dihapus. Semua game, termasuk spin, dimainkan lewat EnakGame. Tanpa PIN.
|
||||
|
||||
1. `GET /api/v1/customer/enakgame/games`: daftar game dengan `entry_cost` (EnakCoin per
|
||||
main). Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance`
|
||||
kurang. Spin punya `prizes` untuk menggambar roda.
|
||||
2. `POST /api/v1/customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan header
|
||||
`Idempotency-Key` (satu key per tap; retry memakai key yang sama). Response berisi
|
||||
`session_id` dan `coin_balance` setelah dipotong.
|
||||
3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan hasil main (`score`
|
||||
atau `outcome`; spin cukup `{}`). Server yang menentukan hadiah: perbarui saldo dari
|
||||
`coin_balance`, tampilkan `reward_total`, dan untuk spin hentikan roda di `prize.entry`.
|
||||
|
||||
Rincian spin ada di [`enakgame-spin.md`](./enakgame-spin.md).
|
||||
|
||||
---
|
||||
|
||||
## 9. Yang sudah dihapus / deprecated
|
||||
|
||||
Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `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):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- [ ] Layar saldo akan kedaluwarsa.
|
||||
- [ ] Registrasi device FCM setelah login dan saat token berganti; unregister saat logout.
|
||||
- [ ] 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.
|
||||
- [ ] 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 §9.
|
||||
- [ ] PIN tidak pernah disimpan, di-log, atau dikirim ke analytics.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
@@ -0,0 +1,445 @@
|
||||
# Task Breakdown: EnakPoint & EnakCoin
|
||||
|
||||
**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.
|
||||
|
||||
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000089`),
|
||||
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
|
||||
`router`, dan wiring di `internal/app/app.go`.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan
|
||||
|
||||
| Fase | Task | Terblokir oleh catatan PRD |
|
||||
|---|---|---|
|
||||
| 1. Fondasi | PC-101 – PC-109 | – |
|
||||
| 2. Earning | PC-201 – PC-205 | – |
|
||||
| 3. Pembayaran EnakPoint | PC-301 – PC-308 | N2 (keuangan), N3 (legal) sebelum **rilis** |
|
||||
| 4. Pergerakan saldo | PC-401 – PC-404 | N3 (legal) sebelum **rilis** |
|
||||
| 5. Kedaluwarsa | PC-501 – PC-504 | N4 (model kedaluwarsa) sebelum **dikerjakan** |
|
||||
| 6. Bersih-bersih | PC-601 – PC-602 | – |
|
||||
|
||||
Catatan N2 dan N3 hanya memblokir **rilis** fase 3–4, bukan pengerjaannya. N4
|
||||
memblokir pengerjaan fase 5, karena rumus kedaluwarsanya belum ditentukan.
|
||||
|
||||
```
|
||||
PC-101 ─┬─ PC-103 ── PC-104 ─┬─ PC-105 ── PC-106
|
||||
│ ├─ PC-107
|
||||
│ ├─ PC-108
|
||||
│ ├─ PC-203 ── PC-204 ── PC-205
|
||||
│ ├─ PC-305 ── PC-306 / PC-307
|
||||
│ ├─ PC-401 / PC-402 / PC-403 / PC-404
|
||||
│ └─ PC-502 ── PC-503
|
||||
PC-102 ── PC-109 ─┬─ PC-201 ── PC-202 ── PC-203
|
||||
└─ PC-302
|
||||
PC-301 ── PC-304 ── PC-305
|
||||
PC-303 ── PC-305
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Fondasi
|
||||
|
||||
Semua fase lain bergantung pada fase ini. **PC-104 (wallet engine) adalah inti.** Tidak
|
||||
ada kode lain yang boleh mengubah saldo tanpa lewat engine ini.
|
||||
|
||||
### PC-101 · Migrasi tabel wallet, ledger, dan lot · M
|
||||
- **PRD:** §8 (`customer_wallets`, `wallet_transactions`, `wallet_lots`,
|
||||
`wallet_lot_allocations`), K5, K9
|
||||
- **Kerjakan:** migrasi `000090_create_wallet_tables` (up & down) dengan semua
|
||||
`CHECK` constraint dan index persis seperti di §8.
|
||||
- **Selesai jika:**
|
||||
- Up dan down berjalan bersih di database kosong dan di salinan staging.
|
||||
- Uji constraint langsung di SQL, masing-masing harus ditolak:
|
||||
`PAYMENT` bercurrency `COIN`; `TRANSFER_IN` tanpa `counterparty_customer_id`;
|
||||
`EARN_REVERSAL` tanpa `reverses_transaction_id`; `ADJUSTMENT` tanpa `reason`;
|
||||
`EXPIRE` dengan `reference_type` selain `LOT`; saldo negatif; lot dengan
|
||||
`remaining_amount > original_amount`.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-102 · Migrasi pengaturan organisasi dan riwayat perubahan setting · S
|
||||
- **PRD:** F2, `loyalty_setting_changes` di §8
|
||||
- **Kerjakan:** tabel `organization_settings` (key–value, pola sama dengan
|
||||
`outlet_settings`, `UNIQUE(organization_id, key)`) dan `loyalty_setting_changes`.
|
||||
Saat ini belum ada tempat menyimpan setting per organisasi.
|
||||
- **Selesai jika:** up/down bersih.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-103 · Entities dan repository wallet · M
|
||||
- **PRD:** §7.1–§7.4
|
||||
- **Kerjakan:**
|
||||
- Entities untuk empat tabel PC-101.
|
||||
- Repository yang **selalu** memakai `DBFromContext` (berbeda dari repository
|
||||
gamification yang sekarang memakai `r.db` langsung).
|
||||
- `LockWallet(ctx, customerID)` dengan `SELECT … FOR UPDATE`. Membuat baris wallet
|
||||
jika belum ada (`INSERT … ON CONFLICT DO NOTHING`, lalu lock).
|
||||
- `LockWallets(ctx, a, b)` yang selalu mengunci berurutan berdasarkan `customer_id`.
|
||||
- Update saldo bersyarat yang **mengembalikan error jika 0 baris ter-update**.
|
||||
- Query lot aktif sesuai urutan K9: `expires_at NULLS LAST, created_at`.
|
||||
- **Selesai jika:** test repository menunjukkan update bersyarat gagal saat saldo
|
||||
kurang, dan dua goroutine yang mengunci wallet yang sama berjalan bergantian.
|
||||
- **Bergantung pada:** PC-101
|
||||
|
||||
### PC-104 · Wallet engine (credit / debit / lot / idempotensi) · L
|
||||
- **PRD:** K5, K6, K9, §7, §8.1
|
||||
- **Kerjakan:** processor `WalletProcessor` sebagai **satu-satunya pintu** perubahan
|
||||
saldo:
|
||||
- `Credit(ctx, CreditInput)`: menulis ledger, membuat lot (dengan `expires_at` dan
|
||||
`origin_lot_id` dari input), dan menambah saldo.
|
||||
- `Debit(ctx, DebitInput)`: mengambil dari lot sesuai urutan K9 (atau dari lot
|
||||
tertentu lebih dulu, untuk reversal di F10), menulis alokasi dan ledger, lalu
|
||||
mengurangi saldo. Mengembalikan daftar alokasi, supaya transfer/exchange bisa
|
||||
membuat lot penerima dengan `expires_at` yang sama.
|
||||
- `DebitUpTo`: mengambil sebanyak yang tersedia, untuk reversal dengan shortfall
|
||||
(F10, Q3).
|
||||
- Idempotensi: jika `idempotency_key` sudah ada, kembalikan hasil pertama tanpa
|
||||
mutasi baru.
|
||||
- Validasi di level kode untuk aturan §8.1 (tipe ↔ currency ↔ referensi wajib),
|
||||
sebagai lapisan kedua di atas constraint database.
|
||||
- Semua method mewajibkan caller sudah berada di dalam transaksi dan wallet sudah
|
||||
dikunci.
|
||||
- **Selesai jika:** unit test mencakup debit yang melewati beberapa lot, urutan lot
|
||||
dengan dan tanpa `expires_at`, debit melebihi saldo (ditolak), `DebitUpTo` dengan
|
||||
shortfall, idempotency key ganda, dan saldo = `SUM(ledger)` = `SUM(lot.remaining)`
|
||||
setelah setiap skenario.
|
||||
- **Bergantung pada:** PC-103
|
||||
|
||||
### PC-105 · Migrasi data Point & Token lama · M
|
||||
- **PRD:** §10, Q6
|
||||
- **Kerjakan:** migrasi data (atau command satu kali) yang:
|
||||
- Membuat wallet dari `customer_points` dan penjumlahan semua jenis
|
||||
`customer_tokens`.
|
||||
- Menulis ledger `MIGRATION` dan lot tanpa `expires_at` per customer per currency,
|
||||
dengan rincian per jenis token di `metadata`.
|
||||
- Mengganti `TOKENS` menjadi `COINS` di `campaigns.type` dan
|
||||
`campaign_rules.reward_type`.
|
||||
- **Selesai jika:** jumlah total Point dan Token sebelum = jumlah total saldo wallet
|
||||
sesudah (diuji pada salinan data staging), dan migrasi aman dijalankan dua kali
|
||||
(tidak menggandakan).
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-106 · Endpoint saldo & riwayat customer · M
|
||||
- **PRD:** F6, §9 (customer app)
|
||||
- **Kerjakan:** `GET /customer/wallet` dan `GET /customer/wallet/transactions`
|
||||
(pagination, filter currency/tipe/tanggal). Ganti isi `/customer/points` dan
|
||||
`/customer/tokens` menjadi alias yang membaca `customer_wallets`. Hapus pemakaian
|
||||
`customer_points_repository` untuk saldo.
|
||||
- **Selesai jika:** response menampilkan asal/tujuan setiap mutasi sesuai §8.1, dan
|
||||
aplikasi lama yang memanggil `/points` / `/tokens` tetap mendapat angka yang benar.
|
||||
- **Bergantung pada:** PC-105
|
||||
|
||||
### PC-107 · Wallet customer dan adjustment di dashboard · M
|
||||
- **PRD:** F7
|
||||
- **Kerjakan:** `GET /marketing/customers/:id/wallet` (saldo, lot aktif, mutasi) dan
|
||||
`POST /marketing/customers/:id/wallet/adjust` (alasan wajib, tidak boleh membuat saldo
|
||||
negatif, tercatat `created_by_user`).
|
||||
- **Selesai jika:** adjustment muncul di riwayat customer dengan nama admin dan alasan.
|
||||
Adjustment yang melebihi saldo ditolak.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-108 · Job rekonsiliasi · S
|
||||
- **PRD:** §7.5
|
||||
- **Kerjakan:** query dan job terjadwal yang memeriksa semua invarian §7.5 dan
|
||||
melaporkan selisih ke log dan notifikasi admin.
|
||||
- **Selesai jika:** job menemukan selisih yang sengaja dibuat di data test, dan diam
|
||||
saat data konsisten.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-109 · Pembaca setting loyalitas dan riwayat perubahan · M
|
||||
- **PRD:** F1, F2 (bagian mekanisme, bukan UI), §8 `loyalty_setting_changes`
|
||||
- **Kerjakan:** service yang membaca setting outlet dan organisasi dengan nilai default
|
||||
PRD bila key belum diisi, mengembalikan struct bertipe (bukan string mentah), dan
|
||||
menulis `loyalty_setting_changes` setiap kali setting loyalitas diubah.
|
||||
- **Selesai jika:** outlet tanpa setting mendapat semua default PRD, dan setiap
|
||||
perubahan tercatat nilai lama, nilai baru, dan pelakunya.
|
||||
- **Bergantung pada:** PC-102
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Earning
|
||||
|
||||
### PC-201 · API pengaturan loyalitas outlet · M
|
||||
- **PRD:** F1
|
||||
- **Kerjakan:** `GET/PUT /outlets/:id/loyalty-settings` dengan validasi F1. Response
|
||||
menyertakan **persentase cashback efektif** (`earn_value × nilai EnakPoint /
|
||||
earn_per_amount`).
|
||||
- **Selesai jika:** validasi menolak nilai di luar batas, dan hanya Admin/Manager yang
|
||||
bisa mengubah.
|
||||
- **Bergantung pada:** PC-109
|
||||
|
||||
### PC-202 · Kalkulator earning · S
|
||||
- **PRD:** F1 (rumus), Q1, Q10
|
||||
- **Kerjakan:** fungsi murni `CalculateEarning(order, pointPaidAmount, settings)` yang
|
||||
mengembalikan Point, Coin, basis, dan snapshot setting.
|
||||
- **Selesai jika:** unit test mencakup contoh di PRD (Rp 87.500 → 875 Point, 3 Coin;
|
||||
dengan Rp 20.000 dibayar EnakPoint → 675 Point, 2 Coin), basis di bawah minimum,
|
||||
`max_per_order`, setting nonaktif, dan pajak tidak ikut dihitung.
|
||||
- **Bergantung pada:** PC-201
|
||||
|
||||
### PC-203 · Earning saat order lunas · L
|
||||
- **PRD:** F3
|
||||
- **Kerjakan:**
|
||||
- **Satukan titik "order menjadi lunas".** Saat ini `payment_status = completed`
|
||||
di-set di empat tempat: `OrderProcessorImpl.UpdateOrder`,
|
||||
`OrderProcessorImpl.updateOrderStatus`, dan dua tempat di `split_bill_processor.go`.
|
||||
Buat satu hook `onOrderPaid(orderID)` yang dipanggil dari keempatnya.
|
||||
- Hook menjalankan earning **setelah** transaksi pembayaran commit, sehingga
|
||||
kegagalan earning tidak menggagalkan pembayaran.
|
||||
- Idempotency key `earn:{order_id}:{currency}`.
|
||||
- Lewati order tanpa customer, customer default, atau customer nonaktif.
|
||||
- **Jaring pengaman:** job yang mencari order lunas beberapa hari terakhir yang belum
|
||||
punya `EARN` (dan seharusnya punya), lalu menjalankan ulang earning.
|
||||
- **Selesai jika:** test untuk pembayaran penuh, split bill (lunas di pembayaran
|
||||
terakhir), self-order, dan pemanggilan ganda (hasilnya tetap satu earning). Earning
|
||||
yang sengaja digagalkan tidak menggagalkan pembayaran dan terambil oleh job.
|
||||
- **Bergantung pada:** PC-104, PC-202
|
||||
|
||||
### PC-204 · Reversal earning saat void / refund · M
|
||||
- **PRD:** F10, Q3
|
||||
- **Kerjakan:** panggil reversal dari `VoidOrder`, `RefundOrder`, dan `RefundPayment`.
|
||||
Ambil pertama dari lot `EARN` order tersebut, lalu lot lain. Gunakan `DebitUpTo` dan
|
||||
catat `shortfall`. Refund tidak pernah diblokir.
|
||||
- **Selesai jika:** test untuk void penuh, refund sebagian (proporsional, akumulasi tidak
|
||||
melebihi earning), dan saldo yang sudah terpakai (shortfall tercatat, saldo 0).
|
||||
- **Bergantung pada:** PC-203
|
||||
|
||||
### PC-205 · Tampilkan earning di order dan struk · S
|
||||
- **PRD:** F3
|
||||
- **Kerjakan:** tambah `points_earned` dan `coins_earned` ke response order dan data
|
||||
struk.
|
||||
- **Selesai jika:** nilai sama dengan baris `EARN` di ledger, dan bernilai 0 untuk order
|
||||
tanpa earning.
|
||||
- **Bergantung pada:** PC-203
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Pembayaran EnakPoint
|
||||
|
||||
> Boleh dikerjakan sekarang. **Tidak boleh dirilis** sebelum catatan N2 (keuangan) dan
|
||||
> N3 (legal) ditutup.
|
||||
|
||||
### PC-301 · PIN customer · L
|
||||
- **PRD:** K8, F11, Q16, Q17
|
||||
- **Kerjakan:**
|
||||
- Migrasi kolom PIN di `customers` dan tabel `customer_security_events`.
|
||||
- Purpose OTP baru `pin_setup` dan `pin_reset` di `OtpProcessor`.
|
||||
- Endpoint `/customer/pin/*` (status, OTP, buat, ganti, reset).
|
||||
- Hash bcrypt, tolak PIN lemah (digit sama, berurutan, tanggal lahir).
|
||||
- Kunci 30 menit setelah 5 kali salah, dengan penghitung di database.
|
||||
- Tahan transfer keluar 24 jam setelah reset.
|
||||
- `VerifyPin(ctx, customerID, pin)` untuk dipakai task lain, dengan error
|
||||
`PIN_NOT_SET`, `PIN_INVALID`, `PIN_LOCKED`, `TRANSFER_BLOCKED`.
|
||||
- Hapus PIN oleh admin: `DELETE /marketing/customers/:id/pin`, dan
|
||||
`GET /marketing/customers/:id/security-events`.
|
||||
- PIN tidak pernah muncul di log (termasuk log request body).
|
||||
- **Selesai jika:** test untuk semua aturan di atas, termasuk 5 kali salah lalu PIN
|
||||
benar tetap ditolak selama terkunci, dan reset OTP membuka kunci.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-302 · API pengaturan loyalitas organisasi · S
|
||||
- **PRD:** F2
|
||||
- **Kerjakan:** `GET/PUT /marketing/loyalty-settings` untuk nilai EnakPoint, kurs
|
||||
exchange, dan batas transfer, serta `GET /marketing/loyalty-settings/history`.
|
||||
Sebelum menyimpan perubahan nilai EnakPoint atau kurs, response preview menampilkan
|
||||
total saldo beredar dan nilai rupiahnya sebelum/sesudah.
|
||||
- **Selesai jika:** perubahan tercatat di riwayat, dan transaksi lama tetap memakai nilai
|
||||
yang dibekukan.
|
||||
- **Bergantung pada:** PC-109
|
||||
|
||||
### PC-303 · Payment method EnakPoint · M
|
||||
- **PRD:** F9 (payment method), §8 (perubahan `payments`), §10.5
|
||||
- **Kerjakan:**
|
||||
- Tambah tipe `point` ke `PaymentMethodType` dan validator (`oneof=cash card
|
||||
digital_wallet point`).
|
||||
- Migrasi kolom `points_used` dan `point_value` di `payments`.
|
||||
- Buat payment method sistem "EnakPoint" untuk setiap organisasi yang sudah ada, dan
|
||||
otomatis untuk organisasi baru.
|
||||
- Tolak hapus / ubah tipe method sistem.
|
||||
- Sembunyikan method ini di kasir jika outlet tidak mengaktifkan `accept_payment`.
|
||||
- **Selesai jika:** setiap organisasi punya tepat satu method EnakPoint yang tidak bisa
|
||||
dihapus.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-304 · Kode bayar sekali pakai · M
|
||||
- **PRD:** F9 (persetujuan customer), K8
|
||||
- **Kerjakan:** `POST /customer/wallet/payment-code` (butuh PIN) menghasilkan kode 6
|
||||
digit dan QR yang berlaku 2 menit, disimpan di Redis dengan TTL dan diambil
|
||||
sekali-pakai (`GETDEL`). Kode terikat ke `customer_id`.
|
||||
- **Selesai jika:** kode kedaluwarsa, kode yang sudah dipakai, dan kode milik customer
|
||||
lain semuanya ditolak.
|
||||
- **Bergantung pada:** PC-301
|
||||
|
||||
### PC-305 · Bayar EnakPoint di kasir · L
|
||||
- **PRD:** F9 (perhitungan, pencatatan), K7
|
||||
- **Kerjakan:**
|
||||
- `GET /orders/:id/point-payment/preview`.
|
||||
- Cabang method `point` di `CreatePayment`: validasi kode bayar, cocokkan customer
|
||||
order, hitung `maks_point`, lalu lakukan langkah 1–6 F9 dalam **satu transaksi**
|
||||
bersama pembuatan baris `payments`.
|
||||
- Idempotency `payment:{payment_id}`.
|
||||
- Tolak jika `nominal_rupiah > remaining_amount` (tidak ada kembalian).
|
||||
- **Selesai jika:** test untuk pembayaran penuh, sebagian + tunai (split), batas
|
||||
`max_payment_percent`, order walk-in (ditolak), kode salah (ditolak), dan dua
|
||||
pembayaran bersamaan untuk customer yang sama (saldo tidak terpakai dua kali).
|
||||
Earning (PC-203) menghitung basis tanpa bagian EnakPoint.
|
||||
- **Bergantung pada:** PC-104, PC-303, PC-304
|
||||
|
||||
### PC-306 · Bayar EnakPoint dari app / self-order · M
|
||||
- **PRD:** F9
|
||||
- **Kerjakan:** `POST /customer/orders/:id/pay-with-points` (butuh PIN, hanya untuk
|
||||
order milik customer itu sendiri), memakai logika yang sama dengan PC-305.
|
||||
- **Selesai jika:** customer tidak bisa membayar order milik customer lain.
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
### PC-307 · Refund pembayaran EnakPoint · M
|
||||
- **PRD:** F9 (void/refund), K7, Q13
|
||||
- **Kerjakan:** `PAYMENT_REFUND` di jalur void dan refund memakai `point_value` yang
|
||||
dibekukan, dengan pembulatan ke bawah. Kembalikan ke lot dengan `expires_at` asal
|
||||
(aturan perpanjangan 7 hari mengikuti N4, jadi untuk sekarang cukup pakai tanggal
|
||||
asal). **Tolak** permintaan refund bagian EnakPoint lewat method lain.
|
||||
- **Selesai jika:** test untuk void penuh, refund sebagian, perubahan nilai EnakPoint di
|
||||
antara bayar dan refund (jumlah EnakPoint yang kembali tetap sama), dan percobaan
|
||||
refund tunai atas pembayaran EnakPoint (ditolak).
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
### PC-308 · EnakPoint di laporan · S
|
||||
- **PRD:** F9 (laporan)
|
||||
- **Kerjakan:** laporan per payment method menampilkan EnakPoint terpisah dan tidak
|
||||
menghitungnya sebagai kas masuk. Cek laporan analytics yang menjumlahkan pembayaran.
|
||||
- **Selesai jika:** total kas masuk di laporan tidak berubah saat sebagian order dibayar
|
||||
EnakPoint. Perlakuan akuntansi lanjutan menunggu N2.
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Pergerakan Saldo
|
||||
|
||||
> Boleh dikerjakan sekarang. Transfer (PC-402) **tidak boleh dirilis** sebelum N3
|
||||
> (legal) ditutup.
|
||||
|
||||
### PC-401 · Exchange EnakCoin → EnakPoint · M
|
||||
- **PRD:** F4, K3
|
||||
- **Kerjakan:** `GET /customer/wallet/exchange/preview` dan
|
||||
`POST /customer/wallet/exchange` (butuh PIN, `Idempotency-Key`). Jumlah harus
|
||||
kelipatan `coin_amount`. Kurs dibekukan di metadata. Lot EnakPoint kedaluwarsa pada
|
||||
`min(lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`.
|
||||
- **Selesai jika:** test untuk kurs default 1:1, kurs 10:3, jumlah bukan kelipatan
|
||||
(ditolak), dan `expires_at` lot hasil exchange tidak pernah lebih lama dari lot asal.
|
||||
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||
|
||||
### PC-402 · Transfer antar customer · L
|
||||
- **PRD:** F5, Q4, Q16
|
||||
- **Kerjakan:** `GET /customer/wallet/transfer/recipient` (nama disamarkan) dan
|
||||
`POST /customer/wallet/transfer` (butuh PIN, `Idempotency-Key`). Kunci dua wallet
|
||||
berurutan, cek batas organisasi (per transaksi dan harian), cek
|
||||
`transfer_blocked_until`. Lot penerima mewarisi `expires_at` dan `origin_lot_id` dari
|
||||
lot pengirim. Kirim notifikasi push ke penerima.
|
||||
- **Selesai jika:** test untuk transfer ke diri sendiri / customer lain organisasi /
|
||||
customer default (semua ditolak), batas harian, transfer dua arah bersamaan (tidak
|
||||
deadlock), dan `expires_at` di penerima sama persis dengan pengirim.
|
||||
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||
|
||||
### PC-403 · Semua game memakai EnakCoin · M
|
||||
- **PRD:** F8, K1
|
||||
- **Kerjakan:**
|
||||
- `GamePlayProcessor.PlayGame` memotong EnakCoin lewat wallet engine sesuai
|
||||
`games.metadata.coin_cost`.
|
||||
- Seluruh permainan dalam satu transaksi: ubah repository game, game play, dan game
|
||||
prize ke `DBFromContext`. Gagal mengurangi stok hadiah membatalkan permainan (saat
|
||||
ini hanya di-`Printf`), dan rollback manual `AddTokens` dihapus.
|
||||
- Rename `game_plays.token_used` menjadi `coins_used`.
|
||||
- **Selesai jika:** test untuk saldo kurang (ditolak, tidak ada `game_plays` yang
|
||||
tercatat), stok hadiah gagal (semua dibatalkan), dan `GAME_SPEND` menunjuk
|
||||
`game_plays.id`.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-404 · Telusuri per butir di dashboard · S
|
||||
- **PRD:** F7, §8.1 (per butir)
|
||||
- **Kerjakan:** `GET /marketing/wallet-transactions/:id/trace` yang mengembalikan
|
||||
alokasi lot dan rantai `origin_lot_id` sampai ke lot pertama.
|
||||
- **Selesai jika:** contoh di §8 (A transfer 120 ke B, B bayar 30) menelusuri ke order
|
||||
#ORD-1 milik A.
|
||||
- **Bergantung pada:** PC-402
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — Kedaluwarsa
|
||||
|
||||
> **Jangan dikerjakan sebelum catatan N4 (model kedaluwarsa) diputuskan.** Struktur lot
|
||||
> sudah ada sejak PC-101, jadi yang tersisa hanya rumus, job, dan tampilan.
|
||||
|
||||
### PC-501 · Setting kedaluwarsa · M
|
||||
- **PRD:** F12 (pengaturan), N4
|
||||
- **Kerjakan:** key setting sesuai model yang dipilih di N4, dengan validasi dan preview
|
||||
"saldo yang didapat hari ini kedaluwarsa pada …".
|
||||
- **Bergantung pada:** PC-302, **N4**
|
||||
|
||||
### PC-502 · Hitung `expires_at` saat lot dibuat · M
|
||||
- **PRD:** F12 (tabel kedaluwarsa per lot), N4
|
||||
- **Kerjakan:** satu fungsi `ComputeExpiry(currency, receivedAt, settings)` yang dipakai
|
||||
oleh `EARN` dan `ADJUSTMENT`, serta aturan aktivasi pertama untuk lot lama (termasuk
|
||||
lot `MIGRATION`).
|
||||
- **Bergantung pada:** PC-104, PC-501
|
||||
|
||||
### PC-503 · Job kedaluwarsa · M
|
||||
- **PRD:** F12 (proses kedaluwarsa)
|
||||
- **Kerjakan:** job per jam yang memproses lot lewat tanggal dengan `EXPIRE`
|
||||
(idempotency `expire:{lot_id}`) di bawah lock wallet.
|
||||
- **Jangan menyalin pola `OmsetMilestoneScheduler`.** Scheduler itu menyimpan state di
|
||||
memori, dan menurut komentarnya sendiri bisa mengirim ulang notifikasi setelah
|
||||
restart. Job ini harus aman dijalankan di banyak instance sekaligus: pilih lot
|
||||
dengan `FOR UPDATE SKIP LOCKED`, dan andalkan idempotency key.
|
||||
- **Selesai jika:** dua instance yang berjalan bersamaan tidak menghanguskan lot yang
|
||||
sama dua kali, dan tidak ada lot yang lewat tanggal lebih dari 1 jam tanpa diproses.
|
||||
- **Bergantung pada:** PC-502
|
||||
|
||||
### PC-504 · Pengingat dan tampilan saldo yang akan kedaluwarsa · M
|
||||
- **PRD:** F6 (`/wallet/expiring`), F12 (pengingat)
|
||||
- **Kerjakan:** endpoint `GET /customer/wallet/expiring`, field saldo kedaluwarsa
|
||||
terdekat di `/wallet`, dan notifikasi pengingat yang dikelompokkan per tanggal.
|
||||
Jadwal pengingat mengikuti N4.
|
||||
- **Bergantung pada:** PC-503
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Bersih-bersih
|
||||
|
||||
### PC-601 · Hapus tabel dan kode lama · S
|
||||
- **PRD:** §10.7
|
||||
- **Kerjakan:** satu rilis setelah PC-105 berjalan di production, drop
|
||||
`customer_points` dan `customer_tokens`. Hapus `CustomerPointsProcessor` dan
|
||||
`CustomerTokensProcessor` beserta stub `not implemented`, rute yang di-comment di
|
||||
`router.go`, dan alias `/customer/points` / `/customer/tokens` setelah aplikasi
|
||||
diperbarui.
|
||||
- **Bergantung pada:** PC-106, PC-403, dan konfirmasi bahwa aplikasi sudah tidak
|
||||
memanggil endpoint lama.
|
||||
|
||||
### PC-602 · Dokumentasi integrasi · S
|
||||
- **Kerjakan:** panduan integrasi untuk tim aplikasi dan POS (seperti
|
||||
`integration-weight-based-products.md`): endpoint, kode error PIN, alur kode bayar, dan
|
||||
contoh request/response.
|
||||
- **Bergantung pada:** PC-305, PC-402
|
||||
|
||||
---
|
||||
|
||||
## Yang Bisa Dimulai Sekarang
|
||||
|
||||
Bisa dikerjakan paralel tanpa menunggu apa pun:
|
||||
- **PC-101** (migrasi wallet) → langsung lanjut **PC-103**, lalu **PC-104**
|
||||
- **PC-102** (setting organisasi) → **PC-109**
|
||||
- **PC-301** (PIN customer)
|
||||
- **PC-303** (payment method EnakPoint)
|
||||
|
||||
PC-104 adalah jalur kritis: hampir semua task lain menunggunya.
|
||||
@@ -52,7 +52,6 @@ require (
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -76,7 +75,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,10 +106,10 @@ 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
|
||||
github.com/jackc/pgx/v5 v5.3.0
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
|
||||
@@ -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=
|
||||
|
||||
+85
-45
@@ -34,6 +34,10 @@ type App struct {
|
||||
walletRecon *service.WalletReconciliationJob
|
||||
earningRetry *service.EarningBackfillJob
|
||||
walletExpiry *service.WalletExpiryJob
|
||||
gameSessions *service.GameSessionJob
|
||||
gameBudgets *service.GameBudgetPeriodJob
|
||||
voucherCodes *service.VoucherCodeExpiryJob
|
||||
voucherRecover *service.VoucherRedemptionRecoveryJob
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
|
||||
@@ -67,6 +71,14 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
// Expires balances whose time is up and reminds customers before (docs/prd-point-coin.md F12)
|
||||
a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor(
|
||||
repository.NewWalletExpiryRepository(a.db), processors.loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor))
|
||||
// Refunds or expires EnakGame sessions left open (docs/rfc-enakgame.md §7.3)
|
||||
a.gameSessions = service.NewGameSessionJob(processors.gameSessionProcessor)
|
||||
// Opens next month's global EnakGame budget (docs/rfc-enakgame.md §12)
|
||||
a.gameBudgets = service.NewGameBudgetPeriodJob(processors.gameBudgetProcessor)
|
||||
// Expires voucher codes past their date (docs/rfc-enakgame.md §12)
|
||||
a.voucherCodes = service.NewVoucherCodeExpiryJob(processors.voucherAdminProcessor)
|
||||
// Settles EXTERNAL voucher redemptions left PENDING (docs/rfc-enakgame.md §7.5)
|
||||
a.voucherRecover = service.NewVoucherRedemptionRecoveryJob(processors.voucherRedemptionProcessor)
|
||||
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
validators := a.initValidators()
|
||||
@@ -137,14 +149,11 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.orderIngredientTransactionValidator,
|
||||
services.gamificationService,
|
||||
validators.gamificationValidator,
|
||||
services.rewardService,
|
||||
validators.rewardValidator,
|
||||
services.campaignService,
|
||||
validators.campaignValidator,
|
||||
services.customerAuthService,
|
||||
validators.customerAuthValidator,
|
||||
services.customerPointsService,
|
||||
services.spinGameService,
|
||||
middleware.customerAuthMiddleware,
|
||||
services.userDeviceService,
|
||||
validators.userDeviceValidator,
|
||||
@@ -161,10 +170,12 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
services.customerPinService,
|
||||
services.pointPaymentService,
|
||||
services.customerOrderPaymentService,
|
||||
services.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
services.customerOutletService,
|
||||
services.customerOrderService,
|
||||
services.enakGameAdminService,
|
||||
services.enakGameCustomerService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -185,6 +196,18 @@ func (a *App) Start(port string) error {
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Start(15 * time.Minute)
|
||||
}
|
||||
if a.gameSessions != nil {
|
||||
a.gameSessions.Start(time.Minute)
|
||||
}
|
||||
if a.gameBudgets != nil {
|
||||
a.gameBudgets.Start(24 * time.Hour)
|
||||
}
|
||||
if a.voucherCodes != nil {
|
||||
a.voucherCodes.Start(time.Hour)
|
||||
}
|
||||
if a.voucherRecover != nil {
|
||||
a.voucherRecover.Start(time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@@ -233,6 +256,18 @@ func (a *App) Shutdown() {
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Stop()
|
||||
}
|
||||
if a.gameSessions != nil {
|
||||
a.gameSessions.Stop()
|
||||
}
|
||||
if a.gameBudgets != nil {
|
||||
a.gameBudgets.Stop()
|
||||
}
|
||||
if a.voucherCodes != nil {
|
||||
a.voucherCodes.Stop()
|
||||
}
|
||||
if a.voucherRecover != nil {
|
||||
a.voucherRecover.Stop()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -268,11 +303,7 @@ type repositories struct {
|
||||
accountRepo *repository.AccountRepositoryImpl
|
||||
orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl
|
||||
tierRepo *repository.TierRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
omsetTrackerRepo *repository.OmsetTrackerRepository
|
||||
rewardRepo repository.RewardRepository
|
||||
campaignRepo repository.CampaignRepository
|
||||
campaignRuleRepo repository.CampaignRuleRepository
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
@@ -324,11 +355,7 @@ func (a *App) initRepositories() *repositories {
|
||||
accountRepo: repository.NewAccountRepositoryImpl(a.db),
|
||||
orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl),
|
||||
tierRepo: repository.NewTierRepository(a.db),
|
||||
gameRepo: repository.NewGameRepository(a.db),
|
||||
gamePrizeRepo: repository.NewGamePrizeRepository(a.db),
|
||||
gamePlayRepo: repository.NewGamePlayRepository(a.db),
|
||||
omsetTrackerRepo: repository.NewOmsetTrackerRepository(a.db),
|
||||
rewardRepo: repository.NewRewardRepository(a.db),
|
||||
campaignRepo: repository.NewCampaignRepository(a.db),
|
||||
campaignRuleRepo: repository.NewCampaignRuleRepository(a.db),
|
||||
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
|
||||
@@ -375,11 +402,7 @@ type processors struct {
|
||||
accountProcessor *processor.AccountProcessorImpl
|
||||
orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
gamePlayProcessor *processor.GamePlayProcessor
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor
|
||||
rewardProcessor processor.RewardProcessor
|
||||
campaignProcessor processor.CampaignProcessor
|
||||
campaignRuleProcessor processor.CampaignRuleProcessor
|
||||
customerAuthProcessor processor.CustomerAuthProcessor
|
||||
@@ -397,12 +420,17 @@ 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
|
||||
customerDeviceProcessor *processor.CustomerDeviceProcessor
|
||||
customerOutletProcessor *processor.CustomerOutletProcessor
|
||||
customerOrderProcessor *processor.CustomerOrderProcessor
|
||||
enakGameAdminProcessor *processor.EnakGameAdminProcessor
|
||||
gameBudgetProcessor *processor.GameBudgetProcessor
|
||||
gameSessionProcessor *processor.GameSessionProcessor
|
||||
voucherAdminProcessor *processor.VoucherAdminProcessor
|
||||
voucherRedemptionProcessor *processor.VoucherRedemptionProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
@@ -414,17 +442,17 @@ 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)
|
||||
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
|
||||
auditLogger := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db))
|
||||
enakGameRepo := repository.NewEnakGameRepository(a.db)
|
||||
gameBudgetRepo := repository.NewGameBudgetRepository(a.db)
|
||||
voucherRepo := repository.NewVoucherRepository(a.db)
|
||||
// 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)
|
||||
@@ -440,7 +468,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),
|
||||
@@ -457,15 +485,11 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo),
|
||||
orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl),
|
||||
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
|
||||
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
|
||||
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
|
||||
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager),
|
||||
omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo),
|
||||
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
|
||||
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
||||
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager))),
|
||||
otpProcessor: otpProcessor,
|
||||
fileClient: fileClient,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
@@ -478,13 +502,24 @@ 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)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
customerOutletProcessor: processor.NewCustomerOutletProcessor(repository.NewCustomerOutletRepository(a.db), loyaltySettingsProcessor),
|
||||
customerOrderProcessor: processor.NewCustomerOrderProcessor(repository.NewCustomerOrderRepository(a.db), earningProcessor),
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager),
|
||||
// EnakGame (docs/rfc-enakgame.md)
|
||||
enakGameAdminProcessor: processor.NewEnakGameAdminProcessor(enakGameRepo, auditLogger, repos.txManager),
|
||||
gameBudgetProcessor: processor.NewGameBudgetProcessor(gameBudgetRepo, auditLogger, repos.txManager),
|
||||
gameSessionProcessor: processor.NewGameSessionProcessor(repository.NewWalletMoveRepository(a.db), enakGameRepo,
|
||||
repository.NewGameSessionRepository(a.db), gameBudgetRepo, repository.NewGameEventRepository(a.db), repository.NewGameRewardCounterRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), auditLogger, repos.txManager),
|
||||
voucherAdminProcessor: processor.NewVoucherAdminProcessor(voucherRepo, auditLogger, repos.txManager),
|
||||
voucherRedemptionProcessor: processor.NewVoucherRedemptionProcessor(repository.NewWalletMoveRepository(a.db), voucherRepo,
|
||||
repository.NewVoucherRedemptionRepository(a.db), customerPinProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo),
|
||||
// No provider has an adapter yet (EG-801): EXTERNAL vouchers stay out of the
|
||||
// catalog and cannot be redeemed until one is added here.
|
||||
processor.VoucherProviders{}, repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -517,11 +552,9 @@ type services struct {
|
||||
accountService service.AccountService
|
||||
orderIngredientTransactionService *service.OrderIngredientTransactionService
|
||||
gamificationService service.GamificationService
|
||||
rewardService service.RewardService
|
||||
campaignService service.CampaignService
|
||||
customerAuthService service.CustomerAuthService
|
||||
customerPointsService service.CustomerPointsService
|
||||
spinGameService service.SpinGameService
|
||||
userDeviceService service.UserDeviceService
|
||||
notificationService service.NotificationService
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
@@ -530,10 +563,12 @@ 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
|
||||
customerOrderService *service.CustomerOrderServiceImpl
|
||||
enakGameAdminService *service.EnakGameAdminServiceImpl
|
||||
enakGameCustomerService *service.EnakGameCustomerServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -548,6 +583,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
inventoryService := service.NewInventoryService(processors.inventoryProcessor)
|
||||
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created
|
||||
paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor)
|
||||
enakGameAudit := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db))
|
||||
gameBudgetMetrics := processor.NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(a.db), repository.NewGameBudgetMetricsRepository(a.db))
|
||||
fileService := service.NewFileServiceImpl(processors.fileProcessor)
|
||||
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
|
||||
analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor)
|
||||
@@ -564,12 +601,10 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
|
||||
accountService := service.NewAccountService(processors.accountProcessor)
|
||||
orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager)
|
||||
gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
|
||||
rewardService := service.NewRewardService(processors.rewardProcessor)
|
||||
gamificationService := service.NewGamificationService(processors.tierProcessor, processors.omsetTrackerProcessor)
|
||||
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
|
||||
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
||||
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor)
|
||||
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
||||
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
||||
|
||||
@@ -605,11 +640,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
accountService: accountService,
|
||||
orderIngredientTransactionService: orderIngredientTransactionService,
|
||||
gamificationService: gamificationService,
|
||||
rewardService: rewardService,
|
||||
campaignService: campaignService,
|
||||
customerAuthService: customerAuthService,
|
||||
customerPointsService: customerPointsService,
|
||||
spinGameService: spinGameService,
|
||||
userDeviceService: userDeviceService,
|
||||
notificationService: notificationService,
|
||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||
@@ -617,11 +650,20 @@ 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),
|
||||
customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor),
|
||||
enakGameAdminService: service.NewEnakGameAdminService(processors.enakGameAdminProcessor, processors.gameBudgetProcessor,
|
||||
gameBudgetMetrics,
|
||||
processor.NewGameBudgetControllerProcessor(repository.NewGameBudgetRepository(a.db), gameBudgetMetrics, repository.NewEnakGameRepository(a.db),
|
||||
enakGameAudit, repos.txManager),
|
||||
processor.NewGameEventProcessor(repository.NewGameEventRepository(a.db), repository.NewEnakGameRepository(a.db), repository.NewGameBudgetRepository(a.db),
|
||||
enakGameAudit, repos.txManager),
|
||||
processors.voucherAdminProcessor,
|
||||
processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(a.db))),
|
||||
enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +701,6 @@ type validators struct {
|
||||
accountValidator *validator.AccountValidatorImpl
|
||||
orderIngredientTransactionValidator *validator.OrderIngredientTransactionValidatorImpl
|
||||
gamificationValidator *validator.GamificationValidatorImpl
|
||||
rewardValidator validator.RewardValidator
|
||||
campaignValidator validator.CampaignValidator
|
||||
customerAuthValidator validator.CustomerAuthValidator
|
||||
userDeviceValidator *validator.UserDeviceValidatorImpl
|
||||
@@ -693,7 +734,6 @@ func (a *App) initValidators() *validators {
|
||||
accountValidator: validator.NewAccountValidator().(*validator.AccountValidatorImpl),
|
||||
orderIngredientTransactionValidator: validator.NewOrderIngredientTransactionValidator().(*validator.OrderIngredientTransactionValidatorImpl),
|
||||
gamificationValidator: validator.NewGamificationValidator(),
|
||||
rewardValidator: validator.NewRewardValidator(),
|
||||
campaignValidator: validator.NewCampaignValidator(),
|
||||
customerAuthValidator: validator.NewCustomerAuthValidator(),
|
||||
userDeviceValidator: validator.NewUserDeviceValidator(),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package constants
|
||||
|
||||
// Budget allocation of revenue used by the parent category cut-off report.
|
||||
// The three shares are expected to add up to 100.
|
||||
const (
|
||||
BudgetLimitPurchasePercent = 60.0
|
||||
BudgetLimitOwnerPercent = 20.0
|
||||
BudgetLimitTeamPercent = 20.0
|
||||
)
|
||||
// Revenue split used by the profit sharing report: the owner takes its fee as SDL and
|
||||
// the team takes the rest. BudgetLimitOwnerPercent is only the default: a parent
|
||||
// category can override it with categories.owner_fee_percent.
|
||||
const BudgetLimitOwnerPercent = 20.0
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package constants
|
||||
|
||||
// EnakGame values (docs/rfc-enakgame.md §5). Each set matches a CHECK in the
|
||||
// migrations.
|
||||
|
||||
// games.status.
|
||||
const (
|
||||
GameStatusDraft = "DRAFT"
|
||||
GameStatusActive = "ACTIVE"
|
||||
GameStatusInactive = "INACTIVE"
|
||||
// The old games, and games taken out of EnakGame for good. Never changed again.
|
||||
GameStatusArchived = "ARCHIVED"
|
||||
)
|
||||
|
||||
// game_reward_configs.reward_type (§8).
|
||||
const (
|
||||
GameRewardTypeFixed = "FIXED"
|
||||
GameRewardTypeScoreBased = "SCORE_BASED"
|
||||
GameRewardTypeOutcomeBased = "OUTCOME_BASED"
|
||||
GameRewardTypeProbability = "PROBABILITY"
|
||||
)
|
||||
|
||||
// game_reward_configs.status. Only status ever changes on a config (D7).
|
||||
const (
|
||||
GameRewardConfigStatusDraft = "DRAFT"
|
||||
GameRewardConfigStatusActive = "ACTIVE"
|
||||
GameRewardConfigStatusRetired = "RETIRED"
|
||||
)
|
||||
|
||||
// game_sessions.status: STARTED → COMPLETED | REFUNDED | EXPIRED (D4).
|
||||
const (
|
||||
GameSessionStatusStarted = "STARTED"
|
||||
GameSessionStatusCompleted = "COMPLETED"
|
||||
GameSessionStatusRefunded = "REFUNDED"
|
||||
GameSessionStatusExpired = "EXPIRED"
|
||||
)
|
||||
|
||||
// game_sessions.refund_reason (§7.3).
|
||||
const (
|
||||
GameSessionRefundSystemError = "SYSTEM_ERROR"
|
||||
GameSessionRefundGameDeactivated = "GAME_DEACTIVATED"
|
||||
)
|
||||
|
||||
// game_budgets.scope (§5.6).
|
||||
const (
|
||||
GameBudgetScopeGlobal = "GLOBAL"
|
||||
GameBudgetScopeEvent = "EVENT"
|
||||
)
|
||||
|
||||
// audit_logs.actor_type (§5.9).
|
||||
const (
|
||||
AuditActorUser = "USER"
|
||||
AuditActorSystem = "SYSTEM"
|
||||
)
|
||||
|
||||
// audit_logs.source: where a change came from.
|
||||
const (
|
||||
AuditSourceAdminAPI = "admin_api"
|
||||
AuditSourceBudgetController = "budget_controller"
|
||||
AuditSourceSessionJob = "session_job"
|
||||
AuditSourceBudgetPeriodJob = "budget_period_job"
|
||||
AuditSourceCustomerAPI = "customer_api"
|
||||
)
|
||||
|
||||
// audit_logs.entity_type: what an audit row is about (§13).
|
||||
const (
|
||||
AuditEntityGame = "GAME"
|
||||
AuditEntityGameRewardConfig = "GAME_REWARD_CONFIG"
|
||||
AuditEntityGameBudget = "GAME_BUDGET"
|
||||
AuditEntityGameEvent = "GAME_EVENT"
|
||||
AuditEntityGameSession = "GAME_SESSION"
|
||||
AuditEntityVoucher = "VOUCHER"
|
||||
)
|
||||
|
||||
// audit_logs.action on a GAME_BUDGET whose recommendation was accepted.
|
||||
const AuditActionRecommendationAccepted = "RECOMMENDATION_ACCEPTED"
|
||||
|
||||
// game_reward_counters.scope_type: what an Economy Guard counter counts for (§5.8).
|
||||
const (
|
||||
GameRewardScopeUser = "USER"
|
||||
GameRewardScopeGame = "GAME"
|
||||
GameRewardScopeEvent = "EVENT"
|
||||
GameRewardScopeGlobal = "GLOBAL"
|
||||
)
|
||||
|
||||
// vouchers.voucher_type (§5.7).
|
||||
const (
|
||||
VoucherTypeFixedValue = "FIXED_VALUE"
|
||||
VoucherTypePercentage = "PERCENTAGE"
|
||||
VoucherTypeFreeItem = "FREE_ITEM"
|
||||
VoucherTypeMerchantBenefit = "MERCHANT_BENEFIT"
|
||||
)
|
||||
|
||||
// vouchers.stock_mode: where a redemption's voucher comes from.
|
||||
const (
|
||||
// A counted stock with nothing to hand out but the redemption itself.
|
||||
VoucherStockStatic = "STATIC"
|
||||
// One imported code per redemption.
|
||||
VoucherStockCodePool = "CODE_POOL"
|
||||
// Issued by a provider (§7.5), not before EG-801.
|
||||
VoucherStockExternal = "EXTERNAL"
|
||||
)
|
||||
|
||||
// vouchers.status.
|
||||
const (
|
||||
VoucherStatusDraft = "DRAFT"
|
||||
VoucherStatusActive = "ACTIVE"
|
||||
VoucherStatusInactive = "INACTIVE"
|
||||
VoucherStatusArchived = "ARCHIVED"
|
||||
)
|
||||
|
||||
// voucher_codes.status.
|
||||
const (
|
||||
VoucherCodeAvailable = "AVAILABLE"
|
||||
VoucherCodeReserved = "RESERVED"
|
||||
VoucherCodeRedeemed = "REDEEMED"
|
||||
VoucherCodeExpired = "EXPIRED"
|
||||
VoucherCodeCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// voucher_redemptions.status.
|
||||
const (
|
||||
VoucherRedemptionPending = "PENDING"
|
||||
VoucherRedemptionCompleted = "COMPLETED"
|
||||
VoucherRedemptionFailed = "FAILED"
|
||||
)
|
||||
|
||||
// Budget statuses (PRD §32), from its utilization and forecast against its thresholds.
|
||||
const (
|
||||
GameBudgetHealthy = "HEALTHY"
|
||||
GameBudgetWarning = "WARNING"
|
||||
GameBudgetCritical = "CRITICAL"
|
||||
GameBudgetExhausted = "EXHAUSTED"
|
||||
)
|
||||
|
||||
// Thresholds of a budget that sets none, in percent (PRD §8). Pending RFC §19.2 #4.
|
||||
const (
|
||||
GameBudgetWarningDefault = int64(70)
|
||||
GameBudgetCriticalDefault = int64(90)
|
||||
)
|
||||
|
||||
// game_events.status. An ACTIVE event changes rewards only between its start_at and
|
||||
// end_at.
|
||||
const (
|
||||
GameEventStatusDraft = "DRAFT"
|
||||
GameEventStatusActive = "ACTIVE"
|
||||
GameEventStatusEnded = "ENDED"
|
||||
GameEventStatusCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// Budget Controller guardrails of a budget that sets none (PRD §31). Pending RFC
|
||||
// §19.2 #4.
|
||||
const (
|
||||
// Percent a recommendation may move rewards by, either way.
|
||||
GameBudgetMaxStepDefault = int64(10)
|
||||
// Bounds of a game's multiplier, in percent of the configuration its admin wrote.
|
||||
GameBudgetMinMultiplierDefault = int64(50)
|
||||
GameBudgetMaxMultiplierDefault = int64(150)
|
||||
// Days after an accepted recommendation before the next one, in the organization.
|
||||
// The same as the forecast window, so the next one sees the effect of the last.
|
||||
GameBudgetCooldownDaysDefault = int64(7)
|
||||
)
|
||||
|
||||
// What a Budget Controller recommendation says (docs/rfc-enakgame.md §10). Only
|
||||
// RECOMMENDED can be accepted.
|
||||
const (
|
||||
GameBudgetRecommended = "RECOMMENDED"
|
||||
// The forecast meets the budget closely enough that rewards stay.
|
||||
GameBudgetRecommendationNoChange = "NO_CHANGE"
|
||||
// The last accepted recommendation of the organization is too recent.
|
||||
GameBudgetRecommendationCooldown = "COOLDOWN"
|
||||
// Every game is already at its min or max multiplier.
|
||||
GameBudgetRecommendationAtLimit = "AT_LIMIT"
|
||||
// No cost in the forecast window to extrapolate from.
|
||||
GameBudgetRecommendationNoData = "INSUFFICIENT_DATA"
|
||||
// The budget's period has not started or has no day left after today.
|
||||
GameBudgetRecommendationOutOfPeriod = "OUT_OF_PERIOD"
|
||||
)
|
||||
@@ -53,15 +53,10 @@ const (
|
||||
// Gamification entities
|
||||
CustomerPointsEntity = "customer_points"
|
||||
TierEntity = "tier"
|
||||
GameEntity = "game"
|
||||
GamePrizeEntity = "game_prize"
|
||||
GamePlayEntity = "game_play"
|
||||
OmsetTrackerEntity = "omset_tracker"
|
||||
RewardEntity = "reward"
|
||||
CampaignEntity = "campaign"
|
||||
CampaignRuleEntity = "campaign_rule"
|
||||
CustomerEntity = "customer"
|
||||
SpinGameHandlerEntity = "spin_game_handler"
|
||||
UserDeviceServiceEntity = "user_device_service"
|
||||
NotificationServiceEntity = "notification_service"
|
||||
NotificationHandlerEntity = "notification_handler"
|
||||
@@ -69,6 +64,7 @@ const (
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
WalletServiceEntity = "wallet_service"
|
||||
EnakGameServiceEntity = "enakgame_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
CustomerPinServiceEntity = "customer_pin_service"
|
||||
)
|
||||
|
||||
@@ -4,23 +4,23 @@ 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"
|
||||
LoyaltyPointEarnPercentKey = "loyalty.point.earn_percent"
|
||||
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
||||
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
||||
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
||||
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
||||
|
||||
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
||||
LoyaltyCoinEarnModeKey = "loyalty.coin.earn_mode"
|
||||
LoyaltyCoinEarnPercentKey = "loyalty.coin.earn_percent"
|
||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||
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
|
||||
@@ -47,6 +47,21 @@ const (
|
||||
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||
)
|
||||
|
||||
// Per organization: EnakGame reward limits (docs/rfc-enakgame.md §5.10). 0 means no
|
||||
// limit. A reward over a limit is cut to what is left of it (§9).
|
||||
const (
|
||||
EnakGameLimitUserDailyKey = "enakgame.limit.user_daily"
|
||||
EnakGameLimitGlobalDailyKey = "enakgame.limit.global_daily"
|
||||
)
|
||||
|
||||
// Modes of loyalty.{point,coin}.earn_mode.
|
||||
const (
|
||||
// earn_value for every earn_per_amount rupiah of the basis.
|
||||
LoyaltyEarnModePerAmount = "PER_AMOUNT"
|
||||
// earn_percent percent of the basis.
|
||||
LoyaltyEarnModePercentage = "PERCENTAGE"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
const (
|
||||
LoyaltyExpiryUnitDay = "DAY"
|
||||
@@ -66,9 +81,8 @@ const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
||||
LoyaltyEarnValueDefault = int64(1)
|
||||
|
||||
LoyaltyMinPaymentPointsDefault = int64(1)
|
||||
LoyaltyMaxPaymentPercentDefault = int64(100)
|
||||
LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount
|
||||
LoyaltyEarnPercentDefault = float64(1)
|
||||
|
||||
LoyaltyPointValueDefault = int64(1)
|
||||
LoyaltyExchangeAmountDefault = int64(1)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,26 +14,28 @@ 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"
|
||||
|
||||
// EnakGame (docs/rfc-enakgame.md §6.1).
|
||||
WalletTxTypeGameSpendRefund = "GAME_SPEND_REFUND"
|
||||
WalletTxTypeGameReward = "GAME_REWARD"
|
||||
WalletTxTypeRewardRedeemRefund = "REWARD_REDEEM_REFUND"
|
||||
)
|
||||
|
||||
// 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"
|
||||
@@ -41,4 +43,5 @@ const (
|
||||
WalletRefTypeLegacyPoints = "LEGACY_POINTS"
|
||||
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
||||
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
|
||||
WalletRefTypeGameSession = "GAME_SESSION"
|
||||
)
|
||||
|
||||
@@ -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 {
|
||||
@@ -266,16 +260,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||
type ParentCategoryTopProduct struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||
@@ -302,19 +307,25 @@ type ParentCategoryAnalyticsDetailResponse struct {
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
@@ -355,19 +366,17 @@ type BudgetCutOff struct {
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SDL float64 `json:"sdl"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
|
||||
@@ -1,29 +1,81 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCategoryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||
IsTeam *bool `json:"is_team,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCategoryRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||
IsTeam *bool `json:"is_team,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// Set when the field is sent as null (or "" for parent_id), which asks for the
|
||||
// value to be removed. A field that is left out stays unchanged.
|
||||
ClearParentID bool `json:"-"`
|
||||
ClearOwnerFeePercent bool `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON tells an explicit null apart from a field that was left out, so a
|
||||
// category can be detached from its parent and an owner fee override can be removed.
|
||||
func (r *UpdateCategoryRequest) UnmarshalJSON(data []byte) error {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isEmpty := func(key string, allowEmptyString bool) bool {
|
||||
value, ok := raw[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
value = bytes.TrimSpace(value)
|
||||
return bytes.Equal(value, []byte("null")) || (allowEmptyString && bytes.Equal(value, []byte(`""`)))
|
||||
}
|
||||
|
||||
clearParentID := isEmpty("parent_id", true)
|
||||
clearOwnerFeePercent := isEmpty("owner_fee_percent", false)
|
||||
if clearParentID {
|
||||
// An empty string is not a valid UUID, so keep it away from the decoder
|
||||
delete(raw, "parent_id")
|
||||
cleaned, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = cleaned
|
||||
}
|
||||
|
||||
type plain UpdateCategoryRequest
|
||||
var decoded plain
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*r = UpdateCategoryRequest(decoded)
|
||||
r.ClearParentID = clearParentID
|
||||
r.ClearOwnerFeePercent = clearOwnerFeePercent
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListCategoriesRequest struct {
|
||||
@@ -39,18 +91,20 @@ type ListCategoriesRequest struct {
|
||||
|
||||
// Category Response DTOs
|
||||
type CategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Order int `json:"order"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Order int `json:"order"`
|
||||
OwnerFeePercent *float64 `json:"owner_fee_percent"`
|
||||
IsTeam bool `json:"is_team"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCategoriesResponse struct {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateCategoryRequestTellsNullFromOmitted(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantParentID *uuid.UUID
|
||||
wantClear bool
|
||||
wantClearFee bool
|
||||
wantFee *float64
|
||||
wantNameIsSet bool
|
||||
}{
|
||||
{name: "omitted leaves parent unchanged", body: `{"name":"Food"}`, wantNameIsSet: true},
|
||||
{name: "null detaches the parent", body: `{"parent_id":null}`, wantClear: true},
|
||||
{name: "empty string detaches the parent", body: `{"name":"Food","parent_id":""}`, wantClear: true, wantNameIsSet: true},
|
||||
{name: "uuid sets the parent", body: `{"parent_id":"` + parentID.String() + `"}`, wantParentID: &parentID},
|
||||
{name: "null removes the owner fee override", body: `{"owner_fee_percent":null}`, wantClearFee: true},
|
||||
{name: "number sets the owner fee", body: `{"owner_fee_percent":35}`, wantFee: func() *float64 { v := 35.0; return &v }()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req UpdateCategoryRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(tt.body), &req))
|
||||
|
||||
require.Equal(t, tt.wantParentID, req.ParentID)
|
||||
require.Equal(t, tt.wantClear, req.ClearParentID)
|
||||
require.Equal(t, tt.wantClearFee, req.ClearOwnerFeePercent)
|
||||
require.Equal(t, tt.wantFee, req.OwnerFeePercent)
|
||||
require.Equal(t, tt.wantNameIsSet, req.Name != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCategoryRequestRejectsInvalidParentID(t *testing.T) {
|
||||
var req UpdateCategoryRequest
|
||||
require.Error(t, json.Unmarshal([]byte(`{"parent_id":"not-a-uuid"}`), &req))
|
||||
}
|
||||
@@ -16,6 +16,10 @@ type RegisterStartRequest struct {
|
||||
PhoneNumber string `json:"phone_number" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
BirthDate string `json:"birth_date" binding:"required"`
|
||||
// The organization (brand) the customer registers with. A customer belongs to one
|
||||
// organization. Optional: when it is left out and the database has exactly one
|
||||
// organization, the customer joins that one.
|
||||
OrganizationID string `json:"organization_id,omitempty"`
|
||||
}
|
||||
|
||||
type RegisterVerifyOtpRequest struct {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Request Contracts
|
||||
type GetCustomerGamesRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Contracts
|
||||
type GetCustomerGamesResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetCustomerGamesResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetCustomerGamesResponseData struct {
|
||||
Games []CustomerGameResponse `json:"games"`
|
||||
}
|
||||
|
||||
type CustomerGameResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CustomerGamePrizeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Ferris Wheel Game Contracts
|
||||
type GetFerrisWheelGameRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetFerrisWheelGameResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetFerrisWheelGameResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetFerrisWheelGameResponseData struct {
|
||||
Game CustomerGameResponse `json:"game"`
|
||||
Prizes []CustomerGamePrizeResponse `json:"prizes"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package contract
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// StartGameSessionRequest is POST /customer/enakgame/sessions (docs/rfc-enakgame.md
|
||||
// §7.1). The Idempotency-Key header is required.
|
||||
type StartGameSessionRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" binding:"required"`
|
||||
}
|
||||
|
||||
// RedeemVoucherRequest is POST /customer/enakgame/vouchers/:id/redeem (§7.4). The
|
||||
// Idempotency-Key header is required.
|
||||
type RedeemVoucherRequest struct {
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGameRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateGameRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,required"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type GameResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListGamesRequest struct {
|
||||
Page int `json:"page" form:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" form:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search" form:"search"`
|
||||
Type string `json:"type" form:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive *bool `json:"is_active" form:"is_active"`
|
||||
SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedGamesResponse struct {
|
||||
Data []GameResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
CoinsUsed int `json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
type GamePlayResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
Prize *GamePrizeResponse `json:"prize,omitempty"`
|
||||
}
|
||||
|
||||
type ListGamePlaysRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
GameID *uuid.UUID `json:"game_id"`
|
||||
CustomerID *uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedGamePlaysResponse struct {
|
||||
Data []GamePlayResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// PlayGameRequest plays a game for a customer. What it costs is the game's
|
||||
// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice.
|
||||
type PlayGameRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGamePrizeRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Weight int `json:"weight" validate:"min=1"`
|
||||
Stock int `json:"stock" validate:"min=0"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateGamePrizeRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,required"`
|
||||
Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"`
|
||||
Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type GamePrizeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Stock int `json:"stock"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListGamePrizesRequest struct {
|
||||
Page int `json:"page" form:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" form:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search" form:"search"`
|
||||
GameID *uuid.UUID `json:"game_id" form:"game_id"`
|
||||
SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedGamePrizesResponse struct {
|
||||
Data []GamePrizeResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -27,15 +27,14 @@ type AddOmsetRequest struct {
|
||||
}
|
||||
|
||||
type OmsetTrackerResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PeriodType string `json:"period_type"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Total int64 `json:"total"`
|
||||
GameID *uuid.UUID `json:"game_id,omitempty"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PeriodType string `json:"period_type"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Total int64 `json:"total"`
|
||||
GameID *uuid.UUID `json:"game_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListOmsetTrackerRequest struct {
|
||||
|
||||
@@ -121,7 +121,7 @@ type OrderItemResponse struct {
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -16,7 +16,7 @@ type CreateProductRequest struct {
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
@@ -38,7 +38,7 @@ type UpdateProductRequest struct {
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` // Replaces the whole list when sent
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
@@ -76,7 +76,7 @@ type ProductResponse struct {
|
||||
Cost float64 `json:"cost"`
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Request Contracts
|
||||
type CreateRewardRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL"`
|
||||
CostPoints int64 `json:"cost_points" binding:"required,min=1"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer" binding:"min=1"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateRewardRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"`
|
||||
CostPoints int64 `json:"cost_points" binding:"required,min=1"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer" binding:"min=1"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type ListRewardsRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
Limit int `form:"limit" binding:"min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
RewardType string `form:"reward_type"`
|
||||
MinPoints *int64 `form:"min_points"`
|
||||
MaxPoints *int64 `form:"max_points"`
|
||||
HasStock *bool `form:"has_stock"`
|
||||
}
|
||||
|
||||
type GetRewardRequest struct {
|
||||
ID uuid.UUID `uri:"id" binding:"required"`
|
||||
}
|
||||
|
||||
type DeleteRewardRequest struct {
|
||||
ID uuid.UUID `uri:"id" binding:"required"`
|
||||
}
|
||||
|
||||
// Response Contracts
|
||||
type RewardResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RewardType string `json:"reward_type"`
|
||||
CostPoints int64 `json:"cost_points"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListRewardsResponse struct {
|
||||
Rewards []RewardResponse `json:"rewards"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// Helper structs
|
||||
type TermsAndConditionsStruct struct {
|
||||
Sections []TncSectionStruct `json:"sections"`
|
||||
ExpiryDays int `json:"expiry_days"`
|
||||
}
|
||||
|
||||
type TncSectionStruct struct {
|
||||
Title string `json:"title"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package contract
|
||||
|
||||
// SpinGameRequest represents the request to play a spin game
|
||||
type SpinGameRequest struct {
|
||||
SpinID string `json:"spin_id" validate:"required,uuid"`
|
||||
}
|
||||
|
||||
// SpinGameResponse represents the response from playing a spin game
|
||||
type SpinGameResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *SpinGameResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// SpinGameResponseData contains the game play result
|
||||
type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
@@ -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
|
||||
@@ -143,16 +141,29 @@ type ProductAnalyticsPerCategory struct {
|
||||
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
|
||||
// top-level category. A category without a parent is its own group.
|
||||
type ProductAnalyticsPerParentCategory struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
// OwnerFeePercent is the owner share of this group's revenue, already resolved to the default
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
// TopProduct is filled by a separate query, so it is not a scanned column
|
||||
TopProduct *ParentCategoryTopProduct `gorm:"-" json:"top_product"`
|
||||
}
|
||||
|
||||
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||
type ParentCategoryTopProduct struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
|
||||
@@ -171,6 +182,8 @@ type BudgetCutOffWeek struct {
|
||||
WeekStart time.Time `json:"week_start"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
// SDL is summed per parent category, each at its own owner fee percent
|
||||
SDL float64 `json:"sdl"`
|
||||
}
|
||||
|
||||
// DashboardOverview represents dashboard overview data
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuditLog is one change to EnakGame configuration or state: who made it, to what,
|
||||
// from what, to what (docs/rfc-enakgame.md §5.9, §13). Append-only.
|
||||
type AuditLog struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
// USER or SYSTEM.
|
||||
ActorType string
|
||||
// The admin for USER; nil for SYSTEM.
|
||||
ActorID *uuid.UUID
|
||||
EntityType string
|
||||
EntityID uuid.UUID
|
||||
Action string
|
||||
// JSON snapshots of the entity; nil when there was nothing before (a create) or
|
||||
// after (a delete).
|
||||
Before json.RawMessage
|
||||
After json.RawMessage
|
||||
Reason *string
|
||||
// admin_api, budget_controller, session_job, ...
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -41,8 +41,14 @@ type Category struct {
|
||||
Order int `gorm:"default:0" json:"order"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
|
||||
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
|
||||
// IsTeam marks a parent category as a team in the revenue split. It has no gorm
|
||||
// default on purpose: GORM would turn an explicit false into the default on insert,
|
||||
// so callers creating a category set it themselves.
|
||||
IsTeam bool `gorm:"not null" json:"is_team"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JSONDocument is a JSONB column kept as raw JSON, for documents whose shape depends
|
||||
// on another column or is only read back as a whole. Empty is SQL NULL.
|
||||
type JSONDocument json.RawMessage
|
||||
|
||||
func (d JSONDocument) Value() (driver.Value, error) {
|
||||
if len(d) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return string(d), nil
|
||||
}
|
||||
|
||||
func (d *JSONDocument) Scan(value interface{}) error {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*d = nil
|
||||
case []byte:
|
||||
*d = append(JSONDocument(nil), v...)
|
||||
case string:
|
||||
*d = JSONDocument(v)
|
||||
default:
|
||||
return errors.New("JSONDocument: unsupported type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d JSONDocument) MarshalJSON() ([]byte, error) {
|
||||
if len(d) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *JSONDocument) UnmarshalJSON(data []byte) error {
|
||||
*d = append(JSONDocument(nil), data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GameResultRules are a game's limits on a result (docs/rfc-enakgame.md §5.1, §7.2)
|
||||
// and its daily reward limit (§5.10, §9). A limit left out does not apply.
|
||||
type GameResultRules struct {
|
||||
MaxScore *int64 `json:"max_score,omitempty"`
|
||||
MinDurationSeconds *int64 `json:"min_duration_seconds,omitempty"`
|
||||
MaxScorePerSecond *float64 `json:"max_score_per_second,omitempty"`
|
||||
// The outcomes a result may report. Empty accepts any.
|
||||
Outcomes []string `json:"outcomes,omitempty"`
|
||||
// EnakCoin the game may give out per day, in Asia/Jakarta.
|
||||
DailyRewardLimit *int64 `json:"daily_reward_limit,omitempty"`
|
||||
}
|
||||
|
||||
func (r GameResultRules) Value() (driver.Value, error) {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (r *GameResultRules) Scan(value interface{}) error {
|
||||
*r = GameResultRules{}
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []byte:
|
||||
return json.Unmarshal(v, r)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(v), r)
|
||||
default:
|
||||
return errors.New("GameResultRules: unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
// GameRewardConfig is one version of how a game computes its base reward (§5.2, §8).
|
||||
// Never updated apart from Status (D7): a change is a new version.
|
||||
type GameRewardConfig struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"`
|
||||
Version int `gorm:"not null" json:"version"`
|
||||
// FIXED, SCORE_BASED, OUTCOME_BASED or PROBABILITY.
|
||||
RewardType string `gorm:"size:30;not null" json:"reward_type"`
|
||||
// Shape per RewardType in §8.
|
||||
Rules JSONDocument `gorm:"type:jsonb;not null" json:"rules"`
|
||||
MaxReward int64 `gorm:"not null" json:"max_reward"`
|
||||
// DRAFT, ACTIVE or RETIRED.
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
EffectiveAt *time.Time `json:"effective_at"`
|
||||
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
// Set together on a version made by accepting a Budget Controller recommendation
|
||||
// (§10): the admin's version it scales, by how much, and for which budget.
|
||||
BaseConfigID *uuid.UUID `gorm:"type:uuid" json:"base_config_id"`
|
||||
Multiplier *float64 `gorm:"type:numeric(6,4)" json:"multiplier"`
|
||||
BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (GameRewardConfig) TableName() string { return "game_reward_configs" }
|
||||
|
||||
// GameSession is one play of a game by a customer (§5.3): STARTED, then COMPLETED,
|
||||
// REFUNDED or EXPIRED, each move a conditional update (D4).
|
||||
type GameSession struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"`
|
||||
// Snapshots taken at start (D7, P3).
|
||||
RewardConfigID uuid.UUID `gorm:"type:uuid;not null" json:"reward_config_id"`
|
||||
EntryCost int64 `gorm:"not null" json:"entry_cost"`
|
||||
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
StartedAt time.Time `gorm:"not null" json:"started_at"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
|
||||
// What the client sent: data only, never a reward amount (P1).
|
||||
Result JSONDocument `gorm:"type:jsonb" json:"result"`
|
||||
RewardBreakdown JSONDocument `gorm:"type:jsonb" json:"reward_breakdown"`
|
||||
RewardTotal int64 `gorm:"not null;default:0" json:"reward_total"`
|
||||
Flagged bool `gorm:"not null;default:false" json:"flagged"`
|
||||
|
||||
SpendTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"spend_transaction_id"`
|
||||
RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"`
|
||||
// SYSTEM_ERROR or GAME_DEACTIVATED.
|
||||
RefundReason *string `gorm:"size:30" json:"refund_reason"`
|
||||
CompletionFailedAt *time.Time `json:"completion_failed_at"`
|
||||
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (GameSession) TableName() string { return "game_sessions" }
|
||||
|
||||
// GameSessionReward is the part of a session's reward one budget paid for, with its
|
||||
// own ledger row (D6).
|
||||
type GameSessionReward struct {
|
||||
SessionID uuid.UUID `gorm:"type:uuid;primary_key" json:"session_id"`
|
||||
BudgetID uuid.UUID `gorm:"type:uuid;primary_key" json:"budget_id"`
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
WalletTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"wallet_transaction_id"`
|
||||
}
|
||||
|
||||
func (GameSessionReward) TableName() string { return "game_session_rewards" }
|
||||
|
||||
// GameBudget is what an organization may spend on EnakGame rewards over a period
|
||||
// (§5.6): GLOBAL for base rewards, EVENT for what an event adds.
|
||||
type GameBudget struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
// GLOBAL or EVENT.
|
||||
Scope string `gorm:"size:20;not null" json:"scope"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
// Dates, inclusive at both ends.
|
||||
PeriodStart time.Time `gorm:"type:date;not null" json:"period_start"`
|
||||
PeriodEnd time.Time `gorm:"type:date;not null" json:"period_end"`
|
||||
// Rupiah.
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
// {"warning": 70, "critical": 90}.
|
||||
Thresholds JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"thresholds"`
|
||||
ExhaustionPolicy *string `gorm:"size:30" json:"exhaustion_policy"`
|
||||
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameBudget) TableName() string { return "game_budgets" }
|
||||
|
||||
// GameEvent is a period in which some games pay more, the extra paid by the event's
|
||||
// own budget (docs/rfc-enakgame.md §5.5, D6). Event and campaign are the same thing.
|
||||
type GameEvent struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Slug string `gorm:"size:100;not null" json:"slug"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
BannerURL *string `gorm:"column:banner_url;size:500" json:"banner_url"`
|
||||
StartAt time.Time `gorm:"not null" json:"start_at"`
|
||||
EndAt time.Time `gorm:"not null" json:"end_at"`
|
||||
Timezone string `gorm:"size:50;not null" json:"timezone"`
|
||||
// DRAFT, ACTIVE, ENDED or CANCELLED.
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
Priority int `gorm:"not null" json:"priority"`
|
||||
// NUMERIC(5,2): at most two decimals.
|
||||
Multiplier *float64 `gorm:"type:numeric(5,2)" json:"multiplier"`
|
||||
Bonus *int64 `json:"bonus"`
|
||||
BudgetID uuid.UUID `gorm:"type:uuid;not null" json:"budget_id"`
|
||||
RewardLimit *int64 `json:"reward_limit"`
|
||||
UserDailyLimit *int64 `json:"user_daily_limit"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameEvent) TableName() string { return "game_events" }
|
||||
@@ -28,10 +28,7 @@ func GetAllEntities() []interface{} {
|
||||
&CustomerTokens{},
|
||||
&Tier{},
|
||||
&Game{},
|
||||
&GamePrize{},
|
||||
&GamePlay{},
|
||||
&OmsetTracker{},
|
||||
&Reward{},
|
||||
&Campaign{},
|
||||
&CampaignRule{},
|
||||
&OtpSession{},
|
||||
|
||||
@@ -16,16 +16,30 @@ const (
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
// The old games' settings. Nothing reads it any more; their coin_cost became
|
||||
// entry_cost (migration 000103).
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
|
||||
// EnakGame (docs/rfc-enakgame.md §5.1). Only an ARCHIVED game, which every old game
|
||||
// is, may lack an organization and a slug.
|
||||
OrganizationID *uuid.UUID `gorm:"type:uuid" json:"organization_id"`
|
||||
Slug *string `gorm:"size:100" json:"slug"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
ThumbnailURL *string `gorm:"column:thumbnail_url;size:500" json:"thumbnail_url"`
|
||||
GameURL *string `gorm:"column:game_url;size:500" json:"game_url"`
|
||||
Version *string `gorm:"size:50" json:"version"`
|
||||
// DRAFT, ACTIVE, INACTIVE or ARCHIVED.
|
||||
Status string `gorm:"size:20;not null;default:ACTIVE" json:"status"`
|
||||
// EnakCoin a session costs; at least 1.
|
||||
EntryCost int64 `gorm:"not null" json:"entry_cost"`
|
||||
SessionTTLSeconds int `gorm:"column:session_ttl_seconds;not null;default:600" json:"session_ttl_seconds"`
|
||||
ResultRules GameResultRules `gorm:"type:jsonb;not null;default:'{}'" json:"result_rules"`
|
||||
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Prizes []GamePrize `gorm:"foreignKey:GameID" json:"prizes,omitempty"`
|
||||
Plays []GamePlay `gorm:"foreignKey:GameID" json:"plays,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Game) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GamePlay struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"`
|
||||
PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"`
|
||||
CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"`
|
||||
Customer Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`
|
||||
Prize *GamePrize `gorm:"foreignKey:PrizeID" json:"prize,omitempty"`
|
||||
}
|
||||
|
||||
func (gp *GamePlay) BeforeCreate(tx *gorm.DB) error {
|
||||
if gp.ID == uuid.Nil {
|
||||
gp.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (GamePlay) TableName() string {
|
||||
return "game_plays"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GamePrize struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Weight int `gorm:"not null" json:"weight" validate:"min=1"`
|
||||
Stock int `gorm:"default:0" json:"stock" validate:"min=0"`
|
||||
MaxStock *int `gorm:"" json:"max_stock,omitempty"`
|
||||
Threshold *int64 `gorm:"" json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `gorm:"type:uuid" json:"fallback_prize_id,omitempty"`
|
||||
Image *string `gorm:"type:varchar(500)" json:"image,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"`
|
||||
|
||||
Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"`
|
||||
FallbackPrize *GamePrize `gorm:"foreignKey:FallbackPrizeID" json:"fallback_prize,omitempty"`
|
||||
Plays []GamePlay `gorm:"foreignKey:PrizeID" json:"plays,omitempty"`
|
||||
}
|
||||
|
||||
func (gp *GamePrize) BeforeCreate(tx *gorm.DB) error {
|
||||
if gp.ID == uuid.Nil {
|
||||
gp.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (GamePrize) TableName() string {
|
||||
return "game_prizes"
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -8,24 +9,26 @@ import (
|
||||
)
|
||||
|
||||
type Product 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"`
|
||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
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"`
|
||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
// PrinterTypes are the stations the product is printed at. Set them through
|
||||
// SetPrinterTypes.
|
||||
PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
@@ -48,6 +51,32 @@ func (Product) TableName() string {
|
||||
return "products"
|
||||
}
|
||||
|
||||
// SetPrinterTypes records the stations the product is printed at, in order, dropping
|
||||
// blanks and repeats.
|
||||
func (p *Product) SetPrinterTypes(printerTypes []string) {
|
||||
cleaned := StringSlice{}
|
||||
seen := make(map[string]bool, len(printerTypes))
|
||||
for _, printerType := range printerTypes {
|
||||
printerType = strings.TrimSpace(printerType)
|
||||
if printerType == "" || seen[printerType] {
|
||||
continue
|
||||
}
|
||||
seen[printerType] = true
|
||||
cleaned = append(cleaned, printerType)
|
||||
}
|
||||
|
||||
p.PrinterTypes = cleaned
|
||||
}
|
||||
|
||||
// GetPrinterTypes returns the stations the product is printed at, never nil, so a
|
||||
// response always carries a list. A product that was not loaded has none.
|
||||
func (p *Product) GetPrinterTypes() []string {
|
||||
if p.PrinterTypes == nil {
|
||||
return []string{}
|
||||
}
|
||||
return []string(p.PrinterTypes)
|
||||
}
|
||||
|
||||
type ProductVariant struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RewardType string
|
||||
|
||||
const (
|
||||
RewardTypeVoucher RewardType = "VOUCHER"
|
||||
RewardTypePhysical RewardType = "PHYSICAL"
|
||||
RewardTypeDigital RewardType = "DIGITAL"
|
||||
RewardTypeBalance RewardType = "BALANCE"
|
||||
)
|
||||
|
||||
// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer
|
||||
type StringSlice []string
|
||||
|
||||
// Scan implements the sql.Scanner interface for StringSlice
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into StringSlice", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for StringSlice
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
type TermsAndConditions struct {
|
||||
Sections []TncSection `json:"sections"`
|
||||
ExpiryDays int `json:"expiry_days"`
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for TermsAndConditions
|
||||
func (t *TermsAndConditions) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*t = TermsAndConditions{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into TermsAndConditions", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, t)
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for TermsAndConditions
|
||||
func (t TermsAndConditions) Value() (driver.Value, error) {
|
||||
return json.Marshal(t)
|
||||
}
|
||||
|
||||
type TncSection struct {
|
||||
Title string `json:"title"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
|
||||
type Reward struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(150);not null" json:"name"`
|
||||
RewardType RewardType `gorm:"type:varchar(50);not null" json:"reward_type"`
|
||||
CostPoints int64 `gorm:"type:bigint;not null" json:"cost_points"`
|
||||
Stock *int `gorm:"type:int" json:"stock,omitempty"`
|
||||
MaxPerCustomer int `gorm:"type:int;default:1" json:"max_per_customer"`
|
||||
Tnc *TermsAndConditions `gorm:"type:jsonb" json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||
Images *StringSlice `gorm:"type:jsonb" json:"images,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Reward) TableName() string {
|
||||
return "rewards"
|
||||
}
|
||||
|
||||
func (r *Reward) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reward) BeforeUpdate(tx *gorm.DB) error {
|
||||
r.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListRewardsRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
Limit int `form:"limit" binding:"min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
RewardType string `form:"reward_type"`
|
||||
MinPoints *int64 `form:"min_points"`
|
||||
MaxPoints *int64 `form:"max_points"`
|
||||
HasStock *bool `form:"has_stock"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer
|
||||
type StringSlice []string
|
||||
|
||||
// Scan implements the sql.Scanner interface for StringSlice
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into StringSlice", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for StringSlice
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Voucher is what EnakPoint is redeemed for (docs/rfc-enakgame.md §5.7).
|
||||
type Voucher struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
ImageURL *string `gorm:"column:image_url;size:500" json:"image_url"`
|
||||
VoucherType string `gorm:"size:30;not null" json:"voucher_type"`
|
||||
// Rupiah: the basis of realized cost.
|
||||
FaceValue int64 `gorm:"not null" json:"face_value"`
|
||||
PointCost int64 `gorm:"not null" json:"point_cost"`
|
||||
// Reporting only.
|
||||
BusinessCost *int64 `json:"business_cost"`
|
||||
StockMode string `gorm:"size:20;not null" json:"stock_mode"`
|
||||
// STATIC only.
|
||||
Stock *int64 `json:"stock"`
|
||||
// EXTERNAL only.
|
||||
Provider *string `gorm:"size:50" json:"provider"`
|
||||
ProviderRef *string `gorm:"size:255" json:"provider_ref"`
|
||||
MaxPerCustomer *int `json:"max_per_customer"`
|
||||
ValidFrom *time.Time `json:"valid_from"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Terms JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"terms"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Voucher) TableName() string { return "vouchers" }
|
||||
|
||||
// VoucherCode is one code of a CODE_POOL voucher.
|
||||
type VoucherCode struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"`
|
||||
Code string `gorm:"size:255;not null" json:"code"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
RedemptionID *uuid.UUID `gorm:"type:uuid" json:"redemption_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VoucherCode) TableName() string { return "voucher_codes" }
|
||||
|
||||
// VoucherRedemption is one redemption, with the voucher's numbers frozen (P3).
|
||||
type VoucherRedemption struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
||||
VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"`
|
||||
IdempotencyKey string `gorm:"size:100;not null" json:"idempotency_key"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
FaceValue int64 `gorm:"not null" json:"face_value"`
|
||||
PointCost int64 `gorm:"not null" json:"point_cost"`
|
||||
VoucherCodeID *uuid.UUID `gorm:"type:uuid" json:"voucher_code_id"`
|
||||
ExternalCode *string `gorm:"size:255" json:"external_code"`
|
||||
ExternalRef *string `gorm:"size:255" json:"external_ref"`
|
||||
DebitTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"debit_transaction_id"`
|
||||
RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"`
|
||||
FailureReason *string `gorm:"size:255" json:"failure_reason"`
|
||||
Attempts int `gorm:"not null;default:0" json:"attempts"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VoucherRedemption) TableName() string { return "voucher_redemptions" }
|
||||
|
||||
// VoucherRedemptionCost is the part of a redemption's face value one source of its
|
||||
// EnakPoint accounts for (D5). BudgetID is nil for EnakPoint not from EnakGame.
|
||||
type VoucherRedemptionCost struct {
|
||||
RedemptionID uuid.UUID `gorm:"type:uuid;not null" json:"redemption_id"`
|
||||
BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"`
|
||||
SourceType string `gorm:"size:30;not null" json:"source_type"`
|
||||
Points int64 `gorm:"not null" json:"points"`
|
||||
Cost int64 `gorm:"not null" json:"cost"`
|
||||
RecognizedAt time.Time `gorm:"not null" json:"recognized_at"`
|
||||
}
|
||||
|
||||
func (VoucherRedemptionCost) TableName() string { return "voucher_redemption_costs" }
|
||||
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerOrderHandler serves the customer app's order history.
|
||||
type CustomerOrderHandler struct {
|
||||
orders service.CustomerOrderService
|
||||
}
|
||||
|
||||
func NewCustomerOrderHandler(orders service.CustomerOrderService) *CustomerOrderHandler {
|
||||
return &CustomerOrderHandler{orders: orders}
|
||||
}
|
||||
|
||||
// List is GET /customer/orders?page=&limit=.
|
||||
func (h *CustomerOrderHandler) List(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOrderHandler::List")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var query models.ListCustomerOrdersQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "page and limit must be whole numbers"),
|
||||
}), "CustomerOrderHandler::List")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.orders.List(c.Request.Context(), customerID, query), "CustomerOrderHandler::List")
|
||||
}
|
||||
|
||||
// Detail is GET /customer/orders/:id.
|
||||
func (h *CustomerOrderHandler) Detail(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOrderHandler::Detail")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderHandler::Detail")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.orders.Detail(c.Request.Context(), customerID, orderID), "CustomerOrderHandler::Detail")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerOutletHandler serves GET /customer/outlets.
|
||||
type CustomerOutletHandler struct {
|
||||
outlets service.CustomerOutletService
|
||||
}
|
||||
|
||||
func NewCustomerOutletHandler(outlets service.CustomerOutletService) *CustomerOutletHandler {
|
||||
return &CustomerOutletHandler{outlets: outlets}
|
||||
}
|
||||
|
||||
// List returns the active outlets of the customer's organization.
|
||||
func (h *CustomerOutletHandler) List(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOutletHandler::List")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.outlets.List(c.Request.Context(), customerID), "CustomerOutletHandler::List")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -93,36 +93,6 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet")
|
||||
}
|
||||
|
||||
func (h *CustomerPointsHandler) GetCustomerGames(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
response, err := h.customerPointsService.GetCustomerGames(ctx)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerGames -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerGames")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerGames")
|
||||
}
|
||||
|
||||
func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
response, err := h.customerPointsService.GetFerrisWheelGame(ctx)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetFerrisWheelGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
}
|
||||
|
||||
// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest
|
||||
// first, filterable by currency, type and date (docs/prd-point-coin.md F6).
|
||||
func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) {
|
||||
|
||||
@@ -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{
|
||||
@@ -98,7 +98,6 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
|
||||
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
|
||||
nil,
|
||||
)))
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
@@ -162,7 +161,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")
|
||||
}
|
||||
}
|
||||
@@ -181,7 +180,7 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
assert.Len(t, first["lots"], 2)
|
||||
|
||||
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
|
||||
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
|
||||
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=REWARD_REDEEM,MIGRATION")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// EnakGameAdminHandler serves /marketing/enakgame (docs/rfc-enakgame.md §11). Every
|
||||
// request works on the admin's own organization.
|
||||
type EnakGameAdminHandler struct {
|
||||
service service.EnakGameAdminService
|
||||
}
|
||||
|
||||
func NewEnakGameAdminHandler(s service.EnakGameAdminService) *EnakGameAdminHandler {
|
||||
return &EnakGameAdminHandler{service: s}
|
||||
}
|
||||
|
||||
// voucherCodeUploadLimit caps a CSV of codes: 50 000 lines of up to 255 characters.
|
||||
const voucherCodeUploadLimit = 16 << 20
|
||||
|
||||
// pathID reads a UUID path parameter, answering 400 when it is not one.
|
||||
func pathID(c *gin.Context, name, method string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "invalid "+name),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// rawBody reads the request body, answering 400 when it cannot.
|
||||
func rawBody(c *gin.Context, method string) ([]byte, bool) {
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "cannot read the request body"),
|
||||
}), method)
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func bindQuery(c *gin.Context, q any, method string) bool {
|
||||
if err := c.ShouldBindQuery(q); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), method)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateGame is POST /marketing/enakgame/games.
|
||||
func (h *EnakGameAdminHandler) CreateGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateGame"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateGame(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListGames is GET /marketing/enakgame/games?status=&search=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListGames(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListGames"
|
||||
var q models.EnakGameListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListGames(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetGame is GET /marketing/enakgame/games/:id.
|
||||
func (h *EnakGameAdminHandler) GetGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetGame"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetGame(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateGame is PUT /marketing/enakgame/games/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateGame"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateGame(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetGameStatus is PUT /marketing/enakgame/games/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetGameStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetGameStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetGameStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// CreateRewardConfig is POST /marketing/enakgame/games/:id/reward-configs.
|
||||
func (h *EnakGameAdminHandler) CreateRewardConfig(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateRewardConfig"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// ListRewardConfigs is GET /marketing/enakgame/games/:id/reward-configs.
|
||||
func (h *EnakGameAdminHandler) ListRewardConfigs(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListRewardConfigs"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListRewardConfigs(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// ActivateRewardConfig is POST /marketing/enakgame/reward-configs/:id/activate.
|
||||
func (h *EnakGameAdminHandler) ActivateRewardConfig(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ActivateRewardConfig"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ActivateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// CreateBudget is POST /marketing/enakgame/budgets.
|
||||
func (h *EnakGameAdminHandler) CreateBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateBudget"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateBudget(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListBudgets is GET /marketing/enakgame/budgets?scope=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListBudgets(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListBudgets"
|
||||
var q models.GameBudgetListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListBudgets(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetBudget is GET /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) GetBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetBudget(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateBudget is PUT /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateBudget(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// DeleteBudget is DELETE /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) DeleteBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::DeleteBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.DeleteBudget(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// BudgetMetrics is GET /marketing/enakgame/budgets/:id/metrics.
|
||||
func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::BudgetMetrics"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// BudgetRecommendation is GET /marketing/enakgame/budgets/:id/recommendation.
|
||||
func (h *EnakGameAdminHandler) BudgetRecommendation(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::BudgetRecommendation"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.BudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// AcceptBudgetRecommendation is POST /marketing/enakgame/budgets/:id/recommendation/accept.
|
||||
func (h *EnakGameAdminHandler) AcceptBudgetRecommendation(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::AcceptBudgetRecommendation"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.AcceptBudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// GameAnalytics is GET /marketing/enakgame/analytics/games?from=&to=&game_id=.
|
||||
func (h *EnakGameAdminHandler) GameAnalytics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GameAnalytics"
|
||||
var q models.EnakGameAnalyticsQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GameAnalytics(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// EconomyAnalytics is GET /marketing/enakgame/analytics/economy?from=&to=.
|
||||
func (h *EnakGameAdminHandler) EconomyAnalytics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::EconomyAnalytics"
|
||||
var q models.EnakGameAnalyticsQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.EconomyAnalytics(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// CreateVoucher is POST /marketing/enakgame/vouchers.
|
||||
func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateVoucher"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateVoucher(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListVouchers is GET /marketing/enakgame/vouchers?status=&search=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListVouchers"
|
||||
var q models.VoucherListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetVoucher is GET /marketing/enakgame/vouchers/:id.
|
||||
func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetVoucher"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetVoucher(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateVoucher is PUT /marketing/enakgame/vouchers/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateVoucher"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateVoucher(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetVoucherStatus is PUT /marketing/enakgame/vouchers/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetVoucherStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetVoucherStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// ImportVoucherCodes is POST /marketing/enakgame/vouchers/:id/codes, with the CSV as
|
||||
// a multipart file named "file" or as the request body.
|
||||
func (h *EnakGameAdminHandler) ImportVoucherCodes(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ImportVoucherCodes"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var data []byte
|
||||
if strings.HasPrefix(c.ContentType(), "multipart/") {
|
||||
file, err := c.FormFile("file")
|
||||
if err == nil {
|
||||
var f multipart.File
|
||||
if f, err = file.Open(); err == nil {
|
||||
data, err = io.ReadAll(io.LimitReader(f, voucherCodeUploadLimit))
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "send the CSV as a file named \"file\""),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
} else if data, ok = rawBody(c, method); !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ImportVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, data), method)
|
||||
}
|
||||
|
||||
// ListVoucherCodes is GET /marketing/enakgame/vouchers/:id/codes?status=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListVoucherCodes(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListVoucherCodes"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q models.VoucherCodeListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, q), method)
|
||||
}
|
||||
|
||||
// CreateEvent is POST /marketing/enakgame/events.
|
||||
func (h *EnakGameAdminHandler) CreateEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateEvent"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateEvent(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListEvents is GET /marketing/enakgame/events?status=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListEvents(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListEvents"
|
||||
var q models.GameEventListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListEvents(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetEvent is GET /marketing/enakgame/events/:id.
|
||||
func (h *EnakGameAdminHandler) GetEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetEvent"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetEvent(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateEvent is PUT /marketing/enakgame/events/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateEvent"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateEvent(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetEventStatus is PUT /marketing/enakgame/events/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetEventStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetEventStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetEventStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// EnakGameCustomerHandler serves /customer/enakgame (docs/rfc-enakgame.md §11).
|
||||
type EnakGameCustomerHandler struct {
|
||||
service service.EnakGameCustomerService
|
||||
}
|
||||
|
||||
func NewEnakGameCustomerHandler(s service.EnakGameCustomerService) *EnakGameCustomerHandler {
|
||||
return &EnakGameCustomerHandler{service: s}
|
||||
}
|
||||
|
||||
// ListGames is GET /customer/enakgame/games.
|
||||
func (h *EnakGameCustomerHandler) ListGames(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListGames"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListGames(c.Request.Context(), customerID), method)
|
||||
}
|
||||
|
||||
// StartSession is POST /customer/enakgame/sessions. It requires Idempotency-Key.
|
||||
func (h *EnakGameCustomerHandler) StartSession(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::StartSession"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.StartGameSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "game_id is required"),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.StartSession(c.Request.Context(), customerID, &req, idempotencyKey(c)), method)
|
||||
}
|
||||
|
||||
// ListSessions is GET /customer/enakgame/sessions?page=&limit=.
|
||||
func (h *EnakGameCustomerHandler) ListSessions(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListSessions"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, page, limit), method)
|
||||
}
|
||||
|
||||
// GetSession is GET /customer/enakgame/sessions/:id.
|
||||
func (h *EnakGameCustomerHandler) GetSession(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::GetSession"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetSession(c.Request.Context(), customerID, id), method)
|
||||
}
|
||||
|
||||
// CompleteSession is POST /customer/enakgame/sessions/:id/complete. The body is read
|
||||
// leniently on purpose: a reward amount, or anything else the backend does not take
|
||||
// from the client, is ignored rather than refused (P1). An empty body is a result
|
||||
// with nothing in it.
|
||||
func (h *EnakGameCustomerHandler) CompleteSession(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::CompleteSession"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in models.GameSessionCompleteInput
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "score must be a whole number and outcome a text"),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CompleteSession(c.Request.Context(), customerID, id, in), method)
|
||||
}
|
||||
|
||||
// ListVouchers is GET /customer/enakgame/vouchers.
|
||||
func (h *EnakGameCustomerHandler) ListVouchers(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListVouchers"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(c.Request.Context(), customerID), method)
|
||||
}
|
||||
|
||||
// RedeemVoucher is POST /customer/enakgame/vouchers/:id/redeem. It requires the PIN
|
||||
// and Idempotency-Key; the body holds the PIN, so it is never logged.
|
||||
func (h *EnakGameCustomerHandler) RedeemVoucher(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::RedeemVoucher"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RedeemVoucherRequest
|
||||
if !bindPinRequest(c, &req, method) {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.RedeemVoucher(c.Request.Context(), customerID, id, &req, idempotencyKey(c), pinRequestInfo(c)), method)
|
||||
}
|
||||
|
||||
// ListRedemptions is GET /customer/enakgame/redemptions?page=&limit=.
|
||||
func (h *EnakGameCustomerHandler) ListRedemptions(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListRedemptions"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListRedemptions(c.Request.Context(), customerID, page, limit), method)
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// enakGameHandlers builds both EnakGame handlers on db, the way app.go does.
|
||||
func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHandler) {
|
||||
txm := repository.NewTxManager(db)
|
||||
audit := processor.NewAuditLogger(repository.NewAuditLogRepository(db))
|
||||
games := repository.NewEnakGameRepository(db)
|
||||
budgets := repository.NewGameBudgetRepository(db)
|
||||
vouchers := repository.NewVoucherRepository(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
customers := repository.NewWalletMoveRepository(db)
|
||||
spendable := repository.NewWalletQueryRepository(db)
|
||||
metrics := processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db))
|
||||
admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService(
|
||||
processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), metrics,
|
||||
processor.NewGameBudgetControllerProcessor(budgets, metrics, games, audit, txm),
|
||||
processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, txm),
|
||||
processor.NewVoucherAdminProcessor(vouchers, audit, txm),
|
||||
processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(db))))
|
||||
customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService(
|
||||
processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets,
|
||||
repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm),
|
||||
spendable, wallet, audit, txm),
|
||||
processor.NewVoucherRedemptionProcessor(customers, vouchers, repository.NewVoucherRedemptionRepository(db),
|
||||
processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), nil, nil), spendable, wallet, processor.VoucherProviders{}, txm)))
|
||||
return admin, customer
|
||||
}
|
||||
|
||||
// Drives /marketing/enakgame and /customer/enakgame over HTTP down to Postgres
|
||||
// (docs/rfc-enakgame.md §11). Needs TEST_DATABASE_URL; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEnakGameEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame http', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_counters WHERE organization_id = ?`,
|
||||
`DELETE FROM game_sessions WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lots WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_wallets WHERE organization_id = ?`,
|
||||
`DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`,
|
||||
`DELETE FROM game_events WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_configs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id = ?`,
|
||||
`DELETE FROM games WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
adminHandler, customerHandler := enakGameHandlers(db)
|
||||
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
|
||||
role := role
|
||||
g := router.Group(prefix+"/enakgame", func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
g.POST("/games", adminHandler.CreateGame)
|
||||
g.GET("/games/:id", adminHandler.GetGame)
|
||||
g.PUT("/games/:id", adminHandler.UpdateGame)
|
||||
g.PUT("/games/:id/status", adminHandler.SetGameStatus)
|
||||
g.POST("/games/:id/reward-configs", auth.RequireLoyaltyManager(), adminHandler.CreateRewardConfig)
|
||||
g.POST("/reward-configs/:id/activate", auth.RequireLoyaltyManager(), adminHandler.ActivateRewardConfig)
|
||||
g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget)
|
||||
g.GET("/budgets/:id/metrics", adminHandler.BudgetMetrics)
|
||||
}
|
||||
c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
|
||||
c.GET("/games", customerHandler.ListGames)
|
||||
c.POST("/sessions", customerHandler.StartSession)
|
||||
c.GET("/sessions/:id", customerHandler.GetSession)
|
||||
|
||||
call := func(method, path, body string, headers ...string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
status, body := call(http.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 2, "status": "ACTIVE",
|
||||
"result_rules": {"max_score": 5000, "outcomes": ["WIN", "LOSE"]}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
gameID := data(body)["id"].(string)
|
||||
assert.Equal(t, map[string]any{"max_score": float64(5000), "outcomes": []any{"WIN", "LOSE"}}, data(body)["result_rules"])
|
||||
|
||||
status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "x", "entry_cost": 1, "entry_cots": 2}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused")
|
||||
status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "runner", "entry_cost": 1}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "slug in use")
|
||||
status, _ = call(http.MethodGet, "/manager/enakgame/games/"+uuid.NewString(), "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodGet, "/manager/enakgame/games/not-a-uuid", "")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// A change keeps the fields it does not send.
|
||||
status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID, `{"entry_cost": 3}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 3, data(body)["entry_cost"])
|
||||
assert.Equal(t, "runner", data(body)["slug"])
|
||||
assert.EqualValues(t, 5000, data(body)["result_rules"].(map[string]any)["max_score"])
|
||||
|
||||
// Reward configurations and budgets need a loyalty manager.
|
||||
config := `{"reward_type": "OUTCOME_BASED", "rules": {"outcomes": {"WIN": 10, "LOSE": 1}}, "max_reward": 10}`
|
||||
status, _ = call(http.MethodPost, "/purchasing/enakgame/games/"+gameID+"/reward-configs", config)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/games/"+gameID+"/reward-configs", config)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/reward-configs/"+data(body)["id"].(string)+"/activate", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, "ACTIVE", data(body)["status"])
|
||||
|
||||
// The customer cannot start before the period has a budget.
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 10,
|
||||
ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
start := `{"game_id": "` + gameID + `"}`
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1")
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
now := time.Now().In(time.FixedZone("WIB", 7*3600))
|
||||
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
budget := `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "` + first.Format("2006-01-02") +
|
||||
`", "period_end": "` + first.AddDate(0, 1, -1).Format("2006-01-02") + `", "amount": 1000000}`
|
||||
status, _ = call(http.MethodPost, "/purchasing/enakgame/budgets", budget)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/budgets", budget)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, body = call(http.MethodGet, "/purchasing/enakgame/budgets/"+data(body)["id"].(string)+"/metrics", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, "HEALTHY", data(body)["status"])
|
||||
assert.EqualValues(t, 1000000, data(body)["remaining"])
|
||||
status, _ = call(http.MethodGet, "/manager/enakgame/budgets/"+uuid.NewString()+"/metrics", "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/games", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
listed := body["data"].([]any)
|
||||
require.Len(t, listed, 1)
|
||||
assert.NotContains(t, listed[0], "result_rules", "the validation limits stay internal")
|
||||
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required")
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
sessionID := data(body)["session_id"].(string)
|
||||
assert.EqualValues(t, 7, data(body)["coin_balance"])
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "X-Idempotency-Key", "k1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, sessionID, data(body)["session_id"])
|
||||
assert.Equal(t, true, data(body)["replayed"])
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", `{}`, "Idempotency-Key", "k2")
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/sessions/"+sessionID, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, "STARTED", data(body)["status"])
|
||||
status, _ = call(http.MethodGet, "/customer/enakgame/sessions/"+uuid.NewString(), "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
// The backend alone decides the reward (P1): a reward in the body is ignored.
|
||||
c.POST("/sessions/:id/complete", customerHandler.CompleteSession)
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "LOSE", "reward": 999, "reward_total": 999}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["reward_total"])
|
||||
assert.EqualValues(t, 8, data(body)["coin_balance"])
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "WIN"}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["reward_total"], "the first completion stands")
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"score": "high"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+uuid.NewString()+"/complete", ``)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
// Deactivating the game is audited; the session waits for the job.
|
||||
status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID+"/status", `{"status": "INACTIVE", "reason": "bug"}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k3")
|
||||
assert.Equal(t, http.StatusBadRequest, status, "an inactive game cannot start")
|
||||
}
|
||||
|
||||
// Drives the voucher endpoints over HTTP down to Postgres (docs/rfc-enakgame.md §7.4,
|
||||
// §11).
|
||||
func TestEnakGameVoucherEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'voucher http', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'Budi', ?, NOW())`, customer, org, string(hash)).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM voucher_redemption_costs WHERE redemption_id IN (SELECT id FROM voucher_redemptions WHERE organization_id = ?)`,
|
||||
`DELETE FROM voucher_redemptions WHERE organization_id = ?`,
|
||||
`DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id = ?)`,
|
||||
`DELETE FROM vouchers WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_security_events WHERE customer_id IN (SELECT id FROM customers WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lots WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_wallets WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: "POINT", Type: "MIGRATION", Amount: 50_000,
|
||||
ReferenceType: "LEGACY_POINTS", ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
adminHandler, customerHandler := enakGameHandlers(db)
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
|
||||
role := role
|
||||
g := router.Group(prefix+"/enakgame", func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
g.POST("/vouchers", auth.RequireLoyaltyManager(), adminHandler.CreateVoucher)
|
||||
g.PUT("/vouchers/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateVoucher)
|
||||
g.POST("/vouchers/:id/codes", auth.RequireLoyaltyManager(), adminHandler.ImportVoucherCodes)
|
||||
g.GET("/vouchers/:id/codes", adminHandler.ListVoucherCodes)
|
||||
}
|
||||
c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
|
||||
c.GET("/vouchers", customerHandler.ListVouchers)
|
||||
c.POST("/vouchers/:id/redeem", customerHandler.RedeemVoucher)
|
||||
c.GET("/redemptions", customerHandler.ListRedemptions)
|
||||
|
||||
send := func(req *http.Request) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
call := func(method, path, body string, headers ...string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
return send(req)
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
voucher := `{"name": "Kopi", "voucher_type": "FREE_ITEM", "face_value": 20000, "point_cost": 15000, "stock_mode": "CODE_POOL", "status": "ACTIVE"}`
|
||||
status, _ := call(http.MethodPost, "/purchasing/enakgame/vouchers", voucher)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body := call(http.MethodPost, "/manager/enakgame/vouchers", voucher)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
id := data(body)["id"].(string)
|
||||
status, _ = call(http.MethodPut, "/manager/enakgame/vouchers/"+id, `{"stock_mode": "STATIC", "stock": 5}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "the stock mode stays")
|
||||
|
||||
// Codes as a multipart file, then the same as a plain body.
|
||||
var form bytes.Buffer
|
||||
w := multipart.NewWriter(&form)
|
||||
part, err := w.CreateFormFile("file", "codes.csv")
|
||||
require.NoError(t, err)
|
||||
_, _ = part.Write([]byte("code\nKOPI-1\nKOPI-2\n"))
|
||||
require.NoError(t, w.Close())
|
||||
req := httptest.NewRequest(http.MethodPost, "/manager/enakgame/vouchers/"+id+"/codes", &form)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
status, body = send(req)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, data(body)["imported"])
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/vouchers/"+id+"/codes", "KOPI-2\nKOPI-3\n", "Content-Type", "text/csv")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["imported"])
|
||||
assert.Equal(t, []any{"KOPI-2"}, data(body)["duplicates"])
|
||||
status, body = call(http.MethodGet, "/manager/enakgame/vouchers/"+id+"/codes", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, map[string]any{"AVAILABLE": float64(3)}, data(body)["counts"])
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/vouchers", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
require.Len(t, body["data"], 1)
|
||||
assert.EqualValues(t, 3, body["data"].([]any)[0].(map[string]any)["available"])
|
||||
|
||||
redeem := "/customer/enakgame/vouchers/" + id + "/redeem"
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required")
|
||||
status, body = call(http.MethodPost, redeem, `{}`, "Idempotency-Key", "r1")
|
||||
assert.Equal(t, http.StatusBadRequest, status, "the PIN is required")
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "000000"}`, "Idempotency-Key", "r1")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, "PIN_INVALID", body["errors"].([]any)[0].(map[string]any)["code"])
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
code := data(body)["code"]
|
||||
assert.NotEmpty(t, code)
|
||||
assert.EqualValues(t, 35_000, data(body)["point_balance"])
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, code, data(body)["code"])
|
||||
assert.Equal(t, true, data(body)["replayed"])
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r2")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r3")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 5_000, data(body)["point_balance"])
|
||||
status, _ = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r5")
|
||||
assert.Equal(t, http.StatusBadRequest, status, "out of codes and of EnakPoint")
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/vouchers/"+uuid.NewString()+"/redeem", `{"pin": "482913"}`, "Idempotency-Key", "r4")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/redemptions", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 3, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
}
|
||||
|
||||
// Drives /marketing/enakgame/events over HTTP, and the events on the customer's game
|
||||
// list (docs/rfc-enakgame.md §11).
|
||||
func TestEnakGameEventEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'event http', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`,
|
||||
`DELETE FROM game_events WHERE organization_id = ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id = ?`,
|
||||
`DELETE FROM games WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
adminHandler, customerHandler := enakGameHandlers(db)
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
|
||||
role := role
|
||||
g := router.Group(prefix+"/enakgame", func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
g.POST("/games", adminHandler.CreateGame)
|
||||
g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget)
|
||||
g.POST("/events", auth.RequireLoyaltyManager(), adminHandler.CreateEvent)
|
||||
g.PUT("/events/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateEvent)
|
||||
g.GET("/events", adminHandler.ListEvents)
|
||||
}
|
||||
router.GET("/customer/enakgame/games", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerHandler.ListGames)
|
||||
call := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(method, path, bytes.NewBufferString(body)))
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
status, body := call(http.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 1, "status": "ACTIVE"}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
gameID := data(body)["id"].(string)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/budgets", `{"scope": "EVENT", "name": "Ramadan", "period_start": "2026-01-01", "period_end": "2030-12-31", "amount": 5000000}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
budgetID := data(body)["id"].(string)
|
||||
|
||||
start := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
end := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
event := `{"name": "Ramadan", "slug": "ramadan", "start_at": "` + start + `", "end_at": "` + end + `", "multiplier": 2,
|
||||
"budget_id": "` + budgetID + `", "game_ids": ["` + gameID + `"], "status": "ACTIVE"}`
|
||||
status, _ = call(http.MethodPost, "/purchasing/enakgame/events", event)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/events", event)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
eventID := data(body)["id"].(string)
|
||||
status, _ = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"multipler": 3}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused")
|
||||
status, body = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"bonus": 5}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, data(body)["multiplier"], "kept")
|
||||
assert.EqualValues(t, 5, data(body)["bonus"])
|
||||
status, body = call(http.MethodGet, "/purchasing/enakgame/events?status=active", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/games", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
games := body["data"].([]any)
|
||||
require.Len(t, games, 1)
|
||||
events := games[0].(map[string]any)["events"].([]any)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, "Ramadan", events[0].(map[string]any)["name"])
|
||||
assert.EqualValues(t, 5, events[0].(map[string]any)["bonus"])
|
||||
assert.NotContains(t, events[0], "budget_id", "the budget stays internal")
|
||||
}
|
||||
|
||||
// EG-1001: the admin steps of docs/enakgame-spin.md make a spin wheel, and a customer
|
||||
// plays it through /customer/enakgame/sessions, over HTTP down to Postgres.
|
||||
func TestEnakGameSpin_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame spin', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_counters WHERE organization_id = ?`,
|
||||
`DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id = ?)`,
|
||||
`DELETE FROM game_sessions WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lots WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_wallets WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_configs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id = ?`,
|
||||
`DELETE FROM games WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 20,
|
||||
ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
adminHandler, customerHandler := enakGameHandlers(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
m := router.Group("/marketing/enakgame", func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, "manager")
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
m.POST("/games", adminHandler.CreateGame)
|
||||
m.POST("/games/:id/reward-configs", adminHandler.CreateRewardConfig)
|
||||
m.POST("/reward-configs/:id/activate", adminHandler.ActivateRewardConfig)
|
||||
m.POST("/budgets", adminHandler.CreateBudget)
|
||||
c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
|
||||
c.GET("/games", customerHandler.ListGames)
|
||||
c.POST("/sessions", customerHandler.StartSession)
|
||||
c.POST("/sessions/:id/complete", customerHandler.CompleteSession)
|
||||
|
||||
call := func(method, path, body string, headers ...string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
require.Equal(t, http.StatusOK, rec.Code, out)
|
||||
return out["data"].(map[string]any)
|
||||
}
|
||||
|
||||
// The admin steps.
|
||||
game := call(http.MethodPost, "/marketing/enakgame/games",
|
||||
`{"name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "ACTIVE"}`)
|
||||
gameID := game["id"].(string)
|
||||
config := call(http.MethodPost, "/marketing/enakgame/games/"+gameID+"/reward-configs", `{"reward_type": "PROBABILITY", "max_reward": 50,
|
||||
"rules": {"table": [
|
||||
{"weight": 50, "amount": 0, "label": "Zonk"},
|
||||
{"weight": 30, "amount": 3, "label": "3 Coin"},
|
||||
{"weight": 15, "amount": 10, "label": "10 Coin"},
|
||||
{"weight": 5, "amount": 50, "label": "Jackpot"}]}}`)
|
||||
call(http.MethodPost, "/marketing/enakgame/reward-configs/"+config["id"].(string)+"/activate", `{}`)
|
||||
now := time.Now().In(time.FixedZone("WIB", 7*3600))
|
||||
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
call(http.MethodPost, "/marketing/enakgame/budgets", `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "`+
|
||||
first.Format("2006-01-02")+`", "period_end": "`+first.AddDate(0, 1, -1).Format("2006-01-02")+`", "amount": 1000000}`)
|
||||
|
||||
// The wheel shows the segments, never their odds.
|
||||
req := httptest.NewRequest(http.MethodGet, "/customer/enakgame/games", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var listed struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Prizes []struct {
|
||||
Entry int `json:"entry"`
|
||||
Label *string `json:"label"`
|
||||
Amount int64 `json:"amount"`
|
||||
} `json:"prizes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed))
|
||||
require.Len(t, listed.Data, 1)
|
||||
require.Len(t, listed.Data[0].Prizes, 4)
|
||||
assert.Equal(t, "Jackpot", *listed.Data[0].Prizes[3].Label)
|
||||
assert.NotContains(t, rec.Body.String(), "weight")
|
||||
|
||||
// Two spins: each lands on a listed segment and pays what it shows.
|
||||
balance := int64(20)
|
||||
for _, key := range []string{"spin-1", "spin-2"} {
|
||||
started := call(http.MethodPost, "/customer/enakgame/sessions", `{"game_id": "`+gameID+`"}`, "Idempotency-Key", key)
|
||||
balance -= 5
|
||||
assert.EqualValues(t, balance, started["coin_balance"])
|
||||
done := call(http.MethodPost, "/customer/enakgame/sessions/"+started["session_id"].(string)+"/complete", `{}`)
|
||||
prize := done["prize"].(map[string]any)
|
||||
entry := int(prize["entry"].(float64))
|
||||
require.True(t, entry >= 1 && entry <= 4, prize)
|
||||
segment := listed.Data[0].Prizes[entry-1]
|
||||
assert.Equal(t, *segment.Label, prize["label"])
|
||||
assert.EqualValues(t, segment.Amount, prize["amount"])
|
||||
assert.EqualValues(t, segment.Amount, done["reward_total"])
|
||||
balance += segment.Amount
|
||||
assert.EqualValues(t, balance, done["coin_balance"])
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type GamificationHandler struct {
|
||||
@@ -25,32 +19,3 @@ func NewGamificationHandler(
|
||||
gamificationValidator: gamificationValidator,
|
||||
}
|
||||
}
|
||||
|
||||
// Play Game Handler
|
||||
func (h *GamificationHandler) PlayGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.PlayGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidatePlayGameRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::PlayGame -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.PlayGame(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::PlayGame")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame")
|
||||
}
|
||||
|
||||
@@ -168,410 +168,6 @@ func (h *GamificationHandler) GetTierByPoints(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetTierByPoints")
|
||||
}
|
||||
|
||||
// Game Handlers
|
||||
func (h *GamificationHandler) CreateGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.CreateGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGame -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGame")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGameRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGame -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGame")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateGame(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::CreateGame")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGame")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGame -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGame")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetGame(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::GetGame")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGame")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListGames(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListGamesRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGames -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGames")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListGamesRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGames -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGames")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListGames(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGames -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::ListGames")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGames")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetActiveGames(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
response, err := h.gamificationService.GetActiveGames(ctx)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetActiveGames -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::GetActiveGames")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetActiveGames")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) UpdateGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateGameRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateGame -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.UpdateGame(ctx, id, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::UpdateGame")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateGame")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeleteGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGame -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteGame")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.gamificationService.DeleteGame(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::DeleteGame")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteGame")
|
||||
}
|
||||
|
||||
// Game Prize Handlers
|
||||
func (h *GamificationHandler) CreateGamePrize(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.CreateGamePrizeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePrize -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGamePrizeRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGamePrize -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateGamePrize(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePrize -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::CreateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGamePrize")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetGamePrize(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrize -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetGamePrize(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrize -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePrize")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListGamePrizes(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListGamePrizesRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePrizes -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePrizes")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListGamePrizesRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGamePrizes -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePrizes")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListGamePrizes(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePrizes -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::ListGamePrizes")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGamePrizes")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) UpdateGamePrize(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateGamePrizeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateGamePrizeRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateGamePrize -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.UpdateGamePrize(ctx, id, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::UpdateGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateGamePrize")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeleteGamePrize(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGamePrize -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.gamificationService.DeleteGamePrize(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGamePrize -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::DeleteGamePrize")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteGamePrize")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetGamePrizesByGameID(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
gameIDStr := c.Param("game_id")
|
||||
gameID, err := uuid.Parse(gameIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrizesByGameID -> invalid game ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid game ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePrizesByGameID")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetGamePrizesByGameID(ctx, gameID)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrizesByGameID -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetGamePrizesByGameID")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePrizesByGameID")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetAvailablePrizes(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
gameIDStr := c.Param("game_id")
|
||||
gameID, err := uuid.Parse(gameIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetAvailablePrizes -> invalid game ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid game ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetAvailablePrizes")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetAvailablePrizes(ctx, gameID)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetAvailablePrizes -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetAvailablePrizes")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetAvailablePrizes")
|
||||
}
|
||||
|
||||
// Game Play Handlers
|
||||
func (h *GamificationHandler) CreateGamePlay(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.CreateGamePlayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePlay -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePlay")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGamePlayRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGamePlay -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePlay")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateGamePlay(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePlay -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::CreateGamePlay")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGamePlay")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetGamePlay(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePlay -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePlayEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePlay")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetGamePlay(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePlay -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::GetGamePlay")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePlay")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListGamePlays(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListGamePlaysRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePlays -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePlays")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListGamePlaysRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGamePlays -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePlays")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListGamePlays(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePlays -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::ListGamePlays")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGamePlays")
|
||||
}
|
||||
|
||||
// Omset Tracker Handlers
|
||||
func (h *GamificationHandler) CreateOmsetTracker(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -91,9 +91,9 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": 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.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.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,14 +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}}`,
|
||||
"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)
|
||||
|
||||
@@ -105,6 +105,7 @@ func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"])
|
||||
assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"])
|
||||
assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"])
|
||||
assert.Equal(t, map[string]any{"user_daily_limit": float64(0), "global_daily_limit": float64(0)}, got["enakgame"], "0 is no limit")
|
||||
impact := got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["outstanding_points"])
|
||||
assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"])
|
||||
@@ -177,4 +178,17 @@ func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
|
||||
// The EnakGame limits (docs/rfc-enakgame.md §5.10) go through the same endpoint and
|
||||
// history.
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings", `{"enakgame": {"user_daily_limit": 50, "global_daily_limit": 10000}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, map[string]any{"user_daily_limit": float64(50), "global_daily_limit": float64(10000)}, data(body)["enakgame"])
|
||||
var limits []struct{ Key, NewValue string }
|
||||
require.NoError(t, db.Raw(`SELECT key, new_value FROM loyalty_setting_changes WHERE organization_id = ? AND key LIKE 'enakgame.%' ORDER BY key`, org).Scan(&limits).Error)
|
||||
assert.Equal(t, []struct{ Key, NewValue string }{{"enakgame.limit.global_daily", "10000"}, {"enakgame.limit.user_daily", "50"}}, limits)
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings", `{"enakgame": {"user_daily_limit": -1}}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"enakgame": {"user_daily_limit": 1}}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type RewardHandler struct {
|
||||
rewardService service.RewardService
|
||||
rewardValidator validator.RewardValidator
|
||||
}
|
||||
|
||||
func NewRewardHandler(rewardService service.RewardService, rewardValidator validator.RewardValidator) *RewardHandler {
|
||||
return &RewardHandler{
|
||||
rewardService: rewardService,
|
||||
rewardValidator: rewardValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RewardHandler) CreateReward(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req contract.CreateRewardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.rewardValidator.ValidateCreateRewardRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::CreateReward -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.rewardService.CreateReward(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::CreateReward")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::CreateReward")
|
||||
}
|
||||
|
||||
func (h *RewardHandler) GetReward(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
idStr := c.Param("id")
|
||||
if idStr == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("RewardHandler::GetReward -> missing ID parameter")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetReward")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.rewardService.GetReward(ctx, idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetReward -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetReward")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetReward")
|
||||
}
|
||||
|
||||
func (h *RewardHandler) ListRewards(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req contract.ListRewardsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.rewardValidator.ValidateListRewardsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::ListRewards -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.rewardService.ListRewards(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::ListRewards")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::ListRewards")
|
||||
}
|
||||
|
||||
func (h *RewardHandler) UpdateReward(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req contract.UpdateRewardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.rewardValidator.ValidateUpdateRewardRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::UpdateReward -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.rewardService.UpdateReward(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateReward")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::UpdateReward")
|
||||
}
|
||||
|
||||
func (h *RewardHandler) DeleteReward(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
idStr := c.Param("id")
|
||||
if idStr == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("RewardHandler::DeleteReward -> missing ID parameter")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::DeleteReward")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.rewardService.DeleteReward(ctx, idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::DeleteReward -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::DeleteReward")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward deleted successfully"), "RewardHandler::DeleteReward")
|
||||
}
|
||||
|
||||
func (h *RewardHandler) UpdateRewardStock(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
idStr := c.Param("id")
|
||||
if idStr == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("RewardHandler::UpdateRewardStock -> missing ID parameter")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock")
|
||||
return
|
||||
}
|
||||
|
||||
stockStr := c.Param("stock")
|
||||
stock, err := strconv.Atoi(stockStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> invalid stock parameter")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.RewardEntity, "Invalid stock parameter")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.rewardService.UpdateRewardStock(ctx, idStr, stock)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateRewardStock")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward stock updated successfully"), "RewardHandler::UpdateRewardStock")
|
||||
}
|
||||
|
||||
func (h *RewardHandler) GetRewardsByType(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
rewardType := c.Param("type")
|
||||
if rewardType == "" {
|
||||
logger.FromContext(c.Request.Context()).Error("RewardHandler::GetRewardsByType -> missing type parameter")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "Type parameter is required")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetRewardsByType")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.rewardService.GetRewardsByType(ctx, rewardType)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetRewardsByType -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetRewardsByType")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetRewardsByType")
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type SpinGameHandler struct {
|
||||
spinGameService service.SpinGameService
|
||||
}
|
||||
|
||||
func NewSpinGameHandler(spinGameService service.SpinGameService) *SpinGameHandler {
|
||||
return &SpinGameHandler{
|
||||
spinGameService: spinGameService,
|
||||
}
|
||||
}
|
||||
|
||||
// PlaySpinGame handles the spin game play request
|
||||
func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) {
|
||||
// Get customer ID from JWT token (set by middleware)
|
||||
customerID, exists := c.Get("customer_id")
|
||||
if !exists {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.SpinGameHandlerEntity, "Customer ID not found in context"),
|
||||
}), "SpinGameHandler::PlaySpinGame")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse spin ID from request body
|
||||
var req contract.SpinGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.SpinGameHandlerEntity, err.Error()),
|
||||
}), "SpinGameHandler::PlaySpinGame")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to model
|
||||
modelReq := &models.SpinGameRequest{
|
||||
SpinID: req.SpinID,
|
||||
}
|
||||
|
||||
// Play the spin game
|
||||
response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq)
|
||||
if err != nil {
|
||||
// Not enough EnakCoin, an inactive game or a prize that just ran out is the
|
||||
// customer's to fix, not a server failure.
|
||||
code := constants.InternalServerErrorCode
|
||||
if errors.Is(err, processor.ErrGamePlayRefused) {
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()),
|
||||
}), "SpinGameHandler::PlaySpinGame")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert model response to contract response
|
||||
contractResponse := mappers.SpinGameResponseModelToContract(response)
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResponse.Data), "SpinGameHandler::PlaySpinGame")
|
||||
}
|
||||
@@ -186,7 +186,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
||||
// The customer's own history shows the adjustment too, with the reason in the
|
||||
// description and without the admin's identity fields.
|
||||
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
|
||||
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))))))
|
||||
crouter := gin.New()
|
||||
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -58,15 +58,23 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
||||
metadata["image_url"] = *req.ImageURL
|
||||
}
|
||||
|
||||
// A category is a team unless the request says otherwise
|
||||
isTeam := true
|
||||
if req.IsTeam != nil {
|
||||
isTeam = *req.IsTeam
|
||||
}
|
||||
|
||||
return &entities.Category{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
BusinessType: "restaurant",
|
||||
Metadata: metadata,
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
OwnerFeePercent: req.OwnerFeePercent,
|
||||
IsTeam: isTeam,
|
||||
BusinessType: "restaurant",
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,18 +102,20 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
||||
}
|
||||
|
||||
return &models.CategoryResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
Order: entity.Order,
|
||||
IsActive: true,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
Order: entity.Order,
|
||||
OwnerFeePercent: entity.OwnerFeePercent,
|
||||
IsTeam: entity.IsTeam,
|
||||
IsActive: true,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,11 +144,25 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
||||
entity.Order = *req.Order
|
||||
}
|
||||
|
||||
if req.ClearOwnerFeePercent {
|
||||
entity.OwnerFeePercent = nil
|
||||
} else if req.OwnerFeePercent != nil {
|
||||
entity.OwnerFeePercent = req.OwnerFeePercent
|
||||
}
|
||||
|
||||
if req.IsTeam != nil {
|
||||
entity.IsTeam = *req.IsTeam
|
||||
}
|
||||
|
||||
if req.OutletID != nil {
|
||||
entity.OutletID = req.OutletID
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
if req.ClearParentID {
|
||||
// Drop the preloaded parent too, so the response stops reporting it
|
||||
entity.ParentID = nil
|
||||
entity.Parent = nil
|
||||
} else if req.ParentID != nil {
|
||||
entity.ParentID = req.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateCategoryRequestToEntityDefaultsToTeam(t *testing.T) {
|
||||
notTeam := false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isTeam *bool
|
||||
want bool
|
||||
}{
|
||||
{name: "omitted is a team", isTeam: nil, want: true},
|
||||
{name: "false is not a team", isTeam: ¬Team, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entity := CreateCategoryRequestToEntity(&models.CreateCategoryRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
Name: "Merchandise",
|
||||
IsTeam: tt.isTeam,
|
||||
})
|
||||
require.Equal(t, tt.want, entity.IsTeam)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToGameResponse converts a game entity to a game response
|
||||
func ToGameResponse(game *entities.Game) *models.GameResponse {
|
||||
if game == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.GameResponse{
|
||||
ID: game.ID,
|
||||
Name: game.Name,
|
||||
Type: string(game.Type),
|
||||
IsActive: game.IsActive,
|
||||
Metadata: game.Metadata,
|
||||
CreatedAt: game.CreatedAt,
|
||||
UpdatedAt: game.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToGameResponses converts a slice of game entities to game responses
|
||||
func ToGameResponses(games []entities.Game) []models.GameResponse {
|
||||
responses := make([]models.GameResponse, len(games))
|
||||
for i, game := range games {
|
||||
responses[i] = *ToGameResponse(&game)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToGameEntity converts a create game request to a game entity
|
||||
func ToGameEntity(req *models.CreateGameRequest) *entities.Game {
|
||||
return &entities.Game{
|
||||
Name: req.Name,
|
||||
Type: entities.GameType(req.Type),
|
||||
IsActive: req.IsActive,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateGameEntity updates a game entity with update request data
|
||||
func UpdateGameEntity(game *entities.Game, req *models.UpdateGameRequest) {
|
||||
if req.Name != nil {
|
||||
game.Name = *req.Name
|
||||
}
|
||||
if req.Type != nil {
|
||||
game.Type = entities.GameType(*req.Type)
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
game.IsActive = *req.IsActive
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
game.Metadata = req.Metadata
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToGamePlayResponse converts a game play entity to a game play response
|
||||
func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse {
|
||||
if gamePlay == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.GamePlayResponse{
|
||||
ID: gamePlay.ID,
|
||||
GameID: gamePlay.GameID,
|
||||
CustomerID: gamePlay.CustomerID,
|
||||
PrizeID: gamePlay.PrizeID,
|
||||
CoinsUsed: gamePlay.CoinsUsed,
|
||||
RandomSeed: gamePlay.RandomSeed,
|
||||
CreatedAt: gamePlay.CreatedAt,
|
||||
Game: ToGameResponse(&gamePlay.Game),
|
||||
Customer: ToCustomerResponse(&gamePlay.Customer),
|
||||
Prize: ToGamePrizeResponse(gamePlay.Prize),
|
||||
}
|
||||
}
|
||||
|
||||
// ToGamePlayResponses converts a slice of game play entities to game play responses
|
||||
func ToGamePlayResponses(gamePlays []entities.GamePlay) []models.GamePlayResponse {
|
||||
responses := make([]models.GamePlayResponse, len(gamePlays))
|
||||
for i, gp := range gamePlays {
|
||||
responses[i] = *ToGamePlayResponse(&gp)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToGamePlayResponsesFromPointers converts a slice of game play entity pointers to game play responses
|
||||
func ToGamePlayResponsesFromPointers(gamePlays []*entities.GamePlay) []models.GamePlayResponse {
|
||||
responses := make([]models.GamePlayResponse, len(gamePlays))
|
||||
for i, gp := range gamePlays {
|
||||
responses[i] = *ToGamePlayResponse(gp)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToGamePlayEntity converts a create game play request to a game play entity
|
||||
func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay {
|
||||
return &entities.GamePlay{
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
CoinsUsed: req.CoinsUsed,
|
||||
RandomSeed: req.RandomSeed,
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToGamePrizeResponse converts a game prize entity to a game prize response
|
||||
func ToGamePrizeResponse(gamePrize *entities.GamePrize) *models.GamePrizeResponse {
|
||||
if gamePrize == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.GamePrizeResponse{
|
||||
ID: gamePrize.ID,
|
||||
GameID: gamePrize.GameID,
|
||||
Name: gamePrize.Name,
|
||||
Weight: gamePrize.Weight,
|
||||
Stock: gamePrize.Stock,
|
||||
MaxStock: gamePrize.MaxStock,
|
||||
Threshold: gamePrize.Threshold,
|
||||
FallbackPrizeID: gamePrize.FallbackPrizeID,
|
||||
Image: gamePrize.Image,
|
||||
Metadata: gamePrize.Metadata,
|
||||
Game: ToGameResponse(&gamePrize.Game),
|
||||
FallbackPrize: ToGamePrizeResponse(gamePrize.FallbackPrize),
|
||||
CreatedAt: gamePrize.CreatedAt,
|
||||
UpdatedAt: gamePrize.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToGamePrizeResponses converts a slice of game prize entities to game prize responses
|
||||
func ToGamePrizeResponses(gamePrizes []entities.GamePrize) []models.GamePrizeResponse {
|
||||
responses := make([]models.GamePrizeResponse, len(gamePrizes))
|
||||
for i, gp := range gamePrizes {
|
||||
responses[i] = *ToGamePrizeResponse(&gp)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToGamePrizeEntity converts a create game prize request to a game prize entity
|
||||
func ToGamePrizeEntity(req *models.CreateGamePrizeRequest) *entities.GamePrize {
|
||||
return &entities.GamePrize{
|
||||
GameID: req.GameID,
|
||||
Name: req.Name,
|
||||
Weight: req.Weight,
|
||||
Stock: req.Stock,
|
||||
MaxStock: req.MaxStock,
|
||||
Threshold: req.Threshold,
|
||||
FallbackPrizeID: req.FallbackPrizeID,
|
||||
Image: req.Image,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateGamePrizeEntity updates a game prize entity with update request data
|
||||
func UpdateGamePrizeEntity(gamePrize *entities.GamePrize, req *models.UpdateGamePrizeRequest) {
|
||||
if req.Name != nil {
|
||||
gamePrize.Name = *req.Name
|
||||
}
|
||||
if req.Weight != nil {
|
||||
gamePrize.Weight = *req.Weight
|
||||
}
|
||||
if req.Stock != nil {
|
||||
gamePrize.Stock = *req.Stock
|
||||
}
|
||||
if req.MaxStock != nil {
|
||||
gamePrize.MaxStock = req.MaxStock
|
||||
}
|
||||
if req.Threshold != nil {
|
||||
gamePrize.Threshold = req.Threshold
|
||||
}
|
||||
if req.FallbackPrizeID != nil {
|
||||
gamePrize.FallbackPrizeID = req.FallbackPrizeID
|
||||
}
|
||||
if req.Image != nil {
|
||||
gamePrize.Image = req.Image
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
gamePrize.Metadata = req.Metadata
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ func ToOmsetTrackerResponse(omsetTracker *entities.OmsetTracker) *models.OmsetTr
|
||||
PeriodEnd: omsetTracker.PeriodEnd,
|
||||
Total: omsetTracker.Total,
|
||||
GameID: omsetTracker.GameID,
|
||||
Game: ToGameResponse(omsetTracker.Game),
|
||||
CreatedAt: omsetTracker.CreatedAt,
|
||||
UpdatedAt: omsetTracker.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
||||
Status: constants.OrderItemStatus(item.Status),
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
PrinterType: item.Product.PrinterType,
|
||||
PrinterTypes: item.Product.GetPrinterTypes(),
|
||||
PrintToChecker: printToChecker,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
||||
Cost: entity.Cost,
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
PrinterTypes: entity.GetPrinterTypes(),
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
HasIngredients: entity.HasIngredients,
|
||||
@@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
||||
Cost: model.Cost,
|
||||
BusinessType: string(model.BusinessType),
|
||||
ImageURL: model.ImageURL,
|
||||
PrinterType: model.PrinterType,
|
||||
PrinterTypes: entities.StringSlice(model.PrinterTypes),
|
||||
UnitID: model.UnitID,
|
||||
SellBy: model.SellBy,
|
||||
HasIngredients: model.HasIngredients,
|
||||
@@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
businessType = string(req.BusinessType)
|
||||
}
|
||||
|
||||
printerType := "kitchen"
|
||||
if req.PrinterType != nil && *req.PrinterType != "" {
|
||||
printerType = *req.PrinterType
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if constants.IsValidSellBy(req.SellBy) {
|
||||
sellBy = req.SellBy
|
||||
@@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
metadata = entities.Metadata(req.Metadata)
|
||||
}
|
||||
|
||||
return &entities.Product{
|
||||
product := &entities.Product{
|
||||
OrganizationID: req.OrganizationID,
|
||||
CategoryID: req.CategoryID,
|
||||
SKU: req.SKU,
|
||||
@@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
Cost: cost,
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: printerType,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
|
||||
product.SetPrinterTypes(req.PrinterTypes)
|
||||
if len(product.PrinterTypes) == 0 {
|
||||
product.SetPrinterTypes([]string{"kitchen"})
|
||||
}
|
||||
|
||||
return product
|
||||
}
|
||||
|
||||
func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
@@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
Cost: entity.Cost,
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
PrinterTypes: entity.GetPrinterTypes(),
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
@@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
||||
entity.ImageURL = req.ImageURL
|
||||
}
|
||||
|
||||
if req.PrinterType != nil {
|
||||
entity.PrinterType = *req.PrinterType
|
||||
if req.PrinterTypes != nil {
|
||||
entity.SetPrinterTypes(req.PrinterTypes)
|
||||
}
|
||||
|
||||
if req.UnitID != nil {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateProductRequestToEntityPrinterTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
printerTypes []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "nothing sent prints in the kitchen",
|
||||
want: []string{"kitchen"},
|
||||
},
|
||||
{
|
||||
name: "a meal-and-drink package prints in the kitchen and the bar",
|
||||
printerTypes: []string{"kitchen", "bar"},
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "blanks and repeats are dropped",
|
||||
printerTypes: []string{" kitchen ", "", "kitchen", "bar"},
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "a list of only blanks falls back to the kitchen",
|
||||
printerTypes: []string{" ", ""},
|
||||
want: []string{"kitchen"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
product := CreateProductRequestToEntity(&models.CreateProductRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
CategoryID: uuid.New(),
|
||||
Name: "Paket Makan Minum",
|
||||
PrinterTypes: tt.printerTypes,
|
||||
})
|
||||
|
||||
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProductEntityFromRequestPrinterTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
printerTypes []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "a save that does not send printers keeps them",
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "printer_types replaces the whole list",
|
||||
printerTypes: []string{"bar"},
|
||||
want: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "an empty printer_types prints nowhere",
|
||||
printerTypes: []string{},
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
product := &entities.Product{}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
|
||||
UpdateProductEntityFromRequest(product, &models.UpdateProductRequest{PrinterTypes: tt.printerTypes})
|
||||
|
||||
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderItemEntityToResponsePrinterTypes(t *testing.T) {
|
||||
product := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
|
||||
response := OrderItemEntityToResponse(&entities.OrderItem{ProductID: product.ID, Product: product}, uuid.New())
|
||||
|
||||
assert.Equal(t, []string{"kitchen", "bar"}, response.PrinterTypes)
|
||||
}
|
||||
|
||||
func TestProductEntityToResponseAlwaysHasAList(t *testing.T) {
|
||||
// The POS reads printer_types as a list, so a product without one sends [] not null.
|
||||
response := ProductEntityToResponse(&entities.Product{})
|
||||
|
||||
assert.Equal(t, []string{}, response.PrinterTypes)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
func ToRewardResponse(entity *entities.Reward) *models.RewardResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tnc *models.TermsAndConditionsStruct
|
||||
if entity.Tnc != nil {
|
||||
var sections []models.TncSectionStruct
|
||||
for _, section := range entity.Tnc.Sections {
|
||||
sections = append(sections, models.TncSectionStruct{
|
||||
Title: section.Title,
|
||||
Rules: section.Rules,
|
||||
})
|
||||
}
|
||||
tnc = &models.TermsAndConditionsStruct{
|
||||
Sections: sections,
|
||||
ExpiryDays: entity.Tnc.ExpiryDays,
|
||||
}
|
||||
}
|
||||
|
||||
var images *[]string
|
||||
if entity.Images != nil {
|
||||
imagesSlice := []string(*entity.Images)
|
||||
images = &imagesSlice
|
||||
}
|
||||
|
||||
return &models.RewardResponse{
|
||||
ID: entity.ID,
|
||||
Name: entity.Name,
|
||||
RewardType: string(entity.RewardType),
|
||||
CostPoints: entity.CostPoints,
|
||||
Stock: entity.Stock,
|
||||
MaxPerCustomer: entity.MaxPerCustomer,
|
||||
Tnc: tnc,
|
||||
Metadata: entity.Metadata,
|
||||
Images: images,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func ToRewardEntity(request *contract.CreateRewardRequest) *entities.Reward {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tnc *entities.TermsAndConditions
|
||||
if request.Tnc != nil {
|
||||
var sections []entities.TncSection
|
||||
for _, section := range request.Tnc.Sections {
|
||||
sections = append(sections, entities.TncSection{
|
||||
Title: section.Title,
|
||||
Rules: section.Rules,
|
||||
})
|
||||
}
|
||||
tnc = &entities.TermsAndConditions{
|
||||
Sections: sections,
|
||||
ExpiryDays: request.Tnc.ExpiryDays,
|
||||
}
|
||||
}
|
||||
|
||||
var images *entities.StringSlice
|
||||
if request.Images != nil {
|
||||
stringSlice := entities.StringSlice(*request.Images)
|
||||
images = &stringSlice
|
||||
}
|
||||
|
||||
return &entities.Reward{
|
||||
Name: request.Name,
|
||||
RewardType: entities.RewardType(request.RewardType),
|
||||
CostPoints: request.CostPoints,
|
||||
Stock: request.Stock,
|
||||
MaxPerCustomer: request.MaxPerCustomer,
|
||||
Tnc: tnc,
|
||||
Metadata: request.Metadata,
|
||||
Images: images,
|
||||
}
|
||||
}
|
||||
|
||||
func ToRewardEntityFromUpdate(request *contract.UpdateRewardRequest) *entities.Reward {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var tnc *entities.TermsAndConditions
|
||||
if request.Tnc != nil {
|
||||
var sections []entities.TncSection
|
||||
for _, section := range request.Tnc.Sections {
|
||||
sections = append(sections, entities.TncSection{
|
||||
Title: section.Title,
|
||||
Rules: section.Rules,
|
||||
})
|
||||
}
|
||||
tnc = &entities.TermsAndConditions{
|
||||
Sections: sections,
|
||||
ExpiryDays: request.Tnc.ExpiryDays,
|
||||
}
|
||||
}
|
||||
|
||||
var images *entities.StringSlice
|
||||
if request.Images != nil {
|
||||
stringSlice := entities.StringSlice(*request.Images)
|
||||
images = &stringSlice
|
||||
}
|
||||
|
||||
return &entities.Reward{
|
||||
ID: request.ID,
|
||||
Name: request.Name,
|
||||
RewardType: entities.RewardType(request.RewardType),
|
||||
CostPoints: request.CostPoints,
|
||||
Stock: request.Stock,
|
||||
MaxPerCustomer: request.MaxPerCustomer,
|
||||
Tnc: tnc,
|
||||
Metadata: request.Metadata,
|
||||
Images: images,
|
||||
}
|
||||
}
|
||||
|
||||
func ToRewardResponseSlice(entities []entities.Reward) []models.RewardResponse {
|
||||
if entities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]models.RewardResponse, len(entities))
|
||||
for i, entity := range entities {
|
||||
responses[i] = *ToRewardResponse(&entity)
|
||||
}
|
||||
|
||||
return responses
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// SpinGameRequestContractToModel converts spin game request from contract to model
|
||||
func SpinGameRequestContractToModel(req *contract.SpinGameRequest) *models.SpinGameRequest {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.SpinGameRequest{
|
||||
SpinID: req.SpinID,
|
||||
}
|
||||
}
|
||||
|
||||
// SpinGameResponseModelToContract converts spin game response from model to contract
|
||||
func SpinGameResponseModelToContract(resp *models.SpinGameResponse) *contract.SpinGameResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &contract.SpinGameResponse{
|
||||
Status: resp.Status,
|
||||
Message: resp.Message,
|
||||
Data: SpinGameResponseDataModelToContract(resp.Data),
|
||||
}
|
||||
}
|
||||
|
||||
// SpinGameResponseDataModelToContract converts spin game response data from model to contract
|
||||
func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *contract.SpinGameResponseData {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &contract.SpinGameResponseData{
|
||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
||||
CoinsRemaining: data.CoinsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
// GamePlayResponseModelToContract converts game play response from model to contract
|
||||
func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.GamePlayResponse {
|
||||
if resp == nil {
|
||||
return contract.GamePlayResponse{}
|
||||
}
|
||||
|
||||
return contract.GamePlayResponse{
|
||||
ID: resp.ID,
|
||||
GameID: resp.GameID,
|
||||
CustomerID: resp.CustomerID,
|
||||
PrizeID: resp.PrizeID,
|
||||
CoinsUsed: resp.CoinsUsed,
|
||||
RandomSeed: resp.RandomSeed,
|
||||
CreatedAt: resp.CreatedAt,
|
||||
Game: nil, // Optional field - can be populated separately if needed
|
||||
Customer: nil, // Optional field - can be populated separately if needed
|
||||
Prize: nil, // Optional field - can be populated separately if needed
|
||||
}
|
||||
}
|
||||
|
||||
// CustomerGamePrizeResponseModelToContract converts customer game prize response from model to contract
|
||||
func CustomerGamePrizeResponseModelToContract(resp *models.CustomerGamePrizeResponse) *contract.CustomerGamePrizeResponse {
|
||||
if resp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &contract.CustomerGamePrizeResponse{
|
||||
ID: resp.ID,
|
||||
GameID: resp.GameID,
|
||||
Name: resp.Name,
|
||||
Image: resp.Image,
|
||||
Metadata: resp.Metadata,
|
||||
CreatedAt: resp.CreatedAt,
|
||||
UpdatedAt: resp.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
||||
|
||||
@@ -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
|
||||
@@ -306,16 +295,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||
type ParentCategoryTopProduct struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||
@@ -342,19 +342,25 @@ type ParentCategoryAnalyticsDetailResponse struct {
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
@@ -395,19 +401,17 @@ type BudgetCutOff struct {
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SDL float64 `json:"sdl"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
|
||||
+37
-26
@@ -20,36 +20,47 @@ type Category struct {
|
||||
}
|
||||
|
||||
type CreateCategoryRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order int `validate:"min=0"`
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order int `validate:"min=0"`
|
||||
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||
// IsTeam defaults to true when left out
|
||||
IsTeam *bool
|
||||
}
|
||||
|
||||
type UpdateCategoryRequest struct {
|
||||
Name *string `validate:"omitempty,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
IsActive *bool
|
||||
Name *string `validate:"omitempty,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||
IsTeam *bool
|
||||
IsActive *bool
|
||||
|
||||
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
|
||||
ClearParentID bool
|
||||
ClearOwnerFeePercent bool
|
||||
}
|
||||
|
||||
type CategoryResponse struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
ParentName *string
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
Order int
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
ParentName *string
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
Order int
|
||||
OwnerFeePercent *float64
|
||||
IsTeam bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Request Models
|
||||
type GetCustomerGamesRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Models
|
||||
type GetCustomerGamesResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetCustomerGamesResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetCustomerGamesResponseData struct {
|
||||
Games []CustomerGameResponse `json:"games"`
|
||||
}
|
||||
|
||||
type CustomerGameResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CustomerGamePrizeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Ferris Wheel Game Models
|
||||
type GetFerrisWheelGameRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetFerrisWheelGameResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetFerrisWheelGameResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetFerrisWheelGameResponseData struct {
|
||||
Game CustomerGameResponse `json:"game"`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CustomerOrderSummary is one order in GET /customer/orders.
|
||||
type CustomerOrderSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletName string `json:"outlet_name"`
|
||||
OrderType string `json:"order_type"`
|
||||
Status string `json:"status"`
|
||||
PaymentStatus string `json:"payment_status"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
IsVoid bool `json:"is_void"`
|
||||
IsRefund bool `json:"is_refund"`
|
||||
// EnakPoint and EnakCoin the order earned; 0 when it earned nothing.
|
||||
PointsEarned int64 `json:"points_earned"`
|
||||
CoinsEarned int64 `json:"coins_earned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerOrderDetail is GET /customer/orders/:id.
|
||||
type CustomerOrderDetail struct {
|
||||
CustomerOrderSummary
|
||||
TableNumber *string `json:"table_number"`
|
||||
Subtotal float64 `json:"subtotal"`
|
||||
DiscountAmount float64 `json:"discount_amount"`
|
||||
TaxAmount float64 `json:"tax_amount"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
Items []CustomerOrderItem `json:"items"`
|
||||
Payments []CustomerOrderPayment `json:"payments"`
|
||||
}
|
||||
|
||||
type CustomerOrderItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
VariantName *string `json:"variant_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Set for products sold by weight, in UnitName.
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
UnitName *string `json:"unit_name,omitempty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
RefundQuantity int `json:"refund_quantity"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CustomerOrderPayment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
MethodName string `json:"method_name"`
|
||||
MethodType string `json:"method_type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListCustomerOrdersQuery is GET /customer/orders.
|
||||
type ListCustomerOrdersQuery struct {
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// CustomerOutlet is one outlet in GET /customer/outlets: where the customer can shop,
|
||||
// and what the outlet does with EnakPoint and EnakCoin.
|
||||
type CustomerOutlet struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address *string `json:"address"`
|
||||
// Orders here earn EnakPoint / EnakCoin.
|
||||
EarnsPoints bool `json:"earns_points"`
|
||||
EarnsCoins bool `json:"earns_coins"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// EnakGameInput is what an admin sends to create or change a game
|
||||
// (docs/rfc-enakgame.md §11). On a change, fields left out keep their value.
|
||||
type EnakGameInput struct {
|
||||
Name string `json:"name"`
|
||||
// SPIN, RAFFLE or MINIGAME; MINIGAME when left out on create.
|
||||
Type string `json:"type"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
ThumbnailURL *string `json:"thumbnail_url"`
|
||||
GameURL *string `json:"game_url"`
|
||||
Version *string `json:"version"`
|
||||
// Create only: DRAFT (default), ACTIVE or INACTIVE. Changed later through the
|
||||
// status endpoint.
|
||||
Status string `json:"status"`
|
||||
EntryCost int64 `json:"entry_cost"`
|
||||
// 600 when left out on create.
|
||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||
ResultRules entities.GameResultRules `json:"result_rules"`
|
||||
}
|
||||
|
||||
type EnakGame struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
ThumbnailURL *string `json:"thumbnail_url"`
|
||||
GameURL *string `json:"game_url"`
|
||||
Version *string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
EntryCost int64 `json:"entry_cost"`
|
||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||
ResultRules entities.GameResultRules `json:"result_rules"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// EnakGameListQuery filters the admin game list. ARCHIVED games are left out unless
|
||||
// asked for by status.
|
||||
type EnakGameListQuery struct {
|
||||
Status string `form:"status"`
|
||||
Search string `form:"search"`
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
type EnakGameStatusInput struct {
|
||||
Status string `json:"status"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// GameRewardConfigInput is a new version of a game's reward configuration (§5.2).
|
||||
type GameRewardConfigInput struct {
|
||||
RewardType string `json:"reward_type"`
|
||||
Rules json.RawMessage `json:"rules"`
|
||||
MaxReward int64 `json:"max_reward"`
|
||||
EffectiveAt *time.Time `json:"effective_at"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
type GameRewardConfigActivateInput struct {
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
type GameRewardConfig struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Version int `json:"version"`
|
||||
RewardType string `json:"reward_type"`
|
||||
Rules json.RawMessage `json:"rules"`
|
||||
MaxReward int64 `json:"max_reward"`
|
||||
Status string `json:"status"`
|
||||
EffectiveAt *time.Time `json:"effective_at"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
Reason *string `json:"reason"`
|
||||
// Set on a version made by accepting a Budget Controller recommendation: the
|
||||
// admin's version it scales, by how much, and for which budget.
|
||||
BaseConfigID *uuid.UUID `json:"base_config_id"`
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
BudgetID *uuid.UUID `json:"budget_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32), and the
|
||||
// guardrails of the Budget Controller (PRD §31).
|
||||
type GameBudgetThresholds struct {
|
||||
Warning *int64 `json:"warning,omitempty"`
|
||||
Critical *int64 `json:"critical,omitempty"`
|
||||
// Percent one recommendation may move rewards by, either way.
|
||||
MaxStepPercent *int64 `json:"max_step_percent,omitempty"`
|
||||
// Bounds of a game's multiplier, in percent of the configuration its admin wrote.
|
||||
MinMultiplierPercent *int64 `json:"min_multiplier_percent,omitempty"`
|
||||
MaxMultiplierPercent *int64 `json:"max_multiplier_percent,omitempty"`
|
||||
// Days after an accepted recommendation before the organization gets another.
|
||||
CooldownDays *int64 `json:"cooldown_days,omitempty"`
|
||||
}
|
||||
|
||||
// GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and
|
||||
// inclusive. On a change, fields left out keep their value; the scope never changes.
|
||||
type GameBudgetInput struct {
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
Amount int64 `json:"amount"`
|
||||
Thresholds GameBudgetThresholds `json:"thresholds"`
|
||||
}
|
||||
|
||||
type GameBudget struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Scope string `json:"scope"`
|
||||
Name string `json:"name"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
Amount int64 `json:"amount"`
|
||||
Thresholds GameBudgetThresholds `json:"thresholds"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GameBudgetListQuery struct {
|
||||
Scope string `form:"scope"`
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
// CustomerEnakGame is a game as the customer app sees it: what it costs and where to
|
||||
// load it, without the validation limits.
|
||||
type CustomerEnakGame struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ThumbnailURL *string `json:"thumbnail_url"`
|
||||
GameURL *string `json:"game_url"`
|
||||
Version *string `json:"version"`
|
||||
EntryCost int64 `json:"entry_cost"`
|
||||
SessionTTLSeconds int `json:"session_ttl_seconds"`
|
||||
// The events making the game pay more right now, highest priority first.
|
||||
Events []CustomerGameEvent `json:"events"`
|
||||
// What a PROBABILITY game, such as a spin wheel, can pay, in the order of its
|
||||
// configuration. Left out for the other reward types.
|
||||
Prizes []GamePrize `json:"prizes,omitempty"`
|
||||
}
|
||||
|
||||
// GamePrize is one entry of a PROBABILITY game, such as a segment of a spin wheel, as
|
||||
// the customer sees it: never its odds.
|
||||
type GamePrize struct {
|
||||
// 1 for the first entry of the configuration.
|
||||
Entry int `json:"entry"`
|
||||
Label *string `json:"label"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
// GameSessionStart is the response of starting a game (§7.1).
|
||||
type GameSessionStart struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
EntryCost int64 `json:"entry_cost"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
// True when this repeats an earlier start with the same Idempotency-Key.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// CustomerGameSession is a session as its customer sees it. The reward breakdown,
|
||||
// the RNG draw and whether it was flagged stay internal.
|
||||
type CustomerGameSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Status string `json:"status"`
|
||||
EntryCost int64 `json:"entry_cost"`
|
||||
RewardTotal int64 `json:"reward_total"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
RefundReason *string `json:"refund_reason"`
|
||||
}
|
||||
|
||||
// GameSessionCompleteInput is what the client reports at the end of a play (§7.2): data
|
||||
// only. Anything else it sends, a reward amount above all, is ignored (P1).
|
||||
type GameSessionCompleteInput struct {
|
||||
Score *int64 `json:"score"`
|
||||
Outcome *string `json:"outcome"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// GameSessionCompletion is the response of completing a session. Sending the same
|
||||
// completion again returns the same response.
|
||||
type GameSessionCompletion struct {
|
||||
SessionID uuid.UUID `json:"session_id"`
|
||||
// COMPLETED, or REFUNDED when the game was turned off during the play.
|
||||
Status string `json:"status"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RewardTotal int64 `json:"reward_total"`
|
||||
// The parts of the reward that are safe to show.
|
||||
Reward GameSessionRewardParts `json:"reward"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
// The daily limits that made the reward smaller than earned: USER_DAILY,
|
||||
// GAME_DAILY or GLOBAL_DAILY.
|
||||
LimitedBy []string `json:"limited_by,omitempty"`
|
||||
// The entry a PROBABILITY game drew, for a spin wheel to stop on. Its amount is
|
||||
// the base reward, before events and limits.
|
||||
Prize *GamePrize `json:"prize,omitempty"`
|
||||
}
|
||||
|
||||
type GameSessionRewardParts struct {
|
||||
Base int64 `json:"base"`
|
||||
// Added by events; 0 until events exist.
|
||||
Event int64 `json:"event"`
|
||||
}
|
||||
|
||||
// VoucherInput creates or changes a voucher (§5.7). On a change, fields left out keep
|
||||
// their value; the stock mode never changes.
|
||||
type VoucherInput struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VoucherType string `json:"voucher_type"`
|
||||
FaceValue int64 `json:"face_value"`
|
||||
PointCost int64 `json:"point_cost"`
|
||||
BusinessCost *int64 `json:"business_cost"`
|
||||
StockMode string `json:"stock_mode"`
|
||||
// STATIC only.
|
||||
Stock *int64 `json:"stock"`
|
||||
// EXTERNAL only.
|
||||
Provider *string `json:"provider"`
|
||||
ProviderRef *string `json:"provider_ref"`
|
||||
MaxPerCustomer *int `json:"max_per_customer"`
|
||||
ValidFrom *time.Time `json:"valid_from"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Terms json.RawMessage `json:"terms"`
|
||||
// Create only: DRAFT (default), ACTIVE or INACTIVE.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Voucher struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VoucherType string `json:"voucher_type"`
|
||||
FaceValue int64 `json:"face_value"`
|
||||
PointCost int64 `json:"point_cost"`
|
||||
BusinessCost *int64 `json:"business_cost"`
|
||||
StockMode string `json:"stock_mode"`
|
||||
Stock *int64 `json:"stock"`
|
||||
Provider *string `json:"provider"`
|
||||
ProviderRef *string `json:"provider_ref"`
|
||||
MaxPerCustomer *int `json:"max_per_customer"`
|
||||
ValidFrom *time.Time `json:"valid_from"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Terms json.RawMessage `json:"terms"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type VoucherListQuery struct {
|
||||
Status string `form:"status"`
|
||||
Search string `form:"search"`
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
type VoucherStatusInput struct {
|
||||
Status string `json:"status"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// VoucherCodeImportResult says what an import of codes did.
|
||||
type VoucherCodeImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
// Codes already in the pool or repeated in the file, skipped.
|
||||
DuplicateCount int `json:"duplicate_count"`
|
||||
Duplicates []string `json:"duplicates"`
|
||||
// Lines that could not be read, skipped.
|
||||
Invalid []VoucherCodeImportProblem `json:"invalid"`
|
||||
}
|
||||
|
||||
type VoucherCodeImportProblem struct {
|
||||
Line int `json:"line"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type VoucherCodeListQuery struct {
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
type VoucherCode struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Status string `json:"status"`
|
||||
RedemptionID *uuid.UUID `json:"redemption_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// VoucherCodes is a pool's codes: how many in each status, and one page of them.
|
||||
type VoucherCodes struct {
|
||||
Counts map[string]int64 `json:"counts"`
|
||||
Codes PaginatedResponse[VoucherCode] `json:"codes"`
|
||||
}
|
||||
|
||||
// CustomerVoucher is a voucher in the customer app's catalog. Available is how many
|
||||
// are left, without saying how many were redeemed or expired.
|
||||
type CustomerVoucher struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
VoucherType string `json:"voucher_type"`
|
||||
FaceValue int64 `json:"face_value"`
|
||||
PointCost int64 `json:"point_cost"`
|
||||
MaxPerCustomer *int `json:"max_per_customer"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Terms json.RawMessage `json:"terms"`
|
||||
Available *int64 `json:"available"`
|
||||
}
|
||||
|
||||
// CustomerVoucherRedemption is a redemption as its customer sees it, with the code to
|
||||
// use. The response of a redeem adds the EnakPoint balance.
|
||||
type CustomerVoucherRedemption struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
VoucherID uuid.UUID `json:"voucher_id"`
|
||||
VoucherName string `json:"voucher_name"`
|
||||
VoucherImageURL *string `json:"voucher_image_url"`
|
||||
VoucherType string `json:"voucher_type"`
|
||||
Status string `json:"status"`
|
||||
FaceValue int64 `json:"face_value"`
|
||||
PointCost int64 `json:"point_cost"`
|
||||
Code *string `json:"code"`
|
||||
CodeExpiresAt *time.Time `json:"code_expires_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type VoucherRedeemResult struct {
|
||||
CustomerVoucherRedemption
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
// True when this repeats an earlier redeem with the same Idempotency-Key.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// GameBudgetMetrics is how a budget stands (docs/rfc-enakgame.md §10, PRD §8). Money is
|
||||
// in rupiah, percents have two decimals.
|
||||
type GameBudgetMetrics struct {
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
Scope string `json:"scope"`
|
||||
PeriodStart string `json:"period_start"`
|
||||
PeriodEnd string `json:"period_end"`
|
||||
// The day, in Asia/Jakarta, the metrics are for.
|
||||
AsOf string `json:"as_of"`
|
||||
Amount int64 `json:"amount"`
|
||||
|
||||
RealizedCost int64 `json:"realized_cost"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
UtilizationPercent float64 `json:"utilization_percent"`
|
||||
|
||||
// Average realized cost per day over the last WindowDays days, today included.
|
||||
DailyBurn int64 `json:"daily_burn"`
|
||||
WindowDays int64 `json:"window_days"`
|
||||
// Days of the period after today.
|
||||
RemainingDays int64 `json:"remaining_days"`
|
||||
ForecastCost int64 `json:"forecast_cost"`
|
||||
ForecastRemaining int64 `json:"forecast_remaining"`
|
||||
ForecastUtilizationPercent float64 `json:"forecast_utilization_percent"`
|
||||
|
||||
CoinIssued int64 `json:"coin_issued"`
|
||||
// What the budget's rewards still hold, spendable: the most that can still turn
|
||||
// into cost.
|
||||
Exposure GameBudgetExposure `json:"exposure"`
|
||||
|
||||
// The thresholds used: the budget's own, or the defaults for those it does not set.
|
||||
Thresholds GameBudgetThresholds `json:"thresholds"`
|
||||
// HEALTHY, WARNING, CRITICAL or EXHAUSTED.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type GameBudgetExposure struct {
|
||||
Coins int64 `json:"coins"`
|
||||
Points int64 `json:"points"`
|
||||
}
|
||||
|
||||
// GameBudgetRecommendation is what the Budget Controller suggests for a global budget
|
||||
// (docs/rfc-enakgame.md §10, PRD §29–§31): a multiplier on every game's reward that
|
||||
// brings the forecast to the budget, within the guardrails.
|
||||
type GameBudgetRecommendation struct {
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
// RECOMMENDED, NO_CHANGE, COOLDOWN, AT_LIMIT, INSUFFICIENT_DATA or OUT_OF_PERIOD.
|
||||
// Only RECOMMENDED can be accepted.
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
|
||||
Metrics GameBudgetMetrics `json:"metrics"`
|
||||
// The guardrails used: the budget's own, or the defaults for those it does not set.
|
||||
Guardrails GameBudgetThresholds `json:"guardrails"`
|
||||
|
||||
// The multiplier that would make the forecast meet the budget, before the
|
||||
// guardrails; nil when there is no cost to extrapolate.
|
||||
TargetMultiplier *float64 `json:"target_multiplier"`
|
||||
// The target within one step, rounded down to two decimals. Accepting sends it back.
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
// When the organization may accept again, during a cooldown.
|
||||
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
|
||||
// The games whose reward would change, each with its new version.
|
||||
Games []GameRewardAdjustment `json:"games"`
|
||||
}
|
||||
|
||||
// GameRewardAdjustment is how accepting a recommendation changes a game's reward: a
|
||||
// new version of its active configuration, with the base's amounts scaled.
|
||||
type GameRewardAdjustment struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
// The active version, and the admin's version both are measured against.
|
||||
RewardConfigID uuid.UUID `json:"reward_config_id"`
|
||||
Version int `json:"version"`
|
||||
BaseConfigID uuid.UUID `json:"base_config_id"`
|
||||
RewardType string `json:"reward_type"`
|
||||
|
||||
CurrentMultiplier float64 `json:"current_multiplier"`
|
||||
NewMultiplier float64 `json:"new_multiplier"`
|
||||
CurrentRules json.RawMessage `json:"current_rules"`
|
||||
NewRules json.RawMessage `json:"new_rules"`
|
||||
CurrentMaxReward int64 `json:"current_max_reward"`
|
||||
NewMaxReward int64 `json:"new_max_reward"`
|
||||
}
|
||||
|
||||
// GameBudgetRecommendationAcceptInput accepts the recommendation the admin saw. When
|
||||
// the recommendation changed since, nothing is applied.
|
||||
type GameBudgetRecommendationAcceptInput struct {
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// GameBudgetRecommendationAccepted is what accepting made: one active version per
|
||||
// game.
|
||||
type GameBudgetRecommendationAccepted struct {
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
RewardConfigs []GameRewardConfig `json:"reward_configs"`
|
||||
}
|
||||
|
||||
// EnakGameAnalyticsQuery is a range of days in Asia/Jakarta, both ends included.
|
||||
type EnakGameAnalyticsQuery struct {
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
// Games analytics only: one game instead of all.
|
||||
GameID string `form:"game_id"`
|
||||
}
|
||||
|
||||
// EnakGameAnalytics is how an organization's games were played over a range of days
|
||||
// (PRD §36 Game), by the day each session started.
|
||||
type EnakGameAnalytics struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Totals EnakGameStats `json:"totals"`
|
||||
Games []EnakGameGameStats `json:"games"`
|
||||
}
|
||||
|
||||
type EnakGameGameStats struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
EnakGameStats
|
||||
}
|
||||
|
||||
type EnakGameStats struct {
|
||||
// Sessions started, whatever became of them.
|
||||
Plays int64 `json:"plays"`
|
||||
Completed int64 `json:"completed"`
|
||||
Refunded int64 `json:"refunded"`
|
||||
Expired int64 `json:"expired"`
|
||||
Flagged int64 `json:"flagged"`
|
||||
// Customers who started at least one session.
|
||||
Players int64 `json:"players"`
|
||||
// Over completed sessions that reported a score; nil when none did.
|
||||
AverageScore *float64 `json:"average_score"`
|
||||
// EnakCoin per completed session, and per play.
|
||||
AverageReward float64 `json:"average_reward"`
|
||||
RewardPerPlay float64 `json:"reward_per_play"`
|
||||
CoinIssued int64 `json:"coin_issued"`
|
||||
// EnakCoin paid to start, and the part refunded.
|
||||
EntryCostPaid int64 `json:"entry_cost_paid"`
|
||||
CoinRefunded int64 `json:"coin_refunded"`
|
||||
}
|
||||
|
||||
// EnakGameEconomyAnalytics is how EnakCoin and EnakPoint moved in an organization over
|
||||
// a range of days (PRD §36 Economy), from the ledger.
|
||||
type EnakGameEconomyAnalytics struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Coin EnakGameCoinFlows `json:"coin"`
|
||||
Point EnakGamePointFlows `json:"point"`
|
||||
// Every ledger type that moved, for what the headline numbers leave out.
|
||||
ByType []WalletFlowTotals `json:"by_type"`
|
||||
}
|
||||
|
||||
type EnakGameCoinFlows struct {
|
||||
// New EnakCoin: game rewards, earning less its reversals, migration and upward
|
||||
// adjustments.
|
||||
Generated int64 `json:"generated"`
|
||||
GameRewards int64 `json:"game_rewards"`
|
||||
// Entry costs, less the refunded ones.
|
||||
SpentOnGames int64 `json:"spent_on_games"`
|
||||
// Exchanged into EnakPoint.
|
||||
Exchanged int64 `json:"exchanged"`
|
||||
// SpentOnGames + Exchanged.
|
||||
Spent int64 `json:"spent"`
|
||||
Expired int64 `json:"expired"`
|
||||
// Held by customers at the end of the range.
|
||||
Outstanding int64 `json:"outstanding"`
|
||||
}
|
||||
|
||||
type EnakGamePointFlows struct {
|
||||
// From shopping, less its reversals.
|
||||
Earned int64 `json:"earned"`
|
||||
// Received from exchanging EnakCoin.
|
||||
Exchanged int64 `json:"exchanged"`
|
||||
// Spent on vouchers, less the refunded redemptions.
|
||||
Redeemed int64 `json:"redeemed"`
|
||||
Expired int64 `json:"expired"`
|
||||
// Held by customers at the end of the range.
|
||||
Balance int64 `json:"balance"`
|
||||
}
|
||||
|
||||
// WalletFlowTotals is what one ledger type moved in one currency.
|
||||
type WalletFlowTotals struct {
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
Credit int64 `json:"credit"`
|
||||
Debit int64 `json:"debit"`
|
||||
Transactions int64 `json:"transactions"`
|
||||
}
|
||||
|
||||
// GameEventInput creates or changes an event (§5.5). On a change, fields left out
|
||||
// keep their value.
|
||||
type GameEventInput struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt time.Time `json:"end_at"`
|
||||
// Asia/Jakarta when left out.
|
||||
Timezone string `json:"timezone"`
|
||||
Priority int `json:"priority"`
|
||||
// At least 1, two decimals at most. 2 adds the base reward once more.
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
Bonus *int64 `json:"bonus"`
|
||||
// An EVENT budget of the organization; it pays what the event adds.
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
RewardLimit *int64 `json:"reward_limit"`
|
||||
UserDailyLimit *int64 `json:"user_daily_limit"`
|
||||
GameIDs []uuid.UUID `json:"game_ids"`
|
||||
// Create only: DRAFT (default) or ACTIVE.
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type GameEvent struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
StartAt time.Time `json:"start_at"`
|
||||
EndAt time.Time `json:"end_at"`
|
||||
Timezone string `json:"timezone"`
|
||||
Status string `json:"status"`
|
||||
Priority int `json:"priority"`
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
Bonus *int64 `json:"bonus"`
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
RewardLimit *int64 `json:"reward_limit"`
|
||||
UserDailyLimit *int64 `json:"user_daily_limit"`
|
||||
GameIDs []uuid.UUID `json:"game_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GameEventListQuery struct {
|
||||
Status string `form:"status"`
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
|
||||
type GameEventStatusInput struct {
|
||||
Status string `json:"status"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// CustomerGameEvent is an event running on a game, as the customer app shows it.
|
||||
type CustomerGameEvent struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
BannerURL *string `json:"banner_url"`
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
Bonus *int64 `json:"bonus"`
|
||||
EndAt time.Time `json:"end_at"`
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGameRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateGameRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,required"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type GameResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListGamesQuery struct {
|
||||
Page int `query:"page" validate:"min=1"`
|
||||
Limit int `query:"limit" validate:"min=1,max=100"`
|
||||
Search string `query:"search"`
|
||||
Type string `query:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive *bool `query:"is_active"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
CoinsUsed int `json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
type GamePlayResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
Prize *GamePrizeResponse `json:"prize,omitempty"`
|
||||
}
|
||||
|
||||
type ListGamePlaysQuery struct {
|
||||
Page int `query:"page" validate:"min=1"`
|
||||
Limit int `query:"limit" validate:"min=1,max=100"`
|
||||
Search string `query:"search"`
|
||||
GameID *uuid.UUID `query:"game_id"`
|
||||
CustomerID *uuid.UUID `query:"customer_id"`
|
||||
PrizeID *uuid.UUID `query:"prize_id"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost.
|
||||
type PlayGameRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGamePrizeRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Weight int `json:"weight" validate:"min=1"`
|
||||
Stock int `json:"stock" validate:"min=0"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateGamePrizeRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,required"`
|
||||
Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"`
|
||||
Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type GamePrizeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Stock int `json:"stock"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListGamePrizesQuery struct {
|
||||
Page int `query:"page" validate:"min=1"`
|
||||
Limit int `query:"limit" validate:"min=1,max=100"`
|
||||
Search string `query:"search"`
|
||||
GameID *uuid.UUID `query:"game_id"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
+39
-19
@@ -5,32 +5,33 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
)
|
||||
|
||||
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
||||
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:
|
||||
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
|
||||
// MaxPerOrder when set.
|
||||
// LoyaltyEarnSettings is how much of one currency an order earns, nothing below
|
||||
// MinOrderAmount, and at most MaxPerOrder when set:
|
||||
//
|
||||
// - PER_AMOUNT: floor(basis / EarnPerAmount) × EarnValue;
|
||||
// - PERCENTAGE: floor(basis × EarnPercent / 100).
|
||||
//
|
||||
// The settings of the mode not in use are kept, so switching back restores them.
|
||||
type LoyaltyEarnSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||
EarnValue int64 `json:"earn_value"`
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
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"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// PER_AMOUNT or PERCENTAGE.
|
||||
EarnMode string `json:"earn_mode"`
|
||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||
EarnValue int64 `json:"earn_value"`
|
||||
// PERCENTAGE: percent of the basis earned, 0–100 with at most two decimals.
|
||||
EarnPercent float64 `json:"earn_percent"`
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
MaxPerOrder *int64 `json:"max_per_order"`
|
||||
}
|
||||
|
||||
// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an
|
||||
@@ -43,6 +44,16 @@ type OrganizationLoyaltySettings struct {
|
||||
Transfer LoyaltyTransferSettings `json:"transfer"`
|
||||
PointExpiry LoyaltyExpirySettings `json:"point_expiry"`
|
||||
CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"`
|
||||
EnakGame EnakGameLimitSettings `json:"enakgame"`
|
||||
}
|
||||
|
||||
// EnakGameLimitSettings caps the EnakCoin EnakGame rewards per day, in Asia/Jakarta
|
||||
// (docs/rfc-enakgame.md §5.10). 0 means no limit.
|
||||
type EnakGameLimitSettings struct {
|
||||
// What one customer may receive.
|
||||
UserDailyLimit int64 `json:"user_daily_limit"`
|
||||
// What the whole organization may give out.
|
||||
GlobalDailyLimit int64 `json:"global_daily_limit"`
|
||||
}
|
||||
|
||||
type LoyaltyExchangeSettings struct {
|
||||
@@ -102,8 +113,8 @@ type OutletLoyaltySettingsView struct {
|
||||
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
||||
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
|
||||
// scale (F1).
|
||||
// earn_per_amount × 100, or earn_percent × point_value in PERCENTAGE mode. Shown
|
||||
// next to the setting so an owner cannot misread the scale (F1).
|
||||
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
||||
// Set on PUT: the keys that changed.
|
||||
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
||||
@@ -168,6 +179,15 @@ type LoyaltySettingsImpact struct {
|
||||
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
||||
}
|
||||
|
||||
// CashbackPercent is the rupiah value of what an order earns as a percentage of its
|
||||
// basis, in the mode the settings are in, rounded to two decimals.
|
||||
func (s LoyaltyEarnSettings) CashbackPercent(pointValue int64) float64 {
|
||||
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
return math.Round(s.EarnPercent*float64(pointValue)*100) / 100
|
||||
}
|
||||
return LoyaltyCashbackPercent(s.EarnValue, pointValue, s.EarnPerAmount)
|
||||
}
|
||||
|
||||
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
||||
// to another on the balances in circulation.
|
||||
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
||||
|
||||
@@ -14,6 +14,14 @@ func TestLoyaltyCashbackPercent(t *testing.T) {
|
||||
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
||||
}
|
||||
|
||||
func TestLoyaltyEarnSettings_CashbackPercent(t *testing.T) {
|
||||
s := LoyaltyEarnSettings{EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 2.5}
|
||||
assert.Equal(t, 1.0, s.CashbackPercent(1))
|
||||
s.EarnMode = "PERCENTAGE"
|
||||
assert.Equal(t, 2.5, s.CashbackPercent(1))
|
||||
assert.Equal(t, 250.0, s.CashbackPercent(100), "each point earned is worth point_value rupiah")
|
||||
}
|
||||
|
||||
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
||||
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
||||
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
||||
|
||||
@@ -27,15 +27,14 @@ type AddOmsetRequest struct {
|
||||
}
|
||||
|
||||
type OmsetTrackerResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PeriodType string `json:"period_type"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Total int64 `json:"total"`
|
||||
GameID *uuid.UUID `json:"game_id,omitempty"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PeriodType string `json:"period_type"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Total int64 `json:"total"`
|
||||
GameID *uuid.UUID `json:"game_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListOmsetTrackerQuery struct {
|
||||
|
||||
@@ -218,7 +218,7 @@ type OrderItemResponse struct {
|
||||
Status constants.OrderItemStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
PrintToChecker bool
|
||||
PaidQuantity int
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,7 +18,7 @@ type Product struct {
|
||||
Cost float64
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
SellBy string
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
@@ -50,7 +50,7 @@ type CreateProductRequest struct {
|
||||
Cost float64 `validate:"min=0"`
|
||||
BusinessType constants.BusinessType `validate:"required"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `validate:"omitempty,dive,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||
@@ -72,7 +72,7 @@ type UpdateProductRequest struct {
|
||||
Price *float64 `validate:"omitempty,min=0"`
|
||||
Cost *float64 `validate:"omitempty,min=0"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `validate:"omitempty,dive,max=50"` // Replaces the whole list when not nil
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||
@@ -112,7 +112,7 @@ type ProductResponse struct {
|
||||
Cost float64
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
SellBy string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateRewardRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"`
|
||||
CostPoints int64 `json:"cost_points" binding:"required,min=1"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer" binding:"min=1"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateRewardRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"`
|
||||
CostPoints int64 `json:"cost_points" binding:"required,min=1"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer" binding:"min=1"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type ListRewardsRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
Limit int `form:"limit" binding:"min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
RewardType string `form:"reward_type"`
|
||||
MinPoints *int64 `form:"min_points"`
|
||||
MaxPoints *int64 `form:"max_points"`
|
||||
HasStock *bool `form:"has_stock"`
|
||||
}
|
||||
|
||||
type RewardResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RewardType string `json:"reward_type"`
|
||||
CostPoints int64 `json:"cost_points"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListRewardsResponse struct {
|
||||
Rewards []RewardResponse `json:"rewards"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
type TermsAndConditionsStruct struct {
|
||||
Sections []TncSectionStruct `json:"sections"`
|
||||
ExpiryDays int `json:"expiry_days"`
|
||||
}
|
||||
|
||||
type TncSectionStruct struct {
|
||||
Title string `json:"title"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user