Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ef226af1 | ||
|
|
892575202b | ||
|
|
c9654a387a | ||
|
|
e94eb2c26d | ||
|
|
e8b31035f7 | ||
|
|
100e006218 | ||
|
|
609d434976 | ||
|
|
35fe5703fd | ||
|
|
e200923b72 | ||
|
|
c988a79d3b | ||
|
|
9b21af8892 | ||
|
|
582dc75543 | ||
|
|
c6062c4bb9 | ||
|
|
5e28b05770 | ||
|
|
4e24f9bbb0 | ||
|
|
645da3048e | ||
|
|
ae8003c1e4 | ||
|
|
3cd88a55c8 | ||
|
|
d1e543a79f | ||
|
|
550122f29c | ||
|
|
d293786cde | ||
|
|
4d63673a25 | ||
|
|
9ce55e6002 |
+1
-1
@@ -15,6 +15,6 @@ COPY --from=build /out/app /app
|
||||
ENV TZ=Asia/Jakarta
|
||||
EXPOSE 4000
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -fsS http://localhost:3300/health || exit 1
|
||||
CMD curl -fsS http://localhost:4000/health || exit 1
|
||||
USER appuser
|
||||
ENTRYPOINT ["/app"]
|
||||
|
||||
+142
-27
@@ -1,62 +1,177 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage:
|
||||
# ./deployment.sh staging deploy staging (branch staging, port 4001)
|
||||
# ./deployment.sh production deploy production (branch main, port 4000)
|
||||
# ./deployment.sh production --rollback kembali ke image sebelum deploy terakhir
|
||||
# Tambahkan --yes untuk melewati konfirmasi production.
|
||||
|
||||
APP_NAME="apskel-pos"
|
||||
LEGACY_CONTAINER="apskel-pos" # nama lama sebelum nama container memakai environment
|
||||
|
||||
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
usage() {
|
||||
echo "Usage: $0 <staging|production> [--rollback] [--yes]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$CURRENT_BRANCH" in
|
||||
main)
|
||||
ENV_MODE="production"
|
||||
fail() {
|
||||
echo "❌ $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Argumen ──────────────────────────────────────────────────────────────────
|
||||
ENV_MODE="${1:-}"
|
||||
shift || true
|
||||
|
||||
ROLLBACK=false
|
||||
ASSUME_YES=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--rollback) ROLLBACK=true ;;
|
||||
--yes|-y) ASSUME_YES=true ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Environment wajib disebut eksplisit, tidak lagi ditebak dari branch aktif
|
||||
case "$ENV_MODE" in
|
||||
production)
|
||||
EXPECTED_BRANCH="main"
|
||||
PORT="4000"
|
||||
;;
|
||||
staging)
|
||||
ENV_MODE="staging"
|
||||
EXPECTED_BRANCH="staging"
|
||||
PORT="4001"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
||||
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
||||
exit 1
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
CONTAINER_NAME="$APP_NAME"
|
||||
CONTAINER_NAME="$APP_NAME-$ENV_MODE"
|
||||
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
||||
PREVIOUS_IMAGE="$APP_NAME:$ENV_MODE-previous"
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
|
||||
# ─── Validasi repo ────────────────────────────────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
|
||||
fail "Repo sedang detached HEAD. Jalankan 'git checkout $EXPECTED_BRANCH' dulu."
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
|
||||
fail "Deploy $ENV_MODE harus dari branch '$EXPECTED_BRANCH', folder ini di branch '$CURRENT_BRANCH'.
|
||||
Pakai folder terpisah per environment, jangan pindah branch di folder yang sedang dipakai container lain."
|
||||
fi
|
||||
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
fail "Ada perubahan yang belum di-commit di folder ini. Bersihkan dulu sebelum deploy."
|
||||
fi
|
||||
|
||||
[ -f "$CONFIG_FILE" ] || fail "Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
|
||||
# ─── Pastikan port tidak dipakai container environment lain ──────────────────
|
||||
OTHER_ON_PORT=$(docker ps --filter "publish=$PORT" --format '{{.Names}}' \
|
||||
| grep -vx "$CONTAINER_NAME" \
|
||||
| { if [ "$ENV_MODE" = "production" ]; then grep -vx "$LEGACY_CONTAINER"; else cat; fi; } \
|
||||
|| true)
|
||||
if [ -n "$OTHER_ON_PORT" ]; then
|
||||
fail "Port $PORT sedang dipakai container lain: $OTHER_ON_PORT"
|
||||
fi
|
||||
|
||||
echo "📦 Environment : $ENV_MODE"
|
||||
echo "🌿 Branch : $CURRENT_BRANCH"
|
||||
echo "🐳 Container : $CONTAINER_NAME"
|
||||
echo "🔌 Port : $PORT"
|
||||
echo "📁 Folder : $(pwd)"
|
||||
[ "$ROLLBACK" = true ] && echo "⏪ Mode : ROLLBACK ke $PREVIOUS_IMAGE"
|
||||
echo ""
|
||||
|
||||
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
exit 1
|
||||
# ─── Konfirmasi production ────────────────────────────────────────────────────
|
||||
if [ "$ENV_MODE" = "production" ] && [ "$ASSUME_YES" != true ]; then
|
||||
read -r -p "⚠️ Ini PRODUCTION. Ketik 'production' untuk lanjut: " CONFIRM
|
||||
[ "$CONFIRM" = "production" ] || fail "Dibatalkan."
|
||||
fi
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
run_container() {
|
||||
local image="$1"
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
# Container production lama masih bernama 'apskel-pos'; staging tidak boleh menyentuhnya
|
||||
if [ "$ENV_MODE" = "production" ]; then
|
||||
docker rm -f "$LEGACY_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "🚀 Running container from $image..."
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
"$image" >/dev/null
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
echo "🩺 Waiting for healthcheck..."
|
||||
for _ in $(seq 1 36); do
|
||||
local status
|
||||
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
|
||||
case "$status" in
|
||||
healthy) return 0 ;;
|
||||
unhealthy|exited|dead|missing) return 1 ;;
|
||||
esac
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ─── Rollback ─────────────────────────────────────────────────────────────────
|
||||
if [ "$ROLLBACK" = true ]; then
|
||||
docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1 || fail "Image '$PREVIOUS_IMAGE' tidak ada, tidak bisa rollback."
|
||||
run_container "$PREVIOUS_IMAGE"
|
||||
wait_healthy || fail "Container hasil rollback tidak healthy. Cek: docker logs $CONTAINER_NAME"
|
||||
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
|
||||
echo "✅ Rollback $ENV_MODE complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Deploy ───────────────────────────────────────────────────────────────────
|
||||
echo "🔄 Pulling latest code (origin/$EXPECTED_BRANCH)..."
|
||||
git fetch origin "$EXPECTED_BRANCH"
|
||||
git merge --ff-only "origin/$EXPECTED_BRANCH"
|
||||
echo " Commit: $(git log -1 --format='%h %s')"
|
||||
|
||||
# Simpan image yang sedang jalan untuk rollback
|
||||
if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
docker tag "$IMAGE_NAME" "$PREVIOUS_IMAGE"
|
||||
fi
|
||||
|
||||
echo "🐳 Building Docker image ($ENV_MODE)..."
|
||||
docker build --target production -t "$IMAGE_NAME" .
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
run_container "$IMAGE_NAME"
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
"$IMAGE_NAME"
|
||||
if ! wait_healthy; then
|
||||
echo "❌ Container baru tidak healthy. Log terakhir:"
|
||||
docker logs --tail 50 "$CONTAINER_NAME" || true
|
||||
if docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1; then
|
||||
echo "⏪ Rolling back ke $PREVIOUS_IMAGE..."
|
||||
run_container "$PREVIOUS_IMAGE"
|
||||
wait_healthy || fail "Rollback juga tidak healthy. Cek manual: docker logs $CONTAINER_NAME"
|
||||
# Jangan biarkan image rusak jadi 'previous' di deploy berikutnya
|
||||
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
|
||||
fail "Deploy gagal, $ENV_MODE sudah dikembalikan ke image sebelumnya."
|
||||
fi
|
||||
fail "Deploy gagal dan tidak ada image sebelumnya untuk rollback."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment $ENV_MODE complete."
|
||||
echo " Container : $CONTAINER_NAME"
|
||||
echo " Port : $PORT"
|
||||
echo " Rollback : $0 $ENV_MODE --rollback"
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
# API EnakPoint & EnakCoin
|
||||
|
||||
30 Sep 2026
|
||||
|
||||
Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat.
|
||||
|
||||
## Konvensi umum
|
||||
|
||||
| Klien | Autentikasi | Prefix |
|
||||
| --- | --- | --- |
|
||||
| Customer app / self-order | `Authorization: Bearer <token customer>` | `/api/v1/customer` |
|
||||
| POS | Token user (kasir/manager) | `/api/v1` |
|
||||
| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` |
|
||||
|
||||
**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan.
|
||||
|
||||
| `code` | HTTP | Arti |
|
||||
| --- | --- | --- |
|
||||
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
|
||||
| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) |
|
||||
| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain |
|
||||
| `429` | 429 | OTP diminta ulang terlalu cepat |
|
||||
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
|
||||
| `PIN_INVALID` | 400 | PIN salah |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN |
|
||||
| `900` | 500 | Kesalahan server |
|
||||
|
||||
**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit.
|
||||
|
||||
**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain.
|
||||
|
||||
**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya.
|
||||
|
||||
## Customer app: saldo & riwayat
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir |
|
||||
| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter |
|
||||
| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal |
|
||||
| PUT | `/customer/devices` | Daftarkan token FCM device |
|
||||
| DELETE | `/customer/devices/:device_id` | Hapus device saat logout |
|
||||
|
||||
### GET /customer/wallet
|
||||
|
||||
```json
|
||||
{
|
||||
"point_balance": 12500,
|
||||
"coin_balance": 8,
|
||||
"point_value": 1,
|
||||
"point_discount_value": 12500,
|
||||
"nearest_expiring": {
|
||||
"point": { "amount": 150, "date": "2026-12-31" },
|
||||
"coin": null
|
||||
},
|
||||
"recent_transactions": [ "… sama seperti item riwayat …" ]
|
||||
}
|
||||
```
|
||||
|
||||
- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang.
|
||||
- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang.
|
||||
- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### GET /customer/wallet/transactions
|
||||
|
||||
| Query | Tipe | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| `page` | int | Default 1 |
|
||||
| `limit` | int | 1–100, default 20 |
|
||||
| `currency` | `POINT` \| `COIN` | Opsional |
|
||||
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` |
|
||||
| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif |
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"currency": "POINT",
|
||||
"type": "EARN",
|
||||
"amount": 875,
|
||||
"balance_after": 12500,
|
||||
"description": "Belanja #ORD-0123 di Outlet Kemang",
|
||||
"source": { "type": "ORDER", "id": "…" },
|
||||
"outlet_id": "…",
|
||||
"group_id": null,
|
||||
"expires_at": "2026-12-31T23:59:59+07:00",
|
||||
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi.
|
||||
|
||||
### GET /customer/wallet/expiring
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### PUT /customer/devices
|
||||
|
||||
```json
|
||||
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`.
|
||||
|
||||
## Customer app: PIN
|
||||
|
||||
PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` |
|
||||
| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` |
|
||||
| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
|
||||
| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN |
|
||||
| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
|
||||
|
||||
1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer.
|
||||
2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa.
|
||||
3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama.
|
||||
|
||||
PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum.
|
||||
|
||||
## Customer app: bayar, exchange, transfer, game
|
||||
|
||||
| Method | Path | PIN | Idempotency-Key |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/customer/wallet/payment-code` | Ya | – |
|
||||
| POST | `/customer/orders/:id/pay-with-points` | Ya | – |
|
||||
| GET | `/customer/wallet/exchange/preview?coins=` | – | – |
|
||||
| POST | `/customer/wallet/exchange` | Ya | Wajib |
|
||||
| GET | `/customer/wallet/transfer/recipient?phone=` | – | – |
|
||||
| POST | `/customer/wallet/transfer` | Ya | Wajib |
|
||||
| POST | `/customer/spin` | – | – |
|
||||
|
||||
### POST /customer/wallet/payment-code
|
||||
|
||||
Body `{ "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
|
||||
```
|
||||
|
||||
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama.
|
||||
|
||||
### POST /customer/orders/:id/pay-with-points
|
||||
|
||||
Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama.
|
||||
|
||||
### GET /customer/wallet/exchange/preview?coins=30
|
||||
|
||||
```json
|
||||
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
|
||||
```
|
||||
|
||||
Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`.
|
||||
|
||||
### POST /customer/wallet/exchange
|
||||
|
||||
Body `{ "coins": 30, "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"coins": 30,
|
||||
"points": 9,
|
||||
"coin_amount": 10,
|
||||
"point_amount": 3,
|
||||
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"coin_balance": 5,
|
||||
"point_balance": 9,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`).
|
||||
|
||||
### GET /customer/wallet/transfer/recipient?phone=081234561234
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`.
|
||||
|
||||
### POST /customer/wallet/transfer
|
||||
|
||||
Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"currency": "POINT",
|
||||
"amount": 120,
|
||||
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
|
||||
"lots": [
|
||||
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
|
||||
{ "amount": 20, "expires_at": null }
|
||||
],
|
||||
"balance": 30,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`.
|
||||
|
||||
### POST /customer/spin
|
||||
|
||||
Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb" },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong.
|
||||
|
||||
## POS: pembayaran EnakPoint
|
||||
|
||||
Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir.
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini |
|
||||
| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) |
|
||||
| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint |
|
||||
|
||||
1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya.
|
||||
2. POS memanggil preview untuk tombol "pakai maksimal".
|
||||
3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa.
|
||||
|
||||
### GET /orders/:id/point-payment/preview
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"customer_id": "…",
|
||||
"eligible": true,
|
||||
"point_balance": 12500,
|
||||
"point_value": 1,
|
||||
"remaining_amount": 87500,
|
||||
"min_payment_points": 1,
|
||||
"max_payment_percent": 100,
|
||||
"max_points": 12500,
|
||||
"max_amount": 12500
|
||||
}
|
||||
```
|
||||
|
||||
Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai:
|
||||
|
||||
```
|
||||
batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint)
|
||||
maks_point = min(saldo, floor(batas_rupiah / point_value))
|
||||
```
|
||||
|
||||
### POST /payments
|
||||
|
||||
Header `X-Idempotency-Key` wajib.
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"payment_method_id": "<id method EnakPoint>",
|
||||
"points": 12500,
|
||||
"payment_code": "482913"
|
||||
}
|
||||
```
|
||||
|
||||
- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian).
|
||||
- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`).
|
||||
- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`.
|
||||
- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru.
|
||||
- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint.
|
||||
|
||||
### Void dan refund
|
||||
|
||||
- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint.
|
||||
- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus.
|
||||
- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`.
|
||||
- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md).
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet |
|
||||
| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) |
|
||||
| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) |
|
||||
| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli |
|
||||
| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual |
|
||||
| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir |
|
||||
| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer |
|
||||
| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) |
|
||||
|
||||
Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak.
|
||||
|
||||
### /outlets/:outlet_id/loyalty-settings
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100.
|
||||
|
||||
### /marketing/loyalty-settings
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": {
|
||||
"enabled": false,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { "…": "sama dengan point_expiry" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field kedaluwarsa | Dipakai mode | Nilai |
|
||||
| --- | --- | --- |
|
||||
| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) |
|
||||
| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak |
|
||||
| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya |
|
||||
| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` |
|
||||
| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan |
|
||||
| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat |
|
||||
|
||||
Response menambahkan:
|
||||
|
||||
- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs.
|
||||
- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak).
|
||||
- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal.
|
||||
- `changes` dan `dry_run`.
|
||||
|
||||
### POST /marketing/customers/:id/wallet/adjust
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
|
||||
```
|
||||
|
||||
`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`.
|
||||
|
||||
### GET /marketing/wallet-transactions/:id/trace
|
||||
|
||||
```json
|
||||
{
|
||||
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" },
|
||||
"lots": [
|
||||
{
|
||||
"amount": 30,
|
||||
"chain": [
|
||||
{ "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } },
|
||||
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`.
|
||||
|
||||
### PIN customer
|
||||
|
||||
`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat.
|
||||
|
||||
## Referensi
|
||||
|
||||
### Tipe mutasi (`type`)
|
||||
|
||||
| `type` | Arah | Arti | `source` / `destination` |
|
||||
| --- | --- | --- | --- |
|
||||
| `EARN` | + | Didapat dari order lunas | `ORDER` |
|
||||
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
|
||||
| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` |
|
||||
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
|
||||
| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) |
|
||||
| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) |
|
||||
| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) |
|
||||
| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) |
|
||||
| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` |
|
||||
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
|
||||
| `ADJUSTMENT` | + / − | Koreksi admin | `USER` |
|
||||
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
|
||||
|
||||
### Notifikasi push (FCM)
|
||||
|
||||
Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`.
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lainnya |
|
||||
| --- | --- | --- |
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
|
||||
| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
|
||||
|
||||
### Endpoint dan field deprecated
|
||||
|
||||
Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
|
||||
|
||||
| Lama | Pengganti |
|
||||
| --- | --- |
|
||||
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
|
||||
@@ -0,0 +1,305 @@
|
||||
# Backoffice EnakPoint & EnakCoin
|
||||
|
||||
30 Sep 2026
|
||||
|
||||
Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game.
|
||||
|
||||
## Layar yang perlu dibuat
|
||||
|
||||
Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`.
|
||||
|
||||
| Layar | Endpoint | Tempat di menu |
|
||||
| --- | --- | --- |
|
||||
| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail outlet → tab Loyalitas |
|
||||
| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan |
|
||||
| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat |
|
||||
| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet |
|
||||
| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet |
|
||||
| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan |
|
||||
| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game |
|
||||
|
||||
Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada.
|
||||
|
||||
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan.
|
||||
|
||||
**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error).
|
||||
|
||||
## Setting loyalitas outlet
|
||||
|
||||
Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya.
|
||||
|
||||
`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak.
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Tipe | Default | Validasi |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
||||
| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat | angka | 1 | ≥ 0 |
|
||||
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
|
||||
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
|
||||
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
|
||||
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
|
||||
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
|
||||
|
||||
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||
|
||||
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
|
||||
|
||||
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu.
|
||||
|
||||
## Setting loyalitas organisasi
|
||||
|
||||
Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku sama untuk semua outlet, jadi diatur sekali per organisasi. Mengubah nilai EnakPoint atau kurs langsung mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya sebelum disimpan.
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": { "…": "lihat bagian kedaluwarsa" },
|
||||
"coin_expiry": { "…": "lihat bagian kedaluwarsa" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Default | Validasi |
|
||||
| --- | --- | --- | --- |
|
||||
| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 |
|
||||
| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 |
|
||||
| `transfer.enabled` | Izinkan transfer antar customer | aktif | – |
|
||||
| `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 |
|
||||
| `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 |
|
||||
| `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, dihitung per currency, reset tengah malam WIB |
|
||||
|
||||
### Alur simpan
|
||||
|
||||
1. Owner mengubah form.
|
||||
2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek yang diubah. Tidak ada yang tersimpan.
|
||||
3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti.
|
||||
4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs berubah), dan `expiry_activations` (bila ada, lihat bagian kedaluwarsa).
|
||||
5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`.
|
||||
|
||||
### Dialog dampak
|
||||
|
||||
`impact` berisi saldo beredar organisasi dan nilainya sebelum/sesudah:
|
||||
|
||||
| Field `impact` | Tampilkan sebagai |
|
||||
| --- | --- |
|
||||
| `outstanding_points` | EnakPoint beredar |
|
||||
| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … |
|
||||
| `outstanding_coins` | EnakCoin beredar |
|
||||
| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint |
|
||||
| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … |
|
||||
|
||||
Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu.
|
||||
|
||||
## Pengaturan kedaluwarsa
|
||||
|
||||
Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin (`coin_expiry`) dengan salah satu dari dua model; defaultnya mati, dan bila dinyalakan defaultnya hangus setiap 31 Desember.
|
||||
|
||||
```json
|
||||
"point_expiry": {
|
||||
"enabled": true,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tampil saat | Label usulan | Validasi |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | selalu | Saldo bisa kedaluwarsa | – |
|
||||
| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` |
|
||||
| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, format `MM-DD`, `02-29` ditolak |
|
||||
| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 |
|
||||
| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` |
|
||||
| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – |
|
||||
| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat |
|
||||
|
||||
**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama, mis. 31 Desember, atau 30 Juni dan 31 Desember untuk dua kali setahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya, jadi saldo yang didapat 1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Untuk input `fixed_dates`, pakai pemilih tanggal+bulan tanpa tahun.
|
||||
|
||||
**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk, mis. 12 bulan. Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027.
|
||||
|
||||
**Preview.** Response `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: kapan saldo yang didapat sekarang akan kedaluwarsa (`null` = tidak). Tampilkan di bawah form: "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026." Karena dihitung dari nilai yang dikirim, dry run bisa dipakai untuk memperbarui preview saat owner mengubah pilihan.
|
||||
|
||||
**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal, dengan masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode sejak hari ini (`ROLLING`). Dry run mengembalikan `expiry_activations`; tampilkan di dialog konfirmasi dengan kalimat tegas, mis. "1.250.000 EnakPoint milik customer yang ada sekarang akan kedaluwarsa pada 31 Des 2027. Tindakan ini tidak bisa dibatalkan dengan mematikan kedaluwarsa."
|
||||
|
||||
| Field `expiry_activations[]` | Arti |
|
||||
| --- | --- |
|
||||
| `currency` | `POINT` atau `COIN` |
|
||||
| `lots` | Jumlah paket saldo yang diberi tanggal |
|
||||
| `amount` | Total saldo yang diberi tanggal |
|
||||
| `expires_at` | Tanggal kedaluwarsanya |
|
||||
|
||||
**Aturan lain yang perlu dijelaskan di layar:**
|
||||
|
||||
- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya.
|
||||
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
|
||||
- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya.
|
||||
- Saldo hangus tanpa kompensasi apa pun. Customer mendapat pengingat push `reminder_days` hari sebelumnya dan notifikasi saat hangus.
|
||||
|
||||
## Wallet customer
|
||||
|
||||
Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke order asalnya.
|
||||
|
||||
### Saldo, lot, dan riwayat
|
||||
|
||||
`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
|
||||
|
||||
```json
|
||||
{
|
||||
"customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" },
|
||||
"point_balance": 12650,
|
||||
"coin_balance": 8,
|
||||
"spendable_point_balance": 12500,
|
||||
"spendable_coin_balance": 8,
|
||||
"lots": [
|
||||
{ "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" }
|
||||
],
|
||||
"transactions": {
|
||||
"data": [
|
||||
{
|
||||
"id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650,
|
||||
"description": "Transfer ke An*** (08**-****-5678)",
|
||||
"destination": { "type": "WALLET_TX", "id": "…" },
|
||||
"counterparty": { "id": "…", "name": "Anita Rahma" },
|
||||
"created_by": null, "outlet": null, "reason": null, "metadata": {},
|
||||
"created_at": "…"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit).
|
||||
- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`.
|
||||
- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall).
|
||||
|
||||
### Adjustment manual
|
||||
|
||||
`POST /marketing/customers/:id/wallet/adjust`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" }
|
||||
```
|
||||
|
||||
| Field | Aturan |
|
||||
| --- | --- |
|
||||
| `currency` | `POINT` atau `COIN` |
|
||||
| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi |
|
||||
| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" |
|
||||
| `idempotency_key` | Opsional tapi disarankan: buat satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali |
|
||||
|
||||
Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan di dialog bahwa adjustment tidak disertai pembayaran uang, sehingga alasan tidak boleh "pencairan".
|
||||
|
||||
### Telusuri mutasi
|
||||
|
||||
Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-transactions/:id/trace`.
|
||||
|
||||
```json
|
||||
{
|
||||
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" },
|
||||
"lots": [
|
||||
{
|
||||
"amount": 30,
|
||||
"chain": [
|
||||
{ "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } },
|
||||
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya.
|
||||
|
||||
## PIN, riwayat setting, game, dan method EnakPoint
|
||||
|
||||
### PIN & keamanan customer
|
||||
|
||||
Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah menghapusnya, misalnya bila customer kehilangan akses, sehingga customer harus membuat PIN baru lewat OTP di aplikasi.
|
||||
|
||||
- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. `reason` wajib. Tampilkan dialog konfirmasi dengan input alasan.
|
||||
- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" }
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
| `event` | Label usulan |
|
||||
| --- | --- |
|
||||
| `PIN_SET` | PIN dibuat |
|
||||
| `PIN_CHANGED` | PIN diganti |
|
||||
| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) |
|
||||
| `PIN_FAILED` | PIN salah dimasukkan |
|
||||
| `PIN_LOCKED` | PIN terkunci 30 menit |
|
||||
| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) |
|
||||
|
||||
### Riwayat perubahan setting
|
||||
|
||||
`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; tambah `&outlet_id=…` untuk riwayat satu outlet.
|
||||
|
||||
```json
|
||||
{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" }
|
||||
```
|
||||
|
||||
`old_value` `null` berarti sebelumnya masih nilai default. Tampilkan `key` dengan label yang sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint"), dan `changed_by` sebagai nama user.
|
||||
|
||||
### Biaya main game
|
||||
|
||||
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
|
||||
|
||||
### Method pembayaran EnakPoint
|
||||
|
||||
Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`):
|
||||
|
||||
- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti.
|
||||
- Tipe `point` tidak ditawarkan saat membuat method baru.
|
||||
- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint".
|
||||
|
||||
Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk.
|
||||
|
||||
## Pesan error dan checklist
|
||||
|
||||
| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan |
|
||||
| --- | --- | --- | --- |
|
||||
| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer |
|
||||
| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast |
|
||||
| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" |
|
||||
| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalty.point.earn_per_amount must be at least 1`. Untuk validasi form, lebih baik cek batasnya di sisi klien (tabel di tiap bagian) dan tampilkan `cause` hanya sebagai cadangan.
|
||||
|
||||
### Checklist rilis
|
||||
|
||||
- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning.
|
||||
- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi sebelum disimpan.
|
||||
- [ ] Dialog konfirmasi menampilkan `impact` saat nilai EnakPoint atau kurs berubah.
|
||||
- [ ] Dialog konfirmasi menampilkan `expiry_activations` saat kedaluwarsa dinyalakan pertama kali.
|
||||
- [ ] Preview "yang didapat hari ini kedaluwarsa pada …" tampil di bawah pengaturan kedaluwarsa.
|
||||
- [ ] Wallet customer menampilkan saldo yang bisa dipakai, lot, dan riwayat dengan nama asli.
|
||||
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`.
|
||||
- [ ] Tombol Telusuri ada di setiap baris riwayat.
|
||||
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
|
||||
- [ ] Method EnakPoint tampil sebagai method sistem.
|
||||
- [ ] Form game punya input `coin_cost`.
|
||||
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
|
||||
|
||||
Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu.
|
||||
@@ -0,0 +1,634 @@
|
||||
# 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, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
|
||||
EnakCoin yang terpotong.
|
||||
|
||||
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
|
||||
|
||||
---
|
||||
|
||||
## 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`) |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
|
||||
supaya alias ini bisa dihapus.
|
||||
|
||||
Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
|
||||
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
|
||||
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Migrasi printer_type ke printer_types
|
||||
|
||||
1 Oktober 2026
|
||||
|
||||
## Ringkasan
|
||||
|
||||
`printer_type` (string) dihapus dan diganti `printer_types` (array string), sehingga satu produk bisa dicetak ke lebih dari satu printer. Contohnya "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di kitchen dan bar sekaligus.
|
||||
|
||||
Ini breaking change. Setelah backend baru dirilis, client yang masih membaca `printer_type` tidak menerima printer apa pun. Karena itu app POS dan dashboard harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||
|
||||
Yang perlu bertindak:
|
||||
|
||||
- **App POS**: baca `printer_types` dan cetak tiap item ke semua printer di dalamnya.
|
||||
- **Dashboard admin**: ganti pilihan printer di form produk jadi multi-select yang mengirim `printer_types`.
|
||||
|
||||
## Perubahan response
|
||||
|
||||
`printer_type` hilang dari semua response dan digantikan `printer_types`, yang nilainya selalu array dan tidak pernah `null`.
|
||||
|
||||
| Endpoint | Letak field |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/products`, `PUT /api/v1/products/:id`, `GET /api/v1/products`, `GET /api/v1/products/all`, `GET /api/v1/products/:id` | objek produk |
|
||||
| `POST /api/v1/orders`, `GET /api/v1/orders`, `GET /api/v1/orders/:id`, `PUT /api/v1/orders/:id` | `order_items[]` |
|
||||
| `POST /api/v1/orders/:id/add-items` | `added_items[]` dan `updated_order.order_items[]` |
|
||||
| `POST /api/v1/self-order/orders`, `GET /api/v1/self-order/orders/:session_id` | `order_items[]` |
|
||||
| `/api/v1/product-recipes` (semua yang mengembalikan recipe) | `product` |
|
||||
| `/api/v1/inventory` | `product`, selalu `[]` karena produk di sini hanya berisi id dan nama |
|
||||
|
||||
Contoh satu item di `order_items[]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Paket Makan Minum",
|
||||
"printer_types": ["kitchen", "bar"],
|
||||
"print_to_checker": true
|
||||
}
|
||||
```
|
||||
|
||||
Aturan nilainya:
|
||||
|
||||
- `printer_types: []` artinya produk tidak dicetak ke printer mana pun.
|
||||
- Urutan printer sesuai yang disimpan admin, tanpa duplikat.
|
||||
- Void, refund, payment, split bill, dan set customer tidak membawa data printer, sama seperti sebelumnya.
|
||||
|
||||
## Perubahan request produk
|
||||
|
||||
`POST /api/v1/products` dan `PUT /api/v1/products/:id` menerima `printer_types` sebagai pengganti `printer_type`. Kalau `printer_type` masih dikirim, field itu diabaikan tanpa error.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Paket Makan Minum",
|
||||
"category_id": "<uuid>",
|
||||
"price": 25000,
|
||||
"printer_types": ["kitchen", "bar"]
|
||||
}
|
||||
```
|
||||
|
||||
| Request | `printer_types` yang dikirim | Hasil |
|
||||
| --- | --- | --- |
|
||||
| Create | tidak dikirim | `["kitchen"]` |
|
||||
| Create | `["kitchen", "bar"]` | `["kitchen", "bar"]` |
|
||||
| Create | `[]` atau hanya string kosong | `["kitchen"]` |
|
||||
| Update | tidak dikirim | tidak berubah |
|
||||
| Update | `["bar"]` | `["bar"]`, seluruh daftar diganti |
|
||||
| Update | `[]` | `[]`, produk tidak dicetak ke mana pun |
|
||||
|
||||
Sebelum disimpan, spasi di awal dan akhir tiap entri dibuang, lalu entri kosong dan duplikat dihapus. Urutan dipertahankan.
|
||||
|
||||
Tiap entri maksimal 50 karakter. Kalau lebih, request ditolak dengan error code `310` dan pesan `each printer_types entry cannot exceed 50 characters`.
|
||||
|
||||
## Migrasi app POS
|
||||
|
||||
App POS harus mengirim tiap item ke semua printer di `printer_types`, sehingga satu item bisa muncul di lebih dari satu tiket.
|
||||
|
||||
1. Ganti `printer_type` dengan `printer_types` (list string) di model order item dan produk.
|
||||
2. Selama backend lama masih jalan, `printer_types` belum ada di response. Pakai `[printer_type]` kalau `printer_types` tidak ada, supaya app baru bisa dirilis sebelum backend.
|
||||
3. Saat mencetak, kelompokkan item per printer dengan mengulang setiap entri `printer_types` milik item.
|
||||
4. Item dengan `printer_types: []` tidak dicetak ke printer station mana pun.
|
||||
5. `print_to_checker` tidak berubah dan tetap diperlakukan terpisah.
|
||||
6. Untuk tambahan pesanan dari `POST /api/v1/orders/:id/add-items`, cetak dari `added_items[]`.
|
||||
|
||||
```text
|
||||
tiket = {}
|
||||
untuk setiap item di order_items:
|
||||
printers = item.printer_types ?? [item.printer_type] // fallback hanya untuk backend lama
|
||||
untuk setiap printer di printers:
|
||||
tiket[printer].tambah(item)
|
||||
untuk setiap (printer, items) di tiket:
|
||||
cetak items ke printer
|
||||
```
|
||||
|
||||
Perbaikan di `added_items[]`: sebelumnya field ini selalu berisi `product_name` dan printer kosong, serta `print_to_checker: true`. Sekarang isinya lengkap seperti item di `updated_order.order_items[]`, dengan urutan sesuai request. Kalau app selama ini mengakali dengan mencari item baru di `updated_order`, cara itu bisa diganti dengan `added_items[]` langsung.
|
||||
|
||||
## Migrasi dashboard
|
||||
|
||||
Form produk di dashboard harus memakai multi-select printer dan selalu mengirim daftar lengkapnya lewat `printer_types`.
|
||||
|
||||
1. Ganti dropdown printer tunggal dengan multi-select, misalnya checkbox, berisi pilihan printer yang sama.
|
||||
2. Saat membuka form edit, isi pilihan dari `printer_types`. Selama backend lama masih jalan, pakai `[printer_type]` kalau `printer_types` tidak ada.
|
||||
3. Saat menyimpan, kirim `printer_types` berisi semua printer yang dipilih.
|
||||
4. Selama backend lama masih jalan, kirim juga `printer_type` berisi printer pertama. Backend lama hanya membaca `printer_type`, dan backend baru mengabaikannya.
|
||||
5. Di daftar produk, tampilkan semua printer dari `printer_types`.
|
||||
|
||||
Backend tidak membatasi nilai printer. Nilainya harus sama persis dengan nama printer yang dikenal app POS, termasuk huruf besar dan kecilnya.
|
||||
|
||||
Create dengan `printer_types: []` tetap menghasilkan `["kitchen"]`. Produk tanpa printer dibuat dulu, lalu di-update dengan `printer_types: []`.
|
||||
|
||||
## Database dan urutan rilis
|
||||
|
||||
Migration `000099_add_printer_types_to_products` menambah kolom `products.printer_types` (JSONB, `NOT NULL`, default `["kitchen"]`), mengisinya dari `printer_type`, lalu menghapus kolom `printer_type` beserta index-nya.
|
||||
|
||||
- Produk dengan `printer_type` berisi nilai menjadi `[printer_type]`.
|
||||
- Produk dengan `printer_type` `NULL` atau kosong menjadi `[]`.
|
||||
|
||||
Urutan rilis:
|
||||
|
||||
1. Rilis app POS baru, yang membaca `printer_types` dengan fallback ke `printer_type`, ke semua outlet.
|
||||
2. Rilis dashboard baru, yang mengirim `printer_types` dan `printer_type`.
|
||||
3. Jalankan migration `000099` tepat sebelum deploy backend baru. Di antara keduanya, backend lama gagal menyimpan produk karena kolom `printer_type` sudah tidak ada.
|
||||
4. Setelah backend baru jalan, dashboard boleh berhenti mengirim `printer_type`, dan fallback di app POS boleh dihapus.
|
||||
5. Atur produk multi-printer, misalnya "Paket Makan Minum" ke `["kitchen", "bar"]`.
|
||||
|
||||
Outlet yang masih memakai app POS lama setelah langkah 3 tidak menerima printer untuk semua item. Pastikan langkah 1 sudah selesai di semua outlet.
|
||||
|
||||
Rollback: jalankan down migration dan deploy backend lama bersamaan. Down migration membuat ulang kolom `printer_type` berisi printer pertama, lalu menghapus `printer_types`, jadi yang hilang hanya printer tambahan.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] App POS baru (dengan fallback) terpasang di semua outlet
|
||||
- [ ] Dashboard baru mengirim `printer_types` dan `printer_type`
|
||||
- [ ] Migration `000099` dan backend baru dirilis di staging
|
||||
- [ ] Uji "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di dua printer, saat order baru dan saat tambah pesanan
|
||||
- [ ] Migration `000099` dan backend baru dirilis di production
|
||||
- [ ] Dashboard berhenti mengirim `printer_type`
|
||||
- [ ] Fallback `printer_type` di app POS dihapus
|
||||
|
||||
## FAQ
|
||||
|
||||
**Kenapa `printer_type` dihapus, bukan dipertahankan?** Supaya hanya ada satu sumber data printer. Dua field yang menyimpan hal yang sama bisa saling berbeda.
|
||||
|
||||
**Apakah kitchen dan bar menerima tiket yang sama?** Ya. Keduanya mencetak "Paket Makan Minum" lengkap dengan varian dan modifier-nya. Memecah isi paket per station butuh fitur bundle, yang di luar rilis ini.
|
||||
|
||||
**Apakah nilai printer dibatasi?** Tidak. Nilainya string bebas sampai 50 karakter, dan harus sama dengan nama printer di app POS.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
||||
# Task Breakdown: EnakPoint & EnakCoin
|
||||
|
||||
**Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md)
|
||||
**Tanggal:** 2026-09-29
|
||||
|
||||
Setiap task menyebut bagian PRD yang dikerjakan, lapisan kode yang disentuh, task yang
|
||||
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
|
||||
**L** 4–5 hari.
|
||||
|
||||
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000089`),
|
||||
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
|
||||
`router`, dan wiring di `internal/app/app.go`.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan
|
||||
|
||||
| Fase | Task | Terblokir oleh catatan PRD |
|
||||
|---|---|---|
|
||||
| 1. Fondasi | PC-101 – PC-109 | – |
|
||||
| 2. Earning | PC-201 – PC-205 | – |
|
||||
| 3. Pembayaran EnakPoint | PC-301 – PC-308 | N2 (keuangan), N3 (legal) sebelum **rilis** |
|
||||
| 4. Pergerakan saldo | PC-401 – PC-404 | N3 (legal) sebelum **rilis** |
|
||||
| 5. Kedaluwarsa | PC-501 – PC-504 | N4 (model kedaluwarsa) sebelum **dikerjakan** |
|
||||
| 6. Bersih-bersih | PC-601 – PC-602 | – |
|
||||
|
||||
Catatan N2 dan N3 hanya memblokir **rilis** fase 3–4, bukan pengerjaannya. N4
|
||||
memblokir pengerjaan fase 5, karena rumus kedaluwarsanya belum ditentukan.
|
||||
|
||||
```
|
||||
PC-101 ─┬─ PC-103 ── PC-104 ─┬─ PC-105 ── PC-106
|
||||
│ ├─ PC-107
|
||||
│ ├─ PC-108
|
||||
│ ├─ PC-203 ── PC-204 ── PC-205
|
||||
│ ├─ PC-305 ── PC-306 / PC-307
|
||||
│ ├─ PC-401 / PC-402 / PC-403 / PC-404
|
||||
│ └─ PC-502 ── PC-503
|
||||
PC-102 ── PC-109 ─┬─ PC-201 ── PC-202 ── PC-203
|
||||
└─ PC-302
|
||||
PC-301 ── PC-304 ── PC-305
|
||||
PC-303 ── PC-305
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Fondasi
|
||||
|
||||
Semua fase lain bergantung pada fase ini. **PC-104 (wallet engine) adalah inti.** Tidak
|
||||
ada kode lain yang boleh mengubah saldo tanpa lewat engine ini.
|
||||
|
||||
### PC-101 · Migrasi tabel wallet, ledger, dan lot · M
|
||||
- **PRD:** §8 (`customer_wallets`, `wallet_transactions`, `wallet_lots`,
|
||||
`wallet_lot_allocations`), K5, K9
|
||||
- **Kerjakan:** migrasi `000090_create_wallet_tables` (up & down) dengan semua
|
||||
`CHECK` constraint dan index persis seperti di §8.
|
||||
- **Selesai jika:**
|
||||
- Up dan down berjalan bersih di database kosong dan di salinan staging.
|
||||
- Uji constraint langsung di SQL, masing-masing harus ditolak:
|
||||
`PAYMENT` bercurrency `COIN`; `TRANSFER_IN` tanpa `counterparty_customer_id`;
|
||||
`EARN_REVERSAL` tanpa `reverses_transaction_id`; `ADJUSTMENT` tanpa `reason`;
|
||||
`EXPIRE` dengan `reference_type` selain `LOT`; saldo negatif; lot dengan
|
||||
`remaining_amount > original_amount`.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-102 · Migrasi pengaturan organisasi dan riwayat perubahan setting · S
|
||||
- **PRD:** F2, `loyalty_setting_changes` di §8
|
||||
- **Kerjakan:** tabel `organization_settings` (key–value, pola sama dengan
|
||||
`outlet_settings`, `UNIQUE(organization_id, key)`) dan `loyalty_setting_changes`.
|
||||
Saat ini belum ada tempat menyimpan setting per organisasi.
|
||||
- **Selesai jika:** up/down bersih.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-103 · Entities dan repository wallet · M
|
||||
- **PRD:** §7.1–§7.4
|
||||
- **Kerjakan:**
|
||||
- Entities untuk empat tabel PC-101.
|
||||
- Repository yang **selalu** memakai `DBFromContext` (berbeda dari repository
|
||||
gamification yang sekarang memakai `r.db` langsung).
|
||||
- `LockWallet(ctx, customerID)` dengan `SELECT … FOR UPDATE`. Membuat baris wallet
|
||||
jika belum ada (`INSERT … ON CONFLICT DO NOTHING`, lalu lock).
|
||||
- `LockWallets(ctx, a, b)` yang selalu mengunci berurutan berdasarkan `customer_id`.
|
||||
- Update saldo bersyarat yang **mengembalikan error jika 0 baris ter-update**.
|
||||
- Query lot aktif sesuai urutan K9: `expires_at NULLS LAST, created_at`.
|
||||
- **Selesai jika:** test repository menunjukkan update bersyarat gagal saat saldo
|
||||
kurang, dan dua goroutine yang mengunci wallet yang sama berjalan bergantian.
|
||||
- **Bergantung pada:** PC-101
|
||||
|
||||
### PC-104 · Wallet engine (credit / debit / lot / idempotensi) · L
|
||||
- **PRD:** K5, K6, K9, §7, §8.1
|
||||
- **Kerjakan:** processor `WalletProcessor` sebagai **satu-satunya pintu** perubahan
|
||||
saldo:
|
||||
- `Credit(ctx, CreditInput)`: menulis ledger, membuat lot (dengan `expires_at` dan
|
||||
`origin_lot_id` dari input), dan menambah saldo.
|
||||
- `Debit(ctx, DebitInput)`: mengambil dari lot sesuai urutan K9 (atau dari lot
|
||||
tertentu lebih dulu, untuk reversal di F10), menulis alokasi dan ledger, lalu
|
||||
mengurangi saldo. Mengembalikan daftar alokasi, supaya transfer/exchange bisa
|
||||
membuat lot penerima dengan `expires_at` yang sama.
|
||||
- `DebitUpTo`: mengambil sebanyak yang tersedia, untuk reversal dengan shortfall
|
||||
(F10, Q3).
|
||||
- Idempotensi: jika `idempotency_key` sudah ada, kembalikan hasil pertama tanpa
|
||||
mutasi baru.
|
||||
- Validasi di level kode untuk aturan §8.1 (tipe ↔ currency ↔ referensi wajib),
|
||||
sebagai lapisan kedua di atas constraint database.
|
||||
- Semua method mewajibkan caller sudah berada di dalam transaksi dan wallet sudah
|
||||
dikunci.
|
||||
- **Selesai jika:** unit test mencakup debit yang melewati beberapa lot, urutan lot
|
||||
dengan dan tanpa `expires_at`, debit melebihi saldo (ditolak), `DebitUpTo` dengan
|
||||
shortfall, idempotency key ganda, dan saldo = `SUM(ledger)` = `SUM(lot.remaining)`
|
||||
setelah setiap skenario.
|
||||
- **Bergantung pada:** PC-103
|
||||
|
||||
### PC-105 · Migrasi data Point & Token lama · M
|
||||
- **PRD:** §10, Q6
|
||||
- **Kerjakan:** migrasi data (atau command satu kali) yang:
|
||||
- Membuat wallet dari `customer_points` dan penjumlahan semua jenis
|
||||
`customer_tokens`.
|
||||
- Menulis ledger `MIGRATION` dan lot tanpa `expires_at` per customer per currency,
|
||||
dengan rincian per jenis token di `metadata`.
|
||||
- Mengganti `TOKENS` menjadi `COINS` di `campaigns.type` dan
|
||||
`campaign_rules.reward_type`.
|
||||
- **Selesai jika:** jumlah total Point dan Token sebelum = jumlah total saldo wallet
|
||||
sesudah (diuji pada salinan data staging), dan migrasi aman dijalankan dua kali
|
||||
(tidak menggandakan).
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-106 · Endpoint saldo & riwayat customer · M
|
||||
- **PRD:** F6, §9 (customer app)
|
||||
- **Kerjakan:** `GET /customer/wallet` dan `GET /customer/wallet/transactions`
|
||||
(pagination, filter currency/tipe/tanggal). Ganti isi `/customer/points` dan
|
||||
`/customer/tokens` menjadi alias yang membaca `customer_wallets`. Hapus pemakaian
|
||||
`customer_points_repository` untuk saldo.
|
||||
- **Selesai jika:** response menampilkan asal/tujuan setiap mutasi sesuai §8.1, dan
|
||||
aplikasi lama yang memanggil `/points` / `/tokens` tetap mendapat angka yang benar.
|
||||
- **Bergantung pada:** PC-105
|
||||
|
||||
### PC-107 · Wallet customer dan adjustment di dashboard · M
|
||||
- **PRD:** F7
|
||||
- **Kerjakan:** `GET /marketing/customers/:id/wallet` (saldo, lot aktif, mutasi) dan
|
||||
`POST /marketing/customers/:id/wallet/adjust` (alasan wajib, tidak boleh membuat saldo
|
||||
negatif, tercatat `created_by_user`).
|
||||
- **Selesai jika:** adjustment muncul di riwayat customer dengan nama admin dan alasan.
|
||||
Adjustment yang melebihi saldo ditolak.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-108 · Job rekonsiliasi · S
|
||||
- **PRD:** §7.5
|
||||
- **Kerjakan:** query dan job terjadwal yang memeriksa semua invarian §7.5 dan
|
||||
melaporkan selisih ke log dan notifikasi admin.
|
||||
- **Selesai jika:** job menemukan selisih yang sengaja dibuat di data test, dan diam
|
||||
saat data konsisten.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-109 · Pembaca setting loyalitas dan riwayat perubahan · M
|
||||
- **PRD:** F1, F2 (bagian mekanisme, bukan UI), §8 `loyalty_setting_changes`
|
||||
- **Kerjakan:** service yang membaca setting outlet dan organisasi dengan nilai default
|
||||
PRD bila key belum diisi, mengembalikan struct bertipe (bukan string mentah), dan
|
||||
menulis `loyalty_setting_changes` setiap kali setting loyalitas diubah.
|
||||
- **Selesai jika:** outlet tanpa setting mendapat semua default PRD, dan setiap
|
||||
perubahan tercatat nilai lama, nilai baru, dan pelakunya.
|
||||
- **Bergantung pada:** PC-102
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Earning
|
||||
|
||||
### PC-201 · API pengaturan loyalitas outlet · M
|
||||
- **PRD:** F1
|
||||
- **Kerjakan:** `GET/PUT /outlets/:id/loyalty-settings` dengan validasi F1. Response
|
||||
menyertakan **persentase cashback efektif** (`earn_value × nilai EnakPoint /
|
||||
earn_per_amount`).
|
||||
- **Selesai jika:** validasi menolak nilai di luar batas, dan hanya Admin/Manager yang
|
||||
bisa mengubah.
|
||||
- **Bergantung pada:** PC-109
|
||||
|
||||
### PC-202 · Kalkulator earning · S
|
||||
- **PRD:** F1 (rumus), Q1, Q10
|
||||
- **Kerjakan:** fungsi murni `CalculateEarning(order, pointPaidAmount, settings)` yang
|
||||
mengembalikan Point, Coin, basis, dan snapshot setting.
|
||||
- **Selesai jika:** unit test mencakup contoh di PRD (Rp 87.500 → 875 Point, 3 Coin;
|
||||
dengan Rp 20.000 dibayar EnakPoint → 675 Point, 2 Coin), basis di bawah minimum,
|
||||
`max_per_order`, setting nonaktif, dan pajak tidak ikut dihitung.
|
||||
- **Bergantung pada:** PC-201
|
||||
|
||||
### PC-203 · Earning saat order lunas · L
|
||||
- **PRD:** F3
|
||||
- **Kerjakan:**
|
||||
- **Satukan titik "order menjadi lunas".** Saat ini `payment_status = completed`
|
||||
di-set di empat tempat: `OrderProcessorImpl.UpdateOrder`,
|
||||
`OrderProcessorImpl.updateOrderStatus`, dan dua tempat di `split_bill_processor.go`.
|
||||
Buat satu hook `onOrderPaid(orderID)` yang dipanggil dari keempatnya.
|
||||
- Hook menjalankan earning **setelah** transaksi pembayaran commit, sehingga
|
||||
kegagalan earning tidak menggagalkan pembayaran.
|
||||
- Idempotency key `earn:{order_id}:{currency}`.
|
||||
- Lewati order tanpa customer, customer default, atau customer nonaktif.
|
||||
- **Jaring pengaman:** job yang mencari order lunas beberapa hari terakhir yang belum
|
||||
punya `EARN` (dan seharusnya punya), lalu menjalankan ulang earning.
|
||||
- **Selesai jika:** test untuk pembayaran penuh, split bill (lunas di pembayaran
|
||||
terakhir), self-order, dan pemanggilan ganda (hasilnya tetap satu earning). Earning
|
||||
yang sengaja digagalkan tidak menggagalkan pembayaran dan terambil oleh job.
|
||||
- **Bergantung pada:** PC-104, PC-202
|
||||
|
||||
### PC-204 · Reversal earning saat void / refund · M
|
||||
- **PRD:** F10, Q3
|
||||
- **Kerjakan:** panggil reversal dari `VoidOrder`, `RefundOrder`, dan `RefundPayment`.
|
||||
Ambil pertama dari lot `EARN` order tersebut, lalu lot lain. Gunakan `DebitUpTo` dan
|
||||
catat `shortfall`. Refund tidak pernah diblokir.
|
||||
- **Selesai jika:** test untuk void penuh, refund sebagian (proporsional, akumulasi tidak
|
||||
melebihi earning), dan saldo yang sudah terpakai (shortfall tercatat, saldo 0).
|
||||
- **Bergantung pada:** PC-203
|
||||
|
||||
### PC-205 · Tampilkan earning di order dan struk · S
|
||||
- **PRD:** F3
|
||||
- **Kerjakan:** tambah `points_earned` dan `coins_earned` ke response order dan data
|
||||
struk.
|
||||
- **Selesai jika:** nilai sama dengan baris `EARN` di ledger, dan bernilai 0 untuk order
|
||||
tanpa earning.
|
||||
- **Bergantung pada:** PC-203
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Pembayaran EnakPoint
|
||||
|
||||
> Boleh dikerjakan sekarang. **Tidak boleh dirilis** sebelum catatan N2 (keuangan) dan
|
||||
> N3 (legal) ditutup.
|
||||
|
||||
### PC-301 · PIN customer · L
|
||||
- **PRD:** K8, F11, Q16, Q17
|
||||
- **Kerjakan:**
|
||||
- Migrasi kolom PIN di `customers` dan tabel `customer_security_events`.
|
||||
- Purpose OTP baru `pin_setup` dan `pin_reset` di `OtpProcessor`.
|
||||
- Endpoint `/customer/pin/*` (status, OTP, buat, ganti, reset).
|
||||
- Hash bcrypt, tolak PIN lemah (digit sama, berurutan, tanggal lahir).
|
||||
- Kunci 30 menit setelah 5 kali salah, dengan penghitung di database.
|
||||
- Tahan transfer keluar 24 jam setelah reset.
|
||||
- `VerifyPin(ctx, customerID, pin)` untuk dipakai task lain, dengan error
|
||||
`PIN_NOT_SET`, `PIN_INVALID`, `PIN_LOCKED`, `TRANSFER_BLOCKED`.
|
||||
- Hapus PIN oleh admin: `DELETE /marketing/customers/:id/pin`, dan
|
||||
`GET /marketing/customers/:id/security-events`.
|
||||
- PIN tidak pernah muncul di log (termasuk log request body).
|
||||
- **Selesai jika:** test untuk semua aturan di atas, termasuk 5 kali salah lalu PIN
|
||||
benar tetap ditolak selama terkunci, dan reset OTP membuka kunci.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-302 · API pengaturan loyalitas organisasi · S
|
||||
- **PRD:** F2
|
||||
- **Kerjakan:** `GET/PUT /marketing/loyalty-settings` untuk nilai EnakPoint, kurs
|
||||
exchange, dan batas transfer, serta `GET /marketing/loyalty-settings/history`.
|
||||
Sebelum menyimpan perubahan nilai EnakPoint atau kurs, response preview menampilkan
|
||||
total saldo beredar dan nilai rupiahnya sebelum/sesudah.
|
||||
- **Selesai jika:** perubahan tercatat di riwayat, dan transaksi lama tetap memakai nilai
|
||||
yang dibekukan.
|
||||
- **Bergantung pada:** PC-109
|
||||
|
||||
### PC-303 · Payment method EnakPoint · M
|
||||
- **PRD:** F9 (payment method), §8 (perubahan `payments`), §10.5
|
||||
- **Kerjakan:**
|
||||
- Tambah tipe `point` ke `PaymentMethodType` dan validator (`oneof=cash card
|
||||
digital_wallet point`).
|
||||
- Migrasi kolom `points_used` dan `point_value` di `payments`.
|
||||
- Buat payment method sistem "EnakPoint" untuk setiap organisasi yang sudah ada, dan
|
||||
otomatis untuk organisasi baru.
|
||||
- Tolak hapus / ubah tipe method sistem.
|
||||
- Sembunyikan method ini di kasir jika outlet tidak mengaktifkan `accept_payment`.
|
||||
- **Selesai jika:** setiap organisasi punya tepat satu method EnakPoint yang tidak bisa
|
||||
dihapus.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-304 · Kode bayar sekali pakai · M
|
||||
- **PRD:** F9 (persetujuan customer), K8
|
||||
- **Kerjakan:** `POST /customer/wallet/payment-code` (butuh PIN) menghasilkan kode 6
|
||||
digit dan QR yang berlaku 2 menit, disimpan di Redis dengan TTL dan diambil
|
||||
sekali-pakai (`GETDEL`). Kode terikat ke `customer_id`.
|
||||
- **Selesai jika:** kode kedaluwarsa, kode yang sudah dipakai, dan kode milik customer
|
||||
lain semuanya ditolak.
|
||||
- **Bergantung pada:** PC-301
|
||||
|
||||
### PC-305 · Bayar EnakPoint di kasir · L
|
||||
- **PRD:** F9 (perhitungan, pencatatan), K7
|
||||
- **Kerjakan:**
|
||||
- `GET /orders/:id/point-payment/preview`.
|
||||
- Cabang method `point` di `CreatePayment`: validasi kode bayar, cocokkan customer
|
||||
order, hitung `maks_point`, lalu lakukan langkah 1–6 F9 dalam **satu transaksi**
|
||||
bersama pembuatan baris `payments`.
|
||||
- Idempotency `payment:{payment_id}`.
|
||||
- Tolak jika `nominal_rupiah > remaining_amount` (tidak ada kembalian).
|
||||
- **Selesai jika:** test untuk pembayaran penuh, sebagian + tunai (split), batas
|
||||
`max_payment_percent`, order walk-in (ditolak), kode salah (ditolak), dan dua
|
||||
pembayaran bersamaan untuk customer yang sama (saldo tidak terpakai dua kali).
|
||||
Earning (PC-203) menghitung basis tanpa bagian EnakPoint.
|
||||
- **Bergantung pada:** PC-104, PC-303, PC-304
|
||||
|
||||
### PC-306 · Bayar EnakPoint dari app / self-order · M
|
||||
- **PRD:** F9
|
||||
- **Kerjakan:** `POST /customer/orders/:id/pay-with-points` (butuh PIN, hanya untuk
|
||||
order milik customer itu sendiri), memakai logika yang sama dengan PC-305.
|
||||
- **Selesai jika:** customer tidak bisa membayar order milik customer lain.
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
### PC-307 · Refund pembayaran EnakPoint · M
|
||||
- **PRD:** F9 (void/refund), K7, Q13
|
||||
- **Kerjakan:** `PAYMENT_REFUND` di jalur void dan refund memakai `point_value` yang
|
||||
dibekukan, dengan pembulatan ke bawah. Kembalikan ke lot dengan `expires_at` asal
|
||||
(aturan perpanjangan 7 hari mengikuti N4, jadi untuk sekarang cukup pakai tanggal
|
||||
asal). **Tolak** permintaan refund bagian EnakPoint lewat method lain.
|
||||
- **Selesai jika:** test untuk void penuh, refund sebagian, perubahan nilai EnakPoint di
|
||||
antara bayar dan refund (jumlah EnakPoint yang kembali tetap sama), dan percobaan
|
||||
refund tunai atas pembayaran EnakPoint (ditolak).
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
### PC-308 · EnakPoint di laporan · S
|
||||
- **PRD:** F9 (laporan)
|
||||
- **Kerjakan:** laporan per payment method menampilkan EnakPoint terpisah dan tidak
|
||||
menghitungnya sebagai kas masuk. Cek laporan analytics yang menjumlahkan pembayaran.
|
||||
- **Selesai jika:** total kas masuk di laporan tidak berubah saat sebagian order dibayar
|
||||
EnakPoint. Perlakuan akuntansi lanjutan menunggu N2.
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Pergerakan Saldo
|
||||
|
||||
> Boleh dikerjakan sekarang. Transfer (PC-402) **tidak boleh dirilis** sebelum N3
|
||||
> (legal) ditutup.
|
||||
|
||||
### PC-401 · Exchange EnakCoin → EnakPoint · M
|
||||
- **PRD:** F4, K3
|
||||
- **Kerjakan:** `GET /customer/wallet/exchange/preview` dan
|
||||
`POST /customer/wallet/exchange` (butuh PIN, `Idempotency-Key`). Jumlah harus
|
||||
kelipatan `coin_amount`. Kurs dibekukan di metadata. Lot EnakPoint kedaluwarsa pada
|
||||
`min(lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`.
|
||||
- **Selesai jika:** test untuk kurs default 1:1, kurs 10:3, jumlah bukan kelipatan
|
||||
(ditolak), dan `expires_at` lot hasil exchange tidak pernah lebih lama dari lot asal.
|
||||
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||
|
||||
### PC-402 · Transfer antar customer · L
|
||||
- **PRD:** F5, Q4, Q16
|
||||
- **Kerjakan:** `GET /customer/wallet/transfer/recipient` (nama disamarkan) dan
|
||||
`POST /customer/wallet/transfer` (butuh PIN, `Idempotency-Key`). Kunci dua wallet
|
||||
berurutan, cek batas organisasi (per transaksi dan harian), cek
|
||||
`transfer_blocked_until`. Lot penerima mewarisi `expires_at` dan `origin_lot_id` dari
|
||||
lot pengirim. Kirim notifikasi push ke penerima.
|
||||
- **Selesai jika:** test untuk transfer ke diri sendiri / customer lain organisasi /
|
||||
customer default (semua ditolak), batas harian, transfer dua arah bersamaan (tidak
|
||||
deadlock), dan `expires_at` di penerima sama persis dengan pengirim.
|
||||
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||
|
||||
### PC-403 · Semua game memakai EnakCoin · M
|
||||
- **PRD:** F8, K1
|
||||
- **Kerjakan:**
|
||||
- `GamePlayProcessor.PlayGame` memotong EnakCoin lewat wallet engine sesuai
|
||||
`games.metadata.coin_cost`.
|
||||
- Seluruh permainan dalam satu transaksi: ubah repository game, game play, dan game
|
||||
prize ke `DBFromContext`. Gagal mengurangi stok hadiah membatalkan permainan (saat
|
||||
ini hanya di-`Printf`), dan rollback manual `AddTokens` dihapus.
|
||||
- Rename `game_plays.token_used` menjadi `coins_used`.
|
||||
- **Selesai jika:** test untuk saldo kurang (ditolak, tidak ada `game_plays` yang
|
||||
tercatat), stok hadiah gagal (semua dibatalkan), dan `GAME_SPEND` menunjuk
|
||||
`game_plays.id`.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-404 · Telusuri per butir di dashboard · S
|
||||
- **PRD:** F7, §8.1 (per butir)
|
||||
- **Kerjakan:** `GET /marketing/wallet-transactions/:id/trace` yang mengembalikan
|
||||
alokasi lot dan rantai `origin_lot_id` sampai ke lot pertama.
|
||||
- **Selesai jika:** contoh di §8 (A transfer 120 ke B, B bayar 30) menelusuri ke order
|
||||
#ORD-1 milik A.
|
||||
- **Bergantung pada:** PC-402
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — Kedaluwarsa
|
||||
|
||||
> **Jangan dikerjakan sebelum catatan N4 (model kedaluwarsa) diputuskan.** Struktur lot
|
||||
> sudah ada sejak PC-101, jadi yang tersisa hanya rumus, job, dan tampilan.
|
||||
|
||||
### PC-501 · Setting kedaluwarsa · M
|
||||
- **PRD:** F12 (pengaturan), N4
|
||||
- **Kerjakan:** key setting sesuai model yang dipilih di N4, dengan validasi dan preview
|
||||
"saldo yang didapat hari ini kedaluwarsa pada …".
|
||||
- **Bergantung pada:** PC-302, **N4**
|
||||
|
||||
### PC-502 · Hitung `expires_at` saat lot dibuat · M
|
||||
- **PRD:** F12 (tabel kedaluwarsa per lot), N4
|
||||
- **Kerjakan:** satu fungsi `ComputeExpiry(currency, receivedAt, settings)` yang dipakai
|
||||
oleh `EARN` dan `ADJUSTMENT`, serta aturan aktivasi pertama untuk lot lama (termasuk
|
||||
lot `MIGRATION`).
|
||||
- **Bergantung pada:** PC-104, PC-501
|
||||
|
||||
### PC-503 · Job kedaluwarsa · M
|
||||
- **PRD:** F12 (proses kedaluwarsa)
|
||||
- **Kerjakan:** job per jam yang memproses lot lewat tanggal dengan `EXPIRE`
|
||||
(idempotency `expire:{lot_id}`) di bawah lock wallet.
|
||||
- **Jangan menyalin pola `OmsetMilestoneScheduler`.** Scheduler itu menyimpan state di
|
||||
memori, dan menurut komentarnya sendiri bisa mengirim ulang notifikasi setelah
|
||||
restart. Job ini harus aman dijalankan di banyak instance sekaligus: pilih lot
|
||||
dengan `FOR UPDATE SKIP LOCKED`, dan andalkan idempotency key.
|
||||
- **Selesai jika:** dua instance yang berjalan bersamaan tidak menghanguskan lot yang
|
||||
sama dua kali, dan tidak ada lot yang lewat tanggal lebih dari 1 jam tanpa diproses.
|
||||
- **Bergantung pada:** PC-502
|
||||
|
||||
### PC-504 · Pengingat dan tampilan saldo yang akan kedaluwarsa · M
|
||||
- **PRD:** F6 (`/wallet/expiring`), F12 (pengingat)
|
||||
- **Kerjakan:** endpoint `GET /customer/wallet/expiring`, field saldo kedaluwarsa
|
||||
terdekat di `/wallet`, dan notifikasi pengingat yang dikelompokkan per tanggal.
|
||||
Jadwal pengingat mengikuti N4.
|
||||
- **Bergantung pada:** PC-503
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Bersih-bersih
|
||||
|
||||
### PC-601 · Hapus tabel dan kode lama · S
|
||||
- **PRD:** §10.7
|
||||
- **Kerjakan:** satu rilis setelah PC-105 berjalan di production, drop
|
||||
`customer_points` dan `customer_tokens`. Hapus `CustomerPointsProcessor` dan
|
||||
`CustomerTokensProcessor` beserta stub `not implemented`, rute yang di-comment di
|
||||
`router.go`, dan alias `/customer/points` / `/customer/tokens` setelah aplikasi
|
||||
diperbarui.
|
||||
- **Bergantung pada:** PC-106, PC-403, dan konfirmasi bahwa aplikasi sudah tidak
|
||||
memanggil endpoint lama.
|
||||
|
||||
### PC-602 · Dokumentasi integrasi · S
|
||||
- **Kerjakan:** panduan integrasi untuk tim aplikasi dan POS (seperti
|
||||
`integration-weight-based-products.md`): endpoint, kode error PIN, alur kode bayar, dan
|
||||
contoh request/response.
|
||||
- **Bergantung pada:** PC-305, PC-402
|
||||
|
||||
---
|
||||
|
||||
## Yang Bisa Dimulai Sekarang
|
||||
|
||||
Bisa dikerjakan paralel tanpa menunggu apa pun:
|
||||
- **PC-101** (migrasi wallet) → langsung lanjut **PC-103**, lalu **PC-104**
|
||||
- **PC-102** (setting organisasi) → **PC-109**
|
||||
- **PC-301** (PIN customer)
|
||||
- **PC-303** (payment method EnakPoint)
|
||||
|
||||
PC-104 adalah jalur kritis: hampir semua task lain menunggunya.
|
||||
+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)
|
||||
|
||||
@@ -52,7 +52,6 @@ const (
|
||||
TableEntity = "table"
|
||||
// Gamification entities
|
||||
CustomerPointsEntity = "customer_points"
|
||||
CustomerTokensEntity = "customer_tokens"
|
||||
TierEntity = "tier"
|
||||
GameEntity = "game"
|
||||
GamePrizeEntity = "game_prize"
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
type CreateCampaignRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
|
||||
type CampaignRuleStruct struct {
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
|
||||
@@ -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"`
|
||||
@@ -83,26 +25,6 @@ type PointsHistoryItem struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetCustomerTokensResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetCustomerTokensResponseData struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type TokensHistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetCustomerWalletResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
@@ -111,8 +33,6 @@ type GetCustomerWalletResponse struct {
|
||||
|
||||
type GetCustomerWalletResponseData struct {
|
||||
TotalPoints int64 `json:"total_points"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -14,13 +14,11 @@ type CreateGamePlayRequest struct {
|
||||
}
|
||||
|
||||
type GamePlayResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
// Deprecated: the same as CoinsUsed, kept until the apps read coins_used.
|
||||
TokenUsed int `json:"token_used"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
@@ -35,7 +33,7 @@ type ListGamePlaysRequest struct {
|
||||
GameID *uuid.UUID `json:"game_id"`
|
||||
CustomerID *uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
@@ -58,6 +56,4 @@ type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ type OrderItemResponse struct {
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type CreateProductRequest struct {
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
@@ -38,7 +38,7 @@ type UpdateProductRequest struct {
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` // Replaces the whole list when sent
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
@@ -76,7 +76,7 @@ type ProductResponse struct {
|
||||
Cost float64 `json:"cost"`
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
|
||||
@@ -17,6 +17,4 @@ type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
}
|
||||
|
||||
@@ -16,27 +16,6 @@ const (
|
||||
CampaignTypeMixed CampaignType = "MIXED"
|
||||
)
|
||||
|
||||
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
|
||||
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
|
||||
const legacyCampaignTokens = "TOKENS"
|
||||
|
||||
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
|
||||
func NormalizeCampaignType(t string) CampaignType {
|
||||
if t == legacyCampaignTokens {
|
||||
return CampaignTypeCoins
|
||||
}
|
||||
return CampaignType(t)
|
||||
}
|
||||
|
||||
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
|
||||
// else as is.
|
||||
func NormalizeCampaignRewardType(t string) CampaignRewardType {
|
||||
if t == legacyCampaignTokens {
|
||||
return CampaignRewardTypeCoins
|
||||
}
|
||||
return CampaignRewardType(t)
|
||||
}
|
||||
|
||||
type RuleType string
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
|
||||
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
|
||||
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
|
||||
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
|
||||
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
|
||||
|
||||
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
|
||||
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
|
||||
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -8,24 +9,26 @@ import (
|
||||
)
|
||||
|
||||
type Product struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
// PrinterTypes are the stations the product is printed at. Set them through
|
||||
// SetPrinterTypes.
|
||||
PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
@@ -48,6 +51,32 @@ func (Product) TableName() string {
|
||||
return "products"
|
||||
}
|
||||
|
||||
// SetPrinterTypes records the stations the product is printed at, in order, dropping
|
||||
// blanks and repeats.
|
||||
func (p *Product) SetPrinterTypes(printerTypes []string) {
|
||||
cleaned := StringSlice{}
|
||||
seen := make(map[string]bool, len(printerTypes))
|
||||
for _, printerType := range printerTypes {
|
||||
printerType = strings.TrimSpace(printerType)
|
||||
if printerType == "" || seen[printerType] {
|
||||
continue
|
||||
}
|
||||
seen[printerType] = true
|
||||
cleaned = append(cleaned, printerType)
|
||||
}
|
||||
|
||||
p.PrinterTypes = cleaned
|
||||
}
|
||||
|
||||
// GetPrinterTypes returns the stations the product is printed at, never nil, so a
|
||||
// response always carries a list. A product that was not loaded has none.
|
||||
func (p *Product) GetPrinterTypes() []string {
|
||||
if p.PrinterTypes == nil {
|
||||
return []string{}
|
||||
}
|
||||
return []string(p.PrinterTypes)
|
||||
}
|
||||
|
||||
type ProductVariant struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
|
||||
@@ -59,40 +59,6 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Get customer ID from context (set by middleware)
|
||||
customerID, exists := c.Get("customer_id")
|
||||
if !exists {
|
||||
logger.FromContext(ctx).Error("Customer ID not found in context")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), "CustomerPointsHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
customerIDStr, ok := customerID.(string)
|
||||
if !ok {
|
||||
logger.FromContext(ctx).Error("Invalid customer ID type in context")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid customer ID"),
|
||||
}), "CustomerPointsHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.customerPointsService.GetCustomerTokens(ctx, customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
@@ -204,3 +170,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")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
// Drives the customer wallet endpoints over HTTP down to Postgres, to show the app
|
||||
// gets the right numbers from the new endpoints and from the old /points, /tokens
|
||||
// gets the right numbers from the new endpoints and from the old /points
|
||||
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
@@ -111,7 +111,6 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
|
||||
g := router.Group(who.prefix, as(who.id))
|
||||
g.GET("/points", h.GetCustomerPoints)
|
||||
g.GET("/tokens", h.GetCustomerTokens)
|
||||
g.GET("/wallet", h.GetCustomerWallet)
|
||||
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
|
||||
}
|
||||
@@ -128,16 +127,12 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
return body["data"].(map[string]any)["data"].(map[string]any)
|
||||
}
|
||||
|
||||
// Old app: /points and /tokens keep their shape and show the spendable balance.
|
||||
// Old app: /points keeps its shape and shows the spendable balance.
|
||||
status, body := get("/a/points")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
|
||||
assert.Len(t, inner(body)["points_history"], 3)
|
||||
|
||||
status, body = get("/a/tokens")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 8, inner(body)["total_tokens"])
|
||||
|
||||
// /wallet: new fields, with the old ones still there.
|
||||
status, body = get("/a/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
@@ -151,9 +146,10 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
assert.Equal(t, map[string]any{"amount": float64(250), "date": time.Date(next, 7, 2, 0, 0, 0, 0, time.UTC).Format("2006-01-02")},
|
||||
nearest["point"], "both lots fall on 2 July in Jakarta although they are on different UTC days")
|
||||
assert.Nil(t, nearest["coin"])
|
||||
for _, legacy := range []string{"total_points", "total_tokens", "points_history", "tokens_history", "last_updated"} {
|
||||
for _, legacy := range []string{"total_points", "points_history", "last_updated"} {
|
||||
assert.Contains(t, w, legacy)
|
||||
}
|
||||
assert.NotContains(t, w, "total_tokens", "tokens are gone; EnakCoin is coin_balance")
|
||||
assert.EqualValues(t, 250, w["total_points"])
|
||||
|
||||
recent := w["recent_transactions"].([]any)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
|
||||
return &entities.Campaign{
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Type: entities.NormalizeCampaignType(request.Type),
|
||||
Type: entities.CampaignType(request.Type),
|
||||
StartDate: request.StartDate,
|
||||
EndDate: request.EndDate,
|
||||
IsActive: request.IsActive,
|
||||
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
|
||||
ID: request.ID,
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Type: entities.NormalizeCampaignType(request.Type),
|
||||
Type: entities.CampaignType(request.Type),
|
||||
StartDate: request.StartDate,
|
||||
EndDate: request.EndDate,
|
||||
IsActive: request.IsActive,
|
||||
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
|
||||
CampaignID: campaignID,
|
||||
RuleType: entities.RuleType(request.RuleType),
|
||||
ConditionValue: request.ConditionValue,
|
||||
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
|
||||
RewardType: entities.CampaignRewardType(request.RewardType),
|
||||
RewardValue: request.RewardValue,
|
||||
RewardSubtype: rewardSubtype,
|
||||
RewardRefID: request.RewardRefID,
|
||||
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
|
||||
CampaignID: campaignID,
|
||||
RuleType: entities.RuleType(request.RuleType),
|
||||
ConditionValue: request.ConditionValue,
|
||||
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
|
||||
RewardType: entities.CampaignRewardType(request.RewardType),
|
||||
RewardValue: request.RewardValue,
|
||||
RewardSubtype: rewardSubtype,
|
||||
RewardRefID: request.RewardRefID,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
||||
Status: constants.OrderItemStatus(item.Status),
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
PrinterType: item.Product.PrinterType,
|
||||
PrinterTypes: item.Product.GetPrinterTypes(),
|
||||
PrintToChecker: printToChecker,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
||||
Cost: entity.Cost,
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
PrinterTypes: entity.GetPrinterTypes(),
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
HasIngredients: entity.HasIngredients,
|
||||
@@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
||||
Cost: model.Cost,
|
||||
BusinessType: string(model.BusinessType),
|
||||
ImageURL: model.ImageURL,
|
||||
PrinterType: model.PrinterType,
|
||||
PrinterTypes: entities.StringSlice(model.PrinterTypes),
|
||||
UnitID: model.UnitID,
|
||||
SellBy: model.SellBy,
|
||||
HasIngredients: model.HasIngredients,
|
||||
@@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
businessType = string(req.BusinessType)
|
||||
}
|
||||
|
||||
printerType := "kitchen"
|
||||
if req.PrinterType != nil && *req.PrinterType != "" {
|
||||
printerType = *req.PrinterType
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if constants.IsValidSellBy(req.SellBy) {
|
||||
sellBy = req.SellBy
|
||||
@@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
metadata = entities.Metadata(req.Metadata)
|
||||
}
|
||||
|
||||
return &entities.Product{
|
||||
product := &entities.Product{
|
||||
OrganizationID: req.OrganizationID,
|
||||
CategoryID: req.CategoryID,
|
||||
SKU: req.SKU,
|
||||
@@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
Cost: cost,
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: printerType,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
|
||||
product.SetPrinterTypes(req.PrinterTypes)
|
||||
if len(product.PrinterTypes) == 0 {
|
||||
product.SetPrinterTypes([]string{"kitchen"})
|
||||
}
|
||||
|
||||
return product
|
||||
}
|
||||
|
||||
func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
@@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
Cost: entity.Cost,
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
PrinterTypes: entity.GetPrinterTypes(),
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
@@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
||||
entity.ImageURL = req.ImageURL
|
||||
}
|
||||
|
||||
if req.PrinterType != nil {
|
||||
entity.PrinterType = *req.PrinterType
|
||||
if req.PrinterTypes != nil {
|
||||
entity.SetPrinterTypes(req.PrinterTypes)
|
||||
}
|
||||
|
||||
if req.UnitID != nil {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateProductRequestToEntityPrinterTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
printerTypes []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "nothing sent prints in the kitchen",
|
||||
want: []string{"kitchen"},
|
||||
},
|
||||
{
|
||||
name: "a meal-and-drink package prints in the kitchen and the bar",
|
||||
printerTypes: []string{"kitchen", "bar"},
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "blanks and repeats are dropped",
|
||||
printerTypes: []string{" kitchen ", "", "kitchen", "bar"},
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "a list of only blanks falls back to the kitchen",
|
||||
printerTypes: []string{" ", ""},
|
||||
want: []string{"kitchen"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
product := CreateProductRequestToEntity(&models.CreateProductRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
CategoryID: uuid.New(),
|
||||
Name: "Paket Makan Minum",
|
||||
PrinterTypes: tt.printerTypes,
|
||||
})
|
||||
|
||||
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProductEntityFromRequestPrinterTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
printerTypes []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "a save that does not send printers keeps them",
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "printer_types replaces the whole list",
|
||||
printerTypes: []string{"bar"},
|
||||
want: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "an empty printer_types prints nowhere",
|
||||
printerTypes: []string{},
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
product := &entities.Product{}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
|
||||
UpdateProductEntityFromRequest(product, &models.UpdateProductRequest{PrinterTypes: tt.printerTypes})
|
||||
|
||||
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderItemEntityToResponsePrinterTypes(t *testing.T) {
|
||||
product := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
|
||||
response := OrderItemEntityToResponse(&entities.OrderItem{ProductID: product.ID, Product: product}, uuid.New())
|
||||
|
||||
assert.Equal(t, []string{"kitchen", "bar"}, response.PrinterTypes)
|
||||
}
|
||||
|
||||
func TestProductEntityToResponseAlwaysHasAList(t *testing.T) {
|
||||
// The POS reads printer_types as a list, so a product without one sends [] not null.
|
||||
response := ProductEntityToResponse(&entities.Product{})
|
||||
|
||||
assert.Equal(t, []string{}, response.PrinterTypes)
|
||||
}
|
||||
@@ -36,10 +36,9 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
|
||||
}
|
||||
|
||||
return &contract.SpinGameResponseData{
|
||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
||||
CoinsRemaining: data.CoinsRemaining,
|
||||
TokensRemaining: data.CoinsRemaining,
|
||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
||||
CoinsRemaining: data.CoinsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +54,6 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam
|
||||
CustomerID: resp.CustomerID,
|
||||
PrizeID: resp.PrizeID,
|
||||
CoinsUsed: resp.CoinsUsed,
|
||||
TokenUsed: resp.CoinsUsed,
|
||||
RandomSeed: resp.RandomSeed,
|
||||
CreatedAt: resp.CreatedAt,
|
||||
Game: nil, // Optional field - can be populated separately if needed
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
type CreateCampaignRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -88,7 +88,7 @@ type CreateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -100,7 +100,7 @@ type UpdateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -126,7 +126,7 @@ type ListCampaignRulesResponse struct {
|
||||
type CampaignRuleStruct struct {
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
|
||||
@@ -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"`
|
||||
@@ -83,26 +25,6 @@ type PointsHistoryItem struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetCustomerTokensResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetCustomerTokensResponseData struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type TokensHistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetCustomerWalletResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
@@ -124,8 +46,6 @@ type GetCustomerWalletResponseData struct {
|
||||
// Deprecated: the shape of this endpoint before the wallet, kept for app versions
|
||||
// that still read it. Filled from the wallet like the fields above.
|
||||
TotalPoints int64 `json:"total_points"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -33,7 +33,7 @@ type ListGamePlaysQuery struct {
|
||||
GameID *uuid.UUID `query:"game_id"`
|
||||
CustomerID *uuid.UUID `query:"customer_id"`
|
||||
PrizeID *uuid.UUID `query:"prize_id"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
|
||||
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.
|
||||
|
||||
@@ -218,7 +218,7 @@ type OrderItemResponse struct {
|
||||
Status constants.OrderItemStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
PrintToChecker bool
|
||||
PaidQuantity int
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type Product struct {
|
||||
Cost float64
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
SellBy string
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
@@ -50,7 +50,7 @@ type CreateProductRequest struct {
|
||||
Cost float64 `validate:"min=0"`
|
||||
BusinessType constants.BusinessType `validate:"required"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `validate:"omitempty,dive,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||
@@ -72,7 +72,7 @@ type UpdateProductRequest struct {
|
||||
Price *float64 `validate:"omitempty,min=0"`
|
||||
Cost *float64 `validate:"omitempty,min=0"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `validate:"omitempty,dive,max=50"` // Replaces the whole list when not nil
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||
@@ -112,7 +112,7 @@ type ProductResponse struct {
|
||||
Cost float64
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
SellBy string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.Lis
|
||||
Page: req.Page,
|
||||
Limit: req.Limit,
|
||||
Search: req.Search,
|
||||
Type: string(entities.NormalizeCampaignType(req.Type)),
|
||||
Type: req.Type,
|
||||
IsActive: req.IsActive,
|
||||
ShowOnApp: req.ShowOnApp,
|
||||
StartDate: req.StartDate,
|
||||
@@ -178,7 +178,7 @@ func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *con
|
||||
CampaignID: req.CampaignID,
|
||||
RuleType: entities.RuleType(req.RuleType),
|
||||
ConditionValue: req.ConditionValue,
|
||||
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
|
||||
RewardType: entities.CampaignRewardType(req.RewardType),
|
||||
RewardValue: req.RewardValue,
|
||||
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
|
||||
RewardRefID: req.RewardRefID,
|
||||
@@ -218,7 +218,7 @@ func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *cont
|
||||
Limit: req.Limit,
|
||||
CampaignID: req.CampaignID,
|
||||
RuleType: req.RuleType,
|
||||
RewardType: string(entities.NormalizeCampaignRewardType(req.RewardType)),
|
||||
RewardType: req.RewardType,
|
||||
}
|
||||
|
||||
// Get from repository
|
||||
@@ -247,7 +247,7 @@ func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *con
|
||||
CampaignID: req.CampaignID,
|
||||
RuleType: entities.RuleType(req.RuleType),
|
||||
ConditionValue: req.ConditionValue,
|
||||
RewardType: entities.NormalizeCampaignRewardType(req.RewardType),
|
||||
RewardType: entities.CampaignRewardType(req.RewardType),
|
||||
RewardValue: req.RewardValue,
|
||||
RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype),
|
||||
RewardRefID: req.RewardRefID,
|
||||
|
||||
@@ -22,54 +22,7 @@ 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
|
||||
// The 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).
|
||||
|
||||
@@ -89,22 +42,6 @@ func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := p.walletQuery.LegacyTokens(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
|
||||
}
|
||||
return &models.GetCustomerTokensResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer tokens retrieved successfully.",
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
@@ -224,3 +161,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)
|
||||
}
|
||||
|
||||
@@ -99,10 +99,6 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
// The column was renamed; the old name still sorts the same way.
|
||||
if query.SortBy == "token_used" {
|
||||
query.SortBy = "coins_used"
|
||||
}
|
||||
|
||||
// Get game plays from repository
|
||||
gamePlays, total, err := p.gamePlayRepo.List(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The items AddToOrder creates have no product loaded, so their responses used to go out
|
||||
// without a name or a printer, and the POS could not print them.
|
||||
func TestAddedItemsFromOrderUsesTheReloadedProduct(t *testing.T) {
|
||||
outletID := uuid.New()
|
||||
|
||||
paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||
paket.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
paket.ProductOutletPrices = []entities.ProductOutletPrice{{OutletID: outletID, PrintToChecker: false}}
|
||||
esTeh := entities.Product{ID: uuid.New(), Name: "Es Teh"}
|
||||
esTeh.SetPrinterTypes([]string{"bar"})
|
||||
|
||||
existing := entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID, Product: esTeh}
|
||||
addedPaket := &entities.OrderItem{ID: uuid.New(), ProductID: paket.ID}
|
||||
addedEsTeh := &entities.OrderItem{ID: uuid.New(), ProductID: esTeh.ID}
|
||||
|
||||
order := &entities.Order{
|
||||
OutletID: outletID,
|
||||
OrderItems: []entities.OrderItem{
|
||||
existing,
|
||||
{ID: addedEsTeh.ID, ProductID: esTeh.ID, Product: esTeh},
|
||||
{ID: addedPaket.ID, ProductID: paket.ID, Product: paket},
|
||||
},
|
||||
}
|
||||
|
||||
responses := addedItemsFromOrder([]*entities.OrderItem{addedPaket, addedEsTeh}, order)
|
||||
|
||||
require.Len(t, responses, 2, "only the added items, not the ones already on the order")
|
||||
assert.Equal(t, addedPaket.ID, responses[0].ID, "in the order they were requested")
|
||||
assert.Equal(t, "Paket Makan Minum", responses[0].ProductName)
|
||||
assert.Equal(t, []string{"kitchen", "bar"}, responses[0].PrinterTypes)
|
||||
assert.False(t, responses[0].PrintToChecker, "the outlet's print_to_checker is loaded too")
|
||||
assert.Equal(t, addedEsTeh.ID, responses[1].ID)
|
||||
assert.Equal(t, []string{"bar"}, responses[1].PrinterTypes)
|
||||
}
|
||||
@@ -316,6 +316,18 @@ func resolveLineQuantity(product *entities.Product, quantity int, weight *float6
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.CreateOrderRequest, organizationID uuid.UUID) (*models.OrderResponse, error) {
|
||||
// The order's customer earns EnakPoint and EnakCoin once it is paid, so it must be
|
||||
// one of the organization's own; orders.customer_id has no foreign key to catch it.
|
||||
customerID := req.CustomerID
|
||||
if customerID != nil && *customerID == uuid.Nil {
|
||||
customerID = nil
|
||||
}
|
||||
if customerID != nil {
|
||||
if _, err := p.customerRepo.GetByIDAndOrganization(ctx, *customerID, organizationID); err != nil {
|
||||
return nil, fmt.Errorf("customer not found or does not belong to the organization: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
orderNumber, err := p.orderRepo.GetNextOrderNumber(ctx, organizationID, req.OutletID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate order number: %w", err)
|
||||
@@ -401,7 +413,7 @@ func (p *OrderProcessorImpl) CreateOrder(ctx context.Context, req *models.Create
|
||||
OrganizationID: organizationID,
|
||||
OutletID: req.OutletID,
|
||||
UserID: req.UserID,
|
||||
CustomerID: req.CustomerID,
|
||||
CustomerID: customerID,
|
||||
OrderNumber: orderNumber,
|
||||
TableNumber: req.TableNumber,
|
||||
OrderType: entities.OrderType(req.OrderType),
|
||||
@@ -556,16 +568,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to update order: %w", err)
|
||||
}
|
||||
|
||||
var addedItemResponses []models.OrderItemResponse
|
||||
for _, orderItem := range addedOrderItems {
|
||||
if err := p.orderItemRepo.Create(ctx, orderItem); err != nil {
|
||||
return nil, fmt.Errorf("failed to create order item: %w", err)
|
||||
}
|
||||
|
||||
itemResponse := mappers.OrderItemEntityToResponse(orderItem, order.OutletID)
|
||||
if itemResponse != nil {
|
||||
addedItemResponses = append(addedItemResponses, *itemResponse)
|
||||
}
|
||||
}
|
||||
|
||||
orderWithRelations, err := p.orderRepo.GetWithRelations(ctx, orderID)
|
||||
@@ -573,6 +579,10 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
return nil, fmt.Errorf("failed to retrieve updated order: %w", err)
|
||||
}
|
||||
|
||||
// The items just created carry no product, so take them from the reloaded order: the
|
||||
// POS prints added items from this response and needs their name and printers.
|
||||
addedItemResponses := addedItemsFromOrder(addedOrderItems, orderWithRelations)
|
||||
|
||||
updatedOrderResponse := mappers.OrderEntityToResponse(orderWithRelations)
|
||||
p.attachEarnings(ctx, updatedOrderResponse)
|
||||
|
||||
@@ -584,6 +594,26 @@ func (p *OrderProcessorImpl) AddToOrder(ctx context.Context, orderID uuid.UUID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addedItemsFromOrder maps the added items in the order they were requested, using their
|
||||
// copies in the reloaded order, which have the product and its outlet prices loaded.
|
||||
func addedItemsFromOrder(added []*entities.OrderItem, order *entities.Order) []models.OrderItemResponse {
|
||||
reloaded := make(map[uuid.UUID]*entities.OrderItem, len(order.OrderItems))
|
||||
for i := range order.OrderItems {
|
||||
reloaded[order.OrderItems[i].ID] = &order.OrderItems[i]
|
||||
}
|
||||
|
||||
var responses []models.OrderItemResponse
|
||||
for _, item := range added {
|
||||
if loaded, ok := reloaded[item.ID]; ok {
|
||||
item = loaded
|
||||
}
|
||||
if response := mappers.OrderItemEntityToResponse(item, order.OutletID); response != nil {
|
||||
responses = append(responses, *response)
|
||||
}
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func (p *OrderProcessorImpl) UpdateOrder(ctx context.Context, id uuid.UUID, req *models.UpdateOrderRequest) (*models.OrderResponse, error) {
|
||||
// Get existing order
|
||||
order, err := p.orderRepo.GetByID(ctx, id)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -199,7 +199,7 @@ func (p *ProductRecipeProcessorImpl) entityToResponse(entity *entities.ProductRe
|
||||
Cost: entity.Product.Cost,
|
||||
BusinessType: string(entity.Product.BusinessType),
|
||||
ImageURL: entity.Product.ImageURL,
|
||||
PrinterType: entity.Product.PrinterType,
|
||||
PrinterTypes: entity.Product.GetPrinterTypes(),
|
||||
Metadata: entity.Product.Metadata,
|
||||
IsActive: entity.Product.IsActive,
|
||||
CreatedAt: entity.Product.CreatedAt,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -82,10 +82,6 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens, err := p.legacyTokens(ctx, customerID, balances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := &models.GetCustomerWalletResponseData{
|
||||
PointBalance: balances[constants.WalletCurrencyPoint],
|
||||
@@ -94,13 +90,10 @@ func (p *WalletQueryProcessor) Summary(ctx context.Context, customerID uuid.UUID
|
||||
PointDiscountValue: balances[constants.WalletCurrencyPoint] * pointValue,
|
||||
RecentTransactions: recent,
|
||||
TotalPoints: points.TotalPoints,
|
||||
TotalTokens: tokens.TotalTokens,
|
||||
PointsHistory: points.PointsHistory,
|
||||
TokensHistory: tokens.TokensHistory,
|
||||
LastUpdated: points.LastUpdated,
|
||||
}
|
||||
if tokens.LastUpdated.After(data.LastUpdated) {
|
||||
data.LastUpdated = tokens.LastUpdated
|
||||
if len(recent) > 0 {
|
||||
data.LastUpdated = recent[0].CreatedAt
|
||||
}
|
||||
for _, e := range expiring {
|
||||
entry := &models.CustomerWalletExpiring{Amount: e.Amount, Date: e.Date}
|
||||
@@ -161,32 +154,6 @@ func (p *WalletQueryProcessor) legacyPoints(ctx context.Context, customerID uuid
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// LegacyTokens is the pre-wallet GET /customer/tokens. Tokens are EnakCoin now.
|
||||
func (p *WalletQueryProcessor) LegacyTokens(ctx context.Context, customerID uuid.UUID) (*models.GetCustomerTokensResponseData, error) {
|
||||
balances, err := p.repo.SpendableBalances(ctx, customerID, p.now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.legacyTokens(ctx, customerID, balances)
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) legacyTokens(ctx context.Context, customerID uuid.UUID, balances map[string]int64) (*models.GetCustomerTokensResponseData, error) {
|
||||
recent, err := p.recent(ctx, customerID, constants.WalletCurrencyCoin, walletLegacyHistorySize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &models.GetCustomerTokensResponseData{TotalTokens: balances[constants.WalletCurrencyCoin]}
|
||||
for _, tx := range recent {
|
||||
data.TokensHistory = append(data.TokensHistory, models.TokensHistoryItem{
|
||||
ID: tx.ID.String(), Tokens: tx.Amount, Type: tx.Type, Description: tx.Description, CreatedAt: tx.CreatedAt,
|
||||
})
|
||||
}
|
||||
if len(recent) > 0 {
|
||||
data.LastUpdated = recent[0].CreatedAt
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (p *WalletQueryProcessor) recent(ctx context.Context, customerID uuid.UUID, currency string, limit int) ([]models.CustomerWalletTransaction, error) {
|
||||
rows, _, err := p.repo.ListTransactions(ctx, repository.WalletTransactionFilter{
|
||||
CustomerID: customerID, Currency: currency, Limit: limit,
|
||||
@@ -315,3 +282,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
|
||||
}
|
||||
|
||||
@@ -122,7 +122,6 @@ func TestWalletQueryProcessor_SummaryShowsWhereEachRowCameFromOrWent(t *testing.
|
||||
|
||||
// The pre-wallet fields are still filled, from the wallet.
|
||||
assert.Equal(t, int64(250), data.TotalPoints)
|
||||
assert.Equal(t, int64(8), data.TotalTokens)
|
||||
require.Len(t, data.PointsHistory, 2)
|
||||
assert.Equal(t, int64(-50), data.PointsHistory[0].Points)
|
||||
assert.Equal(t, constants.WalletTxTypePayment, data.PointsHistory[0].Type)
|
||||
@@ -174,12 +173,6 @@ func TestWalletQueryProcessor_LegacyEndpointsSplitByCurrency(t *testing.T) {
|
||||
assert.Equal(t, int64(100), points.TotalPoints)
|
||||
require.Len(t, points.PointsHistory, 1)
|
||||
assert.Equal(t, int64(100), points.PointsHistory[0].Points)
|
||||
|
||||
tokens, err := p.LegacyTokens(context.Background(), uuid.New())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(8), tokens.TotalTokens)
|
||||
require.Len(t, tokens.TokensHistory, 1)
|
||||
assert.Equal(t, int64(8), tokens.TokensHistory[0].Tokens)
|
||||
}
|
||||
|
||||
func TestWalletQueryProcessor_TransactionsQuery(t *testing.T) {
|
||||
@@ -243,3 +236,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 {
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *ProductIngredientRepository) Create(ctx context.Context, productIngredi
|
||||
func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizationID uuid.UUID) (*entities.ProductIngredient, error) {
|
||||
query := `
|
||||
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
||||
FROM product_ingredients pi
|
||||
LEFT JOIN products p ON pi.product_id = p.id
|
||||
@@ -70,7 +70,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat
|
||||
&product.Cost,
|
||||
&product.BusinessType,
|
||||
&product.ImageURL,
|
||||
&product.PrinterType,
|
||||
&product.PrinterTypes,
|
||||
&product.UnitID,
|
||||
&product.HasIngredients,
|
||||
&product.Metadata,
|
||||
@@ -103,7 +103,7 @@ func (r *ProductIngredientRepository) GetByID(ctx context.Context, id, organizat
|
||||
func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, productID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
|
||||
query := `
|
||||
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
||||
FROM product_ingredients pi
|
||||
LEFT JOIN products p ON pi.product_id = p.id
|
||||
@@ -143,7 +143,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc
|
||||
&product.Cost,
|
||||
&product.BusinessType,
|
||||
&product.ImageURL,
|
||||
&product.PrinterType,
|
||||
&product.PrinterTypes,
|
||||
&product.UnitID,
|
||||
&product.HasIngredients,
|
||||
&product.Metadata,
|
||||
@@ -178,7 +178,7 @@ func (r *ProductIngredientRepository) GetByProductID(ctx context.Context, produc
|
||||
func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ingredientID, organizationID uuid.UUID) ([]*entities.ProductIngredient, error) {
|
||||
query := `
|
||||
SELECT pi.id, pi.organization_id, pi.outlet_id, pi.product_id, pi.ingredient_id, pi.quantity, pi.created_at, pi.updated_at,
|
||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_type, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||
p.id, p.organization_id, p.category_id, p.sku, p.name, p.description, p.price, p.cost, p.business_type, p.image_url, p.printer_types, p.unit_id, p.has_ingredients, p.metadata, p.is_active, p.created_at, p.updated_at,
|
||||
i.id, i.organization_id, i.outlet_id, i.name, i.unit_id, i.cost, i.stock, i.is_semi_finished, i.is_active, i.metadata, i.created_at, i.updated_at
|
||||
FROM product_ingredients pi
|
||||
LEFT JOIN products p ON pi.product_id = p.id
|
||||
@@ -218,7 +218,7 @@ func (r *ProductIngredientRepository) GetByIngredientID(ctx context.Context, ing
|
||||
&product.Cost,
|
||||
&product.BusinessType,
|
||||
&product.ImageURL,
|
||||
&product.PrinterType,
|
||||
&product.PrinterTypes,
|
||||
&product.UnitID,
|
||||
&product.HasIngredients,
|
||||
&product.Metadata,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see walletTestDB.
|
||||
func TestProductPrinterTypes_AgainstPostgres(t *testing.T) {
|
||||
db := walletTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
org, category := uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'printer test', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO categories (id, organization_id, name) VALUES (?, ?, 'Paket')`, category, org).Error)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM products WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM categories WHERE id = ?`, category)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
repo := NewProductRepositoryImpl(db)
|
||||
product := &entities.Product{OrganizationID: org, CategoryID: category, Name: "Paket Makan Minum", Price: 25000}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
require.NoError(t, repo.Create(ctx, product))
|
||||
|
||||
stored, err := repo.GetByID(ctx, product.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, entities.StringSlice{"kitchen", "bar"}, stored.PrinterTypes)
|
||||
|
||||
stored.SetPrinterTypes([]string{"bar"})
|
||||
require.NoError(t, repo.Update(ctx, stored))
|
||||
|
||||
var printerTypes string
|
||||
require.NoError(t, db.Raw(`SELECT printer_types::text FROM products WHERE id = ?`, product.ID).Scan(&printerTypes).Error)
|
||||
assert.Equal(t, `["bar"]`, printerTypes)
|
||||
|
||||
// The old single column is gone.
|
||||
var columns int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM information_schema.columns WHERE table_name = 'products' AND column_name = 'printer_type'`).Scan(&columns).Error)
|
||||
assert.Zero(t, columns)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -169,9 +169,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
customer.Use(r.customerAuthMiddleware.ValidateCustomerToken())
|
||||
{
|
||||
customer.GET("/points", r.customerPointsHandler.GetCustomerPoints)
|
||||
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 +541,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",
|
||||
|
||||
@@ -177,7 +177,7 @@ func (s *campaignService) CreateCampaignRule(ctx context.Context, req *contract.
|
||||
}
|
||||
|
||||
// Validate reward value based on reward type
|
||||
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
|
||||
if req.RewardType == "POINTS" || req.RewardType == "COINS" {
|
||||
if req.RewardValue == nil || *req.RewardValue <= 0 {
|
||||
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func (s *campaignService) UpdateCampaignRule(ctx context.Context, req *contract.
|
||||
}
|
||||
|
||||
// Validate reward value based on reward type
|
||||
if req.RewardType == "POINTS" || req.RewardType == "COINS" || req.RewardType == "TOKENS" {
|
||||
if req.RewardType == "POINTS" || req.RewardType == "COINS" {
|
||||
if req.RewardValue == nil || *req.RewardValue <= 0 {
|
||||
return nil, fmt.Errorf("reward value must be positive for %s type", req.RewardType)
|
||||
}
|
||||
@@ -295,7 +295,7 @@ func (s *campaignService) GetCampaignRulesByCampaignID(ctx context.Context, camp
|
||||
}
|
||||
|
||||
func (s *campaignService) validateCampaignType(campaignType string) error {
|
||||
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
|
||||
validTypes := []string{"REWARD", "POINTS", "COINS", "MIXED"}
|
||||
for _, validType := range validTypes {
|
||||
if campaignType == validType {
|
||||
return nil
|
||||
@@ -335,7 +335,7 @@ func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStr
|
||||
}
|
||||
|
||||
// Validate reward value based on reward type
|
||||
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" {
|
||||
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" {
|
||||
if rule.RewardValue == nil || *rule.RewardValue <= 0 {
|
||||
return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1)
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func (s *campaignService) validateRuleType(ruleType string) error {
|
||||
}
|
||||
|
||||
func (s *campaignService) validateRewardType(rewardType string) error {
|
||||
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
|
||||
validTypes := []string{"POINTS", "COINS", "REWARD"}
|
||||
for _, validType := range validTypes {
|
||||
if rewardType == validType {
|
||||
return nil
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
|
||||
type CustomerPointsService interface {
|
||||
GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error)
|
||||
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)
|
||||
}
|
||||
@@ -40,19 +40,6 @@ func (s *customerPointsService) GetCustomerPoints(ctx context.Context, customerI
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *customerPointsService) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
|
||||
if customerID == "" {
|
||||
return nil, fmt.Errorf("customer ID is required")
|
||||
}
|
||||
|
||||
response, err := s.customerPointsProcessor.GetCustomerTotalTokensAPI(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
|
||||
if customerID == "" {
|
||||
return nil, fmt.Errorf("customer ID is required")
|
||||
@@ -90,3 +77,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{
|
||||
@@ -401,7 +276,6 @@ func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayR
|
||||
CustomerID: model.CustomerID,
|
||||
PrizeID: model.PrizeID,
|
||||
CoinsUsed: model.CoinsUsed,
|
||||
TokenUsed: model.CoinsUsed,
|
||||
RandomSeed: model.RandomSeed,
|
||||
CreatedAt: model.CreatedAt,
|
||||
Game: game,
|
||||
@@ -431,10 +305,9 @@ func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameR
|
||||
}
|
||||
|
||||
return &contract.PlayGameResponse{
|
||||
GamePlay: gamePlayValue,
|
||||
PrizeWon: prizeWon,
|
||||
CoinsRemaining: model.CoinsRemaining,
|
||||
TokensRemaining: model.CoinsRemaining,
|
||||
GamePlay: gamePlayValue,
|
||||
PrizeWon: prizeWon,
|
||||
CoinsRemaining: model.CoinsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,9 @@ func InventoryModelResponseToResponse(inv *models.InventoryResponse) *contract.I
|
||||
IsLowStock: inv.IsLowStock,
|
||||
UpdatedAt: inv.UpdatedAt,
|
||||
Product: &contract.ProductResponse{
|
||||
ID: inv.ProductID,
|
||||
Name: inv.ProductName,
|
||||
ID: inv.ProductID,
|
||||
Name: inv.ProductName,
|
||||
PrinterTypes: []string{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func CreateOrderContractToModel(req *contract.CreateOrderRequest) *models.Create
|
||||
return &models.CreateOrderRequest{
|
||||
OutletID: req.OutletID,
|
||||
UserID: req.UserID,
|
||||
CustomerID: req.CustomerID,
|
||||
TableID: req.TableID,
|
||||
TableNumber: req.TableNumber,
|
||||
OrderType: constants.OrderType(req.OrderType),
|
||||
@@ -117,7 +118,7 @@ func OrderModelToContract(resp *models.OrderResponse) *contract.OrderResponse {
|
||||
Status: string(item.Status),
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
PrinterType: item.PrinterType,
|
||||
PrinterTypes: item.PrinterTypes,
|
||||
PrintToChecker: item.PrintToChecker,
|
||||
PaidQuantity: item.PaidQuantity,
|
||||
}
|
||||
@@ -194,6 +195,7 @@ func AddToOrderModelToContract(resp *models.AddToOrderResponse) *contract.AddToO
|
||||
Status: string(item.Status),
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
PrinterTypes: item.PrinterTypes,
|
||||
PrintToChecker: item.PrintToChecker,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package transformer
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The POS prints items added to an open order from this response, so each one has to
|
||||
// say where it is printed.
|
||||
func TestAddToOrderModelToContractCarriesPrinters(t *testing.T) {
|
||||
result := AddToOrderModelToContract(&models.AddToOrderResponse{
|
||||
AddedItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}},
|
||||
})
|
||||
|
||||
require.Len(t, result.AddedItems, 1)
|
||||
assert.Equal(t, []string{"kitchen", "bar"}, result.AddedItems[0].PrinterTypes)
|
||||
}
|
||||
|
||||
// Follows an order from the database row to the JSON the POS reads.
|
||||
func TestOrderJSONCarriesPrinters(t *testing.T) {
|
||||
paket := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||
paket.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
order := &entities.Order{
|
||||
ID: uuid.New(),
|
||||
OrderItems: []entities.OrderItem{{ID: uuid.New(), ProductID: paket.ID, Product: paket, Quantity: 1}},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(OrderModelToContract(mappers.OrderEntityToResponse(order)))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, string(body), `"printer_types":["kitchen","bar"]`)
|
||||
assert.NotContains(t, string(body), `"printer_type":`, "printer_types replaced it")
|
||||
}
|
||||
|
||||
func TestOrderModelToContractCarriesPrinters(t *testing.T) {
|
||||
result := OrderModelToContract(&models.OrderResponse{
|
||||
OrderItems: []models.OrderItemResponse{{PrinterTypes: []string{"kitchen", "bar"}}},
|
||||
})
|
||||
|
||||
require.Len(t, result.OrderItems, 1)
|
||||
assert.Equal(t, []string{"kitchen", "bar"}, result.OrderItems[0].PrinterTypes)
|
||||
}
|
||||
@@ -32,6 +32,20 @@ func TestCreateOrderContractToModelCarriesWeight(t *testing.T) {
|
||||
require.Equal(t, weight, *result.OrderItems[0].Weight)
|
||||
}
|
||||
|
||||
func TestCreateOrderContractToModelCarriesCustomer(t *testing.T) {
|
||||
customerID := uuid.New()
|
||||
|
||||
result := CreateOrderContractToModel(&contract.CreateOrderRequest{
|
||||
OutletID: uuid.New(),
|
||||
UserID: uuid.New(),
|
||||
CustomerID: &customerID,
|
||||
OrderItems: []contract.CreateOrderItemRequest{{ProductID: uuid.New(), Quantity: 1}},
|
||||
})
|
||||
|
||||
require.NotNil(t, result.CustomerID, "the order's customer is who earns EnakPoint and EnakCoin")
|
||||
require.Equal(t, customerID, *result.CustomerID)
|
||||
}
|
||||
|
||||
func TestAddToOrderContractToModelCarriesWeight(t *testing.T) {
|
||||
weight := 0.8
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ func CreateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Cr
|
||||
Cost: cost,
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: req.PrinterType,
|
||||
PrinterTypes: req.PrinterTypes,
|
||||
PrintToChecker: req.PrintToChecker,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
@@ -91,7 +91,7 @@ func UpdateProductRequestToModel(apctx *appcontext.ContextInfo, req *contract.Up
|
||||
Price: req.Price,
|
||||
Cost: req.Cost,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: req.PrinterType,
|
||||
PrinterTypes: req.PrinterTypes,
|
||||
PrintToChecker: req.PrintToChecker,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: req.SellBy,
|
||||
@@ -152,7 +152,7 @@ func ProductModelResponseToResponse(prod *models.ProductResponse) *contract.Prod
|
||||
Cost: prod.Cost,
|
||||
BusinessType: string(prod.BusinessType),
|
||||
ImageURL: prod.ImageURL,
|
||||
PrinterType: prod.PrinterType,
|
||||
PrinterTypes: prod.PrinterTypes,
|
||||
PrintToChecker: prod.PrintToChecker,
|
||||
UnitID: prod.UnitID,
|
||||
SellBy: prod.SellBy,
|
||||
|
||||
@@ -254,7 +254,7 @@ func (v *CampaignValidatorImpl) ValidateDeleteCampaignRuleRequest(req *contract.
|
||||
}
|
||||
|
||||
func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool {
|
||||
validTypes := []string{"REWARD", "POINTS", "COINS", "TOKENS", "MIXED"}
|
||||
validTypes := []string{"REWARD", "POINTS", "COINS", "MIXED"}
|
||||
return contains(validTypes, campaignType)
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func (v *CampaignValidatorImpl) isValidRuleType(ruleType string) bool {
|
||||
}
|
||||
|
||||
func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool {
|
||||
validTypes := []string{"POINTS", "COINS", "TOKENS", "REWARD"}
|
||||
validTypes := []string{"POINTS", "COINS", "REWARD"}
|
||||
return contains(validTypes, rewardType)
|
||||
}
|
||||
|
||||
@@ -297,7 +297,7 @@ func (v *CampaignValidatorImpl) validateCampaignRule(rule *contract.CampaignRule
|
||||
}
|
||||
|
||||
// Validate reward value based on reward type
|
||||
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || rule.RewardType == "TOKENS" {
|
||||
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" {
|
||||
if rule.RewardValue == nil || *rule.RewardValue <= 0 {
|
||||
return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber)))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -59,8 +59,8 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
|
||||
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
|
||||
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
|
||||
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
|
||||
return err, code
|
||||
}
|
||||
|
||||
if err, code := validateSellBy(req.SellBy, req.UnitID); err != nil {
|
||||
@@ -70,6 +70,18 @@ func (v *ProductValidatorImpl) ValidateCreateProductRequest(req *contract.Create
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// validatePrinterTypes holds each printer to the 50 characters the single printer_type
|
||||
// column allowed.
|
||||
func validatePrinterTypes(printerTypes []string) (error, string) {
|
||||
for _, printerType := range printerTypes {
|
||||
if len(strings.TrimSpace(printerType)) > 50 {
|
||||
return errors.New("each printer_types entry cannot exceed 50 characters"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// validateSellBy checks how a product is sold and that it carries what that choice
|
||||
// needs. A weight-based product without a unit would produce order lines with no unit
|
||||
// to print, so the receipt could show "4,2" with no idea of what.
|
||||
@@ -100,7 +112,7 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
|
||||
// At least one field should be provided for update
|
||||
if req.CategoryID == nil && req.SKU == nil && req.Name == nil && req.Description == nil &&
|
||||
req.Price == nil && req.Cost == nil && req.BusinessType == nil && req.ImageURL == nil &&
|
||||
req.PrinterType == nil && req.PrintToChecker == nil && req.UnitID == nil &&
|
||||
req.PrinterTypes == nil && req.PrintToChecker == nil && req.UnitID == nil &&
|
||||
req.SellBy == nil && req.Metadata == nil && req.IsActive == nil {
|
||||
return errors.New("at least one field must be provided for update"), constants.MissingFieldErrorCode
|
||||
}
|
||||
@@ -134,8 +146,8 @@ func (v *ProductValidatorImpl) ValidateUpdateProductRequest(req *contract.Update
|
||||
return errors.New("image_url cannot exceed 500 characters"), constants.MalformedFieldErrorCode
|
||||
}
|
||||
|
||||
if req.PrinterType != nil && len(*req.PrinterType) > 50 {
|
||||
return errors.New("printer_type cannot exceed 50 characters"), constants.MalformedFieldErrorCode
|
||||
if err, code := validatePrinterTypes(req.PrinterTypes); err != nil {
|
||||
return err, code
|
||||
}
|
||||
|
||||
// Only the value is checked here. Whether the product ends up with a unit depends on
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package validator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
)
|
||||
|
||||
func TestValidateProductRequestPrinterTypes(t *testing.T) {
|
||||
v := NewProductValidator()
|
||||
tooLong := strings.Repeat("a", 51)
|
||||
|
||||
create := baseCreateRequest()
|
||||
create.PrinterTypes = []string{"kitchen", "bar"}
|
||||
if err, _ := v.ValidateCreateProductRequest(create); err != nil {
|
||||
t.Fatalf("kitchen and bar should be accepted, got: %v", err)
|
||||
}
|
||||
|
||||
create.PrinterTypes = []string{"kitchen", tooLong}
|
||||
if err, _ := v.ValidateCreateProductRequest(create); err == nil {
|
||||
t.Fatal("expected an error for a printer longer than 50 characters")
|
||||
}
|
||||
|
||||
err, _ := v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{tooLong}})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a printer longer than 50 characters on update")
|
||||
}
|
||||
|
||||
// An update that only changes the printers is a real update.
|
||||
err, _ = v.ValidateUpdateProductRequest(&contract.UpdateProductRequest{PrinterTypes: []string{"kitchen", "bar"}})
|
||||
if err != nil {
|
||||
t.Fatalf("an update of only printer_types should be accepted, got: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -13,4 +13,4 @@ DROP INDEX IF EXISTS uq_payment_methods_point_per_organization;
|
||||
|
||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet'));
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery'));
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
-- Paying with EnakPoint (docs/prd-point-coin.md F9, §8, §10.5).
|
||||
|
||||
-- A new payment method type. Every organization has exactly one method of it, made by
|
||||
-- A new payment method type, next to the existing ones (edc and delivery were added to
|
||||
-- the constraint outside the migrations). Every organization has exactly one method of it, made by
|
||||
-- the system, which cannot be deleted or change type.
|
||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet', 'point'));
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));
|
||||
|
||||
CREATE UNIQUE INDEX uq_payment_methods_point_per_organization ON payment_methods(organization_id)
|
||||
WHERE type = 'point';
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Nothing to undo: 000094 now allows the same types.
|
||||
SELECT 1;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Production also has edc and delivery payment methods, allowed by a constraint changed
|
||||
-- outside the migrations, and the code also accepts qr, which no constraint allowed.
|
||||
-- 000094 first rewrote the constraint without them, which
|
||||
-- failed on production and, where it ran, dropped them. Keep every type in use.
|
||||
ALTER TABLE payment_methods DROP CONSTRAINT IF EXISTS payment_methods_type_check;
|
||||
ALTER TABLE payment_methods ADD CONSTRAINT payment_methods_type_check
|
||||
CHECK (type IN ('cash', 'card', 'digital_wallet', 'qr', 'edc', 'delivery', 'point'));
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Only the first printer fits back into printer_type; the extra stations are lost.
|
||||
ALTER TABLE products ADD COLUMN printer_type VARCHAR(50) DEFAULT 'kitchen';
|
||||
|
||||
UPDATE products SET printer_type = COALESCE(printer_types->>0, '');
|
||||
|
||||
CREATE INDEX idx_products_printer_type ON products(printer_type);
|
||||
|
||||
ALTER TABLE products DROP COLUMN printer_types;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- A product can be prepared at more than one station: a meal-and-drink package goes to
|
||||
-- the kitchen and to the bar. printer_types replaces the single printer_type.
|
||||
ALTER TABLE products ADD COLUMN printer_types JSONB NOT NULL DEFAULT '["kitchen"]';
|
||||
|
||||
UPDATE products
|
||||
SET printer_types = CASE
|
||||
WHEN COALESCE(printer_type, '') = '' THEN '[]'::jsonb
|
||||
ELSE jsonb_build_array(printer_type)
|
||||
END;
|
||||
|
||||
-- Dropping the column drops idx_products_printer_type with it.
|
||||
ALTER TABLE products DROP COLUMN printer_type;
|
||||
+2
-2
@@ -500,7 +500,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Cappuccino\",\n \"description\": \"Classic Italian coffee drink\",\n \"category_id\": \"{{category_id}}\",\n \"sku\": \"CAP001\",\n \"barcode\": \"1234567890123\",\n \"price\": 4.50,\n \"cost\": 1.20,\n \"is_active\": true,\n \"has_variants\": false,\n \"image_url\": \"https://example.com/cappuccino.jpg\",\n \"printer_type\": \"kitchen\"\n}"
|
||||
"raw": "{\n \"name\": \"Cappuccino\",\n \"description\": \"Classic Italian coffee drink\",\n \"category_id\": \"{{category_id}}\",\n \"sku\": \"CAP001\",\n \"barcode\": \"1234567890123\",\n \"price\": 4.50,\n \"cost\": 1.20,\n \"is_active\": true,\n \"has_variants\": false,\n \"image_url\": \"https://example.com/cappuccino.jpg\",\n \"printer_types\": [\"kitchen\"]\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/products",
|
||||
@@ -563,7 +563,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"Premium Cappuccino\",\n \"description\": \"Premium Italian coffee drink with extra foam\",\n \"price\": 5.50,\n \"cost\": 1.50,\n \"is_active\": true,\n \"image_url\": \"https://example.com/premium-cappuccino.jpg\",\n \"printer_type\": \"kitchen\"\n}"
|
||||
"raw": "{\n \"name\": \"Premium Cappuccino\",\n \"description\": \"Premium Italian coffee drink with extra foam\",\n \"price\": 5.50,\n \"cost\": 1.50,\n \"is_active\": true,\n \"image_url\": \"https://example.com/premium-cappuccino.jpg\",\n \"printer_types\": [\"kitchen\"]\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/v1/products/{{product_id}}",
|
||||
|
||||
Reference in New Issue
Block a user