Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8003c1e4 | ||
|
|
3cd88a55c8 | ||
|
|
d1e543a79f | ||
|
|
550122f29c | ||
|
|
d293786cde | ||
|
|
4d63673a25 | ||
|
|
9ce55e6002 | ||
|
|
4432f0a10d | ||
|
|
bf9651e221 | ||
|
|
8bf1d5c1a8 | ||
|
|
a18bb072f5 | ||
|
|
694d65b6d8 | ||
|
|
ab3425070b | ||
|
|
0b52edf84e | ||
|
|
d7138b8f87 | ||
|
|
43eac0ced4 | ||
|
|
4b3beaed41 |
@@ -0,0 +1,439 @@
|
||||
# API EnakPoint & EnakCoin
|
||||
|
||||
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.
|
||||
|
||||
## Konvensi umum
|
||||
|
||||
| Klien | Autentikasi | Prefix |
|
||||
| --- | --- | --- |
|
||||
| Customer app / self-order | `Authorization: Bearer <token customer>` | `/api/v1/customer` |
|
||||
| POS | Token user (kasir/manager) | `/api/v1` |
|
||||
| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` |
|
||||
|
||||
**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan.
|
||||
|
||||
| `code` | HTTP | Arti |
|
||||
| --- | --- | --- |
|
||||
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
|
||||
| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) |
|
||||
| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain |
|
||||
| `429` | 429 | OTP diminta ulang terlalu cepat |
|
||||
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
|
||||
| `PIN_INVALID` | 400 | PIN salah |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN |
|
||||
| `900` | 500 | Kesalahan server |
|
||||
|
||||
**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.
|
||||
|
||||
**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya.
|
||||
|
||||
## Customer app: saldo & riwayat
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir |
|
||||
| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter |
|
||||
| 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/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 seperti item riwayat …" ]
|
||||
}
|
||||
```
|
||||
|
||||
- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang.
|
||||
- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang.
|
||||
- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### GET /customer/wallet/transactions
|
||||
|
||||
| Query | Tipe | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
| `from`, `to` | `YYYY-MM-DD` | 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 }
|
||||
}
|
||||
```
|
||||
|
||||
`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi.
|
||||
|
||||
### GET /customer/wallet/expiring
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### PUT /customer/devices
|
||||
|
||||
```json
|
||||
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`.
|
||||
|
||||
## Customer app: PIN
|
||||
|
||||
PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` |
|
||||
| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` |
|
||||
| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
|
||||
| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN |
|
||||
| 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.
|
||||
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
|
||||
|
||||
| Method | Path | PIN | Idempotency-Key |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/customer/wallet/payment-code` | Ya | – |
|
||||
| POST | `/customer/orders/:id/pay-with-points` | Ya | – |
|
||||
| GET | `/customer/wallet/exchange/preview?coins=` | – | – |
|
||||
| POST | `/customer/wallet/exchange` | Ya | Wajib |
|
||||
| GET | `/customer/wallet/transfer/recipient?phone=` | – | – |
|
||||
| POST | `/customer/wallet/transfer` | Ya | Wajib |
|
||||
| POST | `/customer/spin` | – | – |
|
||||
|
||||
### POST /customer/wallet/payment-code
|
||||
|
||||
Body `{ "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
|
||||
```
|
||||
|
||||
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama.
|
||||
|
||||
### POST /customer/orders/:id/pay-with-points
|
||||
|
||||
Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama.
|
||||
|
||||
### GET /customer/wallet/exchange/preview?coins=30
|
||||
|
||||
```json
|
||||
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
|
||||
```
|
||||
|
||||
Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`.
|
||||
|
||||
### POST /customer/wallet/exchange
|
||||
|
||||
Body `{ "coins": 30, "pin": "482913" }`. Response:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`).
|
||||
|
||||
### GET /customer/wallet/transfer/recipient?phone=081234561234
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`.
|
||||
|
||||
### POST /customer/wallet/transfer
|
||||
|
||||
Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
|
||||
```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 → `304`, tidak ada EnakCoin yang terpotong.
|
||||
|
||||
## POS: pembayaran EnakPoint
|
||||
|
||||
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.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md).
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint 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 |
|
||||
| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual |
|
||||
| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir |
|
||||
| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer |
|
||||
| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) |
|
||||
|
||||
Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak.
|
||||
|
||||
### /outlets/:outlet_id/loyalty-settings
|
||||
|
||||
```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 }
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### /marketing/loyalty-settings
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": {
|
||||
"enabled": false,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { "…": "sama dengan point_expiry" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field kedaluwarsa | Dipakai mode | Nilai |
|
||||
| --- | --- | --- |
|
||||
| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) |
|
||||
| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak |
|
||||
| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya |
|
||||
| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` |
|
||||
| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan |
|
||||
| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat |
|
||||
|
||||
Response menambahkan:
|
||||
|
||||
- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs.
|
||||
- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak).
|
||||
- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal.
|
||||
- `changes` dan `dry_run`.
|
||||
|
||||
### POST /marketing/customers/:id/wallet/adjust
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
|
||||
```
|
||||
|
||||
`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`.
|
||||
|
||||
### GET /marketing/wallet-transactions/:id/trace
|
||||
|
||||
```json
|
||||
{
|
||||
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" },
|
||||
"lots": [
|
||||
{
|
||||
"amount": 30,
|
||||
"chain": [
|
||||
{ "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } },
|
||||
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
### PIN customer
|
||||
|
||||
`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat.
|
||||
|
||||
## Referensi
|
||||
|
||||
### Tipe mutasi (`type`)
|
||||
|
||||
| `type` | Arah | Arti | `source` / `destination` |
|
||||
| --- | --- | --- | --- |
|
||||
| `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`) |
|
||||
| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) |
|
||||
| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` |
|
||||
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
|
||||
| `ADJUSTMENT` | + / − | Koreksi admin | `USER` |
|
||||
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
|
||||
|
||||
### Notifikasi push (FCM)
|
||||
|
||||
Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`.
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lainnya |
|
||||
| --- | --- | --- |
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
|
||||
| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
|
||||
|
||||
### Endpoint dan field deprecated
|
||||
|
||||
Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah.
|
||||
|
||||
| Lama | Pengganti |
|
||||
| --- | --- |
|
||||
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
|
||||
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` |
|
||||
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
|
||||
| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` |
|
||||
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
|
||||
|
||||
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
|
||||
@@ -0,0 +1,305 @@
|
||||
# Backoffice EnakPoint & EnakCoin
|
||||
|
||||
30 Sep 2026
|
||||
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
|
||||
| Layar | Endpoint | Tempat di menu |
|
||||
| --- | --- | --- |
|
||||
| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail outlet → tab Loyalitas |
|
||||
| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan |
|
||||
| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
|
||||
`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.
|
||||
|
||||
```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 }
|
||||
}
|
||||
```
|
||||
|
||||
| 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 |
|
||||
| `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).
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## Setting loyalitas organisasi
|
||||
|
||||
Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku sama untuk semua outlet, jadi diatur sekali per organisasi. Mengubah nilai EnakPoint atau kurs langsung mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya sebelum disimpan.
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"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" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Default | Validasi |
|
||||
| --- | --- | --- | --- |
|
||||
| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 |
|
||||
| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 |
|
||||
| `transfer.enabled` | Izinkan transfer antar customer | aktif | – |
|
||||
| `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 |
|
||||
|
||||
### Alur simpan
|
||||
|
||||
1. Owner mengubah form.
|
||||
2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek yang diubah. Tidak ada yang tersimpan.
|
||||
3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti.
|
||||
4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs berubah), dan `expiry_activations` (bila ada, lihat bagian kedaluwarsa).
|
||||
5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`.
|
||||
|
||||
### Dialog dampak
|
||||
|
||||
`impact` berisi saldo beredar organisasi dan nilainya sebelum/sesudah:
|
||||
|
||||
| Field `impact` | Tampilkan sebagai |
|
||||
| --- | --- |
|
||||
| `outstanding_points` | EnakPoint beredar |
|
||||
| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … |
|
||||
| `outstanding_coins` | EnakCoin beredar |
|
||||
| `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.
|
||||
|
||||
## Pengaturan kedaluwarsa
|
||||
|
||||
Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin (`coin_expiry`) dengan salah satu dari dua model; defaultnya mati, dan bila dinyalakan defaultnya hangus setiap 31 Desember.
|
||||
|
||||
```json
|
||||
"point_expiry": {
|
||||
"enabled": true,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tampil saat | Label usulan | Validasi |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | selalu | Saldo bisa kedaluwarsa | – |
|
||||
| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` |
|
||||
| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, format `MM-DD`, `02-29` ditolak |
|
||||
| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 |
|
||||
| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` |
|
||||
| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – |
|
||||
| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat |
|
||||
|
||||
**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama, mis. 31 Desember, atau 30 Juni dan 31 Desember untuk dua kali setahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya, jadi saldo yang didapat 1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Untuk input `fixed_dates`, pakai pemilih tanggal+bulan tanpa tahun.
|
||||
|
||||
**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk, mis. 12 bulan. Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027.
|
||||
|
||||
**Preview.** Response `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: kapan saldo yang didapat sekarang akan kedaluwarsa (`null` = tidak). Tampilkan di bawah form: "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026." Karena dihitung dari nilai yang dikirim, dry run bisa dipakai untuk memperbarui preview saat owner mengubah pilihan.
|
||||
|
||||
**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal, dengan masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode sejak hari ini (`ROLLING`). Dry run mengembalikan `expiry_activations`; tampilkan di dialog konfirmasi dengan kalimat tegas, mis. "1.250.000 EnakPoint milik customer yang ada sekarang akan kedaluwarsa pada 31 Des 2027. Tindakan ini tidak bisa dibatalkan dengan mematikan kedaluwarsa."
|
||||
|
||||
| Field `expiry_activations[]` | Arti |
|
||||
| --- | --- |
|
||||
| `currency` | `POINT` atau `COIN` |
|
||||
| `lots` | Jumlah paket saldo yang diberi tanggal |
|
||||
| `amount` | Total saldo yang diberi tanggal |
|
||||
| `expires_at` | Tanggal kedaluwarsanya |
|
||||
|
||||
**Aturan lain yang perlu dijelaskan di layar:**
|
||||
|
||||
- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya.
|
||||
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
|
||||
- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya.
|
||||
- Saldo hangus tanpa kompensasi apa pun. Customer mendapat pengingat push `reminder_days` hari sebelumnya dan notifikasi saat hangus.
|
||||
|
||||
## Wallet customer
|
||||
|
||||
Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke order asalnya.
|
||||
|
||||
### 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)
|
||||
|
||||
```json
|
||||
{
|
||||
"customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" },
|
||||
"point_balance": 12650,
|
||||
"coin_balance": 8,
|
||||
"spendable_point_balance": 12500,
|
||||
"spendable_coin_balance": 8,
|
||||
"lots": [
|
||||
{ "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" }
|
||||
],
|
||||
"transactions": {
|
||||
"data": [
|
||||
{
|
||||
"id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650,
|
||||
"description": "Transfer ke An*** (08**-****-5678)",
|
||||
"destination": { "type": "WALLET_TX", "id": "…" },
|
||||
"counterparty": { "id": "…", "name": "Anita Rahma" },
|
||||
"created_by": null, "outlet": null, "reason": null, "metadata": {},
|
||||
"created_at": "…"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **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).
|
||||
|
||||
### Adjustment manual
|
||||
|
||||
`POST /marketing/customers/:id/wallet/adjust`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" }
|
||||
```
|
||||
|
||||
| Field | Aturan |
|
||||
| --- | --- |
|
||||
| `currency` | `POINT` atau `COIN` |
|
||||
| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi |
|
||||
| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" |
|
||||
| `idempotency_key` | Opsional tapi disarankan: buat satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali |
|
||||
|
||||
Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan di dialog bahwa adjustment tidak disertai pembayaran uang, sehingga alasan tidak boleh "pencairan".
|
||||
|
||||
### Telusuri mutasi
|
||||
|
||||
Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-transactions/:id/trace`.
|
||||
|
||||
```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": "…" },
|
||||
"lots": [
|
||||
{
|
||||
"amount": 30,
|
||||
"chain": [
|
||||
{ "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } },
|
||||
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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 & keamanan customer
|
||||
|
||||
Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah menghapusnya, misalnya bila customer kehilangan akses, sehingga customer harus membuat PIN baru lewat OTP di aplikasi.
|
||||
|
||||
- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. `reason` wajib. Tampilkan dialog konfirmasi dengan input alasan.
|
||||
- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" }
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
| `event` | Label usulan |
|
||||
| --- | --- |
|
||||
| `PIN_SET` | PIN dibuat |
|
||||
| `PIN_CHANGED` | PIN diganti |
|
||||
| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) |
|
||||
| `PIN_FAILED` | PIN salah dimasukkan |
|
||||
| `PIN_LOCKED` | PIN terkunci 30 menit |
|
||||
| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) |
|
||||
|
||||
### Riwayat perubahan setting
|
||||
|
||||
`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; tambah `&outlet_id=…` untuk riwayat satu outlet.
|
||||
|
||||
```json
|
||||
{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" }
|
||||
```
|
||||
|
||||
`old_value` `null` berarti sebelumnya masih nilai default. Tampilkan `key` dengan label yang sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint"), dan `changed_by` sebagai nama user.
|
||||
|
||||
### 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.
|
||||
|
||||
## 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 |
|
||||
| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" |
|
||||
| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalty.point.earn_per_amount must be at least 1`. Untuk validasi form, lebih baik cek batasnya di sisi klien (tabel di tiap bagian) dan tampilkan `cause` hanya sebagai cadangan.
|
||||
|
||||
### Checklist rilis
|
||||
|
||||
- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning.
|
||||
- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi sebelum disimpan.
|
||||
- [ ] Dialog konfirmasi menampilkan `impact` saat nilai EnakPoint atau kurs berubah.
|
||||
- [ ] Dialog konfirmasi menampilkan `expiry_activations` saat kedaluwarsa dinyalakan pertama kali.
|
||||
- [ ] Preview "yang didapat hari ini kedaluwarsa pada …" tampil di bawah pengaturan kedaluwarsa.
|
||||
- [ ] Wallet customer menampilkan saldo yang bisa dipakai, lot, dan riwayat dengan nama asli.
|
||||
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`.
|
||||
- [ ] Tombol Telusuri ada di setiap baris riwayat.
|
||||
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
|
||||
- [ ] Method EnakPoint tampil sebagai method sistem.
|
||||
- [ ] Form game punya input `coin_cost`.
|
||||
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
|
||||
|
||||
Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu.
|
||||
@@ -0,0 +1,635 @@
|
||||
# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard
|
||||
|
||||
**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
| | 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 |
|
||||
| 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.
|
||||
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.
|
||||
5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah
|
||||
dihapus. Koreksi muncul sebagai baris baru.
|
||||
|
||||
### Format response
|
||||
|
||||
Semua endpoint memakai amplop yang sama:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { … }, "errors": null }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }]
|
||||
}
|
||||
```
|
||||
|
||||
| `code` | HTTP | Arti |
|
||||
|---|---|---|
|
||||
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
|
||||
| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya |
|
||||
| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) |
|
||||
| `429` | 429 | Terlalu cepat meminta ulang (OTP) |
|
||||
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
|
||||
| `PIN_INVALID` | 400 | PIN salah |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN |
|
||||
| `900` | 500 | Kesalahan server |
|
||||
|
||||
---
|
||||
|
||||
## 2. Customer app — saldo & riwayat
|
||||
|
||||
Semua endpoint customer memakai header `Authorization: Bearer <token customer>`.
|
||||
|
||||
### 2.1 Saldo
|
||||
|
||||
`GET /api/v1/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": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**.
|
||||
- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai
|
||||
"setara potongan Rp 12.500".
|
||||
- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa.
|
||||
- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2.
|
||||
|
||||
### 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`
|
||||
|
||||
Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma.
|
||||
`from` / `to` 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": "…",
|
||||
"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 }
|
||||
}
|
||||
```
|
||||
|
||||
- `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.).
|
||||
- `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.
|
||||
|
||||
| `type` | Arah | Arti | `source` / `destination` |
|
||||
|---|---|---|---|
|
||||
| `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` |
|
||||
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
|
||||
| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` |
|
||||
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
|
||||
|
||||
### 2.3 Yang akan kedaluwarsa
|
||||
|
||||
`GET /api/v1/customer/wallet/expiring`
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai
|
||||
akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### 2.4 Notifikasi push (FCM)
|
||||
|
||||
Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token
|
||||
baru**:
|
||||
|
||||
`PUT /api/v1/customer/devices`
|
||||
|
||||
```json
|
||||
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil
|
||||
`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima
|
||||
notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain
|
||||
login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis
|
||||
tidak menerima notifikasi di HP itu lagi.
|
||||
|
||||
Push yang dikirim, dibedakan lewat `data.type`:
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lainnya |
|
||||
|---|---|---|
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
|
||||
| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, sekali per tanggal | `currency`, `amount`, `expiry_date` |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
|
||||
|
||||
Semua nilai di `data` berupa string, sesuai aturan FCM.
|
||||
|
||||
---
|
||||
|
||||
## 3. Customer app — PIN
|
||||
|
||||
PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol
|
||||
di depan tidak hilang. PIN tidak pernah dikembalikan di response.
|
||||
|
||||
### 3.1 Cek status
|
||||
|
||||
`GET /api/v1/customer/pin/status`
|
||||
|
||||
```json
|
||||
{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null }
|
||||
```
|
||||
|
||||
Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN
|
||||
(`has_pin: false`), bukan saat registrasi.
|
||||
|
||||
### 3.2 Membuat PIN pertama kali
|
||||
|
||||
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke
|
||||
nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`.
|
||||
2. `POST /api/v1/customer/pin` dengan
|
||||
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
|
||||
|
||||
PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama
|
||||
(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir
|
||||
(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat
|
||||
menghasilkan `429`.
|
||||
|
||||
### 3.3 Mengganti dan mereset PIN
|
||||
|
||||
- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`.
|
||||
- **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.
|
||||
|
||||
### 3.4 Menangani error PIN
|
||||
|
||||
Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data`
|
||||
**tidak** `null`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": { "code": "PIN_INVALID", "remaining_attempts": 3 },
|
||||
"errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }]
|
||||
}
|
||||
```
|
||||
|
||||
| `data.code` | Field tambahan | Yang ditampilkan aplikasi |
|
||||
|---|---|---|
|
||||
| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) |
|
||||
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" |
|
||||
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN |
|
||||
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" |
|
||||
|
||||
Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang
|
||||
benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan
|
||||
reinstall atau ganti HP.
|
||||
|
||||
---
|
||||
|
||||
## 4. Membayar dengan EnakPoint
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exchange EnakCoin → EnakPoint
|
||||
|
||||
Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1).
|
||||
|
||||
1. **Preview** sebelum minta PIN:
|
||||
|
||||
`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 }
|
||||
```
|
||||
|
||||
Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`,
|
||||
atau EnakCoin tidak cukup).
|
||||
|
||||
2. **Tukar:**
|
||||
|
||||
`POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib,
|
||||
maks. 50 karakter, satu key per percobaan tukar)
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum**
|
||||
PIN dicek, jadi tidak memakan jatah percobaan PIN.
|
||||
- Exchange tidak bisa dibatalkan; tampilkan konfirmasi.
|
||||
- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama
|
||||
dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu.
|
||||
`Idempotency-Key` yang sama untuk jumlah berbeda ditolak.
|
||||
- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots`
|
||||
menunjukkan tanggalnya).
|
||||
|
||||
---
|
||||
|
||||
## 6. Transfer ke customer lain
|
||||
|
||||
1. **Cek penerima** sebelum konfirmasi:
|
||||
|
||||
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri,
|
||||
customer walk-in, atau customer nonaktif dijawab `304`.
|
||||
|
||||
2. **Kirim:**
|
||||
|
||||
`POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib)
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
- `currency`: `POINT` atau `COIN`, satu jenis per transfer.
|
||||
- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per
|
||||
transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas
|
||||
ditolak `304` sebelum PIN dicek.
|
||||
- Transfer final dan tidak bisa dibatalkan customer.
|
||||
- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`).
|
||||
Tampilkan ini ke pengirim.
|
||||
- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4).
|
||||
- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama
|
||||
(`replayed: true`) dan tidak dihitung dua kali terhadap batas harian.
|
||||
|
||||
---
|
||||
|
||||
## 7. Game
|
||||
|
||||
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
|
||||
|
||||
Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
|
||||
"coins_remaining": 7,
|
||||
"tokens_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
|
||||
EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan
|
||||
`tokens_remaining` hanya salinan untuk versi aplikasi lama.
|
||||
|
||||
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Endpoint lama (deprecated)
|
||||
|
||||
Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi
|
||||
pindah. Aplikasi baru jangan memakainya.
|
||||
|
||||
| Lama | Ganti dengan |
|
||||
|---|---|
|
||||
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
|
||||
| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) |
|
||||
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
|
||||
| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` |
|
||||
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
|
||||
|
||||
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
|
||||
supaya alias dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dashboard
|
||||
|
||||
Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
|
||||
|
||||
### 9.1 Pengaturan per outlet
|
||||
|
||||
`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings`
|
||||
|
||||
```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 }
|
||||
}
|
||||
```
|
||||
|
||||
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
|
||||
`point_value` organisasi dan `point_cashback_percent`
|
||||
(`earn_value × point_value / earn_per_amount × 100`). **Tampilkan persentase ini di
|
||||
samping setting** supaya owner tidak salah membaca skala: default di atas setara
|
||||
cashback 1%.
|
||||
|
||||
### 9.2 Pengaturan organisasi
|
||||
|
||||
`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview
|
||||
tanpa menyimpan)
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": {
|
||||
"enabled": false,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { … sama … }
|
||||
}
|
||||
```
|
||||
|
||||
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan:
|
||||
|
||||
- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan
|
||||
`point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan.
|
||||
- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini
|
||||
akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang
|
||||
didapat hari ini kedaluwarsa pada 31 Des 2026".
|
||||
- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama
|
||||
kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya
|
||||
(`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu.
|
||||
- `changes`: key yang berubah.
|
||||
|
||||
**Kedaluwarsa** diatur per currency dengan salah satu model:
|
||||
|
||||
| `mode` | Cara kerja | Field yang dipakai |
|
||||
|---|---|---|
|
||||
| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) |
|
||||
| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` |
|
||||
|
||||
- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum
|
||||
hangus (0 = tanpa pengingat).
|
||||
- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya.
|
||||
- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal
|
||||
hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`).
|
||||
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
|
||||
|
||||
Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20`
|
||||
(tambah `outlet_id=` untuk setting outlet).
|
||||
|
||||
### 9.3 Wallet customer
|
||||
|
||||
- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa
|
||||
dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer,
|
||||
admin, kasir, outlet). Query riwayat sama seperti §2.2.
|
||||
- `POST /api/v1/marketing/customers/:id/wallet/adjust`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
|
||||
```
|
||||
|
||||
`amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak.
|
||||
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.
|
||||
|
||||
### 9.4 PIN customer
|
||||
|
||||
- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN
|
||||
bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin
|
||||
**tidak bisa** membuat, mengganti, atau melihat PIN.
|
||||
- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan:
|
||||
`PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`,
|
||||
`PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat.
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist integrasi
|
||||
|
||||
**Customer app**
|
||||
- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout.
|
||||
- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN.
|
||||
- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang
|
||||
key yang sama saat retry.
|
||||
- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang.
|
||||
- [ ] 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.
|
||||
|
||||
**Dashboard**
|
||||
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
|
||||
`expiry_activations` sebelum owner menyimpan setting.
|
||||
- [ ] Isi `metadata.coin_cost` untuk setiap game.
|
||||
+64
-24
@@ -33,6 +33,7 @@ type App struct {
|
||||
omsetScheduler *service.OmsetMilestoneScheduler
|
||||
walletRecon *service.WalletReconciliationJob
|
||||
earningRetry *service.EarningBackfillJob
|
||||
walletExpiry *service.WalletExpiryJob
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
|
||||
@@ -63,6 +64,9 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
)
|
||||
// Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3)
|
||||
a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor)
|
||||
// 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))
|
||||
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
validators := a.initValidators()
|
||||
@@ -157,6 +161,10 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
services.customerPinService,
|
||||
services.pointPaymentService,
|
||||
services.customerOrderPaymentService,
|
||||
services.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -174,6 +182,9 @@ func (a *App) Start(port string) error {
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Start(30 * time.Minute)
|
||||
}
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Start(15 * time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@@ -219,6 +230,9 @@ func (a *App) Shutdown() {
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Stop()
|
||||
}
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Stop()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -253,7 +267,6 @@ type repositories struct {
|
||||
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
|
||||
accountRepo *repository.AccountRepositoryImpl
|
||||
orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
tierRepo *repository.TierRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
@@ -263,7 +276,6 @@ type repositories struct {
|
||||
campaignRepo repository.CampaignRepository
|
||||
campaignRuleRepo repository.CampaignRuleRepository
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
otpRepo repository.OtpRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
txManager *repository.TxManager
|
||||
@@ -311,7 +323,6 @@ func (a *App) initRepositories() *repositories {
|
||||
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
|
||||
accountRepo: repository.NewAccountRepositoryImpl(a.db),
|
||||
orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl),
|
||||
customerTokensRepo: repository.NewCustomerTokensRepository(a.db),
|
||||
tierRepo: repository.NewTierRepository(a.db),
|
||||
gameRepo: repository.NewGameRepository(a.db),
|
||||
gamePrizeRepo: repository.NewGamePrizeRepository(a.db),
|
||||
@@ -321,7 +332,6 @@ func (a *App) initRepositories() *repositories {
|
||||
campaignRepo: repository.NewCampaignRepository(a.db),
|
||||
campaignRuleRepo: repository.NewCampaignRuleRepository(a.db),
|
||||
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
|
||||
customerPointsRepo: repository.NewCustomerPointsRepository(a.db),
|
||||
otpRepo: repository.NewOtpRepository(a.db),
|
||||
sessionRepo: repository.NewSessionRepository(a.redisClient),
|
||||
txManager: repository.NewTxManager(a.db),
|
||||
@@ -364,7 +374,6 @@ type processors struct {
|
||||
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
|
||||
accountProcessor *processor.AccountProcessorImpl
|
||||
orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl
|
||||
customerTokensProcessor *processor.CustomerTokensProcessor
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
@@ -389,14 +398,22 @@ type processors struct {
|
||||
earningProcessor *processor.EarningProcessor
|
||||
customerPinProcessor *processor.CustomerPinProcessor
|
||||
paymentCodeProcessor *processor.PaymentCodeProcessor
|
||||
pointPaymentProcessor *processor.PointPaymentProcessor
|
||||
walletExchangeProcessor *processor.WalletExchangeProcessor
|
||||
walletTransferProcessor *processor.WalletTransferProcessor
|
||||
walletTraceProcessor *processor.WalletTraceProcessor
|
||||
customerDeviceProcessor *processor.CustomerDeviceProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
fileClient := client.NewFileClient(cfg.S3Config)
|
||||
fcmClient := buildFCMClient(cfg)
|
||||
// Devices of the customer app, for pushes through FCM
|
||||
customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient)
|
||||
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||
// Customer PIN (docs/prd-point-coin.md F11)
|
||||
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor)
|
||||
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor)
|
||||
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
|
||||
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
|
||||
|
||||
@@ -405,6 +422,13 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetLoyalty(earningProcessor)
|
||||
// Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9)
|
||||
pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
|
||||
// Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4)
|
||||
walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
// Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5)
|
||||
walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
@@ -432,11 +456,10 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
|
||||
accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo),
|
||||
orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl),
|
||||
customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo),
|
||||
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
|
||||
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
|
||||
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
|
||||
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo),
|
||||
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),
|
||||
@@ -447,7 +470,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
fileClient: fileClient,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||
notificationProcessor: buildNotificationProcessor(repos, fcmClient),
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||
@@ -456,7 +479,12 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
earningProcessor: earningProcessor,
|
||||
customerPinProcessor: customerPinProcessor,
|
||||
paymentCodeProcessor: paymentCodeProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||
pointPaymentProcessor: pointPaymentProcessor,
|
||||
walletExchangeProcessor: walletExchangeProcessor,
|
||||
walletTransferProcessor: walletTransferProcessor,
|
||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,6 +530,10 @@ type services struct {
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
customerPinService *service.CustomerPinServiceImpl
|
||||
pointPaymentService *service.PointPaymentServiceImpl
|
||||
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
|
||||
customerWalletService *service.CustomerWalletServiceImpl
|
||||
customerDeviceService *service.CustomerDeviceServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -532,12 +564,12 @@ 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.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
|
||||
gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
|
||||
rewardService := service.NewRewardService(processors.rewardProcessor)
|
||||
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
|
||||
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
||||
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor)
|
||||
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
||||
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
||||
|
||||
@@ -583,9 +615,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
|
||||
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),
|
||||
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
|
||||
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,19 +705,23 @@ func (a *App) initValidators() *validators {
|
||||
}
|
||||
}
|
||||
|
||||
// buildFCMClient connects to FCM, or returns nil when it is not configured or fails to
|
||||
// start. FCM failing is non-fatal: pushes are skipped and notifications are still
|
||||
// persisted.
|
||||
func buildFCMClient(cfg *config.Config) client.FCMClient {
|
||||
if cfg.FCM.CredentialsFile == "" {
|
||||
return nil
|
||||
}
|
||||
fcmClient, err := client.NewFCMClient(&cfg.FCM)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return fcmClient
|
||||
}
|
||||
|
||||
// buildNotificationProcessor creates the notification processor with FCM integration.
|
||||
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
|
||||
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
|
||||
var fcmClient client.FCMClient
|
||||
if cfg.FCM.CredentialsFile != "" {
|
||||
var err error
|
||||
fcmClient, err = client.NewFCMClient(&cfg.FCM)
|
||||
if err != nil {
|
||||
// FCM init failure is non-fatal; notifications will still be persisted.
|
||||
fcmClient = nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildNotificationProcessor(repos *repositories, fcmClient client.FCMClient) *processor.NotificationProcessorImpl {
|
||||
return processor.NewNotificationProcessor(
|
||||
repos.notificationRepo,
|
||||
repos.notificationReceiverRepo,
|
||||
|
||||
@@ -42,6 +42,9 @@ const (
|
||||
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
||||
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
||||
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
||||
LoyaltyExpiryModeSuffix = "expiry_mode"
|
||||
LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates"
|
||||
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
@@ -50,6 +53,14 @@ const (
|
||||
LoyaltyExpiryUnitMonth = "MONTH"
|
||||
)
|
||||
|
||||
// Modes of loyalty.{point,coin}.expiry_mode (note N4, decided: both are supported).
|
||||
const (
|
||||
// Every balance expires on the same fixed dates of the year, such as 31 Dec.
|
||||
LoyaltyExpiryModeFixedDate = "FIXED_DATE"
|
||||
// Every balance lasts a period from when it was received.
|
||||
LoyaltyExpiryModeRolling = "ROLLING"
|
||||
)
|
||||
|
||||
// Defaults from the PRD.
|
||||
const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
@@ -68,4 +79,8 @@ const (
|
||||
LoyaltyExpiryPeriodDefault = int64(12)
|
||||
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
||||
LoyaltyExpiryReminderDaysDefault = int64(7)
|
||||
LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate
|
||||
// Once a year on 31 December, as MM-DD.
|
||||
LoyaltyExpiryFixedDatesDefault = "12-31"
|
||||
LoyaltyExpiryGraceMonthsDefault = int64(3)
|
||||
)
|
||||
|
||||
@@ -28,7 +28,11 @@ 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"`
|
||||
@@ -42,6 +46,8 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package contract
|
||||
|
||||
// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer
|
||||
// app got for this device. Send it after login and whenever FCM refreshes the token.
|
||||
type RegisterCustomerDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
FCMToken string `json:"fcm_token" binding:"required"`
|
||||
// android, ios or web.
|
||||
Platform *string `json:"platform"`
|
||||
AppVersion *string `json:"app_version"`
|
||||
}
|
||||
@@ -31,3 +31,9 @@ type RemoveCustomerPinRequest struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -2,66 +2,8 @@ package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Existing gamification contracts
|
||||
type CreateCustomerPointsRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerPointsRequest struct {
|
||||
Points int64 `json:"points" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerPointsRequest struct {
|
||||
Points int64 `json:"points" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerPointsResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerPointsRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedCustomerPointsResponse struct {
|
||||
Data []CustomerPointsResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// New customer API contracts
|
||||
type GetCustomerPointsRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerTokensRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerWalletRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Contracts
|
||||
type GetCustomerPointsResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCustomerTokensRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerTokensRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerTokensResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerTokensRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
TokenType string `json:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedCustomerTokensResponse struct {
|
||||
Data []CustomerTokensResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -9,15 +9,17 @@ import (
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
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"`
|
||||
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"`
|
||||
// Deprecated: the same as CoinsUsed, kept until the apps read coins_used.
|
||||
TokenUsed int `json:"token_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -33,7 +35,7 @@ type ListGamePlaysRequest struct {
|
||||
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 token_used"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
@@ -45,14 +47,17 @@ type PaginatedGamePlaysResponse struct {
|
||||
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"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
}
|
||||
|
||||
@@ -186,9 +186,13 @@ type SetOrderCustomerResponse struct {
|
||||
}
|
||||
|
||||
type CreatePaymentRequest struct {
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
@@ -204,19 +208,23 @@ 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"`
|
||||
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"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
|
||||
|
||||
@@ -14,7 +14,9 @@ type SpinGameResponse struct {
|
||||
|
||||
// SpinGameResponseData contains the game play result
|
||||
type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
}
|
||||
|
||||
@@ -12,3 +12,20 @@ type AdjustCustomerWalletRequest struct {
|
||||
// adjusting twice, so a retried request is safe.
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
// ExchangeCoinsRequest is POST /customer/wallet/exchange (docs/prd-point-coin.md F4).
|
||||
// The Idempotency-Key header is required.
|
||||
type ExchangeCoinsRequest struct {
|
||||
Coins int64 `json:"coins" binding:"required,min=1"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
|
||||
// TransferWalletRequest is POST /customer/wallet/transfer (docs/prd-point-coin.md F5).
|
||||
// The Idempotency-Key header is required.
|
||||
type TransferWalletRequest struct {
|
||||
// POINT or COIN.
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
Amount int64 `json:"amount" binding:"required,min=1"`
|
||||
RecipientPhone string `json:"recipient_phone" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ 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
|
||||
@@ -173,15 +175,15 @@ type BudgetCutOffWeek struct {
|
||||
|
||||
// DashboardOverview represents dashboard overview data
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
type ProfitLossAnalytics struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ type GamePlay struct {
|
||||
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"`
|
||||
TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"`
|
||||
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"`
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerDeviceHandler serves /customer/devices, where the customer app registers
|
||||
// the FCM token of each device it runs on.
|
||||
type CustomerDeviceHandler struct {
|
||||
devices service.CustomerDeviceService
|
||||
}
|
||||
|
||||
func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler {
|
||||
return &CustomerDeviceHandler{devices: devices}
|
||||
}
|
||||
|
||||
// Register is PUT /customer/devices.
|
||||
func (h *CustomerDeviceHandler) Register(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RegisterCustomerDeviceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"),
|
||||
}), "CustomerDeviceHandler::Register")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register")
|
||||
}
|
||||
|
||||
// Unregister is DELETE /customer/devices/:device_id, for logout.
|
||||
func (h *CustomerDeviceHandler) Unregister(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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")
|
||||
}
|
||||
@@ -204,3 +204,26 @@ func walletErrorCode(err error) string {
|
||||
return constants.InternalServerErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomerWalletExpiring is GET /customer/wallet/expiring: what will expire, per
|
||||
// currency and day (docs/prd-point-coin.md F6).
|
||||
func (h *CustomerPointsHandler) GetCustomerWalletExpiring(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerID, ok := c.Get("customer_id")
|
||||
customerIDStr, isString := customerID.(string)
|
||||
if !ok || !isString {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletExpiring")
|
||||
return
|
||||
}
|
||||
response, err := h.customerPointsService.GetCustomerWalletExpiring(ctx, customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletExpiring -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletExpiring")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletExpiring")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the
|
||||
// X- form the rest of the API uses, which is accepted too.
|
||||
const (
|
||||
idempotencyKeyHeader = "Idempotency-Key"
|
||||
legacyIdempotencyKeyHeader = "X-Idempotency-Key"
|
||||
)
|
||||
|
||||
// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies
|
||||
// hold the PIN, so they are never logged.
|
||||
type CustomerWalletHandler struct {
|
||||
wallets service.CustomerWalletService
|
||||
}
|
||||
|
||||
func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler {
|
||||
return &CustomerWalletHandler{wallets: wallets}
|
||||
}
|
||||
|
||||
// PreviewExchange is GET /customer/wallet/exchange/preview?coins=.
|
||||
func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var coins int64
|
||||
if raw := c.Query("coins"); raw != "" {
|
||||
var err error
|
||||
coins, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"),
|
||||
}), "CustomerWalletHandler::PreviewExchange")
|
||||
return
|
||||
}
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange")
|
||||
}
|
||||
|
||||
// Exchange is POST /customer/wallet/exchange.
|
||||
func (h *CustomerWalletHandler) Exchange(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ExchangeCoinsRequest
|
||||
if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange")
|
||||
}
|
||||
|
||||
func idempotencyKey(c *gin.Context) string {
|
||||
if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader))
|
||||
}
|
||||
|
||||
// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=.
|
||||
func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient")
|
||||
}
|
||||
|
||||
// Transfer is POST /customer/wallet/transfer.
|
||||
func (h *CustomerWalletHandler) Transfer(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.TransferWalletRequest
|
||||
if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer")
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GamificationHandler struct {
|
||||
@@ -27,239 +26,6 @@ func NewGamificationHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Customer Points Handlers
|
||||
func (h *GamificationHandler) CreateCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req contract.CreateCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateCustomerPoints(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::CreateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerPoints(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::GetCustomerPoints -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetCustomerPoints(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerPointsByCustomerID(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPointsByCustomerID")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetCustomerPointsByCustomerID(ctx, customerID)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPointsByCustomerID")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPointsByCustomerID")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListCustomerPointsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListCustomerPoints(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::ListCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) UpdateCustomerPoints(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::UpdateCustomerPoints -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.UpdateCustomerPoints(ctx, id, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeleteCustomerPoints(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::DeleteCustomerPoints -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.gamificationService.DeleteCustomerPoints(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeleteCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) AddCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.AddCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.AddCustomerPoints(ctx, customerID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeductCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.DeductCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.DeductCustomerPoints(ctx, customerID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerPoints")
|
||||
}
|
||||
|
||||
// Play Game Handler
|
||||
func (h *GamificationHandler) PlayGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
@@ -288,240 +54,3 @@ func (h *GamificationHandler) PlayGame(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame")
|
||||
}
|
||||
|
||||
// Additional handler methods for other gamification features
|
||||
func (h *GamificationHandler) CreateCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.CreateCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateCustomerTokens(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::CreateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerTokens(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::GetCustomerTokens -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetCustomerTokens(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerTokensByCustomerIDAndType(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := c.Param("token_type")
|
||||
|
||||
response, err := h.gamificationService.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListCustomerTokensRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListCustomerTokens(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::ListCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) UpdateCustomerTokens(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::UpdateCustomerTokens -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.UpdateCustomerTokens(ctx, id, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeleteCustomerTokens(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::DeleteCustomerTokens -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.gamificationService.DeleteCustomerTokens(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeleteCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) AddCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := c.Param("token_type")
|
||||
|
||||
var req contract.AddCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.AddCustomerTokens(ctx, customerID, tokenType, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeductCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := c.Param("token_type")
|
||||
|
||||
var req contract.DeductCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.DeductCustomerTokens(ctx, customerID, tokenType, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerTokens")
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
orgSettings, err := settings.Organization(context.Background(), org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 5
|
||||
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||
_, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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"
|
||||
@@ -202,6 +205,11 @@ 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
|
||||
}
|
||||
@@ -225,6 +233,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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,10 +1,13 @@
|
||||
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"
|
||||
|
||||
@@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) {
|
||||
// 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(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()),
|
||||
contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()),
|
||||
}), "SpinGameHandler::PlaySpinGame")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
||||
}))
|
||||
|
||||
h := NewWalletAdminHandler(
|
||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm)),
|
||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
|
||||
validator.NewWalletValidator(),
|
||||
)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -83,3 +83,17 @@ func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
|
||||
}
|
||||
|
||||
// TraceTransaction is GET /marketing/wallet-transactions/:id/trace.
|
||||
func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction")
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToCustomerPointsResponse converts a customer points entity to a customer points response
|
||||
func ToCustomerPointsResponse(customerPoints *entities.CustomerPoints) *models.CustomerPointsResponse {
|
||||
if customerPoints == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CustomerPointsResponse{
|
||||
ID: customerPoints.ID,
|
||||
CustomerID: customerPoints.CustomerID,
|
||||
Balance: customerPoints.Balance,
|
||||
Customer: ToCustomerResponse(&customerPoints.Customer),
|
||||
CreatedAt: customerPoints.CreatedAt,
|
||||
UpdatedAt: customerPoints.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToCustomerPointsResponses converts a slice of customer points entities to customer points responses
|
||||
func ToCustomerPointsResponses(customerPoints []entities.CustomerPoints) []models.CustomerPointsResponse {
|
||||
responses := make([]models.CustomerPointsResponse, len(customerPoints))
|
||||
for i, cp := range customerPoints {
|
||||
responses[i] = *ToCustomerPointsResponse(&cp)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToCustomerPointsEntity converts a create customer points request to a customer points entity
|
||||
func ToCustomerPointsEntity(req *models.CreateCustomerPointsRequest) *entities.CustomerPoints {
|
||||
return &entities.CustomerPoints{
|
||||
CustomerID: req.CustomerID,
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCustomerPointsEntity updates a customer points entity with update request data
|
||||
func UpdateCustomerPointsEntity(customerPoints *entities.CustomerPoints, req *models.UpdateCustomerPointsRequest) {
|
||||
if req.Balance >= 0 {
|
||||
customerPoints.Balance = req.Balance
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToCustomerTokensResponse converts a customer tokens entity to a customer tokens response
|
||||
func ToCustomerTokensResponse(customerTokens *entities.CustomerTokens) *models.CustomerTokensResponse {
|
||||
if customerTokens == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CustomerTokensResponse{
|
||||
ID: customerTokens.ID,
|
||||
CustomerID: customerTokens.CustomerID,
|
||||
TokenType: string(customerTokens.TokenType),
|
||||
Balance: customerTokens.Balance,
|
||||
Customer: ToCustomerResponse(&customerTokens.Customer),
|
||||
CreatedAt: customerTokens.CreatedAt,
|
||||
UpdatedAt: customerTokens.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToCustomerTokensResponses converts a slice of customer tokens entities to customer tokens responses
|
||||
func ToCustomerTokensResponses(customerTokens []entities.CustomerTokens) []models.CustomerTokensResponse {
|
||||
responses := make([]models.CustomerTokensResponse, len(customerTokens))
|
||||
for i, ct := range customerTokens {
|
||||
responses[i] = *ToCustomerTokensResponse(&ct)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToCustomerTokensEntity converts a create customer tokens request to a customer tokens entity
|
||||
func ToCustomerTokensEntity(req *models.CreateCustomerTokensRequest) *entities.CustomerTokens {
|
||||
return &entities.CustomerTokens{
|
||||
CustomerID: req.CustomerID,
|
||||
TokenType: entities.TokenType(req.TokenType),
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCustomerTokensEntity updates a customer tokens entity with update request data
|
||||
func UpdateCustomerTokensEntity(customerTokens *entities.CustomerTokens, req *models.UpdateCustomerTokensRequest) {
|
||||
if req.Balance >= 0 {
|
||||
customerTokens.Balance = req.Balance
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse {
|
||||
GameID: gamePlay.GameID,
|
||||
CustomerID: gamePlay.CustomerID,
|
||||
PrizeID: gamePlay.PrizeID,
|
||||
TokenUsed: gamePlay.TokenUsed,
|
||||
CoinsUsed: gamePlay.CoinsUsed,
|
||||
RandomSeed: gamePlay.RandomSeed,
|
||||
CreatedAt: gamePlay.CreatedAt,
|
||||
Game: ToGameResponse(&gamePlay.Game),
|
||||
@@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay {
|
||||
return &entities.GamePlay{
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
TokenUsed: req.TokenUsed,
|
||||
CoinsUsed: req.CoinsUsed,
|
||||
RandomSeed: req.RandomSeed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,8 @@ 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,
|
||||
|
||||
@@ -38,7 +38,8 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
|
||||
return &contract.SpinGameResponseData{
|
||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
||||
TokensRemaining: data.TokensRemaining,
|
||||
CoinsRemaining: data.CoinsRemaining,
|
||||
TokensRemaining: data.CoinsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +54,8 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam
|
||||
GameID: resp.GameID,
|
||||
CustomerID: resp.CustomerID,
|
||||
PrizeID: resp.PrizeID,
|
||||
TokenUsed: resp.TokenUsed,
|
||||
CoinsUsed: resp.CoinsUsed,
|
||||
TokenUsed: resp.CoinsUsed,
|
||||
RandomSeed: resp.RandomSeed,
|
||||
CreatedAt: resp.CreatedAt,
|
||||
Game: nil, // Optional field - can be populated separately if needed
|
||||
|
||||
@@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct {
|
||||
|
||||
// PaymentMethodSummary represents the summary of payment method analytics
|
||||
type PaymentMethodSummary struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
// 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"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
TotalPayments int64 `json:"total_payments"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
@@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PaymentCount int64 `json:"payment_count"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// SalesAnalyticsRequest represents the request for sales analytics
|
||||
@@ -464,12 +475,12 @@ type ProfitLossAnalyticsResponse struct {
|
||||
}
|
||||
|
||||
type ProfitLossPurchasing struct {
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,66 +2,8 @@ package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Existing gamification models
|
||||
type CreateCustomerPointsRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerPointsResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerPointsQuery struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedCustomerPointsResponse struct {
|
||||
Data []CustomerPointsResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// New customer API models
|
||||
type GetCustomerPointsRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerTokensRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerWalletRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Models
|
||||
type GetCustomerPointsResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCustomerTokensRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerTokensRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerTokensResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerTokensQuery struct {
|
||||
Page int `query:"page" validate:"min=1"`
|
||||
Limit int `query:"limit" validate:"min=1,max=100"`
|
||||
Search string `query:"search"`
|
||||
TokenType string `query:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
CoinsUsed int `json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type GamePlayResponse struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
TokenUsed int `json:"token_used"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
@@ -33,18 +33,18 @@ type ListGamePlaysQuery struct {
|
||||
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 token_used"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_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"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
|
||||
@@ -57,15 +57,29 @@ type LoyaltyTransferSettings struct {
|
||||
DailyLimit *int64 `json:"daily_limit"`
|
||||
}
|
||||
|
||||
// LoyaltyExpirySettings is how long one currency lasts once received. The expiry
|
||||
// model is still open (note N4); these are only the stored settings.
|
||||
// LoyaltyExpirySettings is when one currency expires once received (F12). Both
|
||||
// models of note N4 are supported, and the owner picks one:
|
||||
//
|
||||
// - FIXED_DATE: everything expires on the next of FixedDates falling on or after
|
||||
// the day received + GraceMonths, so a balance received just before a date moves
|
||||
// on to the one after.
|
||||
// - ROLLING: everything lasts Period Units from the day received, to the end of
|
||||
// that month when EndOfMonth is set.
|
||||
type LoyaltyExpirySettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Period int64 `json:"period"`
|
||||
// DAY or MONTH.
|
||||
Unit string `json:"unit"`
|
||||
EndOfMonth bool `json:"end_of_month"`
|
||||
ReminderDays int64 `json:"reminder_days"`
|
||||
Enabled bool `json:"enabled"`
|
||||
// FIXED_DATE or ROLLING.
|
||||
Mode string `json:"mode"`
|
||||
// FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted.
|
||||
FixedDates []string `json:"fixed_dates"`
|
||||
// FIXED_DATE: how many months a balance lasts at least before a fixed date takes it.
|
||||
GraceMonths int64 `json:"grace_months"`
|
||||
// ROLLING: how long a balance lasts.
|
||||
Period int64 `json:"period"`
|
||||
// ROLLING: DAY or MONTH.
|
||||
Unit string `json:"unit"`
|
||||
EndOfMonth bool `json:"end_of_month"`
|
||||
// Days before expiry the customer is reminded; 0 for no reminder.
|
||||
ReminderDays int64 `json:"reminder_days"`
|
||||
}
|
||||
|
||||
// LoyaltySettingChange is one row of the loyalty settings history.
|
||||
@@ -109,12 +123,32 @@ type OrganizationLoyaltySettingsView struct {
|
||||
OrganizationLoyaltySettings
|
||||
// What the balances in circulation are worth, before and after the change.
|
||||
Impact LoyaltySettingsImpact `json:"impact"`
|
||||
// When a balance received now would expire under these settings (F12).
|
||||
ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"`
|
||||
// The currencies this change turns expiry on for, and the balances affected.
|
||||
ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"`
|
||||
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||
Changes []LoyaltySettingChange `json:"changes"`
|
||||
// True when nothing was saved.
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that
|
||||
// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet.
|
||||
type LoyaltyExpiryActivation struct {
|
||||
Currency string `json:"currency"`
|
||||
Lots int64 `json:"lots"`
|
||||
Amount int64 `json:"amount"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the
|
||||
// EnakPoint received today expire on …". Nil means they never expire.
|
||||
type LoyaltyExpiryPreview struct {
|
||||
Point *time.Time `json:"point"`
|
||||
Coin *time.Time `json:"coin"`
|
||||
}
|
||||
|
||||
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
|
||||
// the balances in circulation are worth (F2). Before and after are equal when neither
|
||||
// changes.
|
||||
|
||||
@@ -28,8 +28,10 @@ type Payment struct {
|
||||
}
|
||||
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID uuid.UUID `validate:"required"`
|
||||
PaymentMethodID uuid.UUID `validate:"required"`
|
||||
OrderID uuid.UUID `validate:"required"`
|
||||
PaymentMethodID uuid.UUID `validate:"required"`
|
||||
Points *int64
|
||||
PaymentCode *string
|
||||
Amount float64 `validate:"required,min=0"`
|
||||
TransactionID *string `validate:"omitempty"`
|
||||
SplitNumber int `validate:"omitempty,min=1"`
|
||||
@@ -60,6 +62,9 @@ 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
|
||||
|
||||
@@ -20,9 +20,9 @@ type SpinGameResponse struct {
|
||||
|
||||
// SpinGameResponseData contains the game play result
|
||||
type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
|
||||
// SpinGamePrizeResponse represents a prize won in the spin game
|
||||
|
||||
@@ -138,3 +138,29 @@ type AdminWalletAdjustmentResult struct {
|
||||
// True when the idempotency key had been used before and nothing changed.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md
|
||||
// F9): whether the order can be paid with EnakPoint and at most how much, for the
|
||||
// cashier's "use maximum" button.
|
||||
type PointPaymentPreview struct {
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
CustomerID *uuid.UUID `json:"customer_id"`
|
||||
Eligible bool `json:"eligible"`
|
||||
// Why not, when not eligible.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
PointValue int64 `json:"point_value"`
|
||||
RemainingAmount float64 `json:"remaining_amount"`
|
||||
MinPaymentPoints int64 `json:"min_payment_points"`
|
||||
MaxPaymentPercent int64 `json:"max_payment_percent"`
|
||||
MaxPoints int64 `json:"max_points"`
|
||||
// Rupiah covered by MaxPoints.
|
||||
MaxAmount int64 `json:"max_amount"`
|
||||
}
|
||||
|
||||
// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md
|
||||
// F6): everything that will expire, per currency and day, soonest first.
|
||||
type CustomerWalletExpiringList struct {
|
||||
Point []CustomerWalletExpiring `json:"point"`
|
||||
Coin []CustomerWalletExpiring `json:"coin"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WalletExchangePreview is GET /customer/wallet/exchange/preview
|
||||
// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give.
|
||||
type WalletExchangePreview struct {
|
||||
// The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint.
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
Coins int64 `json:"coins"`
|
||||
Points int64 `json:"points"`
|
||||
// Whether Coins can be exchanged now, and why not when it cannot.
|
||||
Valid bool `json:"valid"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// WalletMovedLot is part of what an exchange or a transfer delivered, with the
|
||||
// expiry it carried over from the lot it came from (K9).
|
||||
type WalletMovedLot struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// Nil when it never expires.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// WalletExchangeResult is POST /customer/wallet/exchange.
|
||||
type WalletExchangeResult struct {
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
Coins int64 `json:"coins"`
|
||||
Points int64 `json:"points"`
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
// The EnakPoint received, split by expiry.
|
||||
Lots []WalletMovedLot `json:"lots"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
// True when this was a retry of an exchange already made; nothing moved again.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone
|
||||
// number belongs to, masked, so the sender can check before confirming (F5).
|
||||
type WalletTransferRecipient struct {
|
||||
Name string `json:"name"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}
|
||||
|
||||
// WalletTransfer is what a customer asks to send (F5).
|
||||
type WalletTransfer struct {
|
||||
// POINT or COIN.
|
||||
Currency string
|
||||
Amount int64
|
||||
RecipientPhone string
|
||||
}
|
||||
|
||||
// WalletTransferResult is POST /customer/wallet/transfer.
|
||||
type WalletTransferResult struct {
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount"`
|
||||
Recipient WalletTransferRecipient `json:"recipient"`
|
||||
// What the recipient received, split by the expiry it carried over.
|
||||
Lots []WalletMovedLot `json:"lots"`
|
||||
// The sender's balance in the currency sent.
|
||||
Balance int64 `json:"balance"`
|
||||
// True when this was a retry of a transfer already made; nothing moved again.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace
|
||||
// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and
|
||||
// where each of them came from, lot by lot, back to the lot that first brought the
|
||||
// balance into a wallet. It follows balance across transfers, exchanges and refunds.
|
||||
type AdminWalletTrace struct {
|
||||
Transaction AdminWalletTraceRow `json:"transaction"`
|
||||
// One per lot the row took from (a debit) or created (a credit).
|
||||
Lots []AdminWalletTraceLot `json:"lots"`
|
||||
}
|
||||
|
||||
// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain.
|
||||
type AdminWalletTraceLot struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// The lot itself first, then the lot it was carried over from, and so on. The
|
||||
// last step is the lot an EARN, ADJUSTMENT or MIGRATION created.
|
||||
Chain []AdminWalletTraceStep `json:"chain"`
|
||||
}
|
||||
|
||||
// AdminWalletTraceStep is one lot on a chain and the row that created it.
|
||||
type AdminWalletTraceStep struct {
|
||||
Lot AdminWalletLot `json:"lot"`
|
||||
Source AdminWalletTraceRow `json:"source"`
|
||||
}
|
||||
|
||||
// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the
|
||||
// customer it belongs to.
|
||||
type AdminWalletTraceRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Customer AdminWalletNamedRef `json:"customer"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
Amount int64 `json:"amount"`
|
||||
Description string `json:"description"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uuid.UUID `json:"reference_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -63,27 +63,37 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
return nil, fmt.Errorf("failed to get payment method analytics: %w", err)
|
||||
}
|
||||
|
||||
var totalAmount float64
|
||||
// EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own
|
||||
// method but left out of the money received and of the shares. How it is booked
|
||||
// waits on note N2.
|
||||
var cashAmount, pointAmount float64
|
||||
var pointsUsed int64
|
||||
var totalOrders int64
|
||||
var totalPayments int64
|
||||
|
||||
for _, data := range analyticsData {
|
||||
totalAmount += data.TotalAmount
|
||||
if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) {
|
||||
pointAmount += data.TotalAmount
|
||||
pointsUsed += data.PointsUsed
|
||||
} else {
|
||||
cashAmount += data.TotalAmount
|
||||
}
|
||||
totalOrders += data.OrderCount
|
||||
totalPayments += data.PaymentCount
|
||||
}
|
||||
|
||||
// The value of an order includes what EnakPoint paid, so the average does too.
|
||||
var averageOrderValue float64
|
||||
if totalOrders > 0 {
|
||||
averageOrderValue = totalAmount / float64(totalOrders)
|
||||
averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders)
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
var resultData []models.PaymentMethodAnalyticsData
|
||||
for _, data := range analyticsData {
|
||||
cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint)
|
||||
var percentage float64
|
||||
if totalAmount > 0 {
|
||||
percentage = (data.TotalAmount / totalAmount) * 100
|
||||
if cashIn && cashAmount > 0 {
|
||||
percentage = (data.TotalAmount / cashAmount) * 100
|
||||
}
|
||||
|
||||
resultData = append(resultData, models.PaymentMethodAnalyticsData{
|
||||
@@ -94,11 +104,16 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
OrderCount: data.OrderCount,
|
||||
PaymentCount: data.PaymentCount,
|
||||
Percentage: percentage,
|
||||
PointsUsed: data.PointsUsed,
|
||||
CountsAsCashIn: cashIn,
|
||||
})
|
||||
}
|
||||
|
||||
summary := models.PaymentMethodSummary{
|
||||
TotalAmount: totalAmount,
|
||||
TotalAmount: cashAmount,
|
||||
PointAmount: pointAmount,
|
||||
PointsUsed: pointsUsed,
|
||||
TotalWithPoints: cashAmount + pointAmount,
|
||||
TotalOrders: totalOrders,
|
||||
TotalPayments: totalPayments,
|
||||
AverageOrderValue: averageOrderValue,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -24,10 +25,11 @@ type analyticsRepositoryStub struct {
|
||||
exclusiveSummaryCalls int
|
||||
exclusiveSummaryFrom []time.Time
|
||||
exclusiveSummaryTo []time.Time
|
||||
paymentMethods []*entities.PaymentMethodAnalytics
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||
return nil, nil
|
||||
func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||
return s.paymentMethods, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
|
||||
@@ -570,3 +572,35 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t
|
||||
require.Len(t, result.DailySummary, 1)
|
||||
require.Len(t, result.DailyTransactions, 2)
|
||||
}
|
||||
|
||||
// EnakPoint is listed as its own method but is not money in (F9, K7).
|
||||
func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
|
||||
repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{
|
||||
{PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2},
|
||||
{PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1},
|
||||
{PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000},
|
||||
}}
|
||||
p := NewAnalyticsProcessorImpl(repo, nil)
|
||||
|
||||
got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{
|
||||
OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out")
|
||||
assert.Equal(t, 30000.0, got.Summary.PointAmount)
|
||||
assert.Equal(t, int64(30000), got.Summary.PointsUsed)
|
||||
assert.Equal(t, 120000.0, got.Summary.TotalWithPoints)
|
||||
assert.Equal(t, int64(4), got.Summary.TotalOrders)
|
||||
assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid")
|
||||
|
||||
byType := map[string]models.PaymentMethodAnalyticsData{}
|
||||
for _, d := range got.Data {
|
||||
byType[d.PaymentMethodType] = d
|
||||
}
|
||||
assert.True(t, byType["cash"].CountsAsCashIn)
|
||||
assert.False(t, byType["point"].CountsAsCashIn)
|
||||
assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received")
|
||||
assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01)
|
||||
assert.Zero(t, byType["point"].Percentage)
|
||||
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidCustomerDevice wraps every rejection of a device registration.
|
||||
var ErrInvalidCustomerDevice = errors.New("invalid customer device")
|
||||
|
||||
type customerPushSender interface {
|
||||
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
|
||||
}
|
||||
|
||||
// CustomerDeviceProcessor keeps the customer app's FCM tokens and sends push
|
||||
// notifications to a customer's devices.
|
||||
type CustomerDeviceProcessor struct {
|
||||
repo repository.CustomerDeviceRepository
|
||||
// Nil when FCM is not configured; notifications are then skipped.
|
||||
fcm customerPushSender
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewCustomerDeviceProcessor(repo repository.CustomerDeviceRepository, fcm customerPushSender) *CustomerDeviceProcessor {
|
||||
return &CustomerDeviceProcessor{repo: repo, fcm: fcm, now: time.Now}
|
||||
}
|
||||
|
||||
// Register saves the FCM token the app got for this device. The app calls it after
|
||||
// login and whenever FCM gives it a new token.
|
||||
func (p *CustomerDeviceProcessor) Register(ctx context.Context, device repository.CustomerDevice) error {
|
||||
device.DeviceID = strings.TrimSpace(device.DeviceID)
|
||||
device.FCMToken = strings.TrimSpace(device.FCMToken)
|
||||
switch {
|
||||
case device.DeviceID == "" || len(device.DeviceID) > 255:
|
||||
return fmt.Errorf("%w: device_id is required, at most 255 characters", ErrInvalidCustomerDevice)
|
||||
case device.FCMToken == "" || len(device.FCMToken) > 512:
|
||||
return fmt.Errorf("%w: fcm_token is required, at most 512 characters", ErrInvalidCustomerDevice)
|
||||
}
|
||||
if device.Platform != nil {
|
||||
platform := strings.ToLower(strings.TrimSpace(*device.Platform))
|
||||
if platform != "android" && platform != "ios" && platform != "web" {
|
||||
return fmt.Errorf("%w: platform must be android, ios or web", ErrInvalidCustomerDevice)
|
||||
}
|
||||
device.Platform = &platform
|
||||
}
|
||||
return p.repo.Register(ctx, device, p.now())
|
||||
}
|
||||
|
||||
// Unregister forgets a device, so it stops getting the customer's notifications.
|
||||
func (p *CustomerDeviceProcessor) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
return p.repo.Unregister(ctx, customerID, strings.TrimSpace(deviceID))
|
||||
}
|
||||
|
||||
// Notify pushes a notification to every device of the customer through FCM. A
|
||||
// customer without a registered device gets nothing, which is not an error.
|
||||
func (p *CustomerDeviceProcessor) Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
|
||||
if p.fcm == nil {
|
||||
logger.NonContext.Info(fmt.Sprintf("FCM is not configured; not notifying customer %s", customerID))
|
||||
return nil
|
||||
}
|
||||
tokens, err := p.repo.ListTokens(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p.fcm.SendMulticastNotification(ctx, tokens, title, body, data)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// customerDeviceRepoFake keeps devices the way the table does: one row per customer
|
||||
// and device, and a token belongs to one row only.
|
||||
type customerDeviceRepoFake struct{ devices []repository.CustomerDevice }
|
||||
|
||||
func (f *customerDeviceRepoFake) Register(_ context.Context, d repository.CustomerDevice, _ time.Time) error {
|
||||
kept := f.devices[:0]
|
||||
for _, existing := range f.devices {
|
||||
sameRow := existing.CustomerID == d.CustomerID && existing.DeviceID == d.DeviceID
|
||||
if !sameRow && existing.FCMToken != d.FCMToken {
|
||||
kept = append(kept, existing)
|
||||
}
|
||||
}
|
||||
f.devices = append(kept, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *customerDeviceRepoFake) Unregister(_ context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
kept := f.devices[:0]
|
||||
for _, d := range f.devices {
|
||||
if d.CustomerID != customerID || d.DeviceID != deviceID {
|
||||
kept = append(kept, d)
|
||||
}
|
||||
}
|
||||
f.devices = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *customerDeviceRepoFake) ListTokens(_ context.Context, customerID uuid.UUID) ([]string, error) {
|
||||
var tokens []string
|
||||
for _, d := range f.devices {
|
||||
if d.CustomerID == customerID {
|
||||
tokens = append(tokens, d.FCMToken)
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
type fcmFake struct {
|
||||
tokens [][]string
|
||||
title string
|
||||
body string
|
||||
data map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fcmFake) SendMulticastNotification(_ context.Context, tokens []string, title, body string, data map[string]string) error {
|
||||
f.tokens = append(f.tokens, tokens)
|
||||
f.title, f.body, f.data = title, body, data
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestCustomerDevice_NotifiesEveryDeviceOfTheCustomer(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
budi, anita := uuid.New(), uuid.New()
|
||||
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1", Platform: ptr("Android")}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "tablet", FCMToken: "t2"}))
|
||||
// A refreshed token replaces the old one of the same device.
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1b"}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "t3"}))
|
||||
|
||||
require.NoError(t, p.Notify(ctx, budi, "EnakPoint masuk", "Kamu menerima 10 EnakPoint", map[string]string{"type": "X"}))
|
||||
assert.Equal(t, [][]string{{"t2", "t1b"}}, fcm.tokens)
|
||||
assert.Equal(t, "EnakPoint masuk", fcm.title)
|
||||
assert.Equal(t, map[string]string{"type": "X"}, fcm.data)
|
||||
}
|
||||
|
||||
func TestCustomerDevice_TokenMovesToWhoeverLogsInOnThePhone(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
budi, anita := uuid.New(), uuid.New()
|
||||
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "shared"}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "shared"}))
|
||||
|
||||
// Budi's notifications no longer reach the phone Anita is now logged in on.
|
||||
require.NoError(t, p.Notify(ctx, budi, "t", "b", nil))
|
||||
require.NoError(t, p.Notify(ctx, anita, "t", "b", nil))
|
||||
assert.Equal(t, [][]string{{"shared"}}, fcm.tokens)
|
||||
}
|
||||
|
||||
func TestCustomerDevice_NothingToSend(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
|
||||
// No device registered, or logged out: nothing is sent and nothing fails.
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Unregister(ctx, customer, "phone"))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
|
||||
assert.Empty(t, fcm.tokens)
|
||||
|
||||
// FCM not configured.
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, nil).Notify(ctx, customer, "t", "b", nil))
|
||||
}
|
||||
|
||||
func TestCustomerDevice_FCMFailureIsReported(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{err: errors.New("unavailable")}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
|
||||
|
||||
assert.Error(t, p.Notify(ctx, customer, "t", "b", nil))
|
||||
}
|
||||
|
||||
func TestCustomerDevice_RejectsIncompleteRegistrations(t *testing.T) {
|
||||
p := NewCustomerDeviceProcessor(&customerDeviceRepoFake{}, nil)
|
||||
ctx := context.Background()
|
||||
for name, d := range map[string]repository.CustomerDevice{
|
||||
"no device": {FCMToken: "t"},
|
||||
"no token": {DeviceID: "phone", FCMToken: " "},
|
||||
"bad platform": {DeviceID: "phone", FCMToken: "t", Platform: ptr("symbian")},
|
||||
} {
|
||||
assert.ErrorIs(t, p.Register(ctx, d), ErrInvalidCustomerDevice, name)
|
||||
}
|
||||
}
|
||||
@@ -100,24 +100,22 @@ type pinOtpSender interface {
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
}
|
||||
|
||||
// pinAlerter tells a customer their PIN was locked. There is no push channel to
|
||||
// customers yet, so the app sends it by WhatsApp.
|
||||
type pinAlerter interface {
|
||||
SendWhatsAppMessage(phoneNumber, message string) error
|
||||
}
|
||||
// NotificationTypePinLocked is the data type of the push a customer gets when their
|
||||
// PIN locks, so the app can offer the PIN reset.
|
||||
const NotificationTypePinLocked = "PIN_LOCKED"
|
||||
|
||||
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
|
||||
// that moves balance on the customer's request calls VerifyPin first (K8).
|
||||
type CustomerPinProcessor struct {
|
||||
repo repository.CustomerPinRepository
|
||||
otp pinOtpSender
|
||||
alerter pinAlerter
|
||||
now func() time.Time
|
||||
cost int
|
||||
repo repository.CustomerPinRepository
|
||||
otp pinOtpSender
|
||||
notifier customerNotifier
|
||||
now func() time.Time
|
||||
cost int
|
||||
}
|
||||
|
||||
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, alerter pinAlerter) *CustomerPinProcessor {
|
||||
return &CustomerPinProcessor{repo: repo, otp: otp, alerter: alerter, now: time.Now, cost: bcrypt.DefaultCost}
|
||||
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor {
|
||||
return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
|
||||
@@ -295,7 +293,7 @@ func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.Cus
|
||||
// customer; attempts racing it just see the lock.
|
||||
if attempts == pinMaxAttempts {
|
||||
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
|
||||
p.alertLocked(state, *lockedUntil)
|
||||
p.alertLocked(ctx, state, *lockedUntil)
|
||||
}
|
||||
return &PinError{Code: PinErrLocked, Until: lockedUntil}
|
||||
}
|
||||
@@ -420,13 +418,19 @@ func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUI
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) alertLocked(state *repository.CustomerPinState, until time.Time) {
|
||||
if p.alerter == nil || state.PhoneNumber == nil {
|
||||
// alertLocked pushes the lock to the customer's app through FCM (F11). It is best
|
||||
// effort: the lock stands whether or not the push goes out.
|
||||
func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) {
|
||||
if p.notifier == nil {
|
||||
return
|
||||
}
|
||||
message := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
|
||||
body := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
|
||||
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
|
||||
if err := p.alerter.SendWhatsAppMessage(*state.PhoneNumber, message); err != nil {
|
||||
data := map[string]string{
|
||||
"type": NotificationTypePinLocked,
|
||||
"locked_until": until.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ type alerterFake struct {
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *alerterFake) SendWhatsAppMessage(_ string, message string) error {
|
||||
func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages = append(f.messages, message)
|
||||
f.messages = append(f.messages, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func TestCheckNewPin(t *testing.T) {
|
||||
@@ -14,18 +21,18 @@ func TestCheckNewPin(t *testing.T) {
|
||||
assert.NoError(t, checkNewPin(ok, ok, &birth), ok)
|
||||
}
|
||||
for name, c := range map[string][2]string{
|
||||
"too short": {"12345", "12345"},
|
||||
"too long": {"1234567", "1234567"},
|
||||
"not digits": {"12a456", "12a456"},
|
||||
"confirmation": {"482913", "482914"},
|
||||
"one digit": {"111111", "111111"},
|
||||
"zeros": {"000000", "000000"},
|
||||
"run up": {"123456", "123456"},
|
||||
"run up from 4": {"456789", "456789"},
|
||||
"run down": {"654321", "654321"},
|
||||
"run down from 9": {"987654", "987654"},
|
||||
"birth date DDMMYY": {"140390", "140390"},
|
||||
"birth date YYMMDD": {"900314", "900314"},
|
||||
"too short": {"12345", "12345"},
|
||||
"too long": {"1234567", "1234567"},
|
||||
"not digits": {"12a456", "12a456"},
|
||||
"confirmation": {"482913", "482914"},
|
||||
"one digit": {"111111", "111111"},
|
||||
"zeros": {"000000", "000000"},
|
||||
"run up": {"123456", "123456"},
|
||||
"run up from 4": {"456789", "456789"},
|
||||
"run down": {"654321", "654321"},
|
||||
"run down from 9": {"987654", "987654"},
|
||||
"birth date DDMMYY": {"140390", "140390"},
|
||||
"birth date YYMMDD": {"900314", "900314"},
|
||||
} {
|
||||
err := checkNewPin(c[0], c[1], &birth)
|
||||
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
|
||||
@@ -34,3 +41,67 @@ func TestCheckNewPin(t *testing.T) {
|
||||
// Without a birth date only the other rules apply.
|
||||
assert.NoError(t, checkNewPin("140390", "140390", nil))
|
||||
}
|
||||
|
||||
// pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure.
|
||||
type pinRepoFake struct {
|
||||
repository.CustomerPinRepository
|
||||
state repository.CustomerPinState
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) {
|
||||
s := f.state
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
|
||||
if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) {
|
||||
f.state.FailedAttempts, f.state.LockedUntil = 1, nil
|
||||
} else {
|
||||
f.state.FailedAttempts++
|
||||
if f.state.FailedAttempts >= maxAttempts {
|
||||
f.state.LockedUntil = &lockUntil
|
||||
}
|
||||
}
|
||||
return f.state.FailedAttempts, f.state.LockedUntil, nil
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error {
|
||||
f.state.FailedAttempts, f.state.LockedUntil = 0, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) {
|
||||
customer := uuid.New()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
h := string(hash)
|
||||
repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}}
|
||||
notifier := ¬ifierFake{}
|
||||
p := NewCustomerPinProcessor(repo, nil, notifier)
|
||||
now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC)
|
||||
p.now = func() time.Time { return now }
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
_ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{})
|
||||
}
|
||||
assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks")
|
||||
|
||||
err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{})
|
||||
var pinErr *PinError
|
||||
require.ErrorAs(t, err, &pinErr)
|
||||
assert.Equal(t, PinErrLocked, pinErr.Code)
|
||||
|
||||
// Attempts while locked do not push again.
|
||||
_ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{})
|
||||
|
||||
require.Len(t, notifier.pushes[customer], 1)
|
||||
push := notifier.pushes[customer][0]
|
||||
assert.Equal(t, "PIN terkunci", push.title)
|
||||
assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body)
|
||||
assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data)
|
||||
}
|
||||
|
||||
@@ -22,53 +22,6 @@ func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *rep
|
||||
}
|
||||
}
|
||||
|
||||
// Existing gamification methods - placeholder implementations
|
||||
func (p *CustomerPointsProcessor) CreateCustomerPoints(ctx context.Context, req *models.CreateCustomerPointsRequest) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedCustomerPointsResponse, error) {
|
||||
// Return empty paginated response for now
|
||||
return &models.PaginatedCustomerPointsResponse{
|
||||
Data: []models.CustomerPointsResponse{},
|
||||
TotalCount: 0,
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
TotalPages: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error {
|
||||
// TODO: Implement this method
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// The three endpoints below are the customer app's balance screens. They read the
|
||||
// EnakPoint & EnakCoin wallet; customer_points and customer_tokens are no longer used
|
||||
// for balances (docs/prd-point-coin.md §9).
|
||||
@@ -224,3 +177,11 @@ func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*m
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.walletQuery.Expiring(ctx, id)
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CustomerTokensProcessor struct {
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
}
|
||||
|
||||
func NewCustomerTokensProcessor(customerTokensRepo *repository.CustomerTokensRepository) *CustomerTokensProcessor {
|
||||
return &CustomerTokensProcessor{
|
||||
customerTokensRepo: customerTokensRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCustomerTokens creates a new customer tokens record
|
||||
func (p *CustomerTokensProcessor) CreateCustomerTokens(ctx context.Context, req *models.CreateCustomerTokensRequest) (*models.CustomerTokensResponse, error) {
|
||||
// Convert request to entity
|
||||
customerTokens := mappers.ToCustomerTokensEntity(req)
|
||||
|
||||
// Create customer tokens
|
||||
err := p.customerTokensRepo.Create(ctx, customerTokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// GetCustomerTokens retrieves customer tokens by ID
|
||||
func (p *CustomerTokensProcessor) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*models.CustomerTokensResponse, error) {
|
||||
customerTokens, err := p.customerTokensRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// GetCustomerTokensByCustomerIDAndType retrieves customer tokens by customer ID and token type
|
||||
func (p *CustomerTokensProcessor) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*models.CustomerTokensResponse, error) {
|
||||
customerTokens, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// ListCustomerTokens retrieves customer tokens with pagination and filtering
|
||||
func (p *CustomerTokensProcessor) ListCustomerTokens(ctx context.Context, query *models.ListCustomerTokensQuery) (*models.PaginatedResponse[models.CustomerTokensResponse], error) {
|
||||
// Set default values
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Limit > 100 {
|
||||
query.Limit = 100
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
|
||||
// Get customer tokens from repository
|
||||
customerTokens, total, err := p.customerTokensRepo.List(
|
||||
ctx,
|
||||
offset,
|
||||
query.Limit,
|
||||
query.Search,
|
||||
query.TokenType,
|
||||
query.SortBy,
|
||||
query.SortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list customer tokens: %w", err)
|
||||
}
|
||||
|
||||
// Convert to responses
|
||||
responses := mappers.ToCustomerTokensResponses(customerTokens)
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
|
||||
|
||||
return &models.PaginatedResponse[models.CustomerTokensResponse]{
|
||||
Data: responses,
|
||||
Pagination: models.Pagination{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateCustomerTokens updates an existing customer tokens record
|
||||
func (p *CustomerTokensProcessor) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerTokensRequest) (*models.CustomerTokensResponse, error) {
|
||||
// Get existing customer tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
// Update customer tokens fields
|
||||
mappers.UpdateCustomerTokensEntity(customerTokens, req)
|
||||
|
||||
// Save updated customer tokens
|
||||
err = p.customerTokensRepo.Update(ctx, customerTokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// DeleteCustomerTokens deletes a customer tokens record
|
||||
func (p *CustomerTokensProcessor) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error {
|
||||
// Get existing customer tokens
|
||||
_, err := p.customerTokensRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
// Delete customer tokens
|
||||
err = p.customerTokensRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTokens adds tokens to a customer's balance
|
||||
func (p *CustomerTokensProcessor) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) {
|
||||
if tokens <= 0 {
|
||||
return nil, errors.New("tokens must be greater than 0")
|
||||
}
|
||||
|
||||
// Ensure customer tokens record exists
|
||||
_, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure customer tokens: %w", err)
|
||||
}
|
||||
|
||||
// Add tokens
|
||||
err = p.customerTokensRepo.AddTokens(ctx, customerID, entities.TokenType(tokenType), tokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get updated customer tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// DeductTokens deducts tokens from a customer's balance
|
||||
func (p *CustomerTokensProcessor) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) {
|
||||
if tokens <= 0 {
|
||||
return nil, errors.New("tokens must be greater than 0")
|
||||
}
|
||||
|
||||
// Get current customer tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
if customerTokens.Balance < tokens {
|
||||
return nil, errors.New("insufficient tokens balance")
|
||||
}
|
||||
|
||||
// Deduct tokens
|
||||
err = p.customerTokensRepo.DeductTokens(ctx, customerID, entities.TokenType(tokenType), tokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get updated customer tokens
|
||||
updatedCustomerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(updatedCustomerTokens), nil
|
||||
}
|
||||
@@ -36,17 +36,25 @@ type outletSettingsReader interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
}
|
||||
|
||||
// earningSettingsReader gives the outlet's earning settings and the organization's
|
||||
// expiry. LoyaltySettingsProcessor is one.
|
||||
type earningSettingsReader interface {
|
||||
outletSettingsReader
|
||||
organizationSettingsReader
|
||||
}
|
||||
|
||||
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
|
||||
// (docs/prd-point-coin.md F3).
|
||||
type EarningProcessor struct {
|
||||
orders repository.EarningRepository
|
||||
settings outletSettingsReader
|
||||
settings earningSettingsReader
|
||||
now func() time.Time
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewEarningProcessor(orders repository.EarningRepository, settings outletSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx}
|
||||
func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// OnOrderPaid is called once an order has become fully paid and the payment has
|
||||
@@ -87,6 +95,12 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
|
||||
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
|
||||
}
|
||||
|
||||
orgSettings, err := p.settings.Organization(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receivedAt := p.now()
|
||||
|
||||
outcome := &EarningOutcome{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, c := range []struct {
|
||||
@@ -101,19 +115,24 @@ func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID)
|
||||
continue
|
||||
}
|
||||
outletID := order.OutletID
|
||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: *order.CustomerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: c.line.Amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
OutletID: &outletID,
|
||||
Description: earningDescription(order),
|
||||
Metadata: result.Metadata(c.line),
|
||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
||||
// Lots never expire until the expiry model is decided (F12, note N4).
|
||||
}})
|
||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: *order.CustomerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: c.line.Amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
OutletID: &outletID,
|
||||
Description: earningDescription(order),
|
||||
Metadata: result.Metadata(c.line),
|
||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
||||
},
|
||||
Lots: []WalletLotInput{{
|
||||
Amount: c.line.Amount,
|
||||
ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("crediting %s: %w", c.currency, err)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// failingSettings fails for the outlet settings until healed, to stand in for the
|
||||
// database being unreachable right after a payment.
|
||||
type failingSettings struct {
|
||||
real outletSettingsReader
|
||||
real earningSettingsReader
|
||||
mu sync.Mutex
|
||||
fail bool
|
||||
}
|
||||
@@ -227,3 +227,7 @@ func TestEarningProcessor_AgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
||||
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
||||
}
|
||||
|
||||
func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
return f.real.Organization(ctx, organizationID)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
@@ -8,33 +9,55 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game,
|
||||
// its prizes or their EnakCoin. The message says which.
|
||||
var ErrGamePlayRefused = errors.New("game cannot be played")
|
||||
|
||||
const (
|
||||
gameCoinCostKey = "coin_cost"
|
||||
defaultGameCoinCost = 1
|
||||
)
|
||||
|
||||
type gameReader interface {
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error)
|
||||
}
|
||||
|
||||
type gamePrizeStock interface {
|
||||
GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error)
|
||||
DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error
|
||||
}
|
||||
|
||||
type GamePlayProcessor struct {
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
gameRepo gameReader
|
||||
gamePrizeRepo gamePrizeStock
|
||||
wallet *WalletProcessor
|
||||
spendable spendableReader
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewGamePlayProcessor(
|
||||
gamePlayRepo repository.GamePlayRepository,
|
||||
gameRepo *repository.GameRepository,
|
||||
gamePrizeRepo *repository.GamePrizeRepository,
|
||||
customerTokensRepo *repository.CustomerTokensRepository,
|
||||
customerPointsRepo repository.CustomerPointsRepository,
|
||||
gameRepo gameReader,
|
||||
gamePrizeRepo gamePrizeStock,
|
||||
wallet *WalletProcessor,
|
||||
spendable spendableReader,
|
||||
tx TxRunner,
|
||||
) *GamePlayProcessor {
|
||||
return &GamePlayProcessor{
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
customerTokensRepo: customerTokensRepo,
|
||||
customerPointsRepo: customerPointsRepo,
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
wallet: wallet,
|
||||
spendable: spendable,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +99,10 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
// The column was renamed; the old name still sorts the same way.
|
||||
if query.SortBy == "token_used" {
|
||||
query.SortBy = "coins_used"
|
||||
}
|
||||
|
||||
// Get game plays from repository
|
||||
gamePlays, total, err := p.gamePlayRepo.List(
|
||||
@@ -110,101 +137,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PlayGame handles the game playing logic
|
||||
// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md
|
||||
// F8, K1). Every kind of game spends the same EnakCoin, at the game's
|
||||
// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking
|
||||
// the prize from stock happen in one transaction, so a play that cannot be completed,
|
||||
// for instance because the prize just ran out, takes nothing and records nothing.
|
||||
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
|
||||
// Verify game exists and is active
|
||||
refuse := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
|
||||
}
|
||||
game, err := p.gameRepo.GetByID(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
|
||||
if !game.IsActive {
|
||||
return nil, errors.New("game is not active")
|
||||
return nil, refuse("game is not active")
|
||||
}
|
||||
|
||||
// Convert GameType to TokenType
|
||||
tokenType := entities.TokenType(game.Type)
|
||||
|
||||
// Check if customer has enough tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
||||
cost, err := gameCoinCost(game.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
return nil, refuse("%s", err.Error())
|
||||
}
|
||||
|
||||
if customerTokens.Balance < int64(req.TokenUsed) {
|
||||
return nil, errors.New("insufficient tokens")
|
||||
}
|
||||
|
||||
// Deduct tokens
|
||||
err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get available prizes
|
||||
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get available prizes: %w", err)
|
||||
}
|
||||
|
||||
if len(availablePrizes) == 0 {
|
||||
return nil, errors.New("no prizes available")
|
||||
}
|
||||
|
||||
// Convert entities to models for prize selection
|
||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||
for i, prize := range availablePrizes {
|
||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||
}
|
||||
|
||||
// Select prize based on weight
|
||||
selectedPrize := p.selectPrizeByWeight(prizeResponses)
|
||||
|
||||
// Generate random seed for audit
|
||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
// Create game play record
|
||||
gamePlay := &models.CreateGamePlayRequest{
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
TokenUsed: req.TokenUsed,
|
||||
RandomSeed: &randomSeed,
|
||||
}
|
||||
|
||||
gamePlayEntity := mappers.ToGamePlayEntity(gamePlay)
|
||||
if selectedPrize != nil {
|
||||
gamePlayEntity.PrizeID = &selectedPrize.ID
|
||||
}
|
||||
|
||||
err = p.gamePlayRepo.Create(ctx, gamePlayEntity)
|
||||
if err != nil {
|
||||
// Rollback token deduction
|
||||
p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
|
||||
return nil, fmt.Errorf("failed to create game play: %w", err)
|
||||
}
|
||||
|
||||
// Decrease prize stock if prize was won
|
||||
if selectedPrize != nil {
|
||||
err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||
var gamePlay *entities.GamePlay
|
||||
var selectedPrize *models.GamePrizeResponse
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
|
||||
if err != nil {
|
||||
// Log error but don't fail the transaction
|
||||
fmt.Printf("Warning: failed to decrease prize stock: %v\n", err)
|
||||
return fmt.Errorf("failed to get available prizes: %w", err)
|
||||
}
|
||||
}
|
||||
if len(availablePrizes) == 0 {
|
||||
return refuse("no prizes available")
|
||||
}
|
||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||
for i, prize := range availablePrizes {
|
||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||
}
|
||||
selectedPrize = p.selectPrizeByWeight(prizeResponses)
|
||||
|
||||
// Get updated token balance
|
||||
updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
||||
// Random seed kept for audit.
|
||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
gamePlay = &entities.GamePlay{
|
||||
ID: uuid.New(),
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
CoinsUsed: int(cost),
|
||||
RandomSeed: &randomSeed,
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
gamePlay.PrizeID = &selectedPrize.ID
|
||||
}
|
||||
|
||||
// The EnakCoin go first: the debit locks the wallet, so plays by the same
|
||||
// customer at the same time queue up instead of spending the same balance.
|
||||
_, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: req.CustomerID,
|
||||
Currency: constants.WalletCurrencyCoin,
|
||||
Type: constants.WalletTxTypeGameSpend,
|
||||
Amount: cost,
|
||||
ReferenceType: constants.WalletRefTypeGamePlay,
|
||||
ReferenceID: gamePlay.ID,
|
||||
Description: gameSpendDescription(game.Name, selectedPrize),
|
||||
Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost},
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return refuse("not enough EnakCoin: this game costs %d", cost)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
|
||||
return err
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
|
||||
return refuse("the prize just ran out, please play again")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated token balance: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PlayGameResponse{
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity),
|
||||
PrizeWon: selectedPrize,
|
||||
TokensRemaining: updatedTokens.Balance,
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
|
||||
PrizeWon: selectedPrize,
|
||||
CoinsRemaining: balances[constants.WalletCurrencyCoin],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a
|
||||
// whole number of EnakCoin, 1 when it is not set (F8).
|
||||
func gameCoinCost(metadata entities.Metadata) (int64, error) {
|
||||
raw, ok := metadata[gameCoinCostKey]
|
||||
if !ok || raw == nil {
|
||||
return defaultGameCoinCost, nil
|
||||
}
|
||||
var cost int64
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v)
|
||||
}
|
||||
cost = int64(v)
|
||||
case int:
|
||||
cost = int64(v)
|
||||
case int64:
|
||||
cost = v
|
||||
default:
|
||||
return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw)
|
||||
}
|
||||
if cost < 1 {
|
||||
return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost)
|
||||
}
|
||||
return cost, nil
|
||||
}
|
||||
|
||||
// gameSpendDescription is what the customer's history shows for a play, frozen when it
|
||||
// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb".
|
||||
func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string {
|
||||
description := "Main " + gameName
|
||||
if prize != nil {
|
||||
description += ": dapat " + prize.Name
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// selectPrizeByWeight selects a prize based on weight distribution
|
||||
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
|
||||
if len(prizes) == 0 {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Plays against Postgres, so the game repositories really join the transaction: a
|
||||
// refused play leaves no game_plays row and no GAME_SPEND.
|
||||
func TestPlayGame_AgainstPostgres(t *testing.T) {
|
||||
db, _, player, _ := walletMoveDB(t)
|
||||
gameID, prizeID := uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player)
|
||||
db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID)
|
||||
db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID)
|
||||
db.Exec(`DELETE FROM games WHERE id = ?`, gameID)
|
||||
})
|
||||
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
in := earn(player, 3, nil)
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
_, err := wallet.Credit(ctx, in)
|
||||
return err
|
||||
}))
|
||||
p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db),
|
||||
wallet, repository.NewWalletQueryRepository(db), txm)
|
||||
play := func() (*models.PlayGameResponse, error) {
|
||||
return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player})
|
||||
}
|
||||
|
||||
res, err := play()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), res.CoinsRemaining)
|
||||
assert.Equal(t, 2, res.GamePlay.CoinsUsed)
|
||||
|
||||
var spend struct {
|
||||
ReferenceID uuid.UUID
|
||||
Amount int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error)
|
||||
assert.Equal(t, res.GamePlay.ID, spend.ReferenceID)
|
||||
assert.Equal(t, int64(-2), spend.Amount)
|
||||
|
||||
// The only prize is gone, then the EnakCoin are too short: neither play leaves a row.
|
||||
_, err = play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error)
|
||||
_, err = play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
|
||||
var plays, spends int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error)
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error)
|
||||
assert.Equal(t, int64(1), plays)
|
||||
assert.Equal(t, int64(1), spends)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// snapshot saves everything the fake wallet holds and returns what puts it back, so
|
||||
// a test transaction can roll back like a real one.
|
||||
func (f *walletRepoFake) snapshot() func() {
|
||||
wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets))
|
||||
for id, w := range f.wallets {
|
||||
c := *w
|
||||
wallets[id] = &c
|
||||
}
|
||||
transactions := make([]*entities.WalletTransaction, len(f.transactions))
|
||||
for i, t := range f.transactions {
|
||||
c := *t
|
||||
transactions[i] = &c
|
||||
}
|
||||
lots := make([]*entities.WalletLot, len(f.lots))
|
||||
for i, l := range f.lots {
|
||||
c := *l
|
||||
lots[i] = &c
|
||||
}
|
||||
allocations := append([]entities.WalletLotAllocation(nil), f.allocations...)
|
||||
return func() {
|
||||
f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackTx runs fn in place and, when it fails, puts back what each save saved.
|
||||
type rollbackTx struct{ saves []func() func() }
|
||||
|
||||
func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
restores := make([]func(), 0, len(r.saves))
|
||||
for _, save := range r.saves {
|
||||
restores = append(restores, save())
|
||||
}
|
||||
err := fn(ctx)
|
||||
if err != nil {
|
||||
for _, restore := range restores {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type gameFake struct{ games map[uuid.UUID]*entities.Game }
|
||||
|
||||
func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) {
|
||||
g, ok := f.games[id]
|
||||
if !ok {
|
||||
return nil, errors.New("record not found")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
type prizeStockFake struct {
|
||||
prizes []entities.GamePrize
|
||||
stockErr error
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var out []entities.GamePrize
|
||||
for _, p := range f.prizes {
|
||||
if p.GameID == gameID && p.Stock > 0 {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error {
|
||||
if f.stockErr != nil {
|
||||
return f.stockErr
|
||||
}
|
||||
for i := range f.prizes {
|
||||
if f.prizes[i].ID == id {
|
||||
if f.prizes[i].Stock < amount {
|
||||
return repository.ErrGamePrizeOutOfStock
|
||||
}
|
||||
f.prizes[i].Stock -= amount
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return repository.ErrGamePrizeOutOfStock
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) snapshot() func() {
|
||||
prizes := append([]entities.GamePrize(nil), f.prizes...)
|
||||
return func() { f.prizes = prizes }
|
||||
}
|
||||
|
||||
// gamePlayRepoFake records plays; the other methods of the interface are not used.
|
||||
type gamePlayRepoFake struct {
|
||||
repository.GamePlayRepository
|
||||
plays []entities.GamePlay
|
||||
}
|
||||
|
||||
func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error {
|
||||
f.plays = append(f.plays, *gp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *gamePlayRepoFake) snapshot() func() {
|
||||
plays := append([]entities.GamePlay(nil), f.plays...)
|
||||
return func() { f.plays = plays }
|
||||
}
|
||||
|
||||
type gameTestEnv struct {
|
||||
*walletMoveEnv
|
||||
game *entities.Game
|
||||
prize entities.GamePrize
|
||||
stock *prizeStockFake
|
||||
plays *gamePlayRepoFake
|
||||
player uuid.UUID
|
||||
p *GamePlayProcessor
|
||||
}
|
||||
|
||||
func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv {
|
||||
e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)}
|
||||
e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata}
|
||||
e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5}
|
||||
e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}}
|
||||
e.plays = &gamePlayRepoFake{}
|
||||
e.player = e.member("Budi", "081234561234")
|
||||
tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}}
|
||||
e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *gameTestEnv) play() (*models.PlayGameResponse, error) {
|
||||
return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player})
|
||||
}
|
||||
|
||||
func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) {
|
||||
// coin_cost comes back from JSONB as a float64.
|
||||
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
|
||||
e.earnCoins(t, e.player, 10, nil)
|
||||
|
||||
res, err := e.play()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(7), res.CoinsRemaining)
|
||||
require.NotNil(t, res.PrizeWon)
|
||||
assert.Equal(t, e.prize.ID, res.PrizeWon.ID)
|
||||
require.Len(t, e.plays.plays, 1)
|
||||
play := e.plays.plays[0]
|
||||
assert.Equal(t, 3, play.CoinsUsed)
|
||||
assert.Equal(t, e.prize.ID, *play.PrizeID)
|
||||
assert.Equal(t, 4, e.stock.prizes[0].Stock)
|
||||
|
||||
spend := e.repo.transactions[len(e.repo.transactions)-1]
|
||||
assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type)
|
||||
assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency)
|
||||
assert.Equal(t, int64(-3), spend.Amount)
|
||||
assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType)
|
||||
assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play")
|
||||
assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description)
|
||||
}
|
||||
|
||||
func TestPlayGame_CostsOneCoinByDefault(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 2, nil)
|
||||
|
||||
res, err := e.play()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), res.CoinsRemaining)
|
||||
assert.Equal(t, 1, e.plays.plays[0].CoinsUsed)
|
||||
}
|
||||
|
||||
func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
|
||||
e.earnCoins(t, e.player, 2, nil)
|
||||
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Empty(t, e.plays.plays)
|
||||
assert.Equal(t, 5, e.stock.prizes[0].Stock)
|
||||
assert.Equal(t, int64(2), e.coinBalance(t, e.player))
|
||||
}
|
||||
|
||||
func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
rows := len(e.repo.transactions)
|
||||
|
||||
// Another play took the last one between reading the prizes and taking it.
|
||||
e.stock.stockErr = repository.ErrGamePrizeOutOfStock
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
|
||||
// Anything else going wrong with the stock cancels it too.
|
||||
e.stock.stockErr = errors.New("connection reset")
|
||||
_, err = e.play()
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.Empty(t, e.plays.plays)
|
||||
assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind")
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
}
|
||||
|
||||
func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) {
|
||||
for name, metadata := range map[string]entities.Metadata{
|
||||
"zero": {"coin_cost": float64(0)},
|
||||
"fraction": {"coin_cost": 1.5},
|
||||
"not number": {"coin_cost": "2"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newGameTestEnv(t, metadata)
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("inactive", func(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
e.game.IsActive = false
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
})
|
||||
|
||||
t.Run("no prizes left", func(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
e.stock.prizes[0].Stock = 0
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// Expiry rules of docs/prd-point-coin.md F12, with the model of note N4 decided: both
|
||||
// FIXED_DATE and ROLLING are supported and the owner picks one per currency.
|
||||
//
|
||||
// A lot expires at the last second of its expiry day in the customer's time zone, so
|
||||
// "expires on 31 Dec" means it can still be spent all of 31 Dec, and the day the apps
|
||||
// group it under is that day.
|
||||
|
||||
const (
|
||||
// A longer grace would push a balance past a whole cycle of fixed dates.
|
||||
loyaltyExpiryMaxGraceMonths = 24
|
||||
// A refund gives the EnakPoint back at least this long, even when the lot they
|
||||
// came from has expired or is about to (note N4, decided).
|
||||
refundMinimumValidity = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// ExpirySettings picks the settings of one currency.
|
||||
func ExpirySettings(s *models.OrganizationLoyaltySettings, currency string) models.LoyaltyExpirySettings {
|
||||
if currency == constants.WalletCurrencyCoin {
|
||||
return s.CoinExpiry
|
||||
}
|
||||
return s.PointExpiry
|
||||
}
|
||||
|
||||
// ComputeExpiry is when a balance received at receivedAt expires, or nil when it never
|
||||
// does because expiry is off. It is the expiry of every lot that is not carried over
|
||||
// from another one: EARN, ADJUSTMENT and, capped by its origin, EXCHANGE_IN.
|
||||
func ComputeExpiry(s models.LoyaltyExpirySettings, receivedAt time.Time) *time.Time {
|
||||
if !s.Enabled {
|
||||
return nil
|
||||
}
|
||||
day := walletDay(receivedAt)
|
||||
if s.Mode == constants.LoyaltyExpiryModeRolling {
|
||||
if s.Unit == constants.LoyaltyExpiryUnitDay {
|
||||
day = day.AddDate(0, 0, int(s.Period))
|
||||
} else {
|
||||
day = addMonthsClamped(day, int(s.Period))
|
||||
}
|
||||
if s.EndOfMonth {
|
||||
day = lastDayOfMonth(day)
|
||||
}
|
||||
return endOfWalletDay(day)
|
||||
}
|
||||
return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, addMonthsClamped(day, int(s.GraceMonths))))
|
||||
}
|
||||
|
||||
// ActivationExpiry is the expiry given to the balances that already exist, without an
|
||||
// expiry, when expiry is turned on (note N4, decided): a full period from activation
|
||||
// when ROLLING, the second fixed date after activation when FIXED_DATE. Either way no
|
||||
// customer loses a balance soon after the rule is announced.
|
||||
func ActivationExpiry(s models.LoyaltyExpirySettings, at time.Time) *time.Time {
|
||||
if s.Mode == constants.LoyaltyExpiryModeRolling {
|
||||
s.Enabled = true
|
||||
return ComputeExpiry(s, at)
|
||||
}
|
||||
first := fixedDateOnOrAfter(s.FixedDates, walletDay(at))
|
||||
return endOfWalletDay(fixedDateOnOrAfter(s.FixedDates, first.AddDate(0, 0, 1)))
|
||||
}
|
||||
|
||||
// RefundExpiry is the expiry of EnakPoint given back by a refund: that of the lot they
|
||||
// came from, but at least seven days from now (note N4, decided). A lot that never
|
||||
// expires stays so.
|
||||
func RefundExpiry(origin *time.Time, now time.Time) *time.Time {
|
||||
if origin == nil {
|
||||
return nil
|
||||
}
|
||||
minimum := endOfWalletDay(walletDay(now.Add(refundMinimumValidity)))
|
||||
if origin.Before(*minimum) {
|
||||
return minimum
|
||||
}
|
||||
return origin
|
||||
}
|
||||
|
||||
// EarlierExpiry is the sooner of two expiries, where nil means never.
|
||||
func EarlierExpiry(a, b *time.Time) *time.Time {
|
||||
switch {
|
||||
case a == nil:
|
||||
return b
|
||||
case b == nil:
|
||||
return a
|
||||
case b.Before(*a):
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// normalizeDaysOfYear checks days written as MM-DD and returns them sorted without
|
||||
// duplicates. 29 February is refused, as it does not come every year.
|
||||
func normalizeDaysOfYear(days []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, d := range days {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse("01-02", d)
|
||||
if err != nil || parsed.Format("01-02") != d {
|
||||
return nil, fmt.Errorf("has %q, which is not a day of the year written as MM-DD", d)
|
||||
}
|
||||
if d == "02-29" {
|
||||
return nil, errors.New("cannot use 02-29, which is not in every year")
|
||||
}
|
||||
if !seen[d] {
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, errors.New("must list at least one day of the year, as MM-DD")
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fixedDateOnOrAfter is the first of the days of the year that falls on or after day.
|
||||
func fixedDateOnOrAfter(days []string, day time.Time) time.Time {
|
||||
days, err := normalizeDaysOfYear(days)
|
||||
if err != nil {
|
||||
days = []string{constants.LoyaltyExpiryFixedDatesDefault}
|
||||
}
|
||||
for year := day.Year(); ; year++ {
|
||||
for _, d := range days {
|
||||
parsed, _ := time.Parse("01-02", d)
|
||||
candidate := time.Date(year, parsed.Month(), parsed.Day(), 0, 0, 0, 0, walletDisplayLocation)
|
||||
if !candidate.Before(day) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// walletDay is the calendar day of t in the customer's time zone, at midnight.
|
||||
func walletDay(t time.Time) time.Time {
|
||||
local := t.In(walletDisplayLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation)
|
||||
}
|
||||
|
||||
func endOfWalletDay(day time.Time) *time.Time {
|
||||
end := time.Date(day.Year(), day.Month(), day.Day(), 23, 59, 59, 0, walletDisplayLocation)
|
||||
return &end
|
||||
}
|
||||
|
||||
// addMonthsClamped adds months, keeping to the last day of a shorter month: 31 Jan
|
||||
// plus one month is 28 or 29 Feb, not 3 Mar.
|
||||
func addMonthsClamped(day time.Time, months int) time.Time {
|
||||
first := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, day.Location()).AddDate(0, months, 0)
|
||||
last := lastDayOfMonth(first).Day()
|
||||
d := day.Day()
|
||||
if d > last {
|
||||
d = last
|
||||
}
|
||||
return time.Date(first.Year(), first.Month(), d, 0, 0, 0, 0, day.Location())
|
||||
}
|
||||
|
||||
func lastDayOfMonth(day time.Time) time.Time {
|
||||
return time.Date(day.Year(), day.Month()+1, 0, 0, 0, 0, 0, day.Location())
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// wib is a moment in the customer's time zone.
|
||||
func wib(year int, month time.Month, day, hour, minute int) time.Time {
|
||||
return time.Date(year, month, day, hour, minute, 0, 0, walletDisplayLocation)
|
||||
}
|
||||
|
||||
// expiryDay is the day a lot expires on, as the apps show it.
|
||||
func expiryDay(t *testing.T, at *time.Time) string {
|
||||
t.Helper()
|
||||
require.NotNil(t, at)
|
||||
local := at.In(walletDisplayLocation)
|
||||
assert.Equal(t, "23:59:59", local.Format("15:04:05"), "a lot lasts to the end of its day")
|
||||
return local.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func fixedDate(dates ...string) models.LoyaltyExpirySettings {
|
||||
return models.LoyaltyExpirySettings{Enabled: true, Mode: "FIXED_DATE", FixedDates: dates, GraceMonths: 3}
|
||||
}
|
||||
|
||||
func rolling(period int64, unit string, endOfMonth bool) models.LoyaltyExpirySettings {
|
||||
return models.LoyaltyExpirySettings{Enabled: true, Mode: "ROLLING", Period: period, Unit: unit, EndOfMonth: endOfMonth}
|
||||
}
|
||||
|
||||
func TestComputeExpiry_OffNeverExpires(t *testing.T) {
|
||||
s := fixedDate("12-31")
|
||||
s.Enabled = false
|
||||
assert.Nil(t, ComputeExpiry(s, wib(2026, 5, 1, 10, 0)))
|
||||
}
|
||||
|
||||
func TestComputeExpiry_FixedDateWithGrace(t *testing.T) {
|
||||
yearly := fixedDate("12-31")
|
||||
for received, want := range map[time.Time]string{
|
||||
wib(2026, 1, 15, 9, 0): "2026-12-31",
|
||||
wib(2026, 9, 30, 23, 0): "2026-12-31", // 30 Dec + nothing: still this year
|
||||
wib(2026, 10, 1, 0, 30): "2027-12-31", // within 3 months of 31 Dec: next year
|
||||
wib(2026, 12, 31, 8, 0): "2027-12-31",
|
||||
} {
|
||||
assert.Equal(t, want, expiryDay(t, ComputeExpiry(yearly, received)), received.String())
|
||||
}
|
||||
|
||||
twice := fixedDate("12-31", "06-30")
|
||||
assert.Equal(t, "2026-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 3, 1, 12, 0))))
|
||||
assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(twice, wib(2026, 4, 1, 12, 0))))
|
||||
assert.Equal(t, "2027-06-30", expiryDay(t, ComputeExpiry(twice, wib(2026, 10, 1, 12, 0))))
|
||||
|
||||
noGrace := fixedDate("12-31")
|
||||
noGrace.GraceMonths = 0
|
||||
assert.Equal(t, "2026-12-31", expiryDay(t, ComputeExpiry(noGrace, wib(2026, 12, 31, 22, 0))))
|
||||
}
|
||||
|
||||
// The day received is the customer's day: 20:00 UTC on 31 Mar is already 1 Apr in WIB.
|
||||
func TestComputeExpiry_UsesTheCustomersDay(t *testing.T) {
|
||||
received := time.Date(2026, 3, 31, 20, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, "2026-04-11", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), received)))
|
||||
}
|
||||
|
||||
func TestComputeExpiry_Rolling(t *testing.T) {
|
||||
assert.Equal(t, "2027-03-14", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", false), wib(2026, 3, 14, 10, 0))))
|
||||
assert.Equal(t, "2027-03-31", expiryDay(t, ComputeExpiry(rolling(12, "MONTH", true), wib(2026, 3, 14, 10, 0))))
|
||||
assert.Equal(t, "2026-03-24", expiryDay(t, ComputeExpiry(rolling(10, "DAY", false), wib(2026, 3, 14, 10, 0))))
|
||||
// A month that is shorter keeps to its last day.
|
||||
assert.Equal(t, "2026-02-28", expiryDay(t, ComputeExpiry(rolling(1, "MONTH", false), wib(2026, 1, 31, 10, 0))))
|
||||
}
|
||||
|
||||
func TestActivationExpiry(t *testing.T) {
|
||||
// FIXED_DATE: the second fixed date after activation, so nobody loses a balance
|
||||
// weeks after the rule is announced.
|
||||
assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 11, 20, 10, 0))))
|
||||
assert.Equal(t, "2027-12-31", expiryDay(t, ActivationExpiry(fixedDate("12-31"), wib(2026, 12, 31, 10, 0))))
|
||||
assert.Equal(t, "2027-06-30", expiryDay(t, ActivationExpiry(fixedDate("06-30", "12-31"), wib(2026, 11, 20, 10, 0))))
|
||||
// ROLLING: a full period from activation.
|
||||
assert.Equal(t, "2027-11-20", expiryDay(t, ActivationExpiry(rolling(12, "MONTH", false), wib(2026, 11, 20, 10, 0))))
|
||||
}
|
||||
|
||||
func TestRefundExpiry(t *testing.T) {
|
||||
now := wib(2026, 6, 1, 10, 0)
|
||||
later := wib(2026, 12, 31, 23, 59)
|
||||
soon := wib(2026, 6, 3, 23, 59)
|
||||
past := wib(2026, 5, 1, 23, 59)
|
||||
|
||||
assert.Nil(t, RefundExpiry(nil, now))
|
||||
assert.Equal(t, &later, RefundExpiry(&later, now))
|
||||
assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&soon, now)))
|
||||
assert.Equal(t, "2026-06-08", expiryDay(t, RefundExpiry(&past, now)))
|
||||
}
|
||||
|
||||
func TestEarlierExpiry(t *testing.T) {
|
||||
a, b := wib(2026, 1, 1, 0, 0), wib(2026, 2, 1, 0, 0)
|
||||
assert.Equal(t, &a, EarlierExpiry(&a, &b))
|
||||
assert.Equal(t, &a, EarlierExpiry(&b, &a))
|
||||
assert.Equal(t, &a, EarlierExpiry(nil, &a))
|
||||
assert.Equal(t, &a, EarlierExpiry(&a, nil))
|
||||
assert.Nil(t, EarlierExpiry(nil, nil))
|
||||
}
|
||||
|
||||
func TestNormalizeDaysOfYear(t *testing.T) {
|
||||
days, err := normalizeDaysOfYear([]string{" 12-31", "06-30", "12-31", ""})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{"06-30", "12-31"}, days)
|
||||
for _, bad := range [][]string{{}, {"13-01"}, {"12-32"}, {"1-31"}, {"02-29"}, {"31-12"}} {
|
||||
_, err := normalizeDaysOfYear(bad)
|
||||
assert.Error(t, err, bad)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -27,10 +28,11 @@ var ErrInvalidLoyaltySettings = errors.New("invalid loyalty settings")
|
||||
type LoyaltySettingsProcessor struct {
|
||||
repo repository.LoyaltySettingsRepository
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsProcessor(repo repository.LoyaltySettingsRepository, tx TxRunner) *LoyaltySettingsProcessor {
|
||||
return &LoyaltySettingsProcessor{repo: repo, tx: tx}
|
||||
return &LoyaltySettingsProcessor{repo: repo, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Outlet returns an outlet's settings.
|
||||
@@ -111,11 +113,15 @@ func (p *LoyaltySettingsProcessor) UpdateOutlet(ctx context.Context, organizatio
|
||||
}
|
||||
|
||||
// UpdateOrganization replaces an organization's settings and returns what changed.
|
||||
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
//
|
||||
// Turning expiry on for a currency for the first time also gives every existing lot
|
||||
// without an expiry the activation expiry (F12, note N4), and the result lists them.
|
||||
func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organizationID, changedBy uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) {
|
||||
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
var changes []models.LoyaltySettingChange
|
||||
var activations []models.LoyaltyExpiryActivation
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.repo.LockScope(ctx, organizationID); err != nil {
|
||||
return err
|
||||
@@ -132,9 +138,42 @@ func (p *LoyaltySettingsProcessor) UpdateOrganization(ctx context.Context, organ
|
||||
func(key string, value *string) error {
|
||||
return p.repo.SetOrganizationValue(ctx, organizationID, key, value)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activations, err = p.expiryActivations(ctx, organizationID, current, settings, true)
|
||||
return err
|
||||
})
|
||||
return changes, err
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return changes, activations, nil
|
||||
}
|
||||
|
||||
// expiryActivations lists the currencies whose expiry these settings turn on, with the
|
||||
// balances that have no expiry yet and the expiry they get: a full period from now
|
||||
// (note N4, decided). With apply, it dates those lots.
|
||||
func (p *LoyaltySettingsProcessor) expiryActivations(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, apply bool) ([]models.LoyaltyExpiryActivation, error) {
|
||||
activations := []models.LoyaltyExpiryActivation{}
|
||||
for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} {
|
||||
was, will := ExpirySettings(&before, currency), ExpirySettings(&after, currency)
|
||||
if was.Enabled || !will.Enabled {
|
||||
continue
|
||||
}
|
||||
expiresAt := *ActivationExpiry(will, p.now())
|
||||
var lots, amount int64
|
||||
var err error
|
||||
if apply {
|
||||
lots, amount, err = p.repo.DateUndatedLots(ctx, organizationID, currency, expiresAt)
|
||||
} else {
|
||||
lots, amount, err = p.repo.UndatedBalance(ctx, organizationID, currency)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activations = append(activations, models.LoyaltyExpiryActivation{Currency: currency, Lots: lots, Amount: amount, ExpiresAt: expiresAt})
|
||||
}
|
||||
return activations, nil
|
||||
}
|
||||
|
||||
// apply stores every field whose effective value changes and records it. A key left
|
||||
@@ -236,6 +275,10 @@ func organizationLoyaltyFields(s *models.OrganizationLoyaltySettings) []loyaltyF
|
||||
func expiryLoyaltyFields(prefix string, s *models.LoyaltyExpirySettings) []loyaltyField {
|
||||
return []loyaltyField{
|
||||
boolLoyaltyField(prefix+constants.LoyaltyExpiryEnabledSuffix, &s.Enabled, false),
|
||||
enumLoyaltyField(prefix+constants.LoyaltyExpiryModeSuffix, &s.Mode, constants.LoyaltyExpiryModeDefault,
|
||||
constants.LoyaltyExpiryModeFixedDate, constants.LoyaltyExpiryModeRolling),
|
||||
dayOfYearListLoyaltyField(prefix+constants.LoyaltyExpiryFixedDatesSuffix, &s.FixedDates, constants.LoyaltyExpiryFixedDatesDefault),
|
||||
intLoyaltyField(prefix+constants.LoyaltyExpiryGraceMonthsSuffix, &s.GraceMonths, constants.LoyaltyExpiryGraceMonthsDefault, 0, loyaltyExpiryMaxGraceMonths),
|
||||
intLoyaltyField(prefix+constants.LoyaltyExpiryPeriodSuffix, &s.Period, constants.LoyaltyExpiryPeriodDefault, 1, noLoyaltyMax),
|
||||
enumLoyaltyField(prefix+constants.LoyaltyExpiryUnitSuffix, &s.Unit, constants.LoyaltyExpiryUnitDefault,
|
||||
constants.LoyaltyExpiryUnitDay, constants.LoyaltyExpiryUnitMonth),
|
||||
@@ -362,6 +405,46 @@ func enumLoyaltyField(key string, v *string, def string, allowed ...string) loya
|
||||
}
|
||||
}
|
||||
|
||||
// dayOfYearListLoyaltyField is a non-empty set of days of the year, stored as sorted
|
||||
// MM-DD values joined by commas, such as "06-30,12-31".
|
||||
func dayOfYearListLoyaltyField(key string, v *[]string, def string) loyaltyField {
|
||||
normalize := func(days []string) ([]string, error) {
|
||||
out, err := normalizeDaysOfYear(days)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s %v", ErrInvalidLoyaltySettings, key, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return loyaltyField{
|
||||
key: key,
|
||||
parse: func(raw string) bool {
|
||||
days, err := normalize(strings.Split(raw, ","))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
*v = days
|
||||
return true
|
||||
},
|
||||
reset: func() { *v, _ = normalizeDaysOfYear(strings.Split(def, ",")) },
|
||||
validate: func() error {
|
||||
days, err := normalize(*v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*v = days
|
||||
return nil
|
||||
},
|
||||
format: func() *string {
|
||||
days, err := normalizeDaysOfYear(*v)
|
||||
if err != nil {
|
||||
days = *v
|
||||
}
|
||||
s := strings.Join(days, ",")
|
||||
return &s
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// loadLoyaltyFields fills each field from its stored value, or its default when there
|
||||
// is none. A stored value that cannot be used also falls back to the default, so a bad
|
||||
// row can never reach a calculation, and it is logged so it gets fixed.
|
||||
@@ -422,14 +505,15 @@ func diffLoyaltyFields(organizationID uuid.UUID, outletID *uuid.UUID, changedBy
|
||||
}
|
||||
|
||||
// PreviewOrganization validates new organization settings and lists what saving them
|
||||
// would change, without writing anything.
|
||||
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, error) {
|
||||
// would change, and which balances turning expiry on would date, without writing
|
||||
// anything.
|
||||
func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, organizationID uuid.UUID, settings models.OrganizationLoyaltySettings) ([]models.LoyaltySettingChange, []models.LoyaltyExpiryActivation, error) {
|
||||
if err := validateLoyaltyFields(organizationLoyaltyFields(&settings)); err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
raw, err := p.repo.GetOrganizationValues(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
var current models.OrganizationLoyaltySettings
|
||||
loadLoyaltyFields(organizationLoyaltyFields(¤t), raw, "organization "+organizationID.String())
|
||||
@@ -438,5 +522,9 @@ func (p *LoyaltySettingsProcessor) PreviewOrganization(ctx context.Context, orga
|
||||
for _, row := range rows {
|
||||
changes = append(changes, loyaltySettingChangeModel(row))
|
||||
}
|
||||
return changes, nil
|
||||
activations, err := p.expiryActivations(ctx, organizationID, current, settings, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return changes, activations, nil
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -29,6 +30,9 @@ type loyaltyRepoFake struct {
|
||||
orgValues map[string]string
|
||||
changes []entities.LoyaltySettingChange
|
||||
locks []uuid.UUID
|
||||
// Lots without an expiry per currency: how many and what they hold.
|
||||
undated map[string][2]int64
|
||||
dated map[string]time.Time
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) GetOutletValues(context.Context, uuid.UUID) (map[string]string, error) {
|
||||
@@ -113,7 +117,7 @@ func TestLoyaltySettings_OrganizationWithoutSettingsGetsEveryDefault(t *testing.
|
||||
p := NewLoyaltySettingsProcessor(&loyaltyRepoFake{}, txRunnerFake{})
|
||||
s, err := p.Organization(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
expiry := models.LoyaltyExpirySettings{Enabled: false, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
|
||||
expiry := models.LoyaltyExpirySettings{Enabled: false, Mode: "FIXED_DATE", FixedDates: []string{"12-31"}, GraceMonths: 3, Period: 12, Unit: "MONTH", EndOfMonth: false, ReminderDays: 7}
|
||||
assert.Equal(t, models.OrganizationLoyaltySettings{
|
||||
PointValue: 1,
|
||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
||||
@@ -136,6 +140,8 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
constants.LoyaltyPointValueKey: "100",
|
||||
constants.LoyaltyTransferDailyLimitKey: "10000",
|
||||
"loyalty.coin.expiry_unit": "day",
|
||||
"loyalty.point.expiry_fixed_dates": "12-31, 06-30,12-31",
|
||||
"loyalty.coin.expiry_mode": "rolling",
|
||||
},
|
||||
}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
@@ -153,6 +159,9 @@ func TestLoyaltySettings_StoredValuesAreTyped(t *testing.T) {
|
||||
assert.Equal(t, int64(10000), *org.Transfer.DailyLimit)
|
||||
assert.Equal(t, "DAY", org.CoinExpiry.Unit)
|
||||
assert.Equal(t, "MONTH", org.PointExpiry.Unit)
|
||||
assert.Equal(t, []string{"06-30", "12-31"}, org.PointExpiry.FixedDates)
|
||||
assert.Equal(t, "ROLLING", org.CoinExpiry.Mode)
|
||||
assert.Equal(t, "FIXED_DATE", org.PointExpiry.Mode)
|
||||
}
|
||||
|
||||
// A bad row must never reach a calculation: earn_per_amount 0 would divide by zero.
|
||||
@@ -258,7 +267,7 @@ func TestLoyaltySettings_UpdateOrganization(t *testing.T) {
|
||||
settings.CoinExpiry.Enabled = true
|
||||
settings.CoinExpiry.Unit = constants.LoyaltyExpiryUnitDay
|
||||
|
||||
changes, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||
changes, _, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
keys := []string{}
|
||||
for _, c := range changes {
|
||||
@@ -302,11 +311,16 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
"expiry period 0": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Period = 0 },
|
||||
"expiry unit YEAR": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.Unit = "YEAR" },
|
||||
"negative reminder days": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.ReminderDays = -1 },
|
||||
"expiry mode YEARLY": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.Mode = "YEARLY" },
|
||||
"no fixed dates": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = nil },
|
||||
"fixed date 13-01": func(s *models.OrganizationLoyaltySettings) { s.PointExpiry.FixedDates = []string{"13-01"} },
|
||||
"fixed date 02-29": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.FixedDates = []string{"02-29"} },
|
||||
"negative grace": func(s *models.OrganizationLoyaltySettings) { s.CoinExpiry.GraceMonths = -1 },
|
||||
} {
|
||||
s, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
mutate(s)
|
||||
_, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
|
||||
_, _, err = p.UpdateOrganization(ctx, org, uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, ErrInvalidLoyaltySettings, name)
|
||||
}
|
||||
assert.Empty(t, repo.changes, "nothing is written when a value is invalid")
|
||||
@@ -320,3 +334,52 @@ func TestLoyaltySettings_UpdateRejectsInvalidValues(t *testing.T) {
|
||||
_, err = p.UpdateOutlet(ctx, org, uuid.New(), uuid.New(), *s)
|
||||
assert.ErrorIs(t, err, repository.ErrLoyaltyOutletNotFound)
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) UndatedBalance(_ context.Context, _ uuid.UUID, currency string) (int64, int64, error) {
|
||||
u := f.undated[currency]
|
||||
return u[0], u[1], nil
|
||||
}
|
||||
|
||||
func (f *loyaltyRepoFake) DateUndatedLots(_ context.Context, _ uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
|
||||
u := f.undated[currency]
|
||||
if f.dated == nil {
|
||||
f.dated = map[string]time.Time{}
|
||||
}
|
||||
f.dated[currency] = expiresAt
|
||||
delete(f.undated, currency)
|
||||
return u[0], u[1], nil
|
||||
}
|
||||
|
||||
// Turning expiry on dates the balances that had none, a full cycle away; a dry run
|
||||
// only counts them, and saving again dates nothing more.
|
||||
func TestLoyaltySettings_TurningExpiryOnDatesExistingBalances(t *testing.T) {
|
||||
org, admin := uuid.New(), uuid.New()
|
||||
repo := &loyaltyRepoFake{undated: map[string][2]int64{"POINT": {3, 1500}, "COIN": {2, 40}}}
|
||||
p := NewLoyaltySettingsProcessor(repo, txRunnerFake{})
|
||||
p.now = func() time.Time { return time.Date(2026, 11, 20, 3, 0, 0, 0, time.UTC) }
|
||||
ctx := context.Background()
|
||||
|
||||
settings, err := p.Organization(ctx, org)
|
||||
require.NoError(t, err)
|
||||
settings.PointExpiry.Enabled = true
|
||||
|
||||
_, preview, err := p.PreviewOrganization(ctx, org, *settings)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, preview, 1)
|
||||
assert.Equal(t, "POINT", preview[0].Currency)
|
||||
assert.Equal(t, int64(1500), preview[0].Amount)
|
||||
assert.Empty(t, repo.dated, "a dry run dates nothing")
|
||||
|
||||
_, activations, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, activations, 1)
|
||||
assert.Equal(t, int64(3), activations[0].Lots)
|
||||
// Default FIXED_DATE on 31 Dec: the second one after 20 Nov 2026.
|
||||
assert.Equal(t, "2027-12-31", activations[0].ExpiresAt.In(walletDisplayLocation).Format("2006-01-02"))
|
||||
assert.Equal(t, activations[0].ExpiresAt, repo.dated["POINT"])
|
||||
assert.NotContains(t, repo.dated, "COIN", "EnakCoin expiry stays off")
|
||||
|
||||
_, again, err := p.UpdateOrganization(ctx, org, admin, *settings)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, again, "already on: nothing is activated twice")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
@@ -20,6 +21,9 @@ type OrderProcessor interface {
|
||||
CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error)
|
||||
AddToOrder(ctx context.Context, orderID uuid.UUID, req *models.AddToOrderRequest) (*models.AddToOrderResponse, error)
|
||||
UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error)
|
||||
// PayWithPointsInApp pays the customer's own order with EnakPoint from the app or a
|
||||
// self-order, approved by their PIN (docs/prd-point-coin.md F9).
|
||||
PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error)
|
||||
GetOrderByID(ctx context.Context, id uuid.UUID) (*models.OrderResponse, error)
|
||||
ListOrders(ctx context.Context, req *models.ListOrdersRequest) (*models.ListOrdersResponse, error)
|
||||
VoidOrder(ctx context.Context, req *models.VoidOrderRequest, voidedBy uuid.UUID) error
|
||||
@@ -111,6 +115,9 @@ type OrderProcessorImpl struct {
|
||||
inventoryMovementService InventoryMovementService
|
||||
productOutletPriceRepo repository.ProductOutletPriceRepository
|
||||
loyalty OrderLoyalty
|
||||
pointPayments *PointPaymentProcessor
|
||||
paymentCodes paymentCodeRedeemer
|
||||
pins pinVerifier
|
||||
}
|
||||
|
||||
// OrderLoyalty is what the order flow tells and asks the loyalty program
|
||||
@@ -137,6 +144,58 @@ func (p *OrderProcessorImpl) SetLoyalty(loyalty OrderLoyalty) {
|
||||
p.loyalty = loyalty
|
||||
}
|
||||
|
||||
type paymentCodeRedeemer interface {
|
||||
Redeem(ctx context.Context, code string, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
// SetPointPayments enables paying with the EnakPoint method. CreatePayment hands such
|
||||
// payments to pointPayments approved by the code the customer shows at the cashier,
|
||||
// and PayWithPointsInApp approved by the customer's PIN (F9).
|
||||
func (p *OrderProcessorImpl) SetPointPayments(pointPayments *PointPaymentProcessor, codes paymentCodeRedeemer, pins pinVerifier) {
|
||||
p.pointPayments = pointPayments
|
||||
p.paymentCodes = codes
|
||||
p.pins = pins
|
||||
}
|
||||
|
||||
// createPointPayment is CreatePayment for the EnakPoint method. It never uses the
|
||||
// generic payment path, which would record the payment without taking any balance.
|
||||
func (p *OrderProcessorImpl) createPointPayment(ctx context.Context, req *models.CreatePaymentRequest) (*models.PaymentResponse, error) {
|
||||
if p.pointPayments == nil || p.paymentCodes == nil {
|
||||
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
|
||||
}
|
||||
if req.Points == nil || req.PaymentCode == nil || *req.PaymentCode == "" {
|
||||
return nil, fmt.Errorf("%w: points and the customer's payment code are required", ErrPointPaymentRejected)
|
||||
}
|
||||
var cashier *uuid.UUID
|
||||
if id := appcontext.FromContext(ctx).UserID; id != uuid.Nil {
|
||||
cashier = &id
|
||||
}
|
||||
code := *req.PaymentCode
|
||||
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
|
||||
OrderID: req.OrderID,
|
||||
PaymentMethodID: req.PaymentMethodID,
|
||||
Points: *req.Points,
|
||||
CashierID: cashier,
|
||||
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
|
||||
if err := p.paymentCodes.Redeem(ctx, code, customerID); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrPointPaymentRejected, err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Completed {
|
||||
p.onOrderPaid(ctx, req.OrderID)
|
||||
}
|
||||
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
|
||||
}
|
||||
return mappers.PaymentEntityToResponse(payment), nil
|
||||
}
|
||||
|
||||
// onOrderPaid is the single place every path that completes an order's payment goes
|
||||
// through: UpdateOrder, CreatePayment and both kinds of split bill. It must be called
|
||||
// after the payment has committed. The hook runs detached from the caller's
|
||||
@@ -153,10 +212,16 @@ func (p *OrderProcessorImpl) onOrderPaid(ctx context.Context, orderID uuid.UUID)
|
||||
// after their writes have committed, with the same guarantees as onOrderPaid: it can
|
||||
// never block or fail the void or refund.
|
||||
func (p *OrderProcessorImpl) onOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
if p.loyalty == nil {
|
||||
return
|
||||
ctx = repository.DetachTransaction(context.WithoutCancel(ctx))
|
||||
// EnakPoint paid on the order comes back first: the customer is owed it (F9).
|
||||
if p.pointPayments != nil {
|
||||
if _, err := p.pointPayments.RefundForOrder(ctx, orderID); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("OrderProcessorImpl::onOrderRefunded -> failed to return EnakPoint; calling it again is safe")
|
||||
}
|
||||
}
|
||||
if p.loyalty != nil {
|
||||
p.loyalty.OnOrderRefunded(ctx, orderID)
|
||||
}
|
||||
p.loyalty.OnOrderRefunded(repository.DetachTransaction(context.WithoutCancel(ctx)), orderID)
|
||||
}
|
||||
|
||||
// attachEarnings fills points_earned and coins_earned (F3). A failure is logged and
|
||||
@@ -801,6 +866,18 @@ func (p *OrderProcessorImpl) RefundOrder(ctx context.Context, id uuid.UUID, req
|
||||
reason = *req.Reason
|
||||
}
|
||||
|
||||
// An order refund is handed back in cash or another method, so it cannot cover what
|
||||
// was paid with EnakPoint (K7). Checked before anything is written.
|
||||
if p.pointPayments != nil {
|
||||
planned, err := p.plannedOrderRefund(ctx, id, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.pointPayments.EnsureOrderRefundAllowed(ctx, id, planned); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Process refund based on request type
|
||||
if req.RefundAmount != nil {
|
||||
// Full or partial refund by amount
|
||||
@@ -892,10 +969,13 @@ func (p *OrderProcessorImpl) CreatePayment(ctx context.Context, req *models.Crea
|
||||
return nil, fmt.Errorf("order is already fully paid")
|
||||
}
|
||||
|
||||
_, err = p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
||||
method, err := p.paymentMethodRepo.GetByID(ctx, req.PaymentMethodID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
if method.Type == entities.PaymentMethodTypePoint {
|
||||
return p.createPointPayment(ctx, req)
|
||||
}
|
||||
|
||||
totalPaid, err := p.paymentRepo.GetTotalPaidByOrderID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
@@ -1280,6 +1360,11 @@ func (p *OrderProcessorImpl) SplitBill(ctx context.Context, req *models.SplitBil
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("payment method not found: %w", err)
|
||||
}
|
||||
// Splitting with EnakPoint would record a payment without taking any balance; an
|
||||
// EnakPoint part goes through CreatePayment and the rest is split as usual (F9).
|
||||
if payment.Type == entities.PaymentMethodTypePoint {
|
||||
return nil, fmt.Errorf("%w: pay the EnakPoint part as its own payment, not as a split", ErrPointPaymentRejected)
|
||||
}
|
||||
|
||||
customer := &entities.Customer{}
|
||||
if req.CustomerID != uuid.Nil {
|
||||
@@ -1726,3 +1811,71 @@ func (p *OrderProcessorImpl) prepareRefundedIngredientRecipeItem(ctx context.Con
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// PayWithPointsInApp pays an order with EnakPoint on the customer's own request, in the
|
||||
// app or a self-order. The session alone is not enough: the customer's PIN approves
|
||||
// it (K8). An order that is not the customer's own is reported as not found, so the
|
||||
// endpoint does not reveal other customers' orders.
|
||||
func (p *OrderProcessorImpl) PayWithPointsInApp(ctx context.Context, customerID, orderID uuid.UUID, points int64, pin string, info models.CustomerPinRequestInfo) (*models.PaymentResponse, error) {
|
||||
if p.pointPayments == nil || p.pins == nil {
|
||||
return nil, fmt.Errorf("%w: paying with EnakPoint is not available", ErrPointPaymentRejected)
|
||||
}
|
||||
organizationID, owner, err := p.pointPayments.OrderOwner(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner == nil || *owner != customerID {
|
||||
return nil, repository.ErrPointPaymentOrderNotFound
|
||||
}
|
||||
methodID, err := p.pointPayments.PointMethodID(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := p.pointPayments.Pay(ctx, PointPaymentInput{
|
||||
OrderID: orderID,
|
||||
PaymentMethodID: methodID,
|
||||
Points: points,
|
||||
Authorize: func(ctx context.Context, customerID uuid.UUID) error {
|
||||
return p.pins.VerifyPin(ctx, customerID, pin, PinActionPay, info)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Completed {
|
||||
p.onOrderPaid(ctx, orderID)
|
||||
}
|
||||
payment, err := p.paymentRepo.GetByID(ctx, result.Payment.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve created payment: %w", err)
|
||||
}
|
||||
return mappers.PaymentEntityToResponse(payment), nil
|
||||
}
|
||||
|
||||
// plannedOrderRefund is the total RefundOrder is about to hand back, computed the same
|
||||
// way it will be, without writing anything.
|
||||
func (p *OrderProcessorImpl) plannedOrderRefund(ctx context.Context, orderID uuid.UUID, req *models.RefundOrderRequest) (float64, error) {
|
||||
if req.RefundAmount != nil {
|
||||
return *req.RefundAmount, nil
|
||||
}
|
||||
var total float64
|
||||
for _, itemRefund := range req.OrderItems {
|
||||
if itemRefund.RefundAmount != nil {
|
||||
total += *itemRefund.RefundAmount
|
||||
continue
|
||||
}
|
||||
orderItem, err := p.orderItemRepo.GetByID(ctx, itemRefund.OrderItemID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("order item not found: %w", err)
|
||||
}
|
||||
if orderItem.OrderID != orderID {
|
||||
return 0, fmt.Errorf("order item does not belong to this order")
|
||||
}
|
||||
quantity := itemRefund.RefundQuantity
|
||||
if quantity == 0 {
|
||||
quantity = orderItem.Quantity
|
||||
}
|
||||
total += entities.RoundMoney(orderItem.BillableQuantityFor(quantity) * orderItem.UnitPrice)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ type OtpProcessor interface {
|
||||
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
ResendOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
|
||||
// SendWhatsAppMessage sends any message to a customer number, formatted like OTPs.
|
||||
SendWhatsAppMessage(phoneNumber string, message string) error
|
||||
ValidateOtpCode(code string) bool
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
InvalidateOtpSession(ctx context.Context, token string) error
|
||||
@@ -242,10 +240,3 @@ func (p *otpProcessor) formatPhoneNumber(phoneNumber string) string {
|
||||
|
||||
return digits
|
||||
}
|
||||
|
||||
func (p *otpProcessor) SendWhatsAppMessage(phoneNumber string, message string) error {
|
||||
if err := p.fonnteClient.SendWhatsAppMessage(p.formatPhoneNumber(phoneNumber), message); err != nil {
|
||||
return fmt.Errorf("failed to send WhatsApp message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// pointPaymentEnv is an order flow wired as in the app, against Postgres and a
|
||||
// miniredis for payment codes.
|
||||
type pointPaymentEnv struct {
|
||||
t *testing.T
|
||||
db *gorm.DB
|
||||
orders *OrderProcessorImpl
|
||||
payments *PointPaymentProcessor
|
||||
codes *PaymentCodeProcessor
|
||||
org uuid.UUID
|
||||
cashier uuid.UUID
|
||||
outlet uuid.UUID
|
||||
point uuid.UUID
|
||||
cash uuid.UUID
|
||||
walkIn uuid.UUID
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func newPointPaymentEnv(t *testing.T) *pointPaymentEnv {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
e := &pointPaymentEnv{t: t, db: db, org: uuid.New(), cashier: uuid.New(), outlet: uuid.New(), cash: uuid.New()}
|
||||
e.exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'point pay test', 'basic')`, e.org)
|
||||
e.exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, e.cashier, e.org, e.cashier.String()+"@t")
|
||||
e.exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, e.outlet, e.org)
|
||||
e.exec(`INSERT INTO payment_methods (id, organization_id, name, type) VALUES (?, ?, 'Tunai', 'cash')`, e.cash, e.org)
|
||||
var ids []string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM payment_methods WHERE organization_id = ? AND type = 'point'`, e.org).Scan(&ids).Error)
|
||||
require.Len(t, ids, 1)
|
||||
e.point = uuid.MustParse(ids[0])
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, e.org).Scan(&ids).Error)
|
||||
e.walkIn = uuid.MustParse(ids[0])
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, e.org)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM payments WHERE order_id IN (SELECT id FROM orders WHERE organization_id = ?)`, e.org)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, e.outlet)
|
||||
db.Exec(`DELETE FROM payment_methods WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM customers WHERE organization_id = ?`, e.org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, e.outlet)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, e.cashier)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, e.org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
e.payments = NewPointPaymentProcessor(repository.NewPointPaymentRepository(db), settings, repository.NewWalletQueryRepository(db), wallet, txm)
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { client.Close() })
|
||||
e.codes = NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(client), pinVerifierFake{good: "482913"})
|
||||
|
||||
e.orders = &OrderProcessorImpl{
|
||||
orderRepo: repository.NewOrderRepositoryImpl(db),
|
||||
orderItemRepo: repository.NewOrderItemRepositoryImpl(db),
|
||||
paymentRepo: repository.NewPaymentRepositoryImpl(db),
|
||||
paymentMethodRepo: repository.NewPaymentMethodRepositoryImpl(db),
|
||||
splitBillProcessor: nil,
|
||||
txManager: txm,
|
||||
}
|
||||
e.orders.SetLoyalty(NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm))
|
||||
e.orders.SetPointPayments(e.payments, e.codes, pinVerifierFake{good: "482913"})
|
||||
|
||||
// The outlet earns 1 EnakPoint per Rp 100 and accepts EnakPoint.
|
||||
s, err := settings.Outlet(context.Background(), e.outlet)
|
||||
require.NoError(t, err)
|
||||
s.Point.Enabled = true
|
||||
s.PointPayment.AcceptPayment = true
|
||||
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
e.ctx = context.WithValue(context.Background(), appcontext.UserIDKey, e.cashier.String())
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) exec(q string, args ...any) {
|
||||
e.t.Helper()
|
||||
require.NoError(e.t, e.db.Exec(q, args...).Error)
|
||||
}
|
||||
|
||||
// customerWith creates a customer holding the given EnakPoint.
|
||||
func (e *pointPaymentEnv) customerWith(points int64) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
e.exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, id, e.org)
|
||||
if points > 0 {
|
||||
require.NoError(e.t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: id, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration, Amount: points,
|
||||
ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) order(customer uuid.UUID, subtotal float64) uuid.UUID {
|
||||
e.t.Helper()
|
||||
id := uuid.New()
|
||||
e.exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, tax_amount, total_amount, remaining_amount, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, 0, ?, ?, 'pending')`,
|
||||
id, e.org, e.outlet, e.cashier, customer, "PP-"+id.String()[:8], subtotal, subtotal, subtotal)
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) code(customer uuid.UUID) string {
|
||||
e.t.Helper()
|
||||
c, err := e.codes.Issue(context.Background(), customer, "482913", models.CustomerPinRequestInfo{})
|
||||
require.NoError(e.t, err)
|
||||
return c.Code
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) payPoints(order uuid.UUID, points int64, code string) (*models.PaymentResponse, error) {
|
||||
return e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &points, PaymentCode: &code})
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) balance(customer uuid.UUID) int64 {
|
||||
e.t.Helper()
|
||||
var b int64
|
||||
require.NoError(e.t, e.db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
|
||||
return b
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) orderState(order uuid.UUID) (status string, remaining float64) {
|
||||
e.t.Helper()
|
||||
var row struct {
|
||||
PaymentStatus string
|
||||
RemainingAmount float64
|
||||
}
|
||||
require.NoError(e.t, e.db.Raw(`SELECT payment_status, remaining_amount FROM orders WHERE id = ?`, order).Scan(&row).Error)
|
||||
return row.PaymentStatus, row.RemainingAmount
|
||||
}
|
||||
|
||||
func TestPointPayment_FullPayment(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
|
||||
payment, err := e.payPoints(order, 50000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50000.0, payment.Amount)
|
||||
require.NotNil(t, payment.PointsUsed)
|
||||
assert.Equal(t, int64(50000), *payment.PointsUsed)
|
||||
assert.Equal(t, 1.0, *payment.PointValue, "the value is frozen on the payment")
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Zero(t, remaining)
|
||||
assert.Equal(t, int64(50000), e.balance(customer))
|
||||
|
||||
var ledger struct {
|
||||
Amount int64
|
||||
ReferenceType string
|
||||
ReferenceID string
|
||||
OutletID string
|
||||
CreatedByUser string
|
||||
}
|
||||
require.NoError(t, e.db.Raw(`SELECT amount, reference_type, reference_id::text AS reference_id, outlet_id::text AS outlet_id,
|
||||
created_by_user::text AS created_by_user FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT'`, customer).Scan(&ledger).Error)
|
||||
assert.Equal(t, int64(-50000), ledger.Amount)
|
||||
assert.Equal(t, "PAYMENT", ledger.ReferenceType)
|
||||
assert.Equal(t, payment.ID.String(), ledger.ReferenceID)
|
||||
assert.Equal(t, e.outlet.String(), ledger.OutletID)
|
||||
assert.Equal(t, e.cashier.String(), ledger.CreatedByUser, "the cashier who took it")
|
||||
|
||||
// Paid entirely with EnakPoint, so nothing earns (Q10).
|
||||
var earned int64
|
||||
require.NoError(t, e.db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
|
||||
assert.Zero(t, earned)
|
||||
}
|
||||
|
||||
func TestPointPayment_PartialThenCash(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 87500)
|
||||
|
||||
_, err := e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "partial", status)
|
||||
assert.Equal(t, 67500.0, remaining)
|
||||
|
||||
// The rest in cash settles it; earning counts only the part not paid with
|
||||
// EnakPoint: floor(67.500 / 100) = 675.
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
|
||||
require.NoError(t, err)
|
||||
status, _ = e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
var earned int64
|
||||
require.NoError(t, e.db.Raw(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order).Scan(&earned).Error)
|
||||
assert.Equal(t, int64(675), earned)
|
||||
assert.Equal(t, int64(100000-20000+675), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointPayment_PercentCap(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
|
||||
s, err := settings.Outlet(context.Background(), e.outlet)
|
||||
require.NoError(t, err)
|
||||
s.PointPayment.MaxPaymentPercent = 50
|
||||
_, err = settings.UpdateOutlet(context.Background(), e.org, e.outlet, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 100000)
|
||||
|
||||
preview, err := e.payments.Preview(context.Background(), e.org, order)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, preview.Eligible)
|
||||
assert.Equal(t, int64(50000), preview.MaxPoints)
|
||||
assert.Equal(t, int64(100000), preview.PointBalance)
|
||||
|
||||
_, err = e.payPoints(order, 50001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
_, err = e.payPoints(order, 30000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
_, err = e.payPoints(order, 20001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "earlier EnakPoint counts toward the cap")
|
||||
_, err = e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(50000), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointPayment_Refusals(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
other := e.customerWith(100000)
|
||||
|
||||
// A walk-in order cannot be paid with EnakPoint.
|
||||
walkInOrder := e.order(e.walkIn, 10000)
|
||||
preview, err := e.payments.Preview(context.Background(), e.org, walkInOrder)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, preview.Eligible)
|
||||
assert.Contains(t, preview.Reason, "walk-in")
|
||||
_, err = e.payPoints(walkInOrder, 1000, "123456")
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
|
||||
order := e.order(customer, 10000)
|
||||
_, err = e.payPoints(order, 1000, "000000")
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a wrong code")
|
||||
_, err = e.payPoints(order, 1000, e.code(other))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "another customer's code")
|
||||
missing := int64(1000)
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.point, Points: &missing})
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no code at all")
|
||||
|
||||
code := e.code(customer)
|
||||
_, err = e.payPoints(order, 1000, code)
|
||||
require.NoError(t, err)
|
||||
_, err = e.payPoints(order, 1000, code)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "a code is used once, so a double tap takes once")
|
||||
_, err = e.payPoints(order, 9001, e.code(customer))
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "no change is given: not more than what is left")
|
||||
|
||||
// Splitting with the EnakPoint method would skip the balance, so it is refused.
|
||||
e.orders.splitBillProcessor = splitFake{}
|
||||
_, err = e.orders.SplitBill(e.ctx, &models.SplitBillRequest{OrderID: order, PaymentMethodID: e.point, Type: "AMOUNT", Amount: 1000})
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
|
||||
assert.Equal(t, int64(99000), e.balance(customer), "only the one payment took anything")
|
||||
assert.Equal(t, int64(100000), e.balance(other))
|
||||
}
|
||||
|
||||
// Two payments for the same customer at once, on two orders: the balance is taken
|
||||
// once, never twice. Authorization is taken as given so only the balance decides.
|
||||
func TestPointPayment_ConcurrentForOneCustomer(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(30000)
|
||||
orders := []uuid.UUID{e.order(customer, 20000), e.order(customer, 20000)}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]error, len(orders))
|
||||
for i, order := range orders {
|
||||
wg.Add(1)
|
||||
go func(i int, order uuid.UUID) {
|
||||
defer wg.Done()
|
||||
_, results[i] = e.payments.Pay(e.ctx, PointPaymentInput{
|
||||
OrderID: order, PaymentMethodID: e.point, Points: 20000,
|
||||
Authorize: func(context.Context, uuid.UUID) error { return nil },
|
||||
})
|
||||
}(i, order)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
succeeded := 0
|
||||
for _, err := range results {
|
||||
if err == nil {
|
||||
succeeded++
|
||||
} else {
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, succeeded, "30.000 EnakPoint pays one 20.000 order, not two")
|
||||
assert.Equal(t, int64(10000), e.balance(customer))
|
||||
|
||||
// And the wallet still reconciles.
|
||||
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
|
||||
require.NoError(t, err)
|
||||
for _, d := range found {
|
||||
assert.NotEqual(t, customer, d.CustomerID, d.Check)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointPayment_InApp(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
owner := e.customerWith(100000)
|
||||
stranger := e.customerWith(100000)
|
||||
order := e.order(owner, 60000)
|
||||
info := models.CustomerPinRequestInfo{}
|
||||
|
||||
// Another customer cannot pay it, and is not told it exists.
|
||||
_, err := e.orders.PayWithPointsInApp(e.ctx, stranger, order, 1000, "482913", info)
|
||||
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, uuid.New(), 1000, "482913", info)
|
||||
assert.ErrorIs(t, err, repository.ErrPointPaymentOrderNotFound)
|
||||
|
||||
// The session alone is not enough: a wrong PIN takes nothing.
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 1000, "000000", info)
|
||||
var pe *PinError
|
||||
require.ErrorAs(t, err, &pe)
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, int64(100000), e.balance(owner))
|
||||
|
||||
// The owner pays part, then the rest, with the same rules as at the cashier.
|
||||
payment, err := e.orders.PayWithPointsInApp(e.ctx, owner, order, 10000, "482913", info)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10000), *payment.PointsUsed)
|
||||
status, remaining := e.orderState(order)
|
||||
assert.Equal(t, "partial", status)
|
||||
assert.Equal(t, 50000.0, remaining)
|
||||
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50001, "482913", info)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "not more than what is left")
|
||||
_, err = e.orders.PayWithPointsInApp(e.ctx, owner, order, 50000, "482913", info)
|
||||
require.NoError(t, err)
|
||||
status, _ = e.orderState(order)
|
||||
assert.Equal(t, "completed", status)
|
||||
assert.Equal(t, int64(40000), e.balance(owner))
|
||||
assert.Equal(t, int64(100000), e.balance(stranger))
|
||||
|
||||
var createdBy *string
|
||||
require.NoError(t, e.db.Raw(`SELECT created_by_user::text FROM wallet_transactions WHERE customer_id = ? AND type = 'PAYMENT' LIMIT 1`, owner).Scan(&createdBy).Error)
|
||||
assert.Nil(t, createdBy, "no cashier took an in-app payment")
|
||||
}
|
||||
|
||||
// The payment method report counts only money actually received as money in; the
|
||||
// EnakPoint part is listed apart (F9).
|
||||
func TestPointPayment_ReportKeepsEnakPointOutOfCashIn(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 87500)
|
||||
_, err := e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
|
||||
require.NoError(t, err)
|
||||
|
||||
report, err := NewAnalyticsProcessorImpl(repository.NewAnalyticsRepositoryImpl(e.db), nil).GetPaymentMethodAnalytics(context.Background(),
|
||||
&models.PaymentMethodAnalyticsRequest{OrganizationID: e.org, DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now().Add(time.Hour)})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 67500.0, report.Summary.TotalAmount, "money in is the cash, not the order total")
|
||||
assert.Equal(t, 20000.0, report.Summary.PointAmount)
|
||||
assert.Equal(t, int64(20000), report.Summary.PointsUsed)
|
||||
assert.Equal(t, 87500.0, report.Summary.TotalWithPoints)
|
||||
require.Len(t, report.Data, 2)
|
||||
for _, d := range report.Data {
|
||||
assert.Equal(t, d.PaymentMethodType != "point", d.CountsAsCashIn, d.PaymentMethodName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrPointPaymentRejected wraps every reason a payment with EnakPoint is refused: the
|
||||
// order, the customer, the outlet or the amount. The message says which.
|
||||
var ErrPointPaymentRejected = errors.New("EnakPoint payment refused")
|
||||
|
||||
type pointPaymentSettings interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
PointValue(ctx context.Context, organizationID uuid.UUID) (int64, error)
|
||||
}
|
||||
|
||||
type spendableReader interface {
|
||||
SpendableBalances(ctx context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error)
|
||||
}
|
||||
|
||||
// PointPaymentInput is one payment of an order with EnakPoint.
|
||||
type PointPaymentInput struct {
|
||||
OrderID uuid.UUID
|
||||
PaymentMethodID uuid.UUID
|
||||
Points int64
|
||||
// The cashier taking the payment at the POS; nil when the customer pays in the app.
|
||||
CashierID *uuid.UUID
|
||||
// Authorize proves the customer agreed, before anything is taken: at the POS it
|
||||
// redeems the payment code, in the app it checks the PIN (K8).
|
||||
Authorize func(ctx context.Context, customerID uuid.UUID) error
|
||||
}
|
||||
|
||||
// PointPaymentResult is the payment made and where it left the order.
|
||||
type PointPaymentResult struct {
|
||||
Payment *entities.Payment
|
||||
// True when this payment settled the order.
|
||||
Completed bool
|
||||
// Rupiah still to pay with another method.
|
||||
Remaining float64
|
||||
}
|
||||
|
||||
// pointPaymentLimits applies the formula of docs/prd-point-coin.md F9, in cents:
|
||||
//
|
||||
// cap = min(remaining, total × max_payment_percent / 100 − already paid with EnakPoint)
|
||||
// max_points = min(balance, floor(cap / point_value))
|
||||
type pointPaymentLimits struct {
|
||||
RemainingCents int64
|
||||
CapCents int64
|
||||
MaxPoints int64
|
||||
}
|
||||
|
||||
func computePointPaymentLimits(total, totalPaid, paidWithPoints float64, maxPercent, pointValue, balance int64) pointPaymentLimits {
|
||||
remaining := toCents(total) - toCents(totalPaid)
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
byPercent := toCents(total)*maxPercent/100 - toCents(paidWithPoints)
|
||||
capCents := min(remaining, byPercent)
|
||||
if capCents < 0 {
|
||||
capCents = 0
|
||||
}
|
||||
maxPoints := int64(0)
|
||||
if pointValue > 0 {
|
||||
maxPoints = min(balance, capCents/(pointValue*100))
|
||||
}
|
||||
if maxPoints < 0 {
|
||||
maxPoints = 0
|
||||
}
|
||||
return pointPaymentLimits{RemainingCents: remaining, CapCents: capCents, MaxPoints: maxPoints}
|
||||
}
|
||||
|
||||
// PointPaymentProcessor pays orders with EnakPoint (docs/prd-point-coin.md F9).
|
||||
type PointPaymentProcessor struct {
|
||||
repo repository.PointPaymentRepository
|
||||
settings pointPaymentSettings
|
||||
spendable spendableReader
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewPointPaymentProcessor(repo repository.PointPaymentRepository, settings pointPaymentSettings, spendable spendableReader, wallet *WalletProcessor, tx TxRunner) *PointPaymentProcessor {
|
||||
return &PointPaymentProcessor{repo: repo, settings: settings, spendable: spendable, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Preview is GET /orders/:id/point-payment/preview: whether the order can be paid with
|
||||
// EnakPoint, and at most how much.
|
||||
func (p *PointPaymentProcessor) Preview(ctx context.Context, organizationID, orderID uuid.UUID) (*models.PointPaymentPreview, error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if order.OrganizationID != organizationID {
|
||||
return nil, repository.ErrPointPaymentOrderNotFound
|
||||
}
|
||||
preview := &models.PointPaymentPreview{OrderID: orderID, CustomerID: order.CustomerID}
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err := p.settings.PointValue(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview.PointValue = value
|
||||
preview.MinPaymentPoints = settings.PointPayment.MinPaymentPoints
|
||||
preview.MaxPaymentPercent = settings.PointPayment.MaxPaymentPercent
|
||||
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
preview.Reason = reason
|
||||
return preview, nil
|
||||
}
|
||||
limits, balance, err := p.limits(ctx, order, settings, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
preview.PointBalance = balance
|
||||
preview.RemainingAmount = float64(limits.RemainingCents) / 100
|
||||
preview.MaxPoints = limits.MaxPoints
|
||||
preview.MaxAmount = limits.MaxPoints * value
|
||||
if limits.MaxPoints < settings.PointPayment.MinPaymentPoints {
|
||||
preview.Reason = "the customer cannot pay the minimum of EnakPoint on this order"
|
||||
return preview, nil
|
||||
}
|
||||
preview.Eligible = true
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// Pay takes EnakPoint from the order's customer and records the payment. The payment
|
||||
// row, the ledger PAYMENT row, the balance and the order change in one transaction
|
||||
// with the order row and the wallet locked (F9 steps 1–6).
|
||||
func (p *PointPaymentProcessor) Pay(ctx context.Context, in PointPaymentInput) (*PointPaymentResult, error) {
|
||||
reject := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrPointPaymentRejected, fmt.Sprintf(format, args...))
|
||||
}
|
||||
if in.Points <= 0 {
|
||||
return nil, reject("the number of EnakPoint must be positive")
|
||||
}
|
||||
order, err := p.repo.GetOrder(ctx, in.OrderID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
methodOrg, methodType, err := p.repo.GetPaymentMethod(ctx, in.PaymentMethodID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if methodType != string(constants.PaymentMethodTypePoint) || methodOrg != order.OrganizationID {
|
||||
return nil, reject("the payment method is not this organization's EnakPoint method")
|
||||
}
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
return nil, reject("%s", reason)
|
||||
}
|
||||
if in.Points < settings.PointPayment.MinPaymentPoints {
|
||||
return nil, reject("at least %d EnakPoint must be used", settings.PointPayment.MinPaymentPoints)
|
||||
}
|
||||
customerID := *order.CustomerID
|
||||
|
||||
// The customer agrees before anything is taken. A code is used up here even if the
|
||||
// payment then fails, and the customer shows a new one.
|
||||
if in.Authorize == nil {
|
||||
return nil, reject("the customer has not approved the payment")
|
||||
}
|
||||
if err := in.Authorize(ctx, customerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &PointPaymentResult{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
// Lock the order, then the wallet, and read everything again: another payment
|
||||
// may have landed since the checks above.
|
||||
order, err := p.repo.GetOrder(ctx, in.OrderID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reason := pointPaymentOrderProblem(order, settings); reason != "" {
|
||||
return reject("%s", reason)
|
||||
}
|
||||
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := p.settings.PointValue(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, _, err := p.limits(ctx, order, settings, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Points > limits.MaxPoints {
|
||||
return reject("at most %d EnakPoint can pay this order now", limits.MaxPoints)
|
||||
}
|
||||
amountCents := in.Points * value * 100
|
||||
// No change is ever given for EnakPoint (K7); the limits already keep it
|
||||
// within what is left, this only guards that.
|
||||
if amountCents > limits.RemainingCents {
|
||||
return reject("EnakPoint cannot pay more than what is left on the order")
|
||||
}
|
||||
|
||||
pointsUsed := in.Points
|
||||
frozenValue := float64(value)
|
||||
payment := &entities.Payment{
|
||||
ID: uuid.New(),
|
||||
OrderID: order.ID,
|
||||
PaymentMethodID: in.PaymentMethodID,
|
||||
Amount: float64(amountCents) / 100,
|
||||
Status: entities.PaymentTransactionStatusCompleted,
|
||||
PointsUsed: &pointsUsed,
|
||||
PointValue: &frozenValue,
|
||||
Metadata: entities.Metadata{"points_used": pointsUsed, "point_value": value},
|
||||
}
|
||||
if err := p.repo.InsertPayment(ctx, payment); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outletID := order.OutletID
|
||||
if _, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePayment,
|
||||
Amount: in.Points,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceID: payment.ID,
|
||||
OutletID: &outletID,
|
||||
CreatedByUser: in.CashierID,
|
||||
Description: pointPaymentDescription(order, amountCents),
|
||||
Metadata: entities.Metadata{"point_value": value, "amount": payment.Amount},
|
||||
IdempotencyKey: "payment:" + payment.ID.String(),
|
||||
}}); err != nil {
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return reject("the customer does not have enough EnakPoint")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
remainingCents := limits.RemainingCents - amountCents
|
||||
completed := remainingCents == 0
|
||||
if err := p.repo.UpdateOrderAfterPayment(ctx, order.ID, float64(remainingCents)/100, completed); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Payment = payment
|
||||
result.Completed = completed
|
||||
result.Remaining = float64(remainingCents) / 100
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *PointPaymentProcessor) limits(ctx context.Context, order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings, value int64) (pointPaymentLimits, int64, error) {
|
||||
totalPaid, err := p.repo.TotalPaid(ctx, order.ID)
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
paidWithPoints, err := p.repo.PaidWithPoints(ctx, order.ID)
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
balances, err := p.spendable.SpendableBalances(ctx, *order.CustomerID, p.now())
|
||||
if err != nil {
|
||||
return pointPaymentLimits{}, 0, err
|
||||
}
|
||||
balance := balances[constants.WalletCurrencyPoint]
|
||||
return computePointPaymentLimits(order.TotalAmount, totalPaid, paidWithPoints, settings.PointPayment.MaxPaymentPercent, value, balance), balance, nil
|
||||
}
|
||||
|
||||
// pointPaymentOrderProblem says why an order cannot be paid with EnakPoint at all, or
|
||||
// "" when it can.
|
||||
func pointPaymentOrderProblem(order *repository.PointPaymentOrder, settings *models.OutletLoyaltySettings) string {
|
||||
switch {
|
||||
case order.IsVoid:
|
||||
return "the order is void"
|
||||
case order.PaymentStatus == string(entities.PaymentStatusCompleted):
|
||||
return "the order is already paid"
|
||||
case !settings.PointPayment.AcceptPayment:
|
||||
return "this outlet does not accept EnakPoint"
|
||||
case order.CustomerID == nil || order.CustomerIsDefault == nil:
|
||||
return "the order has no customer"
|
||||
case *order.CustomerIsDefault:
|
||||
return "a walk-in order cannot be paid with EnakPoint"
|
||||
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
|
||||
return "the customer is not active"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pointPaymentDescription(order *repository.PointPaymentOrder, amountCents int64) string {
|
||||
description := "Bayar #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
description += " (Rp " + formatRupiah(amountCents/100) + ")"
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// formatRupiah writes 50000 as 50.000.
|
||||
func formatRupiah(n int64) string {
|
||||
s := fmt.Sprintf("%d", n)
|
||||
out := make([]byte, 0, len(s)+len(s)/3)
|
||||
for i, c := range []byte(s) {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, '.')
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// PointMethodID returns the organization's EnakPoint payment method.
|
||||
func (p *PointPaymentProcessor) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
|
||||
return p.repo.PointMethodID(ctx, organizationID)
|
||||
}
|
||||
|
||||
// OrderOwner returns the organization and customer of an order, for checking that a
|
||||
// customer pays only their own order.
|
||||
func (p *PointPaymentProcessor) OrderOwner(ctx context.Context, orderID uuid.UUID) (organizationID uuid.UUID, customerID *uuid.UUID, err error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return uuid.Nil, nil, err
|
||||
}
|
||||
return order.OrganizationID, order.CustomerID, nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestComputePointPaymentLimits(t *testing.T) {
|
||||
for name, c := range map[string]struct {
|
||||
total, paid, paidWithPoints float64
|
||||
percent, value, balance int64
|
||||
wantRemaining, wantMax int64
|
||||
}{
|
||||
// The F9 example: Rp 87.550 left, 50.000 EnakPoint, 100%, Rp 1 a point.
|
||||
"PRD example": {87550, 0, 0, 100, 1, 50000, 8755000, 50000},
|
||||
"balance covers it all": {87550, 0, 0, 100, 1, 100000, 8755000, 87550},
|
||||
"part already paid": {100000, 30000, 0, 100, 1, 100000, 7000000, 70000},
|
||||
"capped by percent": {100000, 0, 0, 50, 1, 100000, 10000000, 50000},
|
||||
"percent counts EnakPoint already used": {100000, 20000, 20000, 50, 1, 100000, 8000000, 30000},
|
||||
"percent cap already used": {100000, 50000, 50000, 50, 1, 100000, 5000000, 0},
|
||||
"point worth more than Rp 1": {87550, 0, 0, 100, 100, 1000, 8755000, 875},
|
||||
"nothing left": {50000, 50000, 0, 100, 1, 100000, 0, 0},
|
||||
"overpaid": {50000, 60000, 0, 100, 1, 100000, 0, 0},
|
||||
"no balance": {50000, 0, 0, 100, 1, 0, 5000000, 0},
|
||||
"cents left over": {10000.50, 0, 0, 100, 1, 100000, 1000050, 10000},
|
||||
} {
|
||||
got := computePointPaymentLimits(c.total, c.paid, c.paidWithPoints, c.percent, c.value, c.balance)
|
||||
assert.Equal(t, c.wantRemaining, got.RemainingCents, name)
|
||||
assert.Equal(t, c.wantMax, got.MaxPoints, name)
|
||||
assert.LessOrEqual(t, got.MaxPoints*c.value*100, got.RemainingCents, "%s: never more than what is left", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatRupiah(t *testing.T) {
|
||||
for n, want := range map[int64]string{0: "0", 999: "999", 1000: "1.000", 50000: "50.000", 1234567: "1.234.567"} {
|
||||
assert.Equal(t, want, formatRupiah(n))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// RefundForOrder gives EnakPoint back for the order's EnakPoint payments, as far as the
|
||||
// order has been voided or those payments refunded (docs/prd-point-coin.md F9, K7):
|
||||
//
|
||||
// - void: every EnakPoint used on the order;
|
||||
// - a refunded EnakPoint payment: floor(refunded rupiah / the frozen point value),
|
||||
// so a later change of the point value does not change how many come back, and a
|
||||
// rupiah remainder below one EnakPoint is lost (Q13).
|
||||
//
|
||||
// Never more than the payment used, and only what has not come back yet, so it can be
|
||||
// called again safely. Returned EnakPoint go back into lots with the expiry of the lots
|
||||
// they were taken from, but at least seven days from the refund (note N4, decided). It
|
||||
// returns how many came back in total.
|
||||
func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid.UUID) (int64, error) {
|
||||
order, err := p.repo.GetOrder(ctx, orderID, false)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
payments, err := p.repo.ListPointPayments(ctx, orderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var returned int64
|
||||
for _, payment := range payments {
|
||||
if payment.LedgerID == nil || payment.CustomerID == nil {
|
||||
continue
|
||||
}
|
||||
target := pointRefundTarget(order.IsVoid, payment)
|
||||
if target == 0 {
|
||||
continue
|
||||
}
|
||||
var n int64
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.wallet.LockWallet(ctx, *payment.CustomerID); err != nil {
|
||||
return err
|
||||
}
|
||||
allocations, err := p.repo.PaymentAllocations(ctx, *payment.LedgerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refunded, err := p.repo.RefundedByOriginLot(ctx, *payment.LedgerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var already int64
|
||||
for _, amount := range refunded {
|
||||
already += amount
|
||||
}
|
||||
toReturn := target - already
|
||||
if toReturn <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fill the lots the payment took from, each up to what it gave.
|
||||
var lots []WalletLotInput
|
||||
left := toReturn
|
||||
for _, a := range allocations {
|
||||
if left == 0 {
|
||||
break
|
||||
}
|
||||
room := a.Amount - refunded[a.LotID]
|
||||
if room <= 0 {
|
||||
continue
|
||||
}
|
||||
take := min(room, left)
|
||||
left -= take
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID})
|
||||
}
|
||||
toReturn -= left
|
||||
|
||||
ledgerID := *payment.LedgerID
|
||||
if _, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: *payment.CustomerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypePaymentRefund,
|
||||
Amount: toReturn,
|
||||
ReferenceType: constants.WalletRefTypePayment,
|
||||
ReferenceID: payment.PaymentID,
|
||||
ReversesTransactionID: &ledgerID,
|
||||
OutletID: payment.OutletID,
|
||||
Description: pointRefundDescription(order),
|
||||
Metadata: entities.Metadata{"point_value": payment.PointValue, "target": target, "void": order.IsVoid},
|
||||
IdempotencyKey: fmt.Sprintf("payment-refund:%s:%d", payment.PaymentID, target),
|
||||
},
|
||||
Lots: lots,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
n = toReturn
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return returned, fmt.Errorf("refunding EnakPoint payment %s: %w", payment.PaymentID, err)
|
||||
}
|
||||
returned += n
|
||||
}
|
||||
return returned, nil
|
||||
}
|
||||
|
||||
// EnsureOrderRefundAllowed refuses an order-level refund that would hand back, in cash
|
||||
// or another method, what was paid with EnakPoint (K7). The EnakPoint part is refunded
|
||||
// through its own payment, and comes back as EnakPoint.
|
||||
func (p *PointPaymentProcessor) EnsureOrderRefundAllowed(ctx context.Context, orderID uuid.UUID, amount float64) error {
|
||||
refundable, err := p.repo.RefundableByOtherMethods(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paidWithPoints, err := p.repo.PaidWithPoints(ctx, orderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if paidWithPoints == 0 {
|
||||
return nil
|
||||
}
|
||||
if toCents(amount) > toCents(refundable) {
|
||||
return fmt.Errorf("%w: at most Rp %s can be refunded this way; the part paid with EnakPoint is refunded through its EnakPoint payment and returns as EnakPoint",
|
||||
ErrPointPaymentRejected, formatRupiah(toCents(refundable)/100))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pointRefundTarget(orderVoid bool, payment repository.PointPaymentRow) int64 {
|
||||
if orderVoid {
|
||||
return payment.PointsUsed
|
||||
}
|
||||
if payment.Status != string(entities.PaymentTransactionStatusRefunded) {
|
||||
return 0
|
||||
}
|
||||
valueCents := toCents(payment.PointValue)
|
||||
if valueCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
return min(payment.PointsUsed, toCents(payment.RefundAmount)/valueCents)
|
||||
}
|
||||
|
||||
func pointRefundDescription(order *repository.PointPaymentOrder) string {
|
||||
description := "Pengembalian #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func (e *pointPaymentEnv) sum(query string, args ...any) int64 {
|
||||
e.t.Helper()
|
||||
var n int64
|
||||
require.NoError(e.t, e.db.Raw(query, args...).Scan(&n).Error)
|
||||
return n
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) returned(order uuid.UUID) int64 {
|
||||
return e.sum(`SELECT COALESCE(SUM(t.amount), 0) FROM wallet_transactions t
|
||||
JOIN payments p ON p.id = t.reference_id
|
||||
WHERE p.order_id = ? AND t.type = 'PAYMENT_REFUND'`, order)
|
||||
}
|
||||
|
||||
func (e *pointPaymentEnv) assertReconciled(customers ...uuid.UUID) {
|
||||
e.t.Helper()
|
||||
found, err := repository.NewWalletReconciliationRepository(e.db).FindDiscrepancies(context.Background(), 1000)
|
||||
require.NoError(e.t, err)
|
||||
for _, d := range found {
|
||||
for _, c := range customers {
|
||||
assert.NotEqual(e.t, c, d.CustomerID, d.Check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointRefund_VoidReturnsEverythingToItsExpiry(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(0)
|
||||
expires := time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, repository.NewTxManager(e.db).WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := NewWalletProcessor(repository.NewWalletRepository(e.db)).Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 40000, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"},
|
||||
Lots: []WalletLotInput{{Amount: 40000, ExpiresAt: &expires}},
|
||||
})
|
||||
return err
|
||||
}))
|
||||
|
||||
// Part of the order paid with EnakPoint, then the order is voided.
|
||||
order := e.order(customer, 50000)
|
||||
_, err := e.payPoints(order, 30000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(10000), e.balance(customer))
|
||||
require.NoError(t, e.orders.VoidOrder(e.ctx, &models.VoidOrderRequest{OrderID: order, Type: "ALL", Reason: "batal"}, e.cashier))
|
||||
|
||||
assert.Equal(t, int64(30000), e.returned(order))
|
||||
assert.Equal(t, int64(40000), e.balance(customer))
|
||||
var expiry time.Time
|
||||
require.NoError(t, e.db.Raw(`SELECT l.expires_at FROM wallet_lots l JOIN wallet_transactions t ON t.id = l.source_transaction_id
|
||||
WHERE t.customer_id = ? AND t.type = 'PAYMENT_REFUND'`, customer).Scan(&expiry).Error)
|
||||
assert.WithinDuration(t, expires, expiry, time.Second, "returned EnakPoint keep the expiry they had")
|
||||
|
||||
// Calling it again returns nothing more.
|
||||
e.orders.onOrderRefunded(e.ctx, order)
|
||||
assert.Equal(t, int64(30000), e.returned(order))
|
||||
e.assertReconciled(customer)
|
||||
}
|
||||
|
||||
func TestPointRefund_PartialRefundFloors(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
payment, err := e.payPoints(order, 50000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Rp 12.345,67 back at Rp 1 a point: 12.345 EnakPoint; the 67 sen are lost (Q13).
|
||||
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 12345.67, "sebagian", e.cashier))
|
||||
assert.Equal(t, int64(12345), e.returned(order))
|
||||
assert.Equal(t, int64(50000+12345), e.balance(customer))
|
||||
e.assertReconciled(customer)
|
||||
}
|
||||
|
||||
func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(e.db), repository.NewTxManager(e.db))
|
||||
setValue := func(v int64) {
|
||||
s, err := settings.Organization(context.Background(), e.org)
|
||||
require.NoError(t, err)
|
||||
s.PointValue = v
|
||||
_, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
setValue(100)
|
||||
|
||||
customer := e.customerWith(1000)
|
||||
order := e.order(customer, 50000)
|
||||
payment, err := e.payPoints(order, 500, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 50000.0, payment.Amount, "500 × Rp 100")
|
||||
|
||||
// The value changes before the refund; the customer still gets back what they used.
|
||||
setValue(250)
|
||||
require.NoError(t, e.orders.RefundPayment(e.ctx, payment.ID, 50000, "semua", e.cashier))
|
||||
assert.Equal(t, int64(500), e.returned(order), "50.000 / the frozen Rp 100, not the new Rp 250")
|
||||
assert.Equal(t, int64(1000), e.balance(customer))
|
||||
}
|
||||
|
||||
func TestPointRefund_NoCashForTheEnakPointPart(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 50000)
|
||||
_, err := e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 30000})
|
||||
require.NoError(t, err)
|
||||
|
||||
amount := 40000.0
|
||||
err = e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier)
|
||||
assert.ErrorIs(t, err, ErrPointPaymentRejected, "Rp 40.000 in cash would include EnakPoint")
|
||||
var refunded float64
|
||||
require.NoError(t, e.db.Raw(`SELECT refund_amount FROM orders WHERE id = ?`, order).Scan(&refunded).Error)
|
||||
assert.Zero(t, refunded, "nothing was written")
|
||||
|
||||
amount = 30000
|
||||
require.NoError(t, e.orders.RefundOrder(e.ctx, order, &models.RefundOrderRequest{RefundAmount: &amount}, e.cashier), "the cash part can be refunded")
|
||||
assert.Zero(t, e.returned(order), "and no EnakPoint came back for it")
|
||||
}
|
||||
|
||||
// Giving back the EnakPoint part does not take earning back: that part never earned.
|
||||
func TestPointRefund_DoesNotReverseEarning(t *testing.T) {
|
||||
e := newPointPaymentEnv(t)
|
||||
customer := e.customerWith(100000)
|
||||
order := e.order(customer, 87500)
|
||||
pointPayment, err := e.payPoints(order, 20000, e.code(customer))
|
||||
require.NoError(t, err)
|
||||
_, err = e.orders.CreatePayment(e.ctx, &models.CreatePaymentRequest{OrderID: order, PaymentMethodID: e.cash, Amount: 67500})
|
||||
require.NoError(t, err)
|
||||
earned := e.sum(`SELECT COALESCE(SUM(amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, order)
|
||||
require.Equal(t, int64(675), earned)
|
||||
|
||||
require.NoError(t, e.orders.RefundPayment(e.ctx, pointPayment.ID, 20000, "kembali", e.cashier))
|
||||
assert.Equal(t, int64(20000), e.returned(order))
|
||||
reversed := e.sum(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL'`, order)
|
||||
assert.Zero(t, reversed, "the EnakPoint part never earned, so giving it back takes nothing")
|
||||
e.assertReconciled(customer)
|
||||
}
|
||||
@@ -21,15 +21,16 @@ const walletDescriptionLimit = 255
|
||||
// WalletAdminProcessor serves the dashboard's view of one customer's wallet and the
|
||||
// manual adjustments an admin makes to it (docs/prd-point-coin.md F7).
|
||||
type WalletAdminProcessor struct {
|
||||
admin repository.WalletAdminRepository
|
||||
query repository.WalletQueryRepository
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
admin repository.WalletAdminRepository
|
||||
query repository.WalletQueryRepository
|
||||
wallet *WalletProcessor
|
||||
settings organizationSettingsReader
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, tx TxRunner) *WalletAdminProcessor {
|
||||
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, tx: tx, now: time.Now}
|
||||
func NewWalletAdminProcessor(admin repository.WalletAdminRepository, query repository.WalletQueryRepository, wallet *WalletProcessor, settings organizationSettingsReader, tx TxRunner) *WalletAdminProcessor {
|
||||
return &WalletAdminProcessor{admin: admin, query: query, wallet: wallet, settings: settings, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// GetCustomerWallet returns the customer's balances, lots and a page of history.
|
||||
@@ -69,17 +70,7 @@ func (p *WalletAdminProcessor) GetCustomerWallet(ctx context.Context, organizati
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range lots {
|
||||
result.Lots = append(result.Lots, models.AdminWalletLot{
|
||||
ID: lot.ID,
|
||||
Currency: lot.Currency,
|
||||
OriginalAmount: lot.OriginalAmount,
|
||||
RemainingAmount: lot.RemainingAmount,
|
||||
ExpiresAt: lot.ExpiresAt,
|
||||
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
|
||||
SourceTransactionID: lot.SourceTransactionID,
|
||||
OriginLotID: lot.OriginLotID,
|
||||
CreatedAt: lot.CreatedAt,
|
||||
})
|
||||
result.Lots = append(result.Lots, adminWalletLot(lot, now))
|
||||
}
|
||||
|
||||
rows, total, err := p.query.ListTransactions(ctx, filter)
|
||||
@@ -136,13 +127,20 @@ func (p *WalletAdminProcessor) Adjust(ctx context.Context, organizationID, admin
|
||||
if _, err := p.admin.GetCustomer(ctx, organizationID, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
if in.Amount > 0 {
|
||||
// Adjustments never expire until expiry is decided (F12, note N4).
|
||||
res, err = p.wallet.Credit(ctx, WalletCreditInput{WalletEntry: entry})
|
||||
} else {
|
||||
if in.Amount < 0 {
|
||||
var err error
|
||||
res, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: entry})
|
||||
return err
|
||||
}
|
||||
// An adjustment that adds is a new balance, and expires like one (F7, F12).
|
||||
settings, err := p.settings.Organization(ctx, organizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err = p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: entry,
|
||||
Lots: []WalletLotInput{{Amount: entry.Amount, ExpiresAt: ComputeExpiry(ExpirySettings(settings, in.Currency), p.now())}},
|
||||
})
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrWalletMoveRejected wraps every reason an exchange or a transfer is refused on
|
||||
// the customer's side: the amount, the limits, the recipient or the balance. The
|
||||
// message says which.
|
||||
var ErrWalletMoveRejected = errors.New("wallet move refused")
|
||||
|
||||
// walletMoveKeyLimit keeps a client's Idempotency-Key short enough to fit, with the
|
||||
// prefix that scopes it to the customer, in wallet_transactions.idempotency_key.
|
||||
const walletMoveKeyLimit = 50
|
||||
|
||||
type organizationSettingsReader interface {
|
||||
Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error)
|
||||
}
|
||||
|
||||
// WalletExchangeProcessor exchanges EnakCoin into EnakPoint (docs/prd-point-coin.md
|
||||
// F4, K3). It is one way only; nothing turns EnakPoint back into EnakCoin.
|
||||
type WalletExchangeProcessor struct {
|
||||
customers repository.WalletMoveRepository
|
||||
settings organizationSettingsReader
|
||||
spendable spendableReader
|
||||
pins pinVerifier
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletExchangeProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner) *WalletExchangeProcessor {
|
||||
return &WalletExchangeProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// Preview is GET /customer/wallet/exchange/preview: the organization's rate and what
|
||||
// exchanging coins would give, so the app can show it before asking for the PIN.
|
||||
func (p *WalletExchangeProcessor) Preview(ctx context.Context, customerID uuid.UUID, coins int64) (*models.WalletExchangePreview, error) {
|
||||
customer, err := p.customers.GetCustomer(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := p.settings.Organization(ctx, customer.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rate := settings.Exchange
|
||||
preview := &models.WalletExchangePreview{
|
||||
CoinAmount: rate.CoinAmount,
|
||||
PointAmount: rate.PointAmount,
|
||||
CoinBalance: balances[constants.WalletCurrencyCoin],
|
||||
Coins: coins,
|
||||
}
|
||||
if reason := exchangeProblem(customer, rate, coins); reason != "" {
|
||||
preview.Reason = reason
|
||||
return preview, nil
|
||||
}
|
||||
preview.Points = exchangePoints(coins, rate)
|
||||
if coins > preview.CoinBalance {
|
||||
preview.Reason = "not enough EnakCoin"
|
||||
return preview, nil
|
||||
}
|
||||
preview.Valid = true
|
||||
return preview, nil
|
||||
}
|
||||
|
||||
// Exchange takes coins EnakCoin and gives the EnakPoint they are worth at the
|
||||
// organization's rate, in one transaction. The two ledger rows share a group and
|
||||
// point at each other, and both freeze the rate. Each EnakPoint lot keeps the expiry
|
||||
// of the EnakCoin lot it came from, so exchanging cannot extend a balance's life
|
||||
// (K9). The PIN approves it (K8).
|
||||
//
|
||||
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
|
||||
// the first exchange, at the rate it was made, without moving anything again.
|
||||
func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.UUID, coins int64, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletExchangeResult, error) {
|
||||
key, err := walletMoveKey(idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
customer, err := p.customers.GetCustomer(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings, err := p.settings.Organization(ctx, customer.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refuse a malformed request before the PIN is checked, so a typo in the amount
|
||||
// costs the customer no PIN attempt.
|
||||
if reason := exchangeProblem(customer, settings.Exchange, coins); reason != "" {
|
||||
return nil, fmt.Errorf("%w: %s", ErrWalletMoveRejected, reason)
|
||||
}
|
||||
if err := p.pins.VerifyPin(ctx, customerID, pin, PinActionExchange, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outKey := fmt.Sprintf("exchange:%s:%s:out", customerID, key)
|
||||
inKey := fmt.Sprintf("exchange:%s:%s:in", customerID, key)
|
||||
result := &models.WalletExchangeResult{Coins: coins}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.wallet.LockWallet(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
rate := settings.Exchange
|
||||
groupID, outID, inID := uuid.New(), uuid.New(), uuid.New()
|
||||
previous, err := p.wallet.FindTransaction(ctx, outKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous != nil && previous.GroupID != nil {
|
||||
// A retry: repeat it with the ids and the rate the first attempt froze, so
|
||||
// both rows replay even if the rate has changed since.
|
||||
outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID
|
||||
rate = frozenExchangeRate(previous.Metadata, rate)
|
||||
}
|
||||
points := exchangePoints(coins, rate)
|
||||
metadata := entities.Metadata{
|
||||
"coins": coins,
|
||||
"points": points,
|
||||
"coin_amount": rate.CoinAmount,
|
||||
"point_amount": rate.PointAmount,
|
||||
}
|
||||
|
||||
out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
TransactionID: outID,
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyCoin,
|
||||
Type: constants.WalletTxTypeExchangeOut,
|
||||
Amount: coins,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx,
|
||||
ReferenceID: inID,
|
||||
GroupID: &groupID,
|
||||
Description: fmt.Sprintf("Tukar %d EnakCoin ke EnakPoint", coins),
|
||||
Metadata: metadata,
|
||||
IdempotencyKey: outKey,
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return fmt.Errorf("%w: not enough EnakCoin", ErrWalletMoveRejected)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
TransactionID: inID,
|
||||
CustomerID: customerID,
|
||||
Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeExchangeIn,
|
||||
Amount: points,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx,
|
||||
ReferenceID: outID,
|
||||
GroupID: &groupID,
|
||||
Description: fmt.Sprintf("Dari tukar %d EnakCoin", coins),
|
||||
Metadata: metadata,
|
||||
IdempotencyKey: inKey,
|
||||
},
|
||||
Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.GroupID = groupID
|
||||
result.Points = points
|
||||
result.CoinAmount = rate.CoinAmount
|
||||
result.PointAmount = rate.PointAmount
|
||||
result.Lots = movedLots(in.Lots)
|
||||
result.Replayed = out.Replayed
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balances, err := p.spendable.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.CoinBalance = balances[constants.WalletCurrencyCoin]
|
||||
result.PointBalance = balances[constants.WalletCurrencyPoint]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// exchangeProblem says why coins cannot be exchanged, or "" when they can as far as
|
||||
// the request goes. The balance is checked under the wallet lock.
|
||||
func exchangeProblem(customer *repository.WalletMoveCustomer, rate models.LoyaltyExchangeSettings, coins int64) string {
|
||||
switch {
|
||||
case !customer.IsActive:
|
||||
return "the customer is not active"
|
||||
case coins <= 0:
|
||||
return "the number of EnakCoin must be positive"
|
||||
case rate.CoinAmount <= 0 || rate.PointAmount <= 0:
|
||||
return "exchange is not available"
|
||||
case coins%rate.CoinAmount != 0:
|
||||
// Otherwise part of the EnakCoin would be lost to rounding (F4).
|
||||
return fmt.Sprintf("EnakCoin are exchanged in multiples of %d", rate.CoinAmount)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// exchangePoints is (coins / coin_amount) × point_amount, for coins that are a
|
||||
// multiple of coin_amount.
|
||||
func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 {
|
||||
return coins / rate.CoinAmount * rate.PointAmount
|
||||
}
|
||||
|
||||
// exchangeLots splits the EnakPoint of an exchange over the EnakCoin lots it took,
|
||||
// so each part keeps the expiry of its lot and points back at it (K9). The share of
|
||||
// a lot is the difference of floor(coins so far × point_amount / coin_amount) before
|
||||
// and after it, which adds up exactly because the total is a multiple of
|
||||
// coin_amount. A lot too small to earn a whole EnakPoint on its own gives none.
|
||||
//
|
||||
// Each part expires at the sooner of its EnakCoin lot's expiry and pointExpiry, when an
|
||||
// EnakPoint received now would expire (F4); nil means never.
|
||||
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings, pointExpiry *time.Time) []WalletLotInput {
|
||||
var lots []WalletLotInput
|
||||
var coinsSoFar int64
|
||||
for _, a := range allocations {
|
||||
before := coinsSoFar * rate.PointAmount / rate.CoinAmount
|
||||
coinsSoFar += a.Amount
|
||||
points := coinsSoFar*rate.PointAmount/rate.CoinAmount - before
|
||||
if points == 0 {
|
||||
continue
|
||||
}
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID})
|
||||
}
|
||||
return lots
|
||||
}
|
||||
|
||||
// frozenExchangeRate reads the rate an exchange was made at from its ledger row.
|
||||
func frozenExchangeRate(metadata entities.Metadata, fallback models.LoyaltyExchangeSettings) models.LoyaltyExchangeSettings {
|
||||
coinAmount, ok1 := metadataInt(metadata, "coin_amount")
|
||||
pointAmount, ok2 := metadataInt(metadata, "point_amount")
|
||||
if !ok1 || !ok2 || coinAmount <= 0 || pointAmount <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return models.LoyaltyExchangeSettings{CoinAmount: coinAmount, PointAmount: pointAmount}
|
||||
}
|
||||
|
||||
// metadataInt reads a whole number from metadata that may have been through JSONB,
|
||||
// which gives numbers back as float64.
|
||||
func metadataInt(metadata entities.Metadata, key string) (int64, bool) {
|
||||
switch v := metadata[key].(type) {
|
||||
case float64:
|
||||
return int64(v), true
|
||||
case int64:
|
||||
return v, true
|
||||
case int:
|
||||
return int64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func movedLots(lots []entities.WalletLot) []models.WalletMovedLot {
|
||||
out := make([]models.WalletMovedLot, 0, len(lots))
|
||||
for _, lot := range lots {
|
||||
out = append(out, models.WalletMovedLot{Amount: lot.OriginalAmount, ExpiresAt: lot.ExpiresAt})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// walletMoveKey checks the client's Idempotency-Key, which exchanges and transfers
|
||||
// require (F4, F5).
|
||||
func walletMoveKey(key string) (string, error) {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("%w: the Idempotency-Key header is required", ErrWalletMoveRejected)
|
||||
}
|
||||
if len(key) > walletMoveKeyLimit {
|
||||
return "", fmt.Errorf("%w: the Idempotency-Key header must be at most %d characters", ErrWalletMoveRejected, walletMoveKeyLimit)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// walletMoveEnv runs exchanges and transfers on the in-memory wallet, so every test
|
||||
// also checks the §7.5 invariants when it ends.
|
||||
type walletMoveEnv struct {
|
||||
*walletTestEnv
|
||||
customers *walletMoveRepoFake
|
||||
settings *models.OrganizationLoyaltySettings
|
||||
pins *movePinFake
|
||||
}
|
||||
|
||||
func newWalletMoveEnv(t *testing.T) *walletMoveEnv {
|
||||
e := &walletMoveEnv{
|
||||
walletTestEnv: newWalletTestEnv(t),
|
||||
customers: &walletMoveRepoFake{byID: map[uuid.UUID]*repository.WalletMoveCustomer{}},
|
||||
settings: &models.OrganizationLoyaltySettings{
|
||||
PointValue: 1,
|
||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1},
|
||||
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||
},
|
||||
pins: &movePinFake{good: "482913"},
|
||||
}
|
||||
e.customers.ledger = e.repo
|
||||
// Ledger rows are stamped from now on, so "today" is the day of e.now.
|
||||
e.repo.clock = e.now
|
||||
return e
|
||||
}
|
||||
|
||||
// member adds an active customer of the organization.
|
||||
func (e *walletMoveEnv) member(name, phone string) uuid.UUID {
|
||||
id := e.customer()
|
||||
e.customers.byID[id] = &repository.WalletMoveCustomer{
|
||||
ID: id, OrganizationID: e.org, Name: name, PhoneNumber: &phone, IsActive: true,
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (e *walletMoveEnv) exchanges() *WalletExchangeProcessor {
|
||||
p := NewWalletExchangeProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{})
|
||||
p.now = func() time.Time { return e.now }
|
||||
return p
|
||||
}
|
||||
|
||||
func (e *walletMoveEnv) transfers(notifier customerNotifier) *WalletTransferProcessor {
|
||||
p := NewWalletTransferProcessor(e.customers, e, e, e.pins, e.p, txRunnerFake{}, notifier)
|
||||
p.now = func() time.Time { return e.now }
|
||||
return p
|
||||
}
|
||||
|
||||
func (e *walletMoveEnv) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
s := *e.settings
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// SpendableBalances sums the unexpired lots, as the real query does.
|
||||
func (e *walletMoveEnv) SpendableBalances(_ context.Context, customerID uuid.UUID, asOf time.Time) (map[string]int64, error) {
|
||||
out := map[string]int64{}
|
||||
for _, lot := range e.repo.lots {
|
||||
if lot.CustomerID == customerID && (lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf)) {
|
||||
out[lot.Currency] += lot.RemainingAmount
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// earnCoins gives a customer an EnakCoin lot.
|
||||
func (e *walletMoveEnv) earnCoins(t *testing.T, customerID uuid.UUID, amount int64, expiresAt *time.Time) *WalletResult {
|
||||
t.Helper()
|
||||
in := earn(customerID, amount, expiresAt)
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
return e.credit(t, in)
|
||||
}
|
||||
|
||||
func (e *walletMoveEnv) coinBalance(t *testing.T, customerID uuid.UUID) int64 {
|
||||
t.Helper()
|
||||
w, err := e.repo.GetWallet(e.ctx, customerID)
|
||||
require.NoError(t, err)
|
||||
return w.CoinBalance
|
||||
}
|
||||
|
||||
type walletMoveRepoFake struct {
|
||||
byID map[uuid.UUID]*repository.WalletMoveCustomer
|
||||
ledger *walletRepoFake
|
||||
}
|
||||
|
||||
func (f *walletMoveRepoFake) GetCustomer(_ context.Context, id uuid.UUID) (*repository.WalletMoveCustomer, error) {
|
||||
c, ok := f.byID[id]
|
||||
if !ok {
|
||||
return nil, repository.ErrWalletNotFound
|
||||
}
|
||||
copied := *c
|
||||
return &copied, nil
|
||||
}
|
||||
|
||||
func (f *walletMoveRepoFake) FindCustomerByPhone(ctx context.Context, phone string) (*repository.WalletMoveCustomer, error) {
|
||||
for id, c := range f.byID {
|
||||
if c.PhoneNumber != nil && *c.PhoneNumber == phone {
|
||||
return f.GetCustomer(ctx, id)
|
||||
}
|
||||
}
|
||||
return nil, repository.ErrWalletNotFound
|
||||
}
|
||||
|
||||
func (f *walletMoveRepoFake) TransferredOutSince(_ context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
|
||||
var total int64
|
||||
for _, tx := range f.ledger.transactions {
|
||||
if tx.CustomerID == customerID && tx.Currency == currency && tx.Type == constants.WalletTxTypeTransferOut && !tx.CreatedAt.Before(since) {
|
||||
total -= tx.Amount
|
||||
}
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// movePinFake accepts one PIN and records the actions it was asked to approve.
|
||||
type movePinFake struct {
|
||||
good string
|
||||
err error
|
||||
actions []PinAction
|
||||
}
|
||||
|
||||
func (f *movePinFake) VerifyPin(_ context.Context, _ uuid.UUID, pin string, action PinAction, _ models.CustomerPinRequestInfo) error {
|
||||
f.actions = append(f.actions, action)
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
if pin != f.good {
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: 4}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWalletExchange_DefaultRateIsOneToOne(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi Santoso", "081234561234")
|
||||
e.earnCoins(t, c, 50, nil)
|
||||
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 50, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(50), res.Points)
|
||||
assert.Equal(t, int64(0), res.CoinBalance)
|
||||
assert.Equal(t, int64(50), res.PointBalance)
|
||||
assert.Equal(t, []PinAction{PinActionExchange}, e.pins.actions)
|
||||
|
||||
out, in := e.repo.transactions[1], e.repo.transactions[2]
|
||||
assert.Equal(t, constants.WalletTxTypeExchangeOut, out.Type)
|
||||
assert.Equal(t, constants.WalletCurrencyCoin, out.Currency)
|
||||
assert.Equal(t, int64(-50), out.Amount)
|
||||
assert.Equal(t, constants.WalletTxTypeExchangeIn, in.Type)
|
||||
assert.Equal(t, constants.WalletCurrencyPoint, in.Currency)
|
||||
assert.Equal(t, int64(50), in.Amount)
|
||||
// The pair shares a group and each row points at the other (§8.1).
|
||||
assert.Equal(t, *out.GroupID, *in.GroupID)
|
||||
assert.Equal(t, res.GroupID, *out.GroupID)
|
||||
assert.Equal(t, in.ID, out.ReferenceID)
|
||||
assert.Equal(t, out.ID, in.ReferenceID)
|
||||
assert.Equal(t, "Tukar 50 EnakCoin ke EnakPoint", out.Description)
|
||||
assert.Equal(t, "Dari tukar 50 EnakCoin", in.Description)
|
||||
// Both rows freeze the rate.
|
||||
for _, row := range []*entities.WalletTransaction{out, in} {
|
||||
assert.Equal(t, int64(1), row.Metadata["coin_amount"])
|
||||
assert.Equal(t, int64(1), row.Metadata["point_amount"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletExchange_TenCoinsForThreePoints(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 35, nil)
|
||||
|
||||
preview, err := e.exchanges().Preview(e.ctx, c, 30)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, preview.Valid)
|
||||
assert.Equal(t, int64(9), preview.Points)
|
||||
assert.Equal(t, int64(35), preview.CoinBalance)
|
||||
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(9), res.Points)
|
||||
assert.Equal(t, int64(5), res.CoinBalance)
|
||||
assert.Equal(t, int64(9), res.PointBalance)
|
||||
}
|
||||
|
||||
func TestWalletExchange_RefusesAmountsThatAreNotAMultiple(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 50, nil)
|
||||
|
||||
preview, err := e.exchanges().Preview(e.ctx, c, 25)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, preview.Valid)
|
||||
assert.Contains(t, preview.Reason, "multiples of 10")
|
||||
|
||||
_, err = e.exchanges().Exchange(e.ctx, c, 25, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||
// Refused before the PIN, so a typo costs no attempt.
|
||||
assert.Empty(t, e.pins.actions)
|
||||
assert.Equal(t, int64(50), e.coinBalance(t, c))
|
||||
|
||||
_, err = e.exchanges().Exchange(e.ctx, c, 0, "482913", "key-2", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||
}
|
||||
|
||||
func TestWalletExchange_NeverOutlivesTheCoinLot(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3}
|
||||
c := e.member("Budi", "081234561234")
|
||||
soon, later := e.at(24*time.Hour), e.at(48*time.Hour)
|
||||
first := e.earnCoins(t, c, 15, soon)
|
||||
second := e.earnCoins(t, c, 15, later)
|
||||
third := e.earnCoins(t, c, 10, nil)
|
||||
|
||||
// 40 EnakCoin take 15 from the lot expiring soonest, 15 from the next and 10 from
|
||||
// the one that never expires, giving 12 EnakPoint split 4 + 5 + 3.
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Lots, 3)
|
||||
assert.Equal(t, models.WalletMovedLot{Amount: 4, ExpiresAt: soon}, res.Lots[0])
|
||||
assert.Equal(t, models.WalletMovedLot{Amount: 5, ExpiresAt: later}, res.Lots[1])
|
||||
assert.Equal(t, models.WalletMovedLot{Amount: 3}, res.Lots[2])
|
||||
|
||||
origins := map[uuid.UUID]*time.Time{}
|
||||
for _, lot := range e.repo.lots {
|
||||
if lot.Currency == constants.WalletCurrencyPoint {
|
||||
require.NotNil(t, lot.OriginLotID)
|
||||
origins[*lot.OriginLotID] = lot.ExpiresAt
|
||||
}
|
||||
}
|
||||
assert.Equal(t, map[uuid.UUID]*time.Time{
|
||||
first.Lots[0].ID: soon, second.Lots[0].ID: later, third.Lots[0].ID: nil,
|
||||
}, origins)
|
||||
for _, lot := range e.repo.lots {
|
||||
if lot.Currency != constants.WalletCurrencyPoint || lot.OriginLotID == nil {
|
||||
continue
|
||||
}
|
||||
for _, coinLot := range e.repo.lots {
|
||||
if coinLot.ID == *lot.OriginLotID && coinLot.ExpiresAt != nil {
|
||||
require.NotNil(t, lot.ExpiresAt, "a lot that expires cannot become one that does not")
|
||||
assert.False(t, lot.ExpiresAt.After(*coinLot.ExpiresAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletExchange_LotTooSmallForAWholePointGivesNone(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 5, e.at(time.Hour))
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
// Neither lot is worth a whole EnakPoint alone; the one that completes the 10
|
||||
// carries it.
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 10, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []models.WalletMovedLot{{Amount: 1}}, res.Lots)
|
||||
}
|
||||
|
||||
func TestWalletExchange_NotEnoughCoins(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
preview, err := e.exchanges().Preview(e.ctx, c, 6)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, preview.Valid)
|
||||
|
||||
_, err = e.exchanges().Exchange(e.ctx, c, 6, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, c))
|
||||
}
|
||||
|
||||
func TestWalletExchange_WrongPinMovesNothing(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
_, err := e.exchanges().Exchange(e.ctx, c, 5, "000000", "key-1", models.CustomerPinRequestInfo{})
|
||||
var pinErr *PinError
|
||||
require.True(t, errors.As(err, &pinErr))
|
||||
assert.Equal(t, PinErrInvalid, pinErr.Code)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, c))
|
||||
}
|
||||
|
||||
func TestWalletExchange_RetryReturnsTheFirstExchangeAtItsRate(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 100, nil)
|
||||
|
||||
first, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
// The rate changes before the retry arrives; the retry still gets the first result.
|
||||
e.settings.Exchange = models.LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 2}
|
||||
again, err := e.exchanges().Exchange(e.ctx, c, 40, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, again.Replayed)
|
||||
assert.Equal(t, first.GroupID, again.GroupID)
|
||||
assert.Equal(t, int64(40), again.Points)
|
||||
assert.Equal(t, int64(60), e.coinBalance(t, c))
|
||||
assert.Len(t, e.repo.transactions, 3)
|
||||
|
||||
// The same key for a different amount is not a retry.
|
||||
_, err = e.exchanges().Exchange(e.ctx, c, 20, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
}
|
||||
|
||||
func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
c := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, c, 5, nil)
|
||||
|
||||
_, err := e.exchanges().Exchange(e.ctx, c, 5, "482913", " ", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||
assert.Empty(t, e.pins.actions)
|
||||
}
|
||||
|
||||
// F4: EnakPoint from an exchange expire at the sooner of the EnakCoin lot's expiry and
|
||||
// when EnakPoint received now would expire.
|
||||
func TestWalletExchange_CappedByThePointExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.now = wib(2026, 6, 1, 10, 0)
|
||||
e.settings.PointExpiry = rolling(30, "DAY", false)
|
||||
c := e.member("Budi", "081234561234")
|
||||
soon, later := e.at(24*time.Hour), e.at(90*24*time.Hour)
|
||||
e.earnCoins(t, c, 10, soon)
|
||||
e.earnCoins(t, c, 10, later)
|
||||
e.earnCoins(t, c, 10, nil)
|
||||
|
||||
res, err := e.exchanges().Exchange(e.ctx, c, 30, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, res.Lots, 3)
|
||||
assert.Equal(t, soon, res.Lots[0].ExpiresAt, "the EnakCoin lot expires first")
|
||||
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[1].ExpiresAt), "30 days of EnakPoint come first")
|
||||
assert.Equal(t, "2026-07-01", expiryDay(t, res.Lots[2].ExpiresAt), "a lot that never expired gets the EnakPoint expiry")
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
// Lots expired per query; a run keeps going until nothing is due.
|
||||
walletExpiryBatchSize = 500
|
||||
// Batches per run at most, so one run cannot run away.
|
||||
walletExpiryMaxBatches = 40
|
||||
)
|
||||
|
||||
// NotificationTypeWalletExpired is the data type of the push a customer gets when
|
||||
// part of their balance expires.
|
||||
const NotificationTypeWalletExpired = "WALLET_EXPIRED"
|
||||
|
||||
// NotificationTypeWalletExpiring is the data type of the reminder a customer gets
|
||||
// before part of their balance expires.
|
||||
const NotificationTypeWalletExpiring = "WALLET_EXPIRING"
|
||||
|
||||
// WalletExpiryProcessor takes what is left in lots whose expiry has passed
|
||||
// (docs/prd-point-coin.md F12, PC-503). It is safe to run on several instances at
|
||||
// once: every lot is expired under its wallet's lock with the key expire:{lot_id}.
|
||||
type WalletExpiryProcessor struct {
|
||||
repo repository.WalletExpiryRepository
|
||||
settings organizationSettingsReader
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
notifier customerNotifier
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletExpiryProcessor(repo repository.WalletExpiryRepository, settings organizationSettingsReader, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletExpiryProcessor {
|
||||
return &WalletExpiryProcessor{repo: repo, settings: settings, wallet: wallet, tx: tx, notifier: notifier, now: time.Now}
|
||||
}
|
||||
|
||||
type walletExpiredKey struct {
|
||||
customerID uuid.UUID
|
||||
currency string
|
||||
}
|
||||
|
||||
// ExpireDue expires every lot due now and tells each customer how much of each
|
||||
// currency they lost, in one push per currency. It returns how many lots it expired.
|
||||
// A lot that fails is logged and left for the next run; it does not stop the others.
|
||||
func (p *WalletExpiryProcessor) ExpireDue(ctx context.Context) (int, error) {
|
||||
asOf := p.now()
|
||||
expired := map[walletExpiredKey]int64{}
|
||||
count := 0
|
||||
for batch := 0; batch < walletExpiryMaxBatches; batch++ {
|
||||
due, err := p.repo.ListDueLots(ctx, asOf, walletExpiryBatchSize)
|
||||
if err != nil {
|
||||
p.notify(ctx, expired)
|
||||
return count, err
|
||||
}
|
||||
progressed := false
|
||||
for _, lot := range due {
|
||||
var res *WalletResult
|
||||
err := p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
res, err = p.wallet.ExpireLot(ctx, lot.ID, func(amount int64) string {
|
||||
return expiryDescription(amount, lot.Currency, lot.SourceDescription)
|
||||
}, asOf)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not expire wallet lot %s; it will be retried", lot.ID), err)
|
||||
continue
|
||||
}
|
||||
if res == nil || res.Transaction == nil || res.Replayed {
|
||||
// Another run got there first, or a payment used it up.
|
||||
continue
|
||||
}
|
||||
progressed = true
|
||||
count++
|
||||
expired[walletExpiredKey{lot.CustomerID, lot.Currency}] += -res.Transaction.Amount
|
||||
}
|
||||
// A short batch was the last; a batch that moved nothing would only come back
|
||||
// the same, whether failing or taken by another instance.
|
||||
if len(due) < walletExpiryBatchSize || !progressed {
|
||||
break
|
||||
}
|
||||
}
|
||||
p.notify(ctx, expired)
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// notify is best effort: the balance has already expired.
|
||||
func (p *WalletExpiryProcessor) notify(ctx context.Context, expired map[walletExpiredKey]int64) {
|
||||
if p.notifier == nil {
|
||||
return
|
||||
}
|
||||
for key, amount := range expired {
|
||||
name := walletCurrencyName(key.currency)
|
||||
data := map[string]string{
|
||||
"type": NotificationTypeWalletExpired,
|
||||
"currency": key.currency,
|
||||
"amount": strconv.FormatInt(amount, 10),
|
||||
}
|
||||
body := fmt.Sprintf("%d %s kamu sudah kedaluwarsa.", amount, name)
|
||||
if err := p.notifier.Notify(ctx, key.customerID, name+" kedaluwarsa", body, data); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about expired %s", key.customerID, name), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expiryDescription is the EXPIRE row's frozen description (§8.1):
|
||||
// "Kedaluwarsa: 150 EnakPoint dari Belanja #ORD-0098".
|
||||
func expiryDescription(amount int64, currency, sourceDescription string) string {
|
||||
description := fmt.Sprintf("Kedaluwarsa: %d %s", amount, walletCurrencyName(currency))
|
||||
if sourceDescription != "" {
|
||||
description += " dari " + sourceDescription
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// SendReminders tells customers, reminder_days before, how much of their balance
|
||||
// expires on a day (F12): one push per customer, currency and expiry day, however many
|
||||
// lots make it up. A reminder is recorded before it is sent, so another instance or a
|
||||
// later run never sends it again; a push that then fails is logged and not retried.
|
||||
// It returns how many reminders it sent.
|
||||
func (p *WalletExpiryProcessor) SendReminders(ctx context.Context) (int, error) {
|
||||
now := p.now()
|
||||
organizations, err := p.repo.OrganizationsWithUpcomingExpiry(ctx, now)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
sent := 0
|
||||
for _, organizationID := range organizations {
|
||||
settings, err := p.settings.Organization(ctx, organizationID)
|
||||
if err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not read the expiry settings of organization %s; its reminders wait for the next run", organizationID), err)
|
||||
continue
|
||||
}
|
||||
for _, currency := range []string{constants.WalletCurrencyPoint, constants.WalletCurrencyCoin} {
|
||||
days := ExpirySettings(settings, currency).ReminderDays
|
||||
if days <= 0 {
|
||||
continue
|
||||
}
|
||||
until := endOfWalletDay(walletDay(now).AddDate(0, 0, int(days)))
|
||||
upcoming, err := p.repo.UpcomingUnreminded(ctx, organizationID, currency, now, *until)
|
||||
if err != nil {
|
||||
return sent, err
|
||||
}
|
||||
for _, u := range upcoming {
|
||||
first, err := p.repo.MarkReminded(ctx, u, currency)
|
||||
if err != nil {
|
||||
return sent, err
|
||||
}
|
||||
if !first {
|
||||
continue
|
||||
}
|
||||
sent++
|
||||
p.remind(ctx, u, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
func (p *WalletExpiryProcessor) remind(ctx context.Context, u repository.UpcomingExpiry, currency string) {
|
||||
if p.notifier == nil {
|
||||
return
|
||||
}
|
||||
name := walletCurrencyName(currency)
|
||||
body := fmt.Sprintf("%d %s akan kedaluwarsa pada %s. Pakai sebelum hangus.", u.Amount, name, formatWalletDate(u.Date))
|
||||
data := map[string]string{
|
||||
"type": NotificationTypeWalletExpiring,
|
||||
"currency": currency,
|
||||
"amount": strconv.FormatInt(u.Amount, 10),
|
||||
"expiry_date": u.Date,
|
||||
}
|
||||
if err := p.notifier.Notify(ctx, u.CustomerID, name+" akan kedaluwarsa", body, data); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not remind customer %s of expiring %s", u.CustomerID, name), err)
|
||||
}
|
||||
}
|
||||
|
||||
var walletMonthNames = [...]string{"Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"}
|
||||
|
||||
// formatWalletDate writes a YYYY-MM-DD date the way the apps do: "31 Okt 2026".
|
||||
func formatWalletDate(date string) string {
|
||||
d, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return fmt.Sprintf("%d %s %d", d.Day(), walletMonthNames[d.Month()-1], d.Year())
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// walletExpiryRepoFake lists the fake wallet's due lots the way the query does, plus
|
||||
// any extra lots a test wants listed.
|
||||
type walletExpiryRepoFake struct {
|
||||
wallet *walletRepoFake
|
||||
extra []repository.DueLot
|
||||
// customer/currency/date of the reminders recorded.
|
||||
reminded map[string]bool
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) ListDueLots(_ context.Context, asOf time.Time, limit int) ([]repository.DueLot, error) {
|
||||
descriptions := map[uuid.UUID]string{}
|
||||
for _, tx := range f.wallet.transactions {
|
||||
descriptions[tx.ID] = tx.Description
|
||||
}
|
||||
due := append([]repository.DueLot(nil), f.extra...)
|
||||
for _, lot := range f.wallet.lots {
|
||||
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && !lot.ExpiresAt.After(asOf) {
|
||||
due = append(due, repository.DueLot{
|
||||
ID: lot.ID, CustomerID: lot.CustomerID, Currency: lot.Currency, Remaining: lot.RemainingAmount,
|
||||
ExpiresAt: *lot.ExpiresAt, SourceDescription: descriptions[lot.SourceTransactionID],
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(due, func(i, j int) bool { return due[i].ExpiresAt.Before(due[j].ExpiresAt) })
|
||||
if len(due) > limit {
|
||||
due = due[:limit]
|
||||
}
|
||||
return due, nil
|
||||
}
|
||||
|
||||
func (e *walletMoveEnv) expiry(notifier customerNotifier) (*WalletExpiryProcessor, *walletExpiryRepoFake) {
|
||||
repo := &walletExpiryRepoFake{wallet: e.repo}
|
||||
p := NewWalletExpiryProcessor(repo, e, e.p, txRunnerFake{}, notifier)
|
||||
p.now = func() time.Time { return e.now }
|
||||
return p, repo
|
||||
}
|
||||
|
||||
func TestWalletExpiry_ExpiresWhatIsDueAndTellsTheCustomer(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
ord := earn(a, 150, e.at(-time.Hour))
|
||||
ord.Description = "Belanja #ORD-0098"
|
||||
due := e.credit(t, ord)
|
||||
e.credit(t, earn(a, 50, e.at(-2*time.Hour)))
|
||||
e.credit(t, earn(a, 70, e.at(time.Hour))) // not yet
|
||||
e.credit(t, earn(a, 30, nil)) // never
|
||||
e.earnCoins(t, a, 4, e.at(-time.Minute))
|
||||
// A payment already used part of the first lot; only the rest expires.
|
||||
_, err := e.p.Debit(e.ctx, WalletDebitInput{WalletEntry: pay(a, 20).WalletEntry, PreferredLotIDs: []uuid.UUID{due.Lots[0].ID}})
|
||||
require.NoError(t, err)
|
||||
|
||||
notifier := ¬ifierFake{}
|
||||
p, _ := e.expiry(notifier)
|
||||
count, err := p.ExpireDue(e.ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3, count)
|
||||
assert.Equal(t, int64(100), e.balance(t, a), "70 not due yet + 30 that never expires")
|
||||
assert.Equal(t, int64(0), e.coinBalance(t, a))
|
||||
|
||||
var expire *entities.WalletTransaction
|
||||
for _, tx := range e.repo.transactions {
|
||||
if tx.Type == constants.WalletTxTypeExpire && tx.ReferenceID == due.Lots[0].ID {
|
||||
expire = tx
|
||||
}
|
||||
}
|
||||
require.NotNil(t, expire)
|
||||
assert.Equal(t, int64(-130), expire.Amount)
|
||||
assert.Equal(t, "Kedaluwarsa: 130 EnakPoint dari Belanja #ORD-0098", expire.Description)
|
||||
assert.Equal(t, constants.WalletRefTypeLot, expire.ReferenceType)
|
||||
assert.Equal(t, "expire:"+due.Lots[0].ID.String(), *expire.IdempotencyKey)
|
||||
|
||||
// One push per currency, with the total.
|
||||
pushes := notifier.pushes[a]
|
||||
require.Len(t, pushes, 2)
|
||||
byCurrency := map[string]pushFake{}
|
||||
for _, p := range pushes {
|
||||
byCurrency[p.data["currency"]] = p
|
||||
}
|
||||
assert.Equal(t, "EnakPoint kedaluwarsa", byCurrency["POINT"].title)
|
||||
assert.Equal(t, "180 EnakPoint kamu sudah kedaluwarsa.", byCurrency["POINT"].body)
|
||||
assert.Equal(t, NotificationTypeWalletExpired, byCurrency["POINT"].data["type"])
|
||||
assert.Equal(t, "4", byCurrency["COIN"].data["amount"])
|
||||
}
|
||||
|
||||
func TestWalletExpiry_RunningAgainExpiresNothingMore(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
// Two instances, one after the other.
|
||||
first, _ := e.expiry(notifier)
|
||||
second, _ := e.expiry(notifier)
|
||||
n1, err := first.ExpireDue(e.ctx)
|
||||
require.NoError(t, err)
|
||||
n2, err := second.ExpireDue(e.ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, n1)
|
||||
assert.Equal(t, 0, n2)
|
||||
assert.Len(t, notifier.pushes[a], 1)
|
||||
var expires int
|
||||
for _, tx := range e.repo.transactions {
|
||||
if tx.Type == constants.WalletTxTypeExpire {
|
||||
expires++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, expires)
|
||||
}
|
||||
|
||||
func TestWalletExpiry_OneFailingLotDoesNotStopTheOthers(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.credit(t, earn(a, 150, e.at(-time.Hour)))
|
||||
p, repo := e.expiry(nil)
|
||||
// A lot listed that ExpireLot cannot find.
|
||||
repo.extra = []repository.DueLot{{ID: uuid.New(), CustomerID: a, Currency: "POINT", Remaining: 5, ExpiresAt: e.now.Add(-3 * time.Hour)}}
|
||||
|
||||
count, err := p.ExpireDue(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, count)
|
||||
assert.Equal(t, int64(0), e.balance(t, a))
|
||||
}
|
||||
|
||||
func TestWalletExpiry_NothingDue(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.credit(t, earn(a, 150, e.at(time.Hour)))
|
||||
notifier := ¬ifierFake{}
|
||||
p, _ := e.expiry(notifier)
|
||||
|
||||
count, err := p.ExpireDue(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, count)
|
||||
assert.Empty(t, notifier.pushes)
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) OrganizationsWithUpcomingExpiry(_ context.Context, asOf time.Time) ([]uuid.UUID, error) {
|
||||
seen := map[uuid.UUID]bool{}
|
||||
var out []uuid.UUID
|
||||
for _, lot := range f.wallet.lots {
|
||||
if lot.RemainingAmount > 0 && lot.ExpiresAt != nil && lot.ExpiresAt.After(asOf) && !seen[lot.OrganizationID] {
|
||||
seen[lot.OrganizationID] = true
|
||||
out = append(out, lot.OrganizationID)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) UpcomingUnreminded(_ context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]repository.UpcomingExpiry, error) {
|
||||
sums := map[[2]string]int64{}
|
||||
var order [][2]string
|
||||
for _, lot := range f.wallet.lots {
|
||||
if lot.OrganizationID != organizationID || lot.Currency != currency || lot.RemainingAmount == 0 ||
|
||||
lot.ExpiresAt == nil || !lot.ExpiresAt.After(asOf) || lot.ExpiresAt.After(until) {
|
||||
continue
|
||||
}
|
||||
key := [2]string{lot.CustomerID.String(), lot.ExpiresAt.In(walletDisplayLocation).Format("2006-01-02")}
|
||||
if f.reminded[key[0]+"/"+currency+"/"+key[1]] {
|
||||
continue
|
||||
}
|
||||
if _, ok := sums[key]; !ok {
|
||||
order = append(order, key)
|
||||
}
|
||||
sums[key] += lot.RemainingAmount
|
||||
}
|
||||
var out []repository.UpcomingExpiry
|
||||
for _, key := range order {
|
||||
out = append(out, repository.UpcomingExpiry{CustomerID: uuid.MustParse(key[0]), Date: key[1], Amount: sums[key]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *walletExpiryRepoFake) MarkReminded(_ context.Context, u repository.UpcomingExpiry, currency string) (bool, error) {
|
||||
if f.reminded == nil {
|
||||
f.reminded = map[string]bool{}
|
||||
}
|
||||
key := u.CustomerID.String() + "/" + currency + "/" + u.Date
|
||||
if f.reminded[key] {
|
||||
return false, nil
|
||||
}
|
||||
f.reminded[key] = true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func TestWalletExpiry_RemindsOncePerDayBeforeExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
e.now = wib(2026, 10, 25, 9, 0)
|
||||
e.settings.PointExpiry.ReminderDays = 7
|
||||
e.settings.CoinExpiry.ReminderDays = 0 // no reminders for EnakCoin
|
||||
a := e.member("Anita", "081200005678")
|
||||
oct31 := wib(2026, 10, 31, 23, 59)
|
||||
nov30 := wib(2026, 11, 30, 23, 59)
|
||||
e.credit(t, earn(a, 100, &oct31))
|
||||
e.credit(t, earn(a, 50, &oct31))
|
||||
e.credit(t, earn(a, 70, &nov30)) // too far off yet
|
||||
e.earnCoins(t, a, 5, &oct31)
|
||||
notifier := ¬ifierFake{}
|
||||
p, _ := e.expiry(notifier)
|
||||
|
||||
sent, err := p.SendReminders(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, sent)
|
||||
require.Len(t, notifier.pushes[a], 1)
|
||||
push := notifier.pushes[a][0]
|
||||
assert.Equal(t, "EnakPoint akan kedaluwarsa", push.title)
|
||||
assert.Equal(t, "150 EnakPoint akan kedaluwarsa pada 31 Okt 2026. Pakai sebelum hangus.", push.body)
|
||||
assert.Equal(t, map[string]string{"type": NotificationTypeWalletExpiring, "currency": "POINT", "amount": "150", "expiry_date": "2026-10-31"}, push.data)
|
||||
|
||||
// The next run, on this instance or another, sends nothing again.
|
||||
again, err := p.SendReminders(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, again)
|
||||
|
||||
// Once 30 Nov comes within seven days, it gets its own reminder.
|
||||
e.now = wib(2026, 11, 23, 9, 0)
|
||||
sent, err = p.SendReminders(e.ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, sent)
|
||||
assert.Equal(t, "70", notifier.pushes[a][1].data["amount"])
|
||||
}
|
||||
|
||||
func TestFormatWalletDate(t *testing.T) {
|
||||
assert.Equal(t, "31 Okt 2026", formatWalletDate("2026-10-31"))
|
||||
assert.Equal(t, "1 Mei 2027", formatWalletDate("2027-05-01"))
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// fixedOrganizationSettings serves the same organization settings to every caller.
|
||||
type fixedOrganizationSettings struct {
|
||||
s models.OrganizationLoyaltySettings
|
||||
}
|
||||
|
||||
func (f fixedOrganizationSettings) Organization(context.Context, uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
s := f.s
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// walletMoveDB opens TEST_DATABASE_URL and creates an organization with two customers,
|
||||
// removed again when the test ends. See internal/repository/wallet_repository_test.go.
|
||||
func walletMoveDB(t *testing.T) (db *gorm.DB, org, a, b uuid.UUID) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, a, b = uuid.New(), uuid.New(), uuid.New()
|
||||
phoneA, phoneB := "08"+a.String()[:10], "08"+b.String()[:10]
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet move test', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Anita', ?), (?, ?, 'Budi Santoso', ?)`,
|
||||
a, org, phoneA, b, org, phoneB).Error)
|
||||
customers := []uuid.UUID{a, b}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ? AND origin_lot_id IS NOT NULL`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
return db, org, a, b
|
||||
}
|
||||
|
||||
func TestWalletExchange_AgainstPostgres(t *testing.T) {
|
||||
db, _, a, _ := walletMoveDB(t)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
|
||||
Exchange: models.LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 3},
|
||||
}}
|
||||
p := NewWalletExchangeProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db),
|
||||
&movePinFake{good: "482913"}, wallet, txm)
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
in := earn(a, 30, &expiry)
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
_, err := wallet.Credit(ctx, in)
|
||||
return err
|
||||
}))
|
||||
|
||||
res, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(6), res.Points)
|
||||
assert.Equal(t, int64(10), res.CoinBalance)
|
||||
assert.Equal(t, int64(6), res.PointBalance)
|
||||
require.Len(t, res.Lots, 1)
|
||||
require.NotNil(t, res.Lots[0].ExpiresAt)
|
||||
assert.True(t, res.Lots[0].ExpiresAt.Equal(expiry))
|
||||
|
||||
// The retry reads the frozen rate back out of JSONB and replays.
|
||||
again, err := p.Exchange(context.Background(), a, 20, "482913", "db-key", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, again.Replayed)
|
||||
assert.Equal(t, int64(6), again.Points)
|
||||
|
||||
var rows int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE group_id = ?`, res.GroupID).Scan(&rows).Error)
|
||||
assert.Equal(t, int64(2), rows)
|
||||
}
|
||||
|
||||
// Transfers in both directions at once must not deadlock: both lock the two wallets
|
||||
// in customer_id order. Every one of them lands, and the totals still reconcile.
|
||||
func TestWalletTransfer_BothWaysAtOnceAgainstPostgres(t *testing.T) {
|
||||
db, _, a, b := walletMoveDB(t)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
moves := repository.NewWalletMoveRepository(db)
|
||||
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
|
||||
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||
}}
|
||||
p := NewWalletTransferProcessor(moves, settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour).Truncate(time.Second)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
if _, err := wallet.Credit(ctx, earn(a, 100, &expiry)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := wallet.Credit(ctx, earn(b, 100, nil))
|
||||
return err
|
||||
}))
|
||||
phone := func(id uuid.UUID) string { return "08" + id.String()[:10] }
|
||||
|
||||
const rounds = 10
|
||||
errs := make(chan error, 2*rounds)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < rounds; i++ {
|
||||
for _, pair := range [][2]uuid.UUID{{a, b}, {b, a}} {
|
||||
wg.Add(1)
|
||||
go func(from, to uuid.UUID, i int) {
|
||||
defer wg.Done()
|
||||
_, err := p.Transfer(context.Background(), from, sendPoints(1, phone(to)), "482913", fmt.Sprintf("race-%d", i), models.CustomerPinRequestInfo{})
|
||||
errs <- err
|
||||
}(pair[0], pair[1], i)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
var balances []int64
|
||||
require.NoError(t, db.Raw(`SELECT point_balance FROM customer_wallets WHERE customer_id IN ? ORDER BY point_balance`, []uuid.UUID{a, b}).Scan(&balances).Error)
|
||||
assert.Equal(t, []int64{100, 100}, balances)
|
||||
|
||||
// B's lots that came from A keep A's expiry to the second.
|
||||
var mismatched int64
|
||||
require.NoError(t, db.Raw(`
|
||||
SELECT COUNT(*) FROM wallet_lots l JOIN wallet_lots o ON o.id = l.origin_lot_id
|
||||
WHERE l.customer_id = ? AND o.customer_id = ? AND l.expires_at IS DISTINCT FROM o.expires_at`, b, a).Scan(&mismatched).Error)
|
||||
assert.Zero(t, mismatched)
|
||||
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
sent, err := moves.TransferredOutSince(ctx, a, constants.WalletCurrencyPoint, startOfWalletDay(time.Now()))
|
||||
assert.Equal(t, int64(rounds), sent)
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
// The example of §8 against Postgres: B's payment of 30 traces back to A's #ORD-1.
|
||||
func TestWalletTrace_AgainstPostgres(t *testing.T) {
|
||||
db, org, a, b := walletMoveDB(t)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := fixedOrganizationSettings{models.OrganizationLoyaltySettings{
|
||||
Transfer: models.LoyaltyTransferSettings{Enabled: true, MinAmount: 1},
|
||||
}}
|
||||
transfers := NewWalletTransferProcessor(repository.NewWalletMoveRepository(db), settings, repository.NewWalletQueryRepository(db), &movePinFake{good: "482913"}, wallet, txm, nil)
|
||||
|
||||
dec, jan := time.Now().Add(30*24*time.Hour), time.Now().Add(60*24*time.Hour)
|
||||
ord1 := earn(a, 100, &dec)
|
||||
ord1.Description = "Belanja #ORD-1"
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
if _, err := wallet.Credit(ctx, ord1); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := wallet.Credit(ctx, earn(a, 50, &jan))
|
||||
return err
|
||||
}))
|
||||
_, err := transfers.Transfer(context.Background(), a, sendPoints(120, "08"+b.String()[:10]), "482913", "trace", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
var payment *WalletResult
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
payment, err = wallet.Debit(ctx, pay(b, 30))
|
||||
return err
|
||||
}))
|
||||
|
||||
trace, err := NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), org, payment.Transaction.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, trace.Lots, 1)
|
||||
chain := trace.Lots[0].Chain
|
||||
require.Len(t, chain, 2)
|
||||
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
|
||||
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
|
||||
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
|
||||
|
||||
_, err = NewWalletTraceProcessor(repository.NewWalletTraceRepository(db)).Trace(context.Background(), uuid.New(), payment.Transaction.ID)
|
||||
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
|
||||
}
|
||||
|
||||
// Two instances of the expiry job at once expire each lot exactly once (PC-503).
|
||||
func TestWalletExpiry_TwoInstancesAgainstPostgres(t *testing.T) {
|
||||
db, _, a, b := walletMoveDB(t)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
for i := 0; i < 5; i++ {
|
||||
for _, c := range []uuid.UUID{a, b} {
|
||||
if _, err := wallet.Credit(ctx, earn(c, 10, &past)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
counts := make([]int, 2)
|
||||
for i := range counts {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
p := NewWalletExpiryProcessor(repository.NewWalletExpiryRepository(db), fixedOrganizationSettings{}, wallet, txm, nil)
|
||||
n, err := p.ExpireDue(context.Background())
|
||||
assert.NoError(t, err)
|
||||
counts[i] = n
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
assert.Equal(t, 10, counts[0]+counts[1], "every lot once, between them")
|
||||
|
||||
var expires, left int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id IN ? AND type = 'EXPIRE'`, []uuid.UUID{a, b}).Scan(&expires).Error)
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(point_balance), 0) FROM customer_wallets WHERE customer_id IN ?`, []uuid.UUID{a, b}).Scan(&left).Error)
|
||||
assert.Equal(t, int64(10), expires)
|
||||
assert.Zero(t, left)
|
||||
}
|
||||
@@ -142,6 +142,62 @@ func (p *WalletProcessor) LockWallets(ctx context.Context, a, b uuid.UUID) error
|
||||
return err
|
||||
}
|
||||
|
||||
// FindTransaction returns the row written under an idempotency key, or nil when the
|
||||
// key is new. Read it under the wallet lock: a flow that writes several rows uses it
|
||||
// to recognise a retry before checking limits the first attempt already counts in.
|
||||
func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey string) (*entities.WalletTransaction, error) {
|
||||
return p.repo.GetTransactionByIdempotencyKey(ctx, idempotencyKey)
|
||||
}
|
||||
|
||||
// ExpireLot takes what is left in a lot whose expiry has passed at asOf, as an EXPIRE
|
||||
// row pointing at the lot (F12). It locks the wallet before reading the lot again, so
|
||||
// it never races a payment for the same balance, and the key expire:{lot_id} makes a
|
||||
// second run, on this instance or another, take nothing more. describe gives the
|
||||
// row's description for the amount taken. It returns nil when
|
||||
// there is nothing to take: the lot is empty, not due, or already expired.
|
||||
func (p *WalletProcessor) ExpireLot(ctx context.Context, lotID uuid.UUID, describe func(amount int64) string, asOf time.Time) (*WalletResult, error) {
|
||||
lot, err := p.getLot(ctx, lotID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.repo.LockWallet(ctx, lot.CustomerID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Read again under the lock: a payment may have used it up meanwhile.
|
||||
if lot, err = p.getLot(ctx, lotID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lot.RemainingAmount == 0 || lot.ExpiresAt == nil || lot.ExpiresAt.After(asOf) {
|
||||
return nil, nil
|
||||
}
|
||||
return p.Debit(ctx, WalletDebitInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: lot.CustomerID,
|
||||
Currency: lot.Currency,
|
||||
Type: constants.WalletTxTypeExpire,
|
||||
Amount: lot.RemainingAmount,
|
||||
ReferenceType: constants.WalletRefTypeLot,
|
||||
ReferenceID: lot.ID,
|
||||
Description: describe(lot.RemainingAmount),
|
||||
Metadata: entities.Metadata{"expires_at": lot.ExpiresAt.UTC().Format(time.RFC3339)},
|
||||
IdempotencyKey: "expire:" + lot.ID.String(),
|
||||
},
|
||||
// Exactly what the lot holds, from the lot itself, even though it has expired.
|
||||
PreferredLotIDs: []uuid.UUID{lot.ID},
|
||||
})
|
||||
}
|
||||
|
||||
func (p *WalletProcessor) getLot(ctx context.Context, lotID uuid.UUID) (*entities.WalletLot, error) {
|
||||
lots, err := p.repo.GetLotsByIDs(ctx, []uuid.UUID{lotID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lots) == 0 {
|
||||
return nil, fmt.Errorf("%w: lot %s does not exist", ErrWalletInvalidEntry, lotID)
|
||||
}
|
||||
return &lots[0], nil
|
||||
}
|
||||
|
||||
// Credit adds Amount to the wallet and creates its lots.
|
||||
func (p *WalletProcessor) Credit(ctx context.Context, in WalletCreditInput) (*WalletResult, error) {
|
||||
if err := validateWalletEntry(&in.WalletEntry, true); err != nil {
|
||||
|
||||
@@ -315,3 +315,22 @@ func walletTransactionFilter(customerID uuid.UUID, q models.ListCustomerWalletTr
|
||||
}
|
||||
return filter, page, nil
|
||||
}
|
||||
|
||||
// Expiring is GET /customer/wallet/expiring: what will expire, grouped by day (F6).
|
||||
func (p *WalletQueryProcessor) Expiring(ctx context.Context, customerID uuid.UUID) (*models.CustomerWalletExpiringList, error) {
|
||||
rows, err := p.repo.ExpiringByDay(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := &models.CustomerWalletExpiringList{Point: []models.CustomerWalletExpiring{}, Coin: []models.CustomerWalletExpiring{}}
|
||||
for _, row := range rows {
|
||||
item := models.CustomerWalletExpiring{Amount: row.Amount, Date: row.Date}
|
||||
switch row.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
list.Point = append(list.Point, item)
|
||||
case constants.WalletCurrencyCoin:
|
||||
list.Coin = append(list.Coin, item)
|
||||
}
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
@@ -243,3 +243,24 @@ func TestWalletQueryProcessor_RejectsBadQueries(t *testing.T) {
|
||||
func (f *walletQueryRepoFake) OrganizationOutstanding(context.Context, uuid.UUID) (int64, int64, error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
func (f *walletQueryRepoFake) ExpiringByDay(context.Context, uuid.UUID, time.Time) ([]repository.WalletExpiringAmount, error) {
|
||||
return f.expiring, nil
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_ExpiringGroupsByCurrencyAndDay(t *testing.T) {
|
||||
repo := &walletQueryRepoFake{org: uuid.New(), expiring: []repository.WalletExpiringAmount{
|
||||
{Currency: "POINT", Date: "2026-10-31", Amount: 150},
|
||||
{Currency: "COIN", Date: "2026-10-31", Amount: 4},
|
||||
{Currency: "POINT", Date: "2026-12-31", Amount: 200},
|
||||
}}
|
||||
got, err := newWalletQueryTest(repo, nil).Expiring(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 150, Date: "2026-10-31"}, {Amount: 200, Date: "2026-12-31"}}, got.Point)
|
||||
assert.Equal(t, []models.CustomerWalletExpiring{{Amount: 4, Date: "2026-10-31"}}, got.Coin)
|
||||
|
||||
empty, err := newWalletQueryTest(&walletQueryRepoFake{org: uuid.New()}, nil).Expiring(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, empty.Point, "an empty list, not null")
|
||||
assert.NotNil(t, empty.Coin)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// walletTraceMaxDepth bounds how far back a chain is followed. Every step is a
|
||||
// transfer, exchange or refund of the step before, so real chains are short; the
|
||||
// bound only guards against a loop in bad data.
|
||||
const walletTraceMaxDepth = 100
|
||||
|
||||
// WalletTraceProcessor traces a ledger row lot by lot for the dashboard
|
||||
// (docs/prd-point-coin.md F7, §8.1). It only reads.
|
||||
type WalletTraceProcessor struct {
|
||||
repo repository.WalletTraceRepository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletTraceProcessor(repo repository.WalletTraceRepository) *WalletTraceProcessor {
|
||||
return &WalletTraceProcessor{repo: repo, now: time.Now}
|
||||
}
|
||||
|
||||
type walletTraceStart struct {
|
||||
lotID uuid.UUID
|
||||
amount int64
|
||||
}
|
||||
|
||||
// Trace follows a row of the organization's ledger: a debit through the lots it took
|
||||
// from, a credit through the lots it created, and each lot back through origin_lot_id
|
||||
// to the lot that first brought the balance in. A row of another organization is
|
||||
// repository.ErrWalletTransactionNotFound.
|
||||
func (p *WalletTraceProcessor) Trace(ctx context.Context, organizationID, transactionID uuid.UUID) (*models.AdminWalletTrace, error) {
|
||||
row, err := p.repo.GetTransaction(ctx, organizationID, transactionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var starts []walletTraceStart
|
||||
if row.Amount < 0 {
|
||||
allocations, err := p.repo.ListAllocations(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range allocations {
|
||||
starts = append(starts, walletTraceStart{lotID: a.LotID, amount: a.Amount})
|
||||
}
|
||||
} else {
|
||||
created, err := p.repo.ListLotsBySource(ctx, row.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, lot := range created {
|
||||
starts = append(starts, walletTraceStart{lotID: lot.ID, amount: lot.OriginalAmount})
|
||||
}
|
||||
}
|
||||
|
||||
lots, err := p.loadChains(ctx, starts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sourceIDs := make([]uuid.UUID, 0, len(lots))
|
||||
customerIDs := []uuid.UUID{row.CustomerID}
|
||||
for _, lot := range lots {
|
||||
sourceIDs = append(sourceIDs, lot.SourceTransactionID)
|
||||
customerIDs = append(customerIDs, lot.CustomerID)
|
||||
}
|
||||
sourceRows, err := p.repo.GetTransactions(ctx, uniqueIDs(sourceIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sources := make(map[uuid.UUID]entities.WalletTransaction, len(sourceRows))
|
||||
for _, s := range sourceRows {
|
||||
sources[s.ID] = s
|
||||
}
|
||||
names, err := p.repo.CustomerNames(ctx, uniqueIDs(customerIDs))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := p.now()
|
||||
trace := &models.AdminWalletTrace{
|
||||
Transaction: walletTraceRow(*row, names),
|
||||
Lots: make([]models.AdminWalletTraceLot, 0, len(starts)),
|
||||
}
|
||||
for _, start := range starts {
|
||||
item := models.AdminWalletTraceLot{Amount: start.amount, Chain: []models.AdminWalletTraceStep{}}
|
||||
visited := map[uuid.UUID]bool{}
|
||||
for id := &start.lotID; id != nil && !visited[*id]; {
|
||||
lot, ok := lots[*id]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
visited[*id] = true
|
||||
item.Chain = append(item.Chain, models.AdminWalletTraceStep{
|
||||
Lot: adminWalletLot(lot, now),
|
||||
Source: walletTraceRow(sources[lot.SourceTransactionID], names),
|
||||
})
|
||||
id = lot.OriginLotID
|
||||
}
|
||||
trace.Lots = append(trace.Lots, item)
|
||||
}
|
||||
return trace, nil
|
||||
}
|
||||
|
||||
// loadChains loads the starting lots and every lot they were carried over from, one
|
||||
// generation at a time.
|
||||
func (p *WalletTraceProcessor) loadChains(ctx context.Context, starts []walletTraceStart) (map[uuid.UUID]entities.WalletLot, error) {
|
||||
lots := map[uuid.UUID]entities.WalletLot{}
|
||||
pending := make([]uuid.UUID, 0, len(starts))
|
||||
for _, s := range starts {
|
||||
pending = append(pending, s.lotID)
|
||||
}
|
||||
for depth := 0; len(pending) > 0 && depth < walletTraceMaxDepth; depth++ {
|
||||
found, err := p.repo.GetLots(ctx, uniqueIDs(pending))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pending = pending[:0]
|
||||
for _, lot := range found {
|
||||
lots[lot.ID] = lot
|
||||
}
|
||||
for _, lot := range found {
|
||||
if lot.OriginLotID != nil {
|
||||
if _, seen := lots[*lot.OriginLotID]; !seen {
|
||||
pending = append(pending, *lot.OriginLotID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func walletTraceRow(row entities.WalletTransaction, names map[uuid.UUID]string) models.AdminWalletTraceRow {
|
||||
return models.AdminWalletTraceRow{
|
||||
ID: row.ID,
|
||||
Customer: models.AdminWalletNamedRef{ID: row.CustomerID, Name: names[row.CustomerID]},
|
||||
Currency: row.Currency,
|
||||
Type: row.Type,
|
||||
Amount: row.Amount,
|
||||
Description: row.Description,
|
||||
ReferenceType: row.ReferenceType,
|
||||
ReferenceID: row.ReferenceID,
|
||||
OutletID: row.OutletID,
|
||||
CreatedAt: row.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func adminWalletLot(lot entities.WalletLot, now time.Time) models.AdminWalletLot {
|
||||
return models.AdminWalletLot{
|
||||
ID: lot.ID,
|
||||
Currency: lot.Currency,
|
||||
OriginalAmount: lot.OriginalAmount,
|
||||
RemainingAmount: lot.RemainingAmount,
|
||||
ExpiresAt: lot.ExpiresAt,
|
||||
Expired: lot.ExpiresAt != nil && !lot.ExpiresAt.After(now),
|
||||
SourceTransactionID: lot.SourceTransactionID,
|
||||
OriginLotID: lot.OriginLotID,
|
||||
CreatedAt: lot.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueIDs(ids []uuid.UUID) []uuid.UUID {
|
||||
seen := make(map[uuid.UUID]bool, len(ids))
|
||||
out := make([]uuid.UUID, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// walletTraceRepoFake reads the fake wallet the way the trace repository reads the
|
||||
// tables.
|
||||
type walletTraceRepoFake struct{ e *walletMoveEnv }
|
||||
|
||||
func (f walletTraceRepoFake) GetTransaction(_ context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
|
||||
for _, tx := range f.e.repo.transactions {
|
||||
if tx.ID == id && tx.OrganizationID == organizationID {
|
||||
c := *tx
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
return nil, repository.ErrWalletTransactionNotFound
|
||||
}
|
||||
|
||||
func (f walletTraceRepoFake) GetTransactions(_ context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||
var out []entities.WalletTransaction
|
||||
for _, tx := range f.e.repo.transactions {
|
||||
if containsID(ids, tx.ID) {
|
||||
out = append(out, *tx)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f walletTraceRepoFake) ListAllocations(ctx context.Context, id uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||
return f.e.repo.ListAllocationsByTransaction(ctx, id)
|
||||
}
|
||||
|
||||
func (f walletTraceRepoFake) ListLotsBySource(ctx context.Context, id uuid.UUID) ([]entities.WalletLot, error) {
|
||||
return f.e.repo.ListLotsBySourceTransaction(ctx, id)
|
||||
}
|
||||
|
||||
func (f walletTraceRepoFake) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
return f.e.repo.GetLotsByIDs(ctx, ids)
|
||||
}
|
||||
|
||||
func (f walletTraceRepoFake) CustomerNames(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
names := map[uuid.UUID]string{}
|
||||
for _, id := range ids {
|
||||
if c, ok := f.e.customers.byID[id]; ok {
|
||||
names[id] = c.Name
|
||||
}
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func containsID(ids []uuid.UUID, id uuid.UUID) bool {
|
||||
for _, v := range ids {
|
||||
if v == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findRow(t *testing.T, e *walletMoveEnv, customerID uuid.UUID, txType string) *entities.WalletTransaction {
|
||||
t.Helper()
|
||||
for _, tx := range e.repo.transactions {
|
||||
if tx.CustomerID == customerID && tx.Type == txType {
|
||||
return tx
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s row for %s", txType, customerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// The example of §8: A has 100 from #ORD-1 and 50 from #ORD-2, sends 120 to B, and B
|
||||
// pays 30. Tracing B's payment leads to A's order #ORD-1.
|
||||
func TestWalletTrace_PaymentLeadsBackToTheSendersOrder(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
ord1 := earn(a, 100, e.at(30*24*time.Hour))
|
||||
ord1.Description = "Belanja #ORD-1"
|
||||
ord2 := earn(a, 50, e.at(60*24*time.Hour))
|
||||
ord2.Description = "Belanja #ORD-2"
|
||||
e.credit(t, ord1)
|
||||
e.credit(t, ord2)
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
payment, err := e.p.Debit(e.ctx, pay(b, 30))
|
||||
require.NoError(t, err)
|
||||
|
||||
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
|
||||
trace, err := p.Trace(e.ctx, e.org, payment.Transaction.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, constants.WalletTxTypePayment, trace.Transaction.Type)
|
||||
assert.Equal(t, "Budi Santoso", trace.Transaction.Customer.Name)
|
||||
require.Len(t, trace.Lots, 1)
|
||||
assert.Equal(t, int64(30), trace.Lots[0].Amount)
|
||||
chain := trace.Lots[0].Chain
|
||||
require.Len(t, chain, 2)
|
||||
assert.Equal(t, constants.WalletTxTypeTransferIn, chain[0].Source.Type)
|
||||
assert.Equal(t, b, chain[0].Source.Customer.ID)
|
||||
assert.Equal(t, constants.WalletTxTypeEarn, chain[1].Source.Type)
|
||||
assert.Equal(t, "Anita", chain[1].Source.Customer.Name)
|
||||
assert.Equal(t, "Belanja #ORD-1", chain[1].Source.Description)
|
||||
assert.Equal(t, constants.WalletRefTypeOrder, chain[1].Source.ReferenceType)
|
||||
assert.Equal(t, ord1.ReferenceID, chain[1].Source.ReferenceID)
|
||||
assert.Nil(t, chain[1].Lot.OriginLotID, "the chain ends at the lot the order created")
|
||||
assert.Equal(t, *chain[0].Lot.OriginLotID, chain[1].Lot.ID)
|
||||
}
|
||||
|
||||
func TestWalletTrace_DebitAndCreditOfATransfer(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.credit(t, earn(a, 100, e.at(time.Hour)))
|
||||
e.credit(t, earn(a, 50, nil))
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
p := NewWalletTraceProcessor(walletTraceRepoFake{e})
|
||||
|
||||
// The sending side took from both of A's lots, which were A's own earnings.
|
||||
out, err := p.Trace(e.ctx, e.org, findRow(t, e, a, constants.WalletTxTypeTransferOut).ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.Lots, 2)
|
||||
assert.Equal(t, []int64{100, 20}, []int64{out.Lots[0].Amount, out.Lots[1].Amount})
|
||||
for _, lot := range out.Lots {
|
||||
require.Len(t, lot.Chain, 1)
|
||||
assert.Equal(t, constants.WalletTxTypeEarn, lot.Chain[0].Source.Type)
|
||||
}
|
||||
|
||||
// The receiving side created two lots, each carried over from one of A's.
|
||||
in, err := p.Trace(e.ctx, e.org, findRow(t, e, b, constants.WalletTxTypeTransferIn).ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, in.Lots, 2)
|
||||
for _, lot := range in.Lots {
|
||||
require.Len(t, lot.Chain, 2)
|
||||
assert.Equal(t, b, lot.Chain[0].Source.Customer.ID)
|
||||
assert.Equal(t, a, lot.Chain[1].Source.Customer.ID)
|
||||
assert.Equal(t, lot.Chain[1].Lot.ExpiresAt, lot.Chain[0].Lot.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalletTrace_OtherOrganizationsRowsAreNotFound(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
res := e.credit(t, earn(a, 10, nil))
|
||||
|
||||
_, err := NewWalletTraceProcessor(walletTraceRepoFake{e}).Trace(e.ctx, uuid.New(), res.Transaction.ID)
|
||||
assert.ErrorIs(t, err, repository.ErrWalletTransactionNotFound)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrWalletRecipientNotFound means no customer of the sender's organization has the
|
||||
// phone number. A customer of another organization is reported the same way, so the
|
||||
// check does not reveal who uses the app elsewhere.
|
||||
var ErrWalletRecipientNotFound = errors.New("no customer of this organization has that phone number")
|
||||
|
||||
// customerNotifier pushes a notification to a customer's app through FCM.
|
||||
// CustomerDeviceProcessor is one.
|
||||
type customerNotifier interface {
|
||||
Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error
|
||||
}
|
||||
|
||||
// NotificationTypeWalletTransferIn is the data type of the push a transfer recipient
|
||||
// gets, so the app can open the transaction.
|
||||
const NotificationTypeWalletTransferIn = "WALLET_TRANSFER_IN"
|
||||
|
||||
// WalletTransferProcessor sends EnakPoint or EnakCoin from one customer to another in
|
||||
// the same organization (docs/prd-point-coin.md F5).
|
||||
type WalletTransferProcessor struct {
|
||||
customers repository.WalletMoveRepository
|
||||
settings organizationSettingsReader
|
||||
spendable spendableReader
|
||||
pins pinVerifier
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
notifier customerNotifier
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewWalletTransferProcessor(customers repository.WalletMoveRepository, settings organizationSettingsReader, spendable spendableReader, pins pinVerifier, wallet *WalletProcessor, tx TxRunner, notifier customerNotifier) *WalletTransferProcessor {
|
||||
return &WalletTransferProcessor{customers: customers, settings: settings, spendable: spendable, pins: pins, wallet: wallet, tx: tx, notifier: notifier, now: time.Now}
|
||||
}
|
||||
|
||||
// Recipient is GET /customer/wallet/transfer/recipient: the masked name and number
|
||||
// of the customer a phone number belongs to, if the sender may send to them.
|
||||
func (p *WalletTransferProcessor) Recipient(ctx context.Context, senderID uuid.UUID, phoneNumber string) (*models.WalletTransferRecipient, error) {
|
||||
sender, err := p.customers.GetCustomer(ctx, senderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recipient, err := p.recipient(ctx, sender, phoneNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return maskedRecipient(recipient), nil
|
||||
}
|
||||
|
||||
// Transfer sends in.Amount of in.Currency to the customer with in.RecipientPhone,
|
||||
// approved by the sender's PIN (K8), and tells the recipient.
|
||||
//
|
||||
// Both wallets are locked in customer_id order, so two transfers in opposite
|
||||
// directions cannot deadlock. TRANSFER_OUT takes from the sender's lots in K9 order,
|
||||
// and TRANSFER_IN gives the recipient lots with exactly the same expiries, pointing
|
||||
// back at the sender's lots, so sending a balance back and forth cannot extend it.
|
||||
// The two rows share a group and name each other's customer.
|
||||
//
|
||||
// idempotencyKey is the client's Idempotency-Key: a retry with the same key returns
|
||||
// the first transfer without moving anything again or counting against the limits.
|
||||
func (p *WalletTransferProcessor) Transfer(ctx context.Context, senderID uuid.UUID, in models.WalletTransfer, pin, idempotencyKey string, info models.CustomerPinRequestInfo) (*models.WalletTransferResult, error) {
|
||||
reject := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrWalletMoveRejected, fmt.Sprintf(format, args...))
|
||||
}
|
||||
key, err := walletMoveKey(idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
currency := strings.ToUpper(strings.TrimSpace(in.Currency))
|
||||
if !constants.IsValidWalletCurrency(currency) {
|
||||
return nil, reject("currency must be POINT or COIN")
|
||||
}
|
||||
if in.Amount <= 0 {
|
||||
return nil, reject("the amount must be positive")
|
||||
}
|
||||
sender, err := p.customers.GetCustomer(ctx, senderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !sender.IsActive {
|
||||
return nil, reject("the customer is not active")
|
||||
}
|
||||
settings, err := p.settings.Organization(ctx, sender.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limits := settings.Transfer
|
||||
switch {
|
||||
case !limits.Enabled:
|
||||
return nil, reject("transfers are turned off")
|
||||
case in.Amount < limits.MinAmount:
|
||||
return nil, reject("at least %d can be sent at a time", limits.MinAmount)
|
||||
case limits.MaxPerTransaction != nil && in.Amount > *limits.MaxPerTransaction:
|
||||
return nil, reject("at most %d can be sent at a time", *limits.MaxPerTransaction)
|
||||
}
|
||||
recipient, err := p.recipient(ctx, sender, in.RecipientPhone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Everything the request alone can get wrong is refused above, before the PIN, so
|
||||
// it costs no attempt. The PIN also refuses a transfer held after a PIN reset.
|
||||
if err := p.pins.VerifyPin(ctx, senderID, pin, PinActionTransfer, info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
to, from := maskedRecipient(recipient), maskedRecipient(sender)
|
||||
outKey := fmt.Sprintf("transfer:%s:%s:out", senderID, key)
|
||||
inKey := fmt.Sprintf("transfer:%s:%s:in", senderID, key)
|
||||
result := &models.WalletTransferResult{Currency: currency, Amount: in.Amount, Recipient: *to}
|
||||
var receivedID uuid.UUID
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
if err := p.wallet.LockWallets(ctx, senderID, recipient.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
groupID, outID, inID := uuid.New(), uuid.New(), uuid.New()
|
||||
previous, err := p.wallet.FindTransaction(ctx, outKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if previous != nil {
|
||||
// A retry: it replays below, so it must not count against the daily limit
|
||||
// it is already part of.
|
||||
if previous.CounterpartyCustomerID == nil || *previous.CounterpartyCustomerID != recipient.ID || previous.GroupID == nil {
|
||||
return ErrWalletIdempotencyConflict
|
||||
}
|
||||
outID, inID, groupID = previous.ID, previous.ReferenceID, *previous.GroupID
|
||||
} else if limits.DailyLimit != nil {
|
||||
sent, err := p.customers.TransferredOutSince(ctx, senderID, currency, startOfWalletDay(p.now()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sent+in.Amount > *limits.DailyLimit {
|
||||
return reject("at most %d can be sent per day; %d is left today", *limits.DailyLimit, max(*limits.DailyLimit-sent, 0))
|
||||
}
|
||||
}
|
||||
|
||||
out, err := p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
TransactionID: outID,
|
||||
CustomerID: senderID,
|
||||
Currency: currency,
|
||||
Type: constants.WalletTxTypeTransferOut,
|
||||
Amount: in.Amount,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx,
|
||||
ReferenceID: inID,
|
||||
GroupID: &groupID,
|
||||
CounterpartyCustomerID: &recipient.ID,
|
||||
Description: truncateRunes(fmt.Sprintf("Transfer ke %s (%s)", to.Name, to.PhoneNumber), walletDescriptionLimit),
|
||||
IdempotencyKey: outKey,
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return reject("not enough %s", walletCurrencyName(currency))
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
received, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
TransactionID: inID,
|
||||
CustomerID: recipient.ID,
|
||||
Currency: currency,
|
||||
Type: constants.WalletTxTypeTransferIn,
|
||||
Amount: in.Amount,
|
||||
ReferenceType: constants.WalletRefTypeWalletTx,
|
||||
ReferenceID: outID,
|
||||
GroupID: &groupID,
|
||||
CounterpartyCustomerID: &senderID,
|
||||
Description: truncateRunes(fmt.Sprintf("Transfer dari %s (%s)", from.Name, from.PhoneNumber), walletDescriptionLimit),
|
||||
IdempotencyKey: inKey,
|
||||
},
|
||||
Lots: out.CarryOver(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.GroupID = groupID
|
||||
result.Lots = movedLots(received.Lots)
|
||||
result.Replayed = out.Replayed
|
||||
receivedID = received.Transaction.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !result.Replayed {
|
||||
p.tellRecipient(ctx, recipient.ID, from, currency, in.Amount, receivedID, result.GroupID)
|
||||
}
|
||||
balances, err := p.spendable.SpendableBalances(ctx, senderID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Balance = balances[currency]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// recipient finds who a phone number belongs to and checks the sender may send to
|
||||
// them: an active customer of the same organization, not the walk-in customer, and
|
||||
// not the sender.
|
||||
func (p *WalletTransferProcessor) recipient(ctx context.Context, sender *repository.WalletMoveCustomer, phoneNumber string) (*repository.WalletMoveCustomer, error) {
|
||||
phoneNumber = strings.TrimSpace(phoneNumber)
|
||||
if phoneNumber == "" {
|
||||
return nil, fmt.Errorf("%w: the recipient's phone number is required", ErrWalletMoveRejected)
|
||||
}
|
||||
recipient, err := p.customers.FindCustomerByPhone(ctx, phoneNumber)
|
||||
if errors.Is(err, repository.ErrWalletNotFound) {
|
||||
return nil, ErrWalletRecipientNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case recipient.OrganizationID != sender.OrganizationID:
|
||||
return nil, ErrWalletRecipientNotFound
|
||||
case recipient.ID == sender.ID:
|
||||
return nil, fmt.Errorf("%w: you cannot send to yourself", ErrWalletMoveRejected)
|
||||
case recipient.IsDefault || !recipient.IsActive:
|
||||
return nil, fmt.Errorf("%w: this customer cannot receive transfers", ErrWalletMoveRejected)
|
||||
}
|
||||
return recipient, nil
|
||||
}
|
||||
|
||||
// tellRecipient pushes the transfer to the recipient's app (F5). It is best effort:
|
||||
// the transfer has already happened, so a failure to send is only logged.
|
||||
func (p *WalletTransferProcessor) tellRecipient(ctx context.Context, recipientID uuid.UUID, sender *models.WalletTransferRecipient, currency string, amount int64, transactionID, groupID uuid.UUID) {
|
||||
if p.notifier == nil {
|
||||
return
|
||||
}
|
||||
name := walletCurrencyName(currency)
|
||||
title := name + " masuk"
|
||||
body := fmt.Sprintf("Kamu menerima %d %s dari %s (%s).", amount, name, sender.Name, sender.PhoneNumber)
|
||||
data := map[string]string{
|
||||
"type": NotificationTypeWalletTransferIn,
|
||||
"transaction_id": transactionID.String(),
|
||||
"group_id": groupID.String(),
|
||||
"currency": currency,
|
||||
"amount": strconv.FormatInt(amount, 10),
|
||||
}
|
||||
if err := p.notifier.Notify(ctx, recipientID, title, body, data); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s about a transfer", recipientID), err)
|
||||
}
|
||||
}
|
||||
|
||||
func maskedRecipient(c *repository.WalletMoveCustomer) *models.WalletTransferRecipient {
|
||||
phone := ""
|
||||
if c.PhoneNumber != nil {
|
||||
phone = maskPhoneNumber(*c.PhoneNumber)
|
||||
}
|
||||
return &models.WalletTransferRecipient{Name: maskName(c.Name), PhoneNumber: phone}
|
||||
}
|
||||
|
||||
// maskName keeps the first two letters of each word, "Budi Santoso" → "Bu*** Sa***",
|
||||
// and one letter of a word that short, so the sender can recognise the recipient
|
||||
// without the app revealing their name (F5, §8.1).
|
||||
func maskName(name string) string {
|
||||
words := strings.Fields(name)
|
||||
if len(words) == 0 {
|
||||
return "***"
|
||||
}
|
||||
for i, w := range words {
|
||||
keep := 2
|
||||
if utf8.RuneCountInString(w) <= 2 {
|
||||
keep = 1
|
||||
}
|
||||
words[i] = string([]rune(w)[:keep]) + "***"
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// maskPhoneNumber keeps the first two and the last four digits:
|
||||
// "081234561234" → "08**-****-1234".
|
||||
func maskPhoneNumber(phone string) string {
|
||||
runes := []rune(strings.TrimSpace(phone))
|
||||
if len(runes) < 8 {
|
||||
return "****"
|
||||
}
|
||||
return string(runes[:2]) + "**-****-" + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func walletCurrencyName(currency string) string {
|
||||
if currency == constants.WalletCurrencyCoin {
|
||||
return "EnakCoin"
|
||||
}
|
||||
return "EnakPoint"
|
||||
}
|
||||
|
||||
// startOfWalletDay is midnight of t's day in the customer's time zone, where the
|
||||
// daily transfer limit starts over.
|
||||
func startOfWalletDay(t time.Time) time.Time {
|
||||
local := t.In(walletDisplayLocation)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, walletDisplayLocation)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type pushFake struct {
|
||||
title, body string
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
// notifierFake records the pushes each customer would get.
|
||||
type notifierFake struct{ pushes map[uuid.UUID][]pushFake }
|
||||
|
||||
func (f *notifierFake) Notify(_ context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
|
||||
if f.pushes == nil {
|
||||
f.pushes = map[uuid.UUID][]pushFake{}
|
||||
}
|
||||
f.pushes[customerID] = append(f.pushes[customerID], pushFake{title: title, body: body, data: data})
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendPoints(amount int64, phone string) models.WalletTransfer {
|
||||
return models.WalletTransfer{Currency: constants.WalletCurrencyPoint, Amount: amount, RecipientPhone: phone}
|
||||
}
|
||||
|
||||
func TestWalletTransfer_MovesBalanceWithItsExpiry(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi Santoso", "081234561234")
|
||||
dec, jan := e.at(30*24*time.Hour), e.at(60*24*time.Hour)
|
||||
first := e.credit(t, earn(a, 100, dec))
|
||||
second := e.credit(t, earn(a, 50, jan))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
// The example in §8: A sends 120, 100 from the lot expiring first and 20 from the next.
|
||||
res, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(30), res.Balance)
|
||||
assert.Equal(t, models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, res.Recipient)
|
||||
assert.Equal(t, []models.WalletMovedLot{{Amount: 100, ExpiresAt: dec}, {Amount: 20, ExpiresAt: jan}}, res.Lots)
|
||||
assert.Equal(t, int64(120), e.balance(t, b))
|
||||
assert.Equal(t, []PinAction{PinActionTransfer}, e.pins.actions)
|
||||
|
||||
var bLots []uuid.UUID
|
||||
for _, lot := range e.repo.lots {
|
||||
if lot.CustomerID == b {
|
||||
require.NotNil(t, lot.OriginLotID)
|
||||
bLots = append(bLots, *lot.OriginLotID)
|
||||
for _, origin := range e.repo.lots {
|
||||
if origin.ID == *lot.OriginLotID {
|
||||
assert.Equal(t, origin.ExpiresAt, lot.ExpiresAt, "the recipient's lot expires exactly when the sender's did")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []uuid.UUID{first.Lots[0].ID, second.Lots[0].ID}, bLots)
|
||||
|
||||
out, in := e.repo.transactions[2], e.repo.transactions[3]
|
||||
assert.Equal(t, constants.WalletTxTypeTransferOut, out.Type)
|
||||
assert.Equal(t, b, *out.CounterpartyCustomerID)
|
||||
assert.Equal(t, in.ID, out.ReferenceID)
|
||||
assert.Equal(t, "Transfer ke Bu*** Sa*** (08**-****-1234)", out.Description)
|
||||
assert.Equal(t, constants.WalletTxTypeTransferIn, in.Type)
|
||||
assert.Equal(t, a, *in.CounterpartyCustomerID)
|
||||
assert.Equal(t, out.ID, in.ReferenceID)
|
||||
assert.Equal(t, *out.GroupID, *in.GroupID)
|
||||
assert.Equal(t, "Transfer dari An*** (08**-****-5678)", in.Description)
|
||||
assert.GreaterOrEqual(t, e.repo.locks[a], 1)
|
||||
assert.GreaterOrEqual(t, e.repo.locks[b], 1)
|
||||
|
||||
assert.Equal(t, []pushFake{{
|
||||
title: "EnakPoint masuk",
|
||||
body: "Kamu menerima 120 EnakPoint dari An*** (08**-****-5678).",
|
||||
data: map[string]string{
|
||||
"type": NotificationTypeWalletTransferIn,
|
||||
"transaction_id": in.ID.String(),
|
||||
"group_id": in.GroupID.String(),
|
||||
"currency": constants.WalletCurrencyPoint,
|
||||
"amount": "120",
|
||||
},
|
||||
}}, notifier.pushes[b])
|
||||
assert.Empty(t, notifier.pushes[a], "the sender gets no push")
|
||||
}
|
||||
|
||||
func TestWalletTransfer_Coins(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.earnCoins(t, a, 10, nil)
|
||||
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, models.WalletTransfer{Currency: "coin", Amount: 4, RecipientPhone: "081234561234"}, "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(6), e.coinBalance(t, a))
|
||||
assert.Equal(t, int64(4), e.coinBalance(t, b))
|
||||
}
|
||||
|
||||
func TestWalletTransfer_RefusesRecipientsItMayNotSendTo(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
|
||||
walkIn := e.member("Walk-in", "081100000000")
|
||||
e.customers.byID[walkIn].IsDefault = true
|
||||
inactive := e.member("Old", "081100000001")
|
||||
e.customers.byID[inactive].IsActive = false
|
||||
elsewhere := e.member("Other Org", "081100000002")
|
||||
e.customers.byID[elsewhere].OrganizationID = uuid.New()
|
||||
|
||||
for phone, want := range map[string]error{
|
||||
"081200005678": ErrWalletMoveRejected, // herself
|
||||
"081100000000": ErrWalletMoveRejected, // the walk-in customer
|
||||
"081100000001": ErrWalletMoveRejected, // inactive
|
||||
"081100000002": ErrWalletRecipientNotFound, // another organization looks like nobody
|
||||
"081999999999": ErrWalletRecipientNotFound,
|
||||
"": ErrWalletMoveRejected,
|
||||
} {
|
||||
_, err := e.transfers(nil).Recipient(e.ctx, a, phone)
|
||||
assert.ErrorIs(t, err, want, phone)
|
||||
_, err = e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, phone), "482913", "key-"+phone, models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, want, phone)
|
||||
}
|
||||
assert.Empty(t, e.pins.actions, "refused before the PIN")
|
||||
assert.Equal(t, int64(100), e.balance(t, a))
|
||||
}
|
||||
|
||||
func TestWalletTransfer_RecipientIsMasked(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi Santoso", "081234561234")
|
||||
|
||||
got, err := e.transfers(nil).Recipient(e.ctx, a, " 081234561234 ")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, &models.WalletTransferRecipient{Name: "Bu*** Sa***", PhoneNumber: "08**-****-1234"}, got)
|
||||
}
|
||||
|
||||
func TestWalletTransfer_OrganizationLimits(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi", "081234561234")
|
||||
e.credit(t, earn(a, 1000, nil))
|
||||
e.settings.Transfer = models.LoyaltyTransferSettings{Enabled: true, MinAmount: 10, MaxPerTransaction: ptr(int64(300)), DailyLimit: ptr(int64(500))}
|
||||
send := func(amount int64, key string) error {
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(amount, "081234561234"), "482913", key, models.CustomerPinRequestInfo{})
|
||||
return err
|
||||
}
|
||||
|
||||
assert.ErrorIs(t, send(9, "below-min"), ErrWalletMoveRejected)
|
||||
assert.ErrorIs(t, send(301, "above-max"), ErrWalletMoveRejected)
|
||||
require.NoError(t, send(300, "k1"))
|
||||
require.NoError(t, send(200, "k2"))
|
||||
// The daily limit is used up; a retry of a transfer already made still replays.
|
||||
assert.ErrorIs(t, send(10, "k3"), ErrWalletMoveRejected)
|
||||
require.NoError(t, send(200, "k2"))
|
||||
|
||||
// It starts over the next day in the customer's time zone.
|
||||
e.now = startOfWalletDay(e.now).AddDate(0, 0, 1).Add(time.Minute)
|
||||
e.repo.clock = e.now
|
||||
require.NoError(t, send(10, "k4"))
|
||||
assert.Equal(t, int64(490), e.balance(t, a))
|
||||
|
||||
e.settings.Transfer.Enabled = false
|
||||
assert.ErrorIs(t, send(10, "k5"), ErrWalletMoveRejected)
|
||||
}
|
||||
|
||||
func TestWalletTransfer_HeldAfterPinReset(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
e.member("Budi", "081234561234")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
until := e.now.Add(time.Hour)
|
||||
e.pins.err = &PinError{Code: PinErrTransferBlocked, Until: &until}
|
||||
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(10, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
var pinErr *PinError
|
||||
require.True(t, errors.As(err, &pinErr))
|
||||
assert.Equal(t, PinErrTransferBlocked, pinErr.Code)
|
||||
assert.Equal(t, int64(100), e.balance(t, a))
|
||||
}
|
||||
|
||||
func TestWalletTransfer_NotEnoughBalance(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
// An expired lot cannot be sent even before the expiry job takes it.
|
||||
e.credit(t, earn(a, 50, e.at(-time.Hour)))
|
||||
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, a, sendPoints(120, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletMoveRejected)
|
||||
assert.Equal(t, int64(0), e.balance(t, b))
|
||||
}
|
||||
|
||||
func TestWalletTransfer_RetryMovesNothingAndTellsNobodyAgain(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
a := e.member("Anita", "081200005678")
|
||||
b := e.member("Budi", "081234561234")
|
||||
e.member("Citra", "081255550000")
|
||||
e.credit(t, earn(a, 100, nil))
|
||||
notifier := ¬ifierFake{}
|
||||
|
||||
first, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
again, err := e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, again.Replayed)
|
||||
assert.Equal(t, first.GroupID, again.GroupID)
|
||||
assert.Equal(t, int64(40), e.balance(t, b))
|
||||
assert.Len(t, notifier.pushes[b], 1)
|
||||
|
||||
// The same key to someone else is not a retry.
|
||||
_, err = e.transfers(notifier).Transfer(e.ctx, a, sendPoints(40, "081255550000"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, ErrWalletIdempotencyConflict)
|
||||
}
|
||||
|
||||
func TestWalletTransfer_UnknownSender(t *testing.T) {
|
||||
e := newWalletMoveEnv(t)
|
||||
_, err := e.transfers(nil).Transfer(e.ctx, uuid.New(), sendPoints(1, "081234561234"), "482913", "key-1", models.CustomerPinRequestInfo{})
|
||||
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
|
||||
}
|
||||
|
||||
func TestMaskName(t *testing.T) {
|
||||
assert.Equal(t, "Bu*** Sa***", maskName("Budi Santoso"))
|
||||
assert.Equal(t, "An***", maskName("Anita"))
|
||||
assert.Equal(t, "A*** Ra***", maskName("Al Rahman"))
|
||||
assert.Equal(t, "***", maskName(" "))
|
||||
assert.Equal(t, "08**-****-1234", maskPhoneNumber("081234561234"))
|
||||
assert.Equal(t, "+6**-****-1234", maskPhoneNumber("+6281234561234"))
|
||||
assert.Equal(t, "****", maskPhoneNumber("12345"))
|
||||
}
|
||||
@@ -99,7 +99,8 @@ func (r *AnalyticsRepositoryImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
pm.type as payment_method_type,
|
||||
COALESCE(SUM(p.amount), 0) as total_amount,
|
||||
COUNT(DISTINCT p.order_id) as order_count,
|
||||
COUNT(p.id) as payment_count
|
||||
COUNT(p.id) as payment_count,
|
||||
COALESCE(SUM(p.points_used), 0) as points_used
|
||||
`).
|
||||
Joins("JOIN payment_methods pm ON p.payment_method_id = pm.id").
|
||||
Joins("JOIN orders o ON p.order_id = o.id").
|
||||
@@ -478,7 +479,7 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
COALESCE(SUM(oi.weight), 0) as weight_sold,
|
||||
COALESCE(SUM(oi.total_price), 0) as revenue,
|
||||
CASE
|
||||
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(` + billableQty + `)
|
||||
WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_price), 0) / SUM(`+billableQty+`)
|
||||
ELSE 0
|
||||
END as average_price,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
@@ -493,14 +494,14 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalytics(ctx context.Context, organ
|
||||
FROM product_recipes pr
|
||||
JOIN ingredients i ON pr.ingredient_id = i.id
|
||||
WHERE pr.product_id = p.id
|
||||
), p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as standard_hpp_total,
|
||||
), p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as standard_hpp_total,
|
||||
CASE
|
||||
WHEN SUM(` + billableQty + `) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(` + billableQty + `)
|
||||
WHEN SUM(`+billableQty+`) > 0 THEN COALESCE(SUM(oi.total_cost), 0) / SUM(`+billableQty+`)
|
||||
ELSE 0
|
||||
END as fifo_hpp_per_unit,
|
||||
COALESCE(SUM(oi.total_cost), 0) as fifo_hpp_total,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) as moving_average_hpp_per_unit,
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(` + billableQty + `), 0) as moving_average_hpp_total
|
||||
COALESCE(mahpp.hpp_per_unit, p.cost, 0) * COALESCE(SUM(`+billableQty+`), 0) as moving_average_hpp_total
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -551,9 +552,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerCategory(ctx context.Con
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.quantity - COALESCE(oi.refund_quantity, 0) ELSE 0 END), 0) as total_quantity,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
@@ -604,9 +605,9 @@ func (r *AnalyticsRepositoryImpl) GetProductAnalyticsPerParentCategory(ctx conte
|
||||
COUNT(DISTINCT c.id) as category_count,
|
||||
COUNT(DISTINCT p.id) as product_count,
|
||||
COUNT(DISTINCT oi.order_id) as order_count,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(shpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_standard_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN oi.total_cost * ((oi.quantity - COALESCE(oi.refund_quantity, 0))::float / NULLIF(oi.quantity, 0)) ELSE 0 END), 0) as total_fifo_hpp,
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * ` + billableQtyNet + ` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
COALESCE(SUM(CASE WHEN oi.is_fully_refunded = false THEN COALESCE(mahpp.hpp_per_unit, p.cost, 0) * `+billableQtyNet+` ELSE 0 END), 0) as total_moving_average_hpp
|
||||
`).
|
||||
Joins("JOIN products p ON oi.product_id = p.id").
|
||||
Joins("JOIN categories c ON p.category_id = c.id").
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CustomerDevice is one install of the customer app that can receive push
|
||||
// notifications.
|
||||
type CustomerDevice struct {
|
||||
CustomerID uuid.UUID
|
||||
DeviceID string
|
||||
Platform *string
|
||||
FCMToken string
|
||||
AppVersion *string
|
||||
}
|
||||
|
||||
// CustomerDeviceRepository keeps the FCM tokens of the customer app.
|
||||
type CustomerDeviceRepository interface {
|
||||
// Register saves the device's current token. A token is only ever one customer's:
|
||||
// registering it takes it away from whoever had it before on that phone, so they
|
||||
// stop getting this customer's notifications.
|
||||
Register(ctx context.Context, device CustomerDevice, now time.Time) error
|
||||
// Unregister forgets a device, for instance on logout. An unknown device is not an error.
|
||||
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error
|
||||
ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error)
|
||||
}
|
||||
|
||||
type customerDeviceRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerDeviceRepository(db *gorm.DB) CustomerDeviceRepository {
|
||||
return &customerDeviceRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) Register(ctx context.Context, d CustomerDevice, now time.Time) error {
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Exec(`DELETE FROM customer_devices WHERE fcm_token = ? AND NOT (customer_id = ? AND device_id = ?)`,
|
||||
d.FCMToken, d.CustomerID, d.DeviceID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to release FCM token: %w", err)
|
||||
}
|
||||
err = tx.Exec(`INSERT INTO customer_devices (customer_id, device_id, platform, fcm_token, app_version, last_active_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (customer_id, device_id) DO UPDATE SET
|
||||
platform = EXCLUDED.platform,
|
||||
fcm_token = EXCLUDED.fcm_token,
|
||||
app_version = EXCLUDED.app_version,
|
||||
last_active_at = EXCLUDED.last_active_at,
|
||||
updated_at = NOW()`,
|
||||
d.CustomerID, d.DeviceID, d.Platform, d.FCMToken, d.AppVersion, now).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register customer device: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Exec(`DELETE FROM customer_devices WHERE customer_id = ? AND device_id = ?`, customerID, deviceID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unregister customer device: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *customerDeviceRepository) ListTokens(ctx context.Context, customerID uuid.UUID) ([]string, error) {
|
||||
var tokens []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customer_devices").
|
||||
Where("customer_id = ? AND fcm_token <> ''", customerID).
|
||||
Order("last_active_at DESC NULLS LAST").
|
||||
Pluck("fcm_token", &tokens).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list customer FCM tokens: %w", err)
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerPointsRepository interface {
|
||||
GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error)
|
||||
GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error)
|
||||
GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error)
|
||||
GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error)
|
||||
}
|
||||
|
||||
type customerPointsRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerPointsRepository(db *gorm.DB) CustomerPointsRepository {
|
||||
return &customerPointsRepository{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) {
|
||||
var totalPoints int64
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&entities.CustomerPoints{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Select("COALESCE(SUM(balance), 0)").
|
||||
Scan(&totalPoints).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
}
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) {
|
||||
var totalTokens int64
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&entities.CustomerTokens{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Select("COALESCE(SUM(balance), 0)").
|
||||
Scan(&totalTokens).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
}
|
||||
|
||||
return totalTokens, nil
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) {
|
||||
var pointsHistory []entities.CustomerPoints
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("created_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Find(&pointsHistory).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
}
|
||||
|
||||
return pointsHistory, nil
|
||||
}
|
||||
|
||||
func (r *customerPointsRepository) GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) {
|
||||
var tokensHistory []entities.CustomerTokens
|
||||
|
||||
query := r.db.WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Order("created_at DESC")
|
||||
|
||||
if limit > 0 {
|
||||
query = query.Limit(limit)
|
||||
}
|
||||
|
||||
err := query.Find(&tokensHistory).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
}
|
||||
|
||||
return tokensHistory, nil
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CustomerTokensRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCustomerTokensRepository(db *gorm.DB) *CustomerTokensRepository {
|
||||
return &CustomerTokensRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) Create(ctx context.Context, customerTokens *entities.CustomerTokens) error {
|
||||
return r.db.WithContext(ctx).Create(customerTokens).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.CustomerTokens, error) {
|
||||
var customerTokens entities.CustomerTokens
|
||||
err := r.db.WithContext(ctx).Preload("Customer").Where("id = ?", id).First(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &customerTokens, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) GetByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) {
|
||||
var customerTokens entities.CustomerTokens
|
||||
err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ? AND token_type = ?", customerID, tokenType).First(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &customerTokens, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID) ([]entities.CustomerTokens, error) {
|
||||
var customerTokens []entities.CustomerTokens
|
||||
err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ?", customerID).Find(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return customerTokens, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) List(ctx context.Context, offset, limit int, search, tokenType string, sortBy, sortOrder string) ([]entities.CustomerTokens, int64, error) {
|
||||
var customerTokens []entities.CustomerTokens
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Preload("Customer")
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
query = query.Joins("JOIN customers ON customer_tokens.customer_id = customers.id").
|
||||
Where("customers.name ILIKE ? OR customers.email ILIKE ?", searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
if tokenType != "" {
|
||||
query = query.Where("token_type = ?", tokenType)
|
||||
}
|
||||
|
||||
if err := query.Model(&entities.CustomerTokens{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if sortBy != "" {
|
||||
if sortOrder == "" {
|
||||
sortOrder = "asc"
|
||||
}
|
||||
query = query.Order(fmt.Sprintf("customer_tokens.%s %s", sortBy, sortOrder))
|
||||
} else {
|
||||
query = query.Order("customer_tokens.created_at DESC")
|
||||
}
|
||||
|
||||
err := query.Offset(offset).Limit(limit).Find(&customerTokens).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return customerTokens, total, nil
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) Update(ctx context.Context, customerTokens *entities.CustomerTokens) error {
|
||||
return r.db.WithContext(ctx).Save(customerTokens).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.CustomerTokens{}, id).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}).
|
||||
Where("customer_id = ? AND token_type = ?", customerID, tokenType).
|
||||
Update("balance", gorm.Expr("balance + ?", tokens)).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType, tokens int64) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.CustomerTokens{}).
|
||||
Where("customer_id = ? AND token_type = ? AND balance >= ?", customerID, tokenType, tokens).
|
||||
Update("balance", gorm.Expr("balance - ?", tokens)).Error
|
||||
}
|
||||
|
||||
func (r *CustomerTokensRepository) EnsureCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType entities.TokenType) (*entities.CustomerTokens, error) {
|
||||
customerTokens, err := r.GetByCustomerIDAndType(ctx, customerID, tokenType)
|
||||
if err == nil {
|
||||
return customerTokens, nil
|
||||
}
|
||||
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create new customer tokens record
|
||||
newCustomerTokens := &entities.CustomerTokens{
|
||||
CustomerID: customerID,
|
||||
TokenType: tokenType,
|
||||
Balance: 0,
|
||||
}
|
||||
|
||||
err = r.Create(ctx, newCustomerTokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newCustomerTokens, nil
|
||||
}
|
||||
@@ -92,7 +92,13 @@ func (r *earningRepository) GetOrderForEarning(ctx context.Context, orderID uuid
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.subtotal, COALESCE(o.discount_amount, 0) AS discount_amount, o.payment_status,
|
||||
COALESCE(o.is_void, false) AS is_void, COALESCE(o.refund_amount, 0) AS refund_amount,
|
||||
COALESCE(o.is_void, false) AS is_void,
|
||||
-- Refunds of EnakPoint payments are left out: that part never earned (Q10),
|
||||
-- so giving it back must not take earning back.
|
||||
COALESCE(o.refund_amount, 0) - COALESCE((
|
||||
SELECT SUM(COALESCE(p.refund_amount, 0)) FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = o.id AND pm.type = 'point'), 0) AS refund_amount,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewGamePlayRepository(db *gorm.DB) GamePlayRepository {
|
||||
}
|
||||
|
||||
func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error {
|
||||
if err := r.db.WithContext(ctx).Create(gamePlay).Error; err != nil {
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil {
|
||||
return fmt.Errorf("failed to create game play: %w", err)
|
||||
}
|
||||
return nil
|
||||
@@ -39,7 +39,7 @@ func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.Game
|
||||
|
||||
func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) {
|
||||
var gamePlay entities.GamePlay
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Preload("Game").
|
||||
Preload("Customer").
|
||||
Preload("Prize").
|
||||
@@ -57,7 +57,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
|
||||
var gamePlays []*entities.GamePlay
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&entities.GamePlay{})
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{})
|
||||
|
||||
// Apply filters
|
||||
if search != "" {
|
||||
@@ -107,7 +107,7 @@ func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search
|
||||
|
||||
func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
|
||||
var gamePlays []*entities.GamePlay
|
||||
query := r.db.WithContext(ctx).
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("customer_id = ?", customerID).
|
||||
Preload("Game").
|
||||
Preload("Prize").
|
||||
@@ -127,7 +127,7 @@ func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uui
|
||||
|
||||
func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) {
|
||||
var gamePlays []*entities.GamePlay
|
||||
query := r.db.WithContext(ctx).
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("game_id = ?", gameID).
|
||||
Preload("Customer").
|
||||
Preload("Prize").
|
||||
@@ -147,7 +147,7 @@ func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID,
|
||||
|
||||
func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.GamePlay{}).
|
||||
Where("customer_id = ?", customerID).
|
||||
Count(&count).Error
|
||||
@@ -159,7 +159,7 @@ func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID u
|
||||
|
||||
func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Model(&entities.GamePlay{}).
|
||||
Where("game_id = ?", gameID).
|
||||
Count(&count).Error
|
||||
|
||||
@@ -3,12 +3,16 @@ package repository
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it.
|
||||
var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock")
|
||||
|
||||
type GamePrizeRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -18,12 +22,12 @@ func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository {
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error {
|
||||
return r.db.WithContext(ctx).Create(gamePrize).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) {
|
||||
var gamePrize entities.GamePrize
|
||||
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -32,7 +36,7 @@ func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entit
|
||||
|
||||
func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var gamePrizes []entities.GamePrize
|
||||
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -43,7 +47,7 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
|
||||
var gamePrizes []entities.GamePrize
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize")
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize")
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
@@ -76,22 +80,31 @@ func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, searc
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error {
|
||||
return r.db.WithContext(ctx).Save(gamePrize).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.GamePrize{}, id).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error
|
||||
}
|
||||
|
||||
// DecreaseStock takes amount from a prize's stock. When less than amount is left it
|
||||
// changes nothing and returns ErrGamePrizeOutOfStock.
|
||||
func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error {
|
||||
return r.db.WithContext(ctx).Model(&entities.GamePrize{}).
|
||||
res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}).
|
||||
Where("id = ? AND stock >= ?", id, amount).
|
||||
Update("stock", gorm.Expr("stock - ?", amount)).Error
|
||||
Update("stock", gorm.Expr("stock - ?", amount))
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("failed to decrease prize stock: %w", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrGamePrizeOutOfStock
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var gamePrizes []entities.GamePrize
|
||||
err := r.db.WithContext(ctx).Preload("Game").Preload("FallbackPrize").
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").
|
||||
Where("game_id = ? AND stock > 0", gameID).
|
||||
Order("weight DESC").
|
||||
Find(&gamePrizes).Error
|
||||
|
||||
@@ -18,12 +18,12 @@ func NewGameRepository(db *gorm.DB) *GameRepository {
|
||||
}
|
||||
|
||||
func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error {
|
||||
return r.db.WithContext(ctx).Create(game).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error
|
||||
}
|
||||
|
||||
func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) {
|
||||
var game entities.Game
|
||||
err := r.db.WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
|
||||
var games []entities.Game
|
||||
var total int64
|
||||
|
||||
query := r.db.WithContext(ctx).Preload("Prizes")
|
||||
query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes")
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
@@ -71,16 +71,16 @@ func (r *GameRepository) List(ctx context.Context, offset, limit int, search, ga
|
||||
}
|
||||
|
||||
func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error {
|
||||
return r.db.WithContext(ctx).Save(game).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error
|
||||
}
|
||||
|
||||
func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
return r.db.WithContext(ctx).Delete(&entities.Game{}, id).Error
|
||||
return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error
|
||||
}
|
||||
|
||||
func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) {
|
||||
var games []entities.Game
|
||||
err := r.db.WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, e
|
||||
|
||||
func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) {
|
||||
var games []entities.Game
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Preload("Prizes").
|
||||
Where("is_active = ? AND type = ?", true, entities.GameTypeSpin).
|
||||
Find(&games).Error
|
||||
@@ -101,7 +101,7 @@ func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Gam
|
||||
|
||||
func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) {
|
||||
var game entities.Game
|
||||
err := r.db.WithContext(ctx).
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Preload("Prizes").
|
||||
Where("is_active = ? AND type = ?", true, "SPIN").
|
||||
First(&game).Error
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
@@ -47,6 +48,12 @@ type LoyaltySettingsRepository interface {
|
||||
InsertChanges(ctx context.Context, changes []entities.LoyaltySettingChange) error
|
||||
// ListChanges returns a page of history, newest first, and the total count.
|
||||
ListChanges(ctx context.Context, filter LoyaltySettingChangeFilter) ([]entities.LoyaltySettingChange, int64, error)
|
||||
|
||||
// UndatedBalance counts an organization's lots of one currency that still hold
|
||||
// something and have no expiry, and what they hold.
|
||||
UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (lots, amount int64, err error)
|
||||
// DateUndatedLots gives those lots an expiry, when expiry is turned on (F12).
|
||||
DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (lots, amount int64, err error)
|
||||
}
|
||||
|
||||
type loyaltySettingsRepository struct {
|
||||
@@ -174,3 +181,39 @@ func (r *loyaltySettingsRepository) ListChanges(ctx context.Context, filter Loya
|
||||
}
|
||||
return changes, total, nil
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) UndatedBalance(ctx context.Context, organizationID uuid.UUID, currency string) (int64, int64, error) {
|
||||
var row struct{ Lots, Amount int64 }
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) AS lots, COALESCE(SUM(remaining_amount), 0) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0`,
|
||||
organizationID, currency).Scan(&row).Error
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to sum undated wallet lots: %w", err)
|
||||
}
|
||||
return row.Lots, row.Amount, nil
|
||||
}
|
||||
|
||||
func (r *loyaltySettingsRepository) DateUndatedLots(ctx context.Context, organizationID uuid.UUID, currency string, expiresAt time.Time) (int64, int64, error) {
|
||||
db, err := r.requireTx(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
var rows []struct{ RemainingAmount int64 }
|
||||
// Only lots that still hold something: an empty lot never expires anything, and
|
||||
// leaving it undated keeps the history as it was.
|
||||
err = db.Raw(`
|
||||
UPDATE wallet_lots SET expires_at = ?
|
||||
WHERE organization_id = ? AND currency = ? AND expires_at IS NULL AND remaining_amount > 0
|
||||
RETURNING remaining_amount`,
|
||||
expiresAt, organizationID, currency).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("failed to date wallet lots: %w", err)
|
||||
}
|
||||
var amount int64
|
||||
for _, row := range rows {
|
||||
amount += row.RemainingAmount
|
||||
}
|
||||
return int64(len(rows)), amount, nil
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestLoyaltySettings_AgainstPostgres(t *testing.T) {
|
||||
orgSettings.PointValue = 200
|
||||
saved := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
|
||||
_, _, err := p.UpdateOrganization(ctx, org, admin, *orgSettings)
|
||||
saved <- err
|
||||
}()
|
||||
select {
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrPointPaymentOrderNotFound means the order does not exist.
|
||||
var ErrPointPaymentOrderNotFound = errors.New("point payment: order not found")
|
||||
|
||||
// PointPaymentOrder is what paying with EnakPoint needs to know about an order.
|
||||
type PointPaymentOrder struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID uuid.UUID
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *uuid.UUID
|
||||
TotalAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
|
||||
// PointPaymentRepository reads and writes what paying an order with EnakPoint touches
|
||||
// (docs/prd-point-coin.md F9). Unlike the order and payment repositories, every method
|
||||
// joins the caller's transaction, since the payment row, the balance and the order
|
||||
// must change together.
|
||||
type PointPaymentRepository interface {
|
||||
// GetOrder reads the order; with lock it also locks the order row for the rest of
|
||||
// the transaction, so two payments of the same order queue up.
|
||||
GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error)
|
||||
// GetPaymentMethod returns a method's organization and type.
|
||||
GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (organizationID uuid.UUID, methodType string, err error)
|
||||
// TotalPaid sums the order's completed payments, as the rest of the order flow does.
|
||||
TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
// PaidWithPoints sums the rupiah of the order's completed EnakPoint payments.
|
||||
PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
// PointMethodID returns the organization's EnakPoint payment method.
|
||||
PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error)
|
||||
InsertPayment(ctx context.Context, payment *entities.Payment) error
|
||||
// UpdateOrderAfterPayment stores what is left to pay and marks the order paid when
|
||||
// nothing is.
|
||||
UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error
|
||||
|
||||
// ListPointPayments returns the order's EnakPoint payments with their PAYMENT rows.
|
||||
ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error)
|
||||
// PaymentAllocations returns the lots a PAYMENT row took from, longest-lasting first.
|
||||
PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error)
|
||||
// RefundedByOriginLot sums, per original lot, what PAYMENT_REFUND rows have already
|
||||
// returned for a PAYMENT row.
|
||||
RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error)
|
||||
// RefundableByOtherMethods is what the order's non-EnakPoint payments can still give
|
||||
// back: paid minus already refunded.
|
||||
RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error)
|
||||
}
|
||||
|
||||
type pointPaymentRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPointPaymentRepository(db *gorm.DB) PointPaymentRepository {
|
||||
return &pointPaymentRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) GetOrder(ctx context.Context, orderID uuid.UUID, lock bool) (*PointPaymentOrder, error) {
|
||||
lockClause := ""
|
||||
if lock {
|
||||
lockClause = "FOR UPDATE OF o"
|
||||
}
|
||||
var rows []struct {
|
||||
ID string
|
||||
OrganizationID string
|
||||
OutletID string
|
||||
OrderNumber string
|
||||
OutletName string
|
||||
CustomerID *string
|
||||
TotalAmount float64
|
||||
PaymentStatus string
|
||||
IsVoid bool
|
||||
CustomerIsDefault *bool
|
||||
CustomerIsActive *bool
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT o.id::text AS id, o.organization_id::text AS organization_id, o.outlet_id::text AS outlet_id,
|
||||
o.order_number, COALESCE(ou.name, '') AS outlet_name, o.customer_id::text AS customer_id,
|
||||
o.total_amount, o.payment_status, COALESCE(o.is_void, false) AS is_void,
|
||||
c.is_default AS customer_is_default, c.is_active AS customer_is_active
|
||||
FROM orders o
|
||||
LEFT JOIN outlets ou ON ou.id = o.outlet_id
|
||||
LEFT JOIN customers c ON c.id = o.customer_id
|
||||
WHERE o.id = ?
|
||||
`+lockClause, orderID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read order: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrPointPaymentOrderNotFound
|
||||
}
|
||||
row := rows[0]
|
||||
order := &PointPaymentOrder{
|
||||
OrderNumber: row.OrderNumber,
|
||||
OutletName: row.OutletName,
|
||||
TotalAmount: row.TotalAmount,
|
||||
PaymentStatus: row.PaymentStatus,
|
||||
IsVoid: row.IsVoid,
|
||||
CustomerIsDefault: row.CustomerIsDefault,
|
||||
CustomerIsActive: row.CustomerIsActive,
|
||||
}
|
||||
order.ID, _ = uuid.Parse(row.ID)
|
||||
order.OrganizationID, _ = uuid.Parse(row.OrganizationID)
|
||||
order.OutletID, _ = uuid.Parse(row.OutletID)
|
||||
if row.CustomerID != nil {
|
||||
if id, err := uuid.Parse(*row.CustomerID); err == nil {
|
||||
order.CustomerID = &id
|
||||
}
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) GetPaymentMethod(ctx context.Context, methodID uuid.UUID) (uuid.UUID, string, error) {
|
||||
var rows []struct {
|
||||
OrganizationID string
|
||||
Type string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT organization_id::text AS organization_id, type FROM payment_methods WHERE id = ?`, methodID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, "", fmt.Errorf("failed to read payment method: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return uuid.Nil, "", fmt.Errorf("payment method not found")
|
||||
}
|
||||
org, _ := uuid.Parse(rows[0].OrganizationID)
|
||||
return org, rows[0].Type, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) TotalPaid(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(amount), 0) FROM payments WHERE order_id = ? AND status = ?`,
|
||||
orderID, entities.PaymentTransactionStatusCompleted).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PaidWithPoints(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount), 0) FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND p.status = ? AND pm.type = ?`,
|
||||
orderID, entities.PaymentTransactionStatusCompleted, constants.PaymentMethodTypePoint).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum EnakPoint payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) InsertPayment(ctx context.Context, payment *entities.Payment) error {
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(payment).Error; err != nil {
|
||||
return fmt.Errorf("failed to create payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) UpdateOrderAfterPayment(ctx context.Context, orderID uuid.UUID, remaining float64, completed bool) error {
|
||||
paymentStatus := entities.PaymentStatusPartial
|
||||
if completed {
|
||||
paymentStatus = entities.PaymentStatusCompleted
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
UPDATE orders SET remaining_amount = ?, payment_status = ?,
|
||||
status = CASE WHEN ? THEN ? ELSE status END, updated_at = NOW()
|
||||
WHERE id = ?`,
|
||||
remaining, paymentStatus, completed, entities.OrderStatusCompleted, orderID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update order after payment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PointMethodID(ctx context.Context, organizationID uuid.UUID) (uuid.UUID, error) {
|
||||
var ids []string
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("payment_methods").
|
||||
Where("organization_id = ? AND type = ?", organizationID, constants.PaymentMethodTypePoint).
|
||||
Limit(1).
|
||||
Pluck("id::text", &ids).Error
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("failed to find the EnakPoint payment method: %w", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return uuid.Nil, fmt.Errorf("the organization has no EnakPoint payment method")
|
||||
}
|
||||
return uuid.Parse(ids[0])
|
||||
}
|
||||
|
||||
// PointPaymentRow is one EnakPoint payment of an order, for refunding it.
|
||||
type PointPaymentRow struct {
|
||||
PaymentID uuid.UUID
|
||||
Status string
|
||||
PointsUsed int64
|
||||
PointValue float64
|
||||
RefundAmount float64
|
||||
// The PAYMENT ledger row that took the balance; nil if none was written.
|
||||
LedgerID *uuid.UUID
|
||||
CustomerID *uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
}
|
||||
|
||||
// PointPaymentAllocation is how much a PAYMENT took from one lot, with that lot's
|
||||
// expiry, so a refund can return it to the same expiry.
|
||||
type PointPaymentAllocation struct {
|
||||
LotID uuid.UUID
|
||||
Amount int64
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) ListPointPayments(ctx context.Context, orderID uuid.UUID) ([]PointPaymentRow, error) {
|
||||
var rows []struct {
|
||||
PaymentID string
|
||||
Status string
|
||||
PointsUsed int64
|
||||
PointValue float64
|
||||
RefundAmount float64
|
||||
LedgerID *string
|
||||
CustomerID *string
|
||||
OutletID *string
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT p.id::text AS payment_id, p.status, p.points_used, p.point_value,
|
||||
COALESCE(p.refund_amount, 0) AS refund_amount,
|
||||
t.id::text AS ledger_id, t.customer_id::text AS customer_id, t.outlet_id::text AS outlet_id
|
||||
FROM payments p
|
||||
JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
LEFT JOIN wallet_transactions t ON t.reference_type = ? AND t.reference_id = p.id AND t.type = ?
|
||||
WHERE p.order_id = ? AND pm.type = ? AND p.points_used IS NOT NULL
|
||||
ORDER BY p.created_at, p.id`,
|
||||
constants.WalletRefTypePayment, constants.WalletTxTypePayment, orderID, constants.PaymentMethodTypePoint).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list EnakPoint payments: %w", err)
|
||||
}
|
||||
out := make([]PointPaymentRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
p := PointPaymentRow{Status: row.Status, PointsUsed: row.PointsUsed, PointValue: row.PointValue, RefundAmount: row.RefundAmount}
|
||||
p.PaymentID, _ = uuid.Parse(row.PaymentID)
|
||||
p.LedgerID = parseOptionalUUID(row.LedgerID)
|
||||
p.CustomerID = parseOptionalUUID(row.CustomerID)
|
||||
p.OutletID = parseOptionalUUID(row.OutletID)
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) PaymentAllocations(ctx context.Context, ledgerID uuid.UUID) ([]PointPaymentAllocation, error) {
|
||||
var rows []struct {
|
||||
LotID string
|
||||
Amount int64
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
// Longest-lasting first: a partial refund gives back the balance that keeps longest.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT a.lot_id::text AS lot_id, a.amount, l.expires_at
|
||||
FROM wallet_lot_allocations a JOIN wallet_lots l ON l.id = a.lot_id
|
||||
WHERE a.transaction_id = ?
|
||||
ORDER BY l.expires_at DESC NULLS FIRST, l.created_at DESC, l.id`, ledgerID).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list payment allocations: %w", err)
|
||||
}
|
||||
out := make([]PointPaymentAllocation, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
id, err := uuid.Parse(row.LotID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, PointPaymentAllocation{LotID: id, Amount: row.Amount, ExpiresAt: row.ExpiresAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) RefundedByOriginLot(ctx context.Context, ledgerID uuid.UUID) (map[uuid.UUID]int64, error) {
|
||||
var rows []struct {
|
||||
OriginLotID string
|
||||
Amount int64
|
||||
}
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT l.origin_lot_id::text AS origin_lot_id, SUM(l.original_amount) AS amount
|
||||
FROM wallet_transactions t JOIN wallet_lots l ON l.source_transaction_id = t.id
|
||||
WHERE t.reverses_transaction_id = ? AND t.type = ? AND l.origin_lot_id IS NOT NULL
|
||||
GROUP BY l.origin_lot_id`, ledgerID, constants.WalletTxTypePaymentRefund).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sum payment refunds: %w", err)
|
||||
}
|
||||
out := make(map[uuid.UUID]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
if id, err := uuid.Parse(row.OriginLotID); err == nil {
|
||||
out[id] = row.Amount
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pointPaymentRepository) RefundableByOtherMethods(ctx context.Context, orderID uuid.UUID) (float64, error) {
|
||||
var total float64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT COALESCE(SUM(p.amount - COALESCE(p.refund_amount, 0)), 0)
|
||||
FROM payments p JOIN payment_methods pm ON pm.id = p.payment_method_id
|
||||
WHERE p.order_id = ? AND pm.type <> ? AND p.status IN (?, ?)`,
|
||||
orderID, constants.PaymentMethodTypePoint,
|
||||
entities.PaymentTransactionStatusCompleted, entities.PaymentTransactionStatusRefunded).Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum refundable payments: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func parseOptionalUUID(s *string) *uuid.UUID {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
id, err := uuid.Parse(*s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &id
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DueLot is a lot whose expiry has passed and that still holds something.
|
||||
type DueLot struct {
|
||||
ID uuid.UUID
|
||||
CustomerID uuid.UUID
|
||||
Currency string
|
||||
Remaining int64
|
||||
ExpiresAt time.Time
|
||||
// The description of the row that created the lot, for the EXPIRE row's.
|
||||
SourceDescription string
|
||||
}
|
||||
|
||||
// UpcomingExpiry is how much of a customer's balance expires on one day.
|
||||
type UpcomingExpiry struct {
|
||||
CustomerID uuid.UUID
|
||||
// A calendar date in walletDisplayTimeZone, formatted YYYY-MM-DD.
|
||||
Date string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
// WalletExpiryRepository finds what the expiry job has to do (docs/prd-point-coin.md
|
||||
// F12). Balances only change through WalletProcessor.
|
||||
type WalletExpiryRepository interface {
|
||||
// ListDueLots returns up to limit lots due at asOf, the longest overdue first. It
|
||||
// takes no lock: locking a lot before its wallet would deadlock against payments,
|
||||
// which lock the wallet first. WalletProcessor.ExpireLot locks and reads again.
|
||||
ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error)
|
||||
|
||||
// OrganizationsWithUpcomingExpiry lists the organizations that have balance
|
||||
// expiring after asOf.
|
||||
OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error)
|
||||
// UpcomingUnreminded sums, per customer and expiry day, the balance of one currency
|
||||
// of an organization expiring after asOf and up to until, leaving out the days the
|
||||
// customer has already been reminded of.
|
||||
UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error)
|
||||
// MarkReminded records a reminder, and reports false when it was already recorded,
|
||||
// by this run or another.
|
||||
MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error)
|
||||
}
|
||||
|
||||
type walletExpiryRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletExpiryRepository(db *gorm.DB) WalletExpiryRepository {
|
||||
return &walletExpiryRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) ListDueLots(ctx context.Context, asOf time.Time, limit int) ([]DueLot, error) {
|
||||
var lots []DueLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT l.id, l.customer_id, l.currency, l.remaining_amount AS remaining, l.expires_at,
|
||||
t.description AS source_description
|
||||
FROM wallet_lots l
|
||||
JOIN wallet_transactions t ON t.id = l.source_transaction_id
|
||||
WHERE l.remaining_amount > 0 AND l.expires_at <= ?
|
||||
ORDER BY l.expires_at, l.id
|
||||
LIMIT ?`, asOf, limit).Scan(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list due wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) OrganizationsWithUpcomingExpiry(ctx context.Context, asOf time.Time) ([]uuid.UUID, error) {
|
||||
var ids []uuid.UUID
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT DISTINCT organization_id FROM wallet_lots
|
||||
WHERE remaining_amount > 0 AND expires_at > ?`, asOf).Scan(&ids).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list organizations with expiring balances: %w", err)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) UpcomingUnreminded(ctx context.Context, organizationID uuid.UUID, currency string, asOf, until time.Time) ([]UpcomingExpiry, error) {
|
||||
var rows []UpcomingExpiry
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
WITH by_day AS (
|
||||
SELECT customer_id, (expires_at AT TIME ZONE ?)::date AS day, SUM(remaining_amount) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE organization_id = ? AND currency = ? AND remaining_amount > 0
|
||||
AND expires_at > ? AND expires_at <= ?
|
||||
GROUP BY customer_id, day
|
||||
)
|
||||
SELECT d.customer_id, to_char(d.day, 'YYYY-MM-DD') AS date, d.amount
|
||||
FROM by_day d
|
||||
LEFT JOIN wallet_expiry_reminders w
|
||||
ON w.customer_id = d.customer_id AND w.currency = ? AND w.expiry_date = d.day
|
||||
WHERE w.customer_id IS NULL
|
||||
ORDER BY d.day, d.customer_id`,
|
||||
walletDisplayTimeZone, organizationID, currency, asOf, until, currency).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list upcoming expiry: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletExpiryRepository) MarkReminded(ctx context.Context, reminder UpcomingExpiry, currency string) (bool, error) {
|
||||
res := DBFromContext(ctx, r.db).WithContext(ctx).Exec(`
|
||||
INSERT INTO wallet_expiry_reminders (customer_id, currency, expiry_date, amount)
|
||||
VALUES (?, ?, ?::date, ?)
|
||||
ON CONFLICT DO NOTHING`, reminder.CustomerID, currency, reminder.Date, reminder.Amount)
|
||||
if res.Error != nil {
|
||||
return false, fmt.Errorf("failed to record expiry reminder: %w", res.Error)
|
||||
}
|
||||
return res.RowsAffected == 1, nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
)
|
||||
|
||||
// WalletMoveCustomer is a customer on either side of an exchange or a transfer.
|
||||
type WalletMoveCustomer struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
Name string
|
||||
PhoneNumber *string
|
||||
IsDefault bool
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
// WalletMoveRepository reads what exchanges and transfers check before moving a
|
||||
// balance (docs/prd-point-coin.md F4, F5). The balance itself only moves through
|
||||
// WalletProcessor.
|
||||
type WalletMoveRepository interface {
|
||||
// GetCustomer returns ErrWalletNotFound when the customer does not exist.
|
||||
GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error)
|
||||
// FindCustomerByPhone returns ErrWalletNotFound when no customer has the number.
|
||||
// Phone numbers are unique across organizations, so there is at most one.
|
||||
FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error)
|
||||
// TransferredOutSince sums what a customer has sent in one currency since a time.
|
||||
// Call it under the sender's wallet lock, so transfers at the same time count.
|
||||
TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type walletMoveRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWalletMoveRepository(db *gorm.DB) WalletMoveRepository {
|
||||
return &walletMoveRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) GetCustomer(ctx context.Context, customerID uuid.UUID) (*WalletMoveCustomer, error) {
|
||||
return r.customer(ctx, "id = ?", customerID)
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) FindCustomerByPhone(ctx context.Context, phoneNumber string) (*WalletMoveCustomer, error) {
|
||||
return r.customer(ctx, "phone_number = ?", phoneNumber)
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) TransferredOutSince(ctx context.Context, customerID uuid.UUID, currency string, since time.Time) (int64, error) {
|
||||
var total int64
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_transactions").
|
||||
Select("COALESCE(SUM(-amount), 0)").
|
||||
Where("customer_id = ? AND currency = ? AND type = ? AND created_at >= ?", customerID, currency, constants.WalletTxTypeTransferOut, since).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to sum transfers: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *walletMoveRepository) customer(ctx context.Context, where string, arg interface{}) (*WalletMoveCustomer, error) {
|
||||
var c WalletMoveCustomer
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("customers").
|
||||
Select("id, organization_id, name, phone_number, is_default, is_active").
|
||||
Where(where, arg).
|
||||
Take(&c).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrWalletNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get customer: %w", err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
@@ -47,6 +47,9 @@ type WalletQueryRepository interface {
|
||||
// NearestExpiring returns, per currency, the earliest day after asOf on which some
|
||||
// balance expires, and how much expires that day.
|
||||
NearestExpiring(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
|
||||
// ExpiringByDay returns, per currency and day, everything that expires after asOf,
|
||||
// soonest first.
|
||||
ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error)
|
||||
// ListTransactions returns a page of the ledger, newest first, and the total count.
|
||||
ListTransactions(ctx context.Context, filter WalletTransactionFilter) ([]entities.WalletTransaction, int64, error)
|
||||
// OrganizationOutstanding sums every wallet balance of an organization.
|
||||
@@ -181,3 +184,19 @@ func (r *walletQueryRepository) OrganizationOutstanding(ctx context.Context, org
|
||||
}
|
||||
return totals.Points, totals.Coins, nil
|
||||
}
|
||||
|
||||
func (r *walletQueryRepository) ExpiringByDay(ctx context.Context, customerID uuid.UUID, asOf time.Time) ([]WalletExpiringAmount, error) {
|
||||
var rows []WalletExpiringAmount
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
|
||||
SELECT currency, to_char((expires_at AT TIME ZONE ?)::date, 'YYYY-MM-DD') AS date,
|
||||
SUM(remaining_amount) AS amount
|
||||
FROM wallet_lots
|
||||
WHERE customer_id = ? AND remaining_amount > 0 AND expires_at > ?
|
||||
GROUP BY currency, date
|
||||
ORDER BY date, currency`, walletDisplayTimeZone, customerID, asOf).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list expiring wallet balance: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// ErrWalletTransactionNotFound means the ledger row does not exist or belongs to
|
||||
// another organization.
|
||||
var ErrWalletTransactionNotFound = errors.New("wallet: transaction not found")
|
||||
|
||||
// WalletTraceRepository reads what tracing a ledger row lot by lot needs
|
||||
// (docs/prd-point-coin.md F7, §8.1). Nothing here changes a balance.
|
||||
type WalletTraceRepository interface {
|
||||
// GetTransaction returns ErrWalletTransactionNotFound unless the row belongs to
|
||||
// the organization.
|
||||
GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error)
|
||||
GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error)
|
||||
// ListAllocations returns what a debit took from each lot, in the order it took.
|
||||
ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error)
|
||||
// ListLotsBySource returns the lots a credit created, oldest first.
|
||||
ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error)
|
||||
GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error)
|
||||
CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error)
|
||||
}
|
||||
|
||||
type walletTraceRepository struct {
|
||||
db *gorm.DB
|
||||
names *walletAdminRepository
|
||||
}
|
||||
|
||||
func NewWalletTraceRepository(db *gorm.DB) WalletTraceRepository {
|
||||
return &walletTraceRepository{db: db, names: &walletAdminRepository{db: db}}
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) GetTransaction(ctx context.Context, organizationID, id uuid.UUID) (*entities.WalletTransaction, error) {
|
||||
var rows []entities.WalletTransaction
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("id = ? AND organization_id = ?", id, organizationID).
|
||||
Limit(1).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet transaction: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, ErrWalletTransactionNotFound
|
||||
}
|
||||
return &rows[0], nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) GetTransactions(ctx context.Context, ids []uuid.UUID) ([]entities.WalletTransaction, error) {
|
||||
var rows []entities.WalletTransaction
|
||||
if len(ids) == 0 {
|
||||
return rows, nil
|
||||
}
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet transactions: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) ListAllocations(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLotAllocation, error) {
|
||||
var rows []entities.WalletLotAllocation
|
||||
// A debit draws in K9 order, so the lots' order is the order it took from them.
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Table("wallet_lot_allocations a").
|
||||
Select("a.*").
|
||||
Joins("JOIN wallet_lots l ON l.id = a.lot_id").
|
||||
Where("a.transaction_id = ?", transactionID).
|
||||
Order("l.expires_at NULLS LAST, l.created_at, l.id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lot allocations: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) ListLotsBySource(ctx context.Context, transactionID uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
err := DBFromContext(ctx, r.db).WithContext(ctx).
|
||||
Where("source_transaction_id = ?", transactionID).
|
||||
Order("created_at, id").
|
||||
Find(&lots).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) GetLots(ctx context.Context, ids []uuid.UUID) ([]entities.WalletLot, error) {
|
||||
var lots []entities.WalletLot
|
||||
if len(ids) == 0 {
|
||||
return lots, nil
|
||||
}
|
||||
if err := DBFromContext(ctx, r.db).WithContext(ctx).Where("id IN ?", ids).Find(&lots).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to get wallet lots: %w", err)
|
||||
}
|
||||
return lots, nil
|
||||
}
|
||||
|
||||
func (r *walletTraceRepository) CustomerNames(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||
return r.names.CustomerNames(ctx, ids)
|
||||
}
|
||||
+21
-27
@@ -57,12 +57,16 @@ type Router struct {
|
||||
walletAdminHandler *handler.WalletAdminHandler
|
||||
loyaltySettingsHandler *handler.LoyaltySettingsHandler
|
||||
customerPinHandler *handler.CustomerPinHandler
|
||||
authMiddleware *middleware.AuthMiddleware
|
||||
pointPaymentHandler *handler.PointPaymentHandler
|
||||
customerOrderPaymentHandler *handler.CustomerOrderPaymentHandler
|
||||
customerWalletHandler *handler.CustomerWalletHandler
|
||||
customerDeviceHandler *handler.CustomerDeviceHandler
|
||||
authMiddleware *middleware.AuthMiddleware
|
||||
customerAuthMiddleware *middleware.CustomerAuthMiddleware
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, redisClient *redis.Client) *Router {
|
||||
func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, pointPaymentService service.PointPaymentService, customerOrderPaymentService service.CustomerOrderPaymentService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, redisClient *redis.Client) *Router {
|
||||
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@@ -111,6 +115,10 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer
|
||||
walletAdminHandler: handler.NewWalletAdminHandler(walletAdminService, walletValidator),
|
||||
loyaltySettingsHandler: handler.NewLoyaltySettingsHandler(loyaltySettingsService),
|
||||
customerPinHandler: handler.NewCustomerPinHandler(customerPinService),
|
||||
pointPaymentHandler: handler.NewPointPaymentHandler(pointPaymentService),
|
||||
customerOrderPaymentHandler: handler.NewCustomerOrderPaymentHandler(customerOrderPaymentService),
|
||||
customerWalletHandler: handler.NewCustomerWalletHandler(customerWalletService),
|
||||
customerDeviceHandler: handler.NewCustomerDeviceHandler(customerDeviceService),
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
@@ -164,7 +172,15 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens)
|
||||
customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet)
|
||||
customer.GET("/wallet/transactions", r.customerPointsHandler.GetCustomerWalletTransactions)
|
||||
customer.GET("/wallet/expiring", r.customerPointsHandler.GetCustomerWalletExpiring)
|
||||
customer.POST("/wallet/payment-code", r.customerPinHandler.IssuePaymentCode)
|
||||
customer.GET("/wallet/exchange/preview", r.customerWalletHandler.PreviewExchange)
|
||||
customer.POST("/wallet/exchange", r.customerWalletHandler.Exchange)
|
||||
customer.GET("/wallet/transfer/recipient", r.customerWalletHandler.TransferRecipient)
|
||||
customer.POST("/wallet/transfer", r.customerWalletHandler.Transfer)
|
||||
customer.PUT("/devices", r.customerDeviceHandler.Register)
|
||||
customer.DELETE("/devices/:device_id", r.customerDeviceHandler.Unregister)
|
||||
customer.POST("/orders/:id/pay-with-points", r.customerOrderPaymentHandler.PayWithPoints)
|
||||
// PIN that approves moving EnakPoint and EnakCoin (docs/prd-point-coin.md F11)
|
||||
customer.GET("/pin/status", r.customerPinHandler.Status)
|
||||
customer.POST("/pin/otp", r.customerPinHandler.RequestOtp)
|
||||
@@ -294,6 +310,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
{
|
||||
orders.GET("", r.orderHandler.ListOrders)
|
||||
orders.GET("/:id", r.orderHandler.GetOrderByID)
|
||||
orders.GET("/:id/point-payment/preview", r.pointPaymentHandler.Preview)
|
||||
orders.POST("", r.orderHandler.CreateOrder)
|
||||
orders.POST("/:id/add-items", middleware.IdempotencyMiddleware(r.redisClient), r.orderHandler.AddToOrder)
|
||||
orders.PUT("/:id", r.orderHandler.UpdateOrder)
|
||||
@@ -525,31 +542,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
gamification := protected.Group("/marketing")
|
||||
gamification.Use(r.authMiddleware.RequireAdminOrManager())
|
||||
{
|
||||
//customerPoints := gamification.Group("/customer-points")
|
||||
//{
|
||||
// customerPoints.POST("", r.gamificationHandler.CreateCustomerPoints)
|
||||
// customerPoints.GET("", r.gamificationHandler.ListCustomerPoints)
|
||||
// customerPoints.GET("/:id", r.gamificationHandler.GetCustomerPoints)
|
||||
// customerPoints.PUT("/:id", r.gamificationHandler.UpdateCustomerPoints)
|
||||
// customerPoints.DELETE("/:id", r.gamificationHandler.DeleteCustomerPoints)
|
||||
// customerPoints.GET("/customer/:customer_id", r.gamificationHandler.GetCustomerPointsByCustomerID)
|
||||
// customerPoints.POST("/customer/:customer_id/add", r.gamificationHandler.AddCustomerPoints)
|
||||
// customerPoints.POST("/customer/:customer_id/deduct", r.gamificationHandler.DeductCustomerPoints)
|
||||
//}
|
||||
|
||||
// Customer Tokens
|
||||
//customerTokens := gamification.Group("/customer-tokens")
|
||||
//{
|
||||
// customerTokens.POST("", r.gamificationHandler.CreateCustomerTokens)
|
||||
// customerTokens.GET("", r.gamificationHandler.ListCustomerTokens)
|
||||
// customerTokens.GET("/:id", r.gamificationHandler.GetCustomerTokens)
|
||||
// customerTokens.PUT("/:id", r.gamificationHandler.UpdateCustomerTokens)
|
||||
// customerTokens.DELETE("/:id", r.gamificationHandler.DeleteCustomerTokens)
|
||||
// customerTokens.GET("/customer/:customer_id/type/:token_type", r.gamificationHandler.GetCustomerTokensByCustomerIDAndType)
|
||||
// customerTokens.POST("/customer/:customer_id/type/:token_type/add", r.gamificationHandler.AddCustomerTokens)
|
||||
// customerTokens.POST("/customer/:customer_id/type/:token_type/deduct", r.gamificationHandler.DeductCustomerTokens)
|
||||
//}
|
||||
|
||||
// Tiers
|
||||
tiers := gamification.Group("/tiers")
|
||||
{
|
||||
@@ -641,6 +633,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
marketingCustomers.DELETE("/:id/pin", r.authMiddleware.RequireLoyaltyManager(), r.customerPinHandler.RemovePin)
|
||||
marketingCustomers.GET("/:id/security-events", r.customerPinHandler.ListSecurityEvents)
|
||||
}
|
||||
// Trace one ledger row lot by lot back to where its balance came from (F7, §8.1)
|
||||
gamification.GET("/wallet-transactions/:id/trace", r.walletAdminHandler.TraceTransaction)
|
||||
|
||||
campaignRules := gamification.Group("/campaign-rules")
|
||||
{
|
||||
|
||||
@@ -28,14 +28,24 @@ func TestAllRoutesRegister(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"GET /api/v1/customer/wallet",
|
||||
"GET /api/v1/customer/wallet/transactions",
|
||||
"GET /api/v1/customer/wallet/expiring",
|
||||
"GET /api/v1/marketing/customers/:id/wallet",
|
||||
"POST /api/v1/marketing/customers/:id/wallet/adjust",
|
||||
"GET /api/v1/marketing/wallet-transactions/:id/trace",
|
||||
"GET /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||
"PUT /api/v1/outlets/:outlet_id/loyalty-settings",
|
||||
"GET /api/v1/marketing/loyalty-settings",
|
||||
"PUT /api/v1/marketing/loyalty-settings",
|
||||
"GET /api/v1/marketing/loyalty-settings/history",
|
||||
"POST /api/v1/customer/wallet/payment-code",
|
||||
"GET /api/v1/customer/wallet/exchange/preview",
|
||||
"POST /api/v1/customer/wallet/exchange",
|
||||
"GET /api/v1/customer/wallet/transfer/recipient",
|
||||
"POST /api/v1/customer/wallet/transfer",
|
||||
"PUT /api/v1/customer/devices",
|
||||
"DELETE /api/v1/customer/devices/:device_id",
|
||||
"GET /api/v1/orders/:id/point-payment/preview",
|
||||
"POST /api/v1/customer/orders/:id/pay-with-points",
|
||||
"GET /api/v1/customer/pin/status",
|
||||
"POST /api/v1/customer/pin/otp",
|
||||
"POST /api/v1/customer/pin",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// CustomerDeviceService registers the customer app's devices for push notifications.
|
||||
type CustomerDeviceService interface {
|
||||
Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response
|
||||
Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response
|
||||
}
|
||||
|
||||
type CustomerDeviceServiceImpl struct {
|
||||
devices *processor.CustomerDeviceProcessor
|
||||
}
|
||||
|
||||
func NewCustomerDeviceService(devices *processor.CustomerDeviceProcessor) *CustomerDeviceServiceImpl {
|
||||
return &CustomerDeviceServiceImpl{devices: devices}
|
||||
}
|
||||
|
||||
func (s *CustomerDeviceServiceImpl) Register(ctx context.Context, customerID uuid.UUID, req *contract.RegisterCustomerDeviceRequest) *contract.Response {
|
||||
err := s.devices.Register(ctx, repository.CustomerDevice{
|
||||
CustomerID: customerID,
|
||||
DeviceID: req.DeviceID,
|
||||
FCMToken: req.FCMToken,
|
||||
Platform: req.Platform,
|
||||
AppVersion: req.AppVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return customerDeviceErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": req.DeviceID})
|
||||
}
|
||||
|
||||
func (s *CustomerDeviceServiceImpl) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) *contract.Response {
|
||||
if err := s.devices.Unregister(ctx, customerID, deviceID); err != nil {
|
||||
return customerDeviceErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(map[string]interface{}{"device_id": deviceID})
|
||||
}
|
||||
|
||||
func customerDeviceErrorResponse(err error) *contract.Response {
|
||||
code := constants.InternalServerErrorCode
|
||||
if errors.Is(err, processor.ErrInvalidCustomerDevice) {
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.RequestEntity, err.Error()),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/transformer"
|
||||
)
|
||||
|
||||
// CustomerOrderPaymentService lets customers pay their own orders with EnakPoint in the
|
||||
// app or a self-order (docs/prd-point-coin.md F9).
|
||||
type CustomerOrderPaymentService interface {
|
||||
PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response
|
||||
}
|
||||
|
||||
type CustomerOrderPaymentServiceImpl struct {
|
||||
orders processor.OrderProcessor
|
||||
}
|
||||
|
||||
func NewCustomerOrderPaymentService(orders processor.OrderProcessor) *CustomerOrderPaymentServiceImpl {
|
||||
return &CustomerOrderPaymentServiceImpl{orders: orders}
|
||||
}
|
||||
|
||||
func (s *CustomerOrderPaymentServiceImpl) PayWithPoints(ctx context.Context, customerID, orderID uuid.UUID, req *contract.PayWithPointsRequest, info models.CustomerPinRequestInfo) *contract.Response {
|
||||
payment, err := s.orders.PayWithPointsInApp(ctx, customerID, orderID, req.Points, req.Pin, info)
|
||||
if err != nil {
|
||||
return PointPaymentErrorResponse(err)
|
||||
}
|
||||
return contract.BuildSuccessResponse(transformer.PaymentModelToContract(payment))
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type CustomerPointsService interface {
|
||||
GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error)
|
||||
GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error)
|
||||
GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error)
|
||||
GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error)
|
||||
GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error)
|
||||
GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error)
|
||||
}
|
||||
@@ -90,3 +91,10 @@ func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Contex
|
||||
}
|
||||
return s.customerPointsProcessor.GetCustomerWalletTransactionsAPI(ctx, customerID, query)
|
||||
}
|
||||
|
||||
func (s *customerPointsService) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
|
||||
if customerID == "" {
|
||||
return nil, fmt.Errorf("customer ID is required")
|
||||
}
|
||||
return s.customerPointsProcessor.GetCustomerWalletExpiringAPI(ctx, customerID)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user