Compare commits
6
Commits
4432f0a10d
...
3cd88a55c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cd88a55c8 | ||
|
|
d1e543a79f | ||
|
|
550122f29c | ||
|
|
d293786cde | ||
|
|
4d63673a25 | ||
|
|
9ce55e6002 |
@@ -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.
|
||||
+12
-8
@@ -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()
|
||||
@@ -178,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()
|
||||
|
||||
@@ -223,6 +230,9 @@ func (a *App) Shutdown() {
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Stop()
|
||||
}
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Stop()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -257,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
|
||||
@@ -267,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
|
||||
@@ -315,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),
|
||||
@@ -325,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),
|
||||
@@ -368,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
|
||||
@@ -451,7 +456,6 @@ 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),
|
||||
@@ -480,7 +484,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
walletTransferProcessor: walletTransferProcessor,
|
||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,7 +564,7 @@ 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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -74,7 +74,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
||||
}))
|
||||
|
||||
h := NewWalletAdminHandler(
|
||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -157,3 +157,10 @@ type PointPaymentPreview struct {
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
//
|
||||
// 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 (the 7-day extension waits on note N4). It returns how many came
|
||||
// back in total.
|
||||
// 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 {
|
||||
@@ -78,7 +78,7 @@ func (p *PointPaymentProcessor) RefundForOrder(ctx context.Context, orderID uuid
|
||||
take := min(room, left)
|
||||
left -= take
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
|
||||
lots = append(lots, WalletLotInput{Amount: take, ExpiresAt: RefundExpiry(a.ExpiresAt, p.now()), OriginLotID: &lotID})
|
||||
}
|
||||
toReturn -= left
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestPointRefund_UsesTheFrozenValue(t *testing.T) {
|
||||
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)
|
||||
_, _, err = settings.UpdateOrganization(context.Background(), e.org, e.cashier, *s)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
setValue(100)
|
||||
|
||||
@@ -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.
|
||||
@@ -126,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 {
|
||||
|
||||
@@ -169,7 +169,7 @@ func (p *WalletExchangeProcessor) Exchange(ctx context.Context, customerID uuid.
|
||||
Metadata: metadata,
|
||||
IdempotencyKey: inKey,
|
||||
},
|
||||
Lots: exchangeLots(out.Allocations, rate),
|
||||
Lots: exchangeLots(out.Allocations, rate, ComputeExpiry(settings.PointExpiry, p.now())),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -225,10 +225,9 @@ func exchangePoints(coins int64, rate models.LoyaltyExchangeSettings) int64 {
|
||||
// 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.
|
||||
//
|
||||
// F4 caps the expiry at now + the EnakPoint validity as well. EnakPoint have no
|
||||
// validity until the expiry model is decided (F12, note N4), so for now the EnakCoin
|
||||
// lot's expiry is the only bound.
|
||||
func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSettings) []WalletLotInput {
|
||||
// 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 {
|
||||
@@ -239,7 +238,7 @@ func exchangeLots(allocations []WalletAllocation, rate models.LoyaltyExchangeSet
|
||||
continue
|
||||
}
|
||||
lotID := a.LotID
|
||||
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: a.ExpiresAt, OriginLotID: &lotID})
|
||||
lots = append(lots, WalletLotInput{Amount: points, ExpiresAt: EarlierExpiry(a.ExpiresAt, pointExpiry), OriginLotID: &lotID})
|
||||
}
|
||||
return lots
|
||||
}
|
||||
|
||||
@@ -332,3 +332,23 @@ func TestWalletExchange_RequiresAnIdempotencyKey(t *testing.T) {
|
||||
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"))
|
||||
}
|
||||
@@ -196,3 +196,42 @@ func TestWalletTrace_AgainstPostgres(t *testing.T) {
|
||||
_, 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)
|
||||
}
|
||||
|
||||
@@ -149,6 +149,55 @@ func (p *WalletProcessor) FindTransaction(ctx context.Context, idempotencyKey st
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ 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)
|
||||
@@ -541,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")
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ 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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,26 +11,6 @@ import (
|
||||
)
|
||||
|
||||
type GamificationService interface {
|
||||
// Customer Points
|
||||
CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
|
||||
GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error)
|
||||
GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error)
|
||||
ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error)
|
||||
UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
|
||||
DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error
|
||||
AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
|
||||
DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error)
|
||||
|
||||
// Customer Tokens
|
||||
CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
|
||||
GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error)
|
||||
GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error)
|
||||
ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error)
|
||||
UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
|
||||
DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error
|
||||
AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
|
||||
DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error)
|
||||
|
||||
// Tiers
|
||||
CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error)
|
||||
GetTier(ctx context.Context, id uuid.UUID) (*contract.TierResponse, error)
|
||||
@@ -72,18 +52,14 @@ type GamificationService interface {
|
||||
}
|
||||
|
||||
type GamificationServiceImpl struct {
|
||||
customerPointsProcessor *processor.CustomerPointsProcessor
|
||||
customerTokensProcessor *processor.CustomerTokensProcessor
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
gamePlayProcessor *processor.GamePlayProcessor
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
gamePlayProcessor *processor.GamePlayProcessor
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor
|
||||
}
|
||||
|
||||
func NewGamificationService(
|
||||
customerPointsProcessor *processor.CustomerPointsProcessor,
|
||||
customerTokensProcessor *processor.CustomerTokensProcessor,
|
||||
tierProcessor *processor.TierProcessor,
|
||||
gameProcessor *processor.GameProcessor,
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor,
|
||||
@@ -91,144 +67,14 @@ func NewGamificationService(
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor,
|
||||
) *GamificationServiceImpl {
|
||||
return &GamificationServiceImpl{
|
||||
customerPointsProcessor: customerPointsProcessor,
|
||||
customerTokensProcessor: customerTokensProcessor,
|
||||
tierProcessor: tierProcessor,
|
||||
gameProcessor: gameProcessor,
|
||||
gamePrizeProcessor: gamePrizeProcessor,
|
||||
gamePlayProcessor: gamePlayProcessor,
|
||||
omsetTrackerProcessor: omsetTrackerProcessor,
|
||||
tierProcessor: tierProcessor,
|
||||
gameProcessor: gameProcessor,
|
||||
gamePrizeProcessor: gamePrizeProcessor,
|
||||
gamePlayProcessor: gamePlayProcessor,
|
||||
omsetTrackerProcessor: omsetTrackerProcessor,
|
||||
}
|
||||
}
|
||||
|
||||
// Customer Points Service Methods
|
||||
func (s *GamificationServiceImpl) CreateCustomerPoints(ctx context.Context, req *contract.CreateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
|
||||
modelReq := transformer.CreateCustomerPointsRequestToModel(req)
|
||||
response, err := s.customerPointsProcessor.CreateCustomerPoints(ctx, modelReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerPointsModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*contract.CustomerPointsResponse, error) {
|
||||
response, err := s.customerPointsProcessor.GetCustomerPoints(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerPointsModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*contract.CustomerPointsResponse, error) {
|
||||
response, err := s.customerPointsProcessor.GetCustomerPointsByCustomerID(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerPointsModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) ListCustomerPoints(ctx context.Context, query *contract.ListCustomerPointsRequest) (*contract.PaginatedCustomerPointsResponse, error) {
|
||||
modelQuery := transformer.ListCustomerPointsRequestToModel(query)
|
||||
response, err := s.customerPointsProcessor.ListCustomerPoints(ctx, modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.PaginatedCustomerPointsResponseToContract(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
|
||||
modelReq := transformer.UpdateCustomerPointsRequestToModel(req)
|
||||
response, err := s.customerPointsProcessor.UpdateCustomerPoints(ctx, id, modelReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerPointsModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error {
|
||||
return s.customerPointsProcessor.DeleteCustomerPoints(ctx, id)
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) AddCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.AddCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
|
||||
response, err := s.customerPointsProcessor.AddPoints(ctx, customerID, req.Points)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerPointsModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) DeductCustomerPoints(ctx context.Context, customerID uuid.UUID, req *contract.DeductCustomerPointsRequest) (*contract.CustomerPointsResponse, error) {
|
||||
response, err := s.customerPointsProcessor.DeductPoints(ctx, customerID, req.Points)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerPointsModelToResponse(response), nil
|
||||
}
|
||||
|
||||
// Customer Tokens Service Methods
|
||||
func (s *GamificationServiceImpl) CreateCustomerTokens(ctx context.Context, req *contract.CreateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
|
||||
modelReq := transformer.CreateCustomerTokensRequestToModel(req)
|
||||
response, err := s.customerTokensProcessor.CreateCustomerTokens(ctx, modelReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerTokensModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*contract.CustomerTokensResponse, error) {
|
||||
response, err := s.customerTokensProcessor.GetCustomerTokens(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerTokensModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*contract.CustomerTokensResponse, error) {
|
||||
response, err := s.customerTokensProcessor.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerTokensModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) ListCustomerTokens(ctx context.Context, query *contract.ListCustomerTokensRequest) (*contract.PaginatedCustomerTokensResponse, error) {
|
||||
modelQuery := transformer.ListCustomerTokensRequestToModel(query)
|
||||
response, err := s.customerTokensProcessor.ListCustomerTokens(ctx, modelQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.PaginatedCustomerTokensResponseToContract(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *contract.UpdateCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
|
||||
modelReq := transformer.UpdateCustomerTokensRequestToModel(req)
|
||||
response, err := s.customerTokensProcessor.UpdateCustomerTokens(ctx, id, modelReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerTokensModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error {
|
||||
return s.customerTokensProcessor.DeleteCustomerTokens(ctx, id)
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) AddCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.AddCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
|
||||
response, err := s.customerTokensProcessor.AddTokens(ctx, customerID, tokenType, req.Tokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerTokensModelToResponse(response), nil
|
||||
}
|
||||
|
||||
func (s *GamificationServiceImpl) DeductCustomerTokens(ctx context.Context, customerID uuid.UUID, tokenType string, req *contract.DeductCustomerTokensRequest) (*contract.CustomerTokensResponse, error) {
|
||||
response, err := s.customerTokensProcessor.DeductTokens(ctx, customerID, tokenType, req.Tokens)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transformer.CustomerTokensModelToResponse(response), nil
|
||||
}
|
||||
|
||||
// Tier Service Methods
|
||||
func (s *GamificationServiceImpl) CreateTier(ctx context.Context, req *contract.CreateTierRequest) (*contract.TierResponse, error) {
|
||||
modelReq := transformer.CreateTierRequestToModel(req)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -133,7 +134,7 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, false)
|
||||
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, *current, nil, nil, false)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
@@ -144,9 +145,11 @@ func (s *LoyaltySettingsServiceImpl) GetOrganizationSettings(ctx context.Context
|
||||
// of the GET response, which are ignored.
|
||||
type organizationSettingsInput struct {
|
||||
*models.OrganizationLoyaltySettings
|
||||
Impact json.RawMessage `json:"impact"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
DryRun json.RawMessage `json:"dry_run"`
|
||||
Impact json.RawMessage `json:"impact"`
|
||||
ExpiryPreview json.RawMessage `json:"expiry_preview"`
|
||||
ExpiryActivations json.RawMessage `json:"expiry_activations"`
|
||||
Changes json.RawMessage `json:"changes"`
|
||||
DryRun json.RawMessage `json:"dry_run"`
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Context, apctx *appcontext.ContextInfo, body []byte, dryRun bool) *contract.Response {
|
||||
@@ -164,10 +167,11 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont
|
||||
}
|
||||
|
||||
var changes []models.LoyaltySettingChange
|
||||
var activations []models.LoyaltyExpiryActivation
|
||||
if dryRun {
|
||||
changes, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
|
||||
changes, activations, err = s.settings.PreviewOrganization(ctx, apctx.OrganizationID, next)
|
||||
} else {
|
||||
changes, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next)
|
||||
changes, activations, err = s.settings.UpdateOrganization(ctx, apctx.OrganizationID, apctx.UserID, next)
|
||||
}
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
@@ -180,7 +184,7 @@ func (s *LoyaltySettingsServiceImpl) UpdateOrganizationSettings(ctx context.Cont
|
||||
}
|
||||
after = *saved
|
||||
}
|
||||
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, dryRun)
|
||||
view, err := s.organizationView(ctx, apctx.OrganizationID, *current, after, changes, activations, dryRun)
|
||||
if err != nil {
|
||||
return loyaltyErrorResponse(err)
|
||||
}
|
||||
@@ -195,18 +199,26 @@ func (s *LoyaltySettingsServiceImpl) ListHistory(ctx context.Context, apctx *app
|
||||
return contract.BuildSuccessResponse(history)
|
||||
}
|
||||
|
||||
func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
|
||||
func (s *LoyaltySettingsServiceImpl) organizationView(ctx context.Context, organizationID uuid.UUID, before, after models.OrganizationLoyaltySettings, changes []models.LoyaltySettingChange, activations []models.LoyaltyExpiryActivation, dryRun bool) (*models.OrganizationLoyaltySettingsView, error) {
|
||||
now := time.Now()
|
||||
points, coins, err := s.outstanding.OrganizationOutstanding(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activations == nil {
|
||||
activations = []models.LoyaltyExpiryActivation{}
|
||||
}
|
||||
if changes == nil {
|
||||
changes = []models.LoyaltySettingChange{}
|
||||
}
|
||||
return &models.OrganizationLoyaltySettingsView{
|
||||
OrganizationLoyaltySettings: after,
|
||||
Impact: models.NewLoyaltySettingsImpact(points, coins, before, after),
|
||||
Changes: changes,
|
||||
DryRun: dryRun,
|
||||
ExpiryPreview: models.LoyaltyExpiryPreview{
|
||||
Point: processor.ComputeExpiry(after.PointExpiry, now),
|
||||
Coin: processor.ComputeExpiry(after.CoinExpiry, now),
|
||||
},
|
||||
Changes: changes,
|
||||
DryRun: dryRun,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
)
|
||||
|
||||
// Lots expire at the end of their day, so running every quarter of an hour keeps any
|
||||
// lot from staying past its expiry for more than about that long (PC-503).
|
||||
const defaultWalletExpiryInterval = 15 * time.Minute
|
||||
|
||||
type walletExpiryWork interface {
|
||||
ExpireDue(ctx context.Context) (int, error)
|
||||
SendReminders(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// WalletExpiryJob expires the balances whose time is up and reminds customers of what
|
||||
// is about to (docs/prd-point-coin.md F12, PC-503, PC-504).
|
||||
// Unlike OmsetMilestoneScheduler it keeps no state in memory: several instances can
|
||||
// run it at once, and a restart repeats nothing, because every lot is expired under
|
||||
// its wallet's lock with an idempotency key.
|
||||
type WalletExpiryJob struct {
|
||||
expirer walletExpiryWork
|
||||
stopCh chan struct{}
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func NewWalletExpiryJob(expirer walletExpiryWork) *WalletExpiryJob {
|
||||
return &WalletExpiryJob{expirer: expirer, stopCh: make(chan struct{})}
|
||||
}
|
||||
|
||||
func (j *WalletExpiryJob) Start(interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = defaultWalletExpiryInterval
|
||||
}
|
||||
go func() {
|
||||
j.RunOnce(context.Background())
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
j.RunOnce(context.Background())
|
||||
case <-j.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
logger.NonContext.Infof("Wallet expiry job started (interval: %s)", interval)
|
||||
}
|
||||
|
||||
func (j *WalletExpiryJob) Stop() {
|
||||
j.stopOnce.Do(func() { close(j.stopCh) })
|
||||
}
|
||||
|
||||
// RunOnce expires what is due, sends the reminders that are due, and returns how many
|
||||
// lots it expired.
|
||||
func (j *WalletExpiryJob) RunOnce(ctx context.Context) int {
|
||||
expired, err := j.expirer.ExpireDue(ctx)
|
||||
if err != nil {
|
||||
logger.NonContext.Error("Wallet expiry failed to run", err)
|
||||
}
|
||||
if expired > 0 {
|
||||
logger.NonContext.Infof("Wallet expiry expired %d lots", expired)
|
||||
}
|
||||
reminded, err := j.expirer.SendReminders(ctx)
|
||||
if err != nil {
|
||||
logger.NonContext.Error("Wallet expiry reminders failed to run", err)
|
||||
}
|
||||
if reminded > 0 {
|
||||
logger.NonContext.Infof("Wallet expiry sent %d reminders", reminded)
|
||||
}
|
||||
return expired
|
||||
}
|
||||
@@ -5,131 +5,6 @@ import (
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// Customer Points Transformers
|
||||
func CreateCustomerPointsRequestToModel(req *contract.CreateCustomerPointsRequest) *models.CreateCustomerPointsRequest {
|
||||
return &models.CreateCustomerPointsRequest{
|
||||
CustomerID: req.CustomerID,
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateCustomerPointsRequestToModel(req *contract.UpdateCustomerPointsRequest) *models.UpdateCustomerPointsRequest {
|
||||
return &models.UpdateCustomerPointsRequest{
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
func ListCustomerPointsRequestToModel(req *contract.ListCustomerPointsRequest) *models.ListCustomerPointsQuery {
|
||||
return &models.ListCustomerPointsQuery{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
SortBy: req.SortBy,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func CustomerPointsModelToResponse(model *models.CustomerPointsResponse) *contract.CustomerPointsResponse {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var customer *contract.CustomerResponse
|
||||
if model.Customer != nil {
|
||||
customer = CustomerModelToResponse(model.Customer)
|
||||
}
|
||||
|
||||
return &contract.CustomerPointsResponse{
|
||||
ID: model.ID,
|
||||
CustomerID: model.CustomerID,
|
||||
Balance: model.Balance,
|
||||
Customer: customer,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func PaginatedCustomerPointsResponseToContract(model *models.PaginatedCustomerPointsResponse) *contract.PaginatedCustomerPointsResponse {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]contract.CustomerPointsResponse, len(model.Data))
|
||||
for i, item := range model.Data {
|
||||
responses[i] = *CustomerPointsModelToResponse(&item)
|
||||
}
|
||||
|
||||
return &contract.PaginatedCustomerPointsResponse{
|
||||
Data: responses,
|
||||
TotalCount: model.TotalCount,
|
||||
Page: model.Page,
|
||||
Limit: model.Limit,
|
||||
TotalPages: model.TotalPages,
|
||||
}
|
||||
}
|
||||
|
||||
// Customer Tokens Transformers
|
||||
func CreateCustomerTokensRequestToModel(req *contract.CreateCustomerTokensRequest) *models.CreateCustomerTokensRequest {
|
||||
return &models.CreateCustomerTokensRequest{
|
||||
CustomerID: req.CustomerID,
|
||||
TokenType: req.TokenType,
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateCustomerTokensRequestToModel(req *contract.UpdateCustomerTokensRequest) *models.UpdateCustomerTokensRequest {
|
||||
return &models.UpdateCustomerTokensRequest{
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
func ListCustomerTokensRequestToModel(req *contract.ListCustomerTokensRequest) *models.ListCustomerTokensQuery {
|
||||
return &models.ListCustomerTokensQuery{
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
TokenType: req.TokenType,
|
||||
SortBy: req.SortBy,
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
func CustomerTokensModelToResponse(model *models.CustomerTokensResponse) *contract.CustomerTokensResponse {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var customer *contract.CustomerResponse
|
||||
if model.Customer != nil {
|
||||
customer = CustomerModelToResponse(model.Customer)
|
||||
}
|
||||
|
||||
return &contract.CustomerTokensResponse{
|
||||
ID: model.ID,
|
||||
CustomerID: model.CustomerID,
|
||||
TokenType: model.TokenType,
|
||||
Balance: model.Balance,
|
||||
Customer: customer,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func PaginatedCustomerTokensResponseToContract(model *models.PaginatedResponse[models.CustomerTokensResponse]) *contract.PaginatedCustomerTokensResponse {
|
||||
responses := make([]contract.CustomerTokensResponse, len(model.Data))
|
||||
for i, item := range model.Data {
|
||||
responses[i] = *CustomerTokensModelToResponse(&item)
|
||||
}
|
||||
|
||||
return &contract.PaginatedCustomerTokensResponse{
|
||||
Data: responses,
|
||||
TotalCount: int(model.Pagination.Total),
|
||||
Page: model.Pagination.Page,
|
||||
Limit: model.Pagination.Limit,
|
||||
TotalPages: model.Pagination.TotalPages,
|
||||
}
|
||||
}
|
||||
|
||||
// Tier Transformers
|
||||
func CreateTierRequestToModel(req *contract.CreateTierRequest) *models.CreateTierRequest {
|
||||
return &models.CreateTierRequest{
|
||||
|
||||
@@ -9,20 +9,6 @@ import (
|
||||
)
|
||||
|
||||
type GamificationValidator interface {
|
||||
// Customer Points
|
||||
ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string)
|
||||
ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string)
|
||||
ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string)
|
||||
ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string)
|
||||
ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string)
|
||||
|
||||
// Customer Tokens
|
||||
ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string)
|
||||
ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string)
|
||||
ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string)
|
||||
ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string)
|
||||
ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string)
|
||||
|
||||
// Tiers
|
||||
ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string)
|
||||
ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string)
|
||||
@@ -60,152 +46,6 @@ func NewGamificationValidator() *GamificationValidatorImpl {
|
||||
}
|
||||
}
|
||||
|
||||
// Customer Points Validators
|
||||
func (v *GamificationValidatorImpl) ValidateCreateCustomerPointsRequest(req *contract.CreateCustomerPointsRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Balance < 0 {
|
||||
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateUpdateCustomerPointsRequest(req *contract.UpdateCustomerPointsRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Balance < 0 {
|
||||
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateListCustomerPointsRequest(req *contract.ListCustomerPointsRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
if req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateAddCustomerPointsRequest(req *contract.AddCustomerPointsRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Points <= 0 {
|
||||
return errors.New("points must be greater than 0"), "INVALID_POINTS"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateDeductCustomerPointsRequest(req *contract.DeductCustomerPointsRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Points <= 0 {
|
||||
return errors.New("points must be greater than 0"), "INVALID_POINTS"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Customer Tokens Validators
|
||||
func (v *GamificationValidatorImpl) ValidateCreateCustomerTokensRequest(req *contract.CreateCustomerTokensRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Balance < 0 {
|
||||
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
|
||||
}
|
||||
|
||||
validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
|
||||
if !contains(validTokenTypes, req.TokenType) {
|
||||
return errors.New("invalid token type"), "INVALID_TOKEN_TYPE"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateUpdateCustomerTokensRequest(req *contract.UpdateCustomerTokensRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Balance < 0 {
|
||||
return errors.New("balance cannot be negative"), "INVALID_BALANCE"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateListCustomerTokensRequest(req *contract.ListCustomerTokensRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.Limit <= 0 {
|
||||
req.Limit = 10
|
||||
}
|
||||
if req.Limit > 100 {
|
||||
req.Limit = 100
|
||||
}
|
||||
|
||||
if req.TokenType != "" {
|
||||
validTokenTypes := []string{"SPIN", "RAFFLE", "MINIGAME"}
|
||||
if !contains(validTokenTypes, req.TokenType) {
|
||||
return errors.New("invalid token type"), "INVALID_TOKEN_TYPE"
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateAddCustomerTokensRequest(req *contract.AddCustomerTokensRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Tokens <= 0 {
|
||||
return errors.New("tokens must be greater than 0"), "INVALID_TOKENS"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
func (v *GamificationValidatorImpl) ValidateDeductCustomerTokensRequest(req *contract.DeductCustomerTokensRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
return err, "VALIDATION_ERROR"
|
||||
}
|
||||
|
||||
if req.Tokens <= 0 {
|
||||
return errors.New("tokens must be greater than 0"), "INVALID_TOKENS"
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// Tier Validators
|
||||
func (v *GamificationValidatorImpl) ValidateCreateTierRequest(req *contract.CreateTierRequest) (error, string) {
|
||||
if err := v.validate.Struct(req); err != nil {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS wallet_expiry_reminders;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Which expiry reminders have gone out (docs/prd-point-coin.md F12): one per
|
||||
-- customer, currency and expiry day. The row is written before the push is sent, so
|
||||
-- several instances of the job, or a restart, never remind twice.
|
||||
CREATE TABLE wallet_expiry_reminders (
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
|
||||
currency VARCHAR(10) NOT NULL CHECK (currency IN ('POINT','COIN')),
|
||||
expiry_date DATE NOT NULL,
|
||||
amount BIGINT NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
PRIMARY KEY (customer_id, currency, expiry_date)
|
||||
);
|
||||
Reference in New Issue
Block a user