Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ef226af1 | ||
|
|
892575202b | ||
|
|
c9654a387a | ||
|
|
e94eb2c26d | ||
|
|
e8b31035f7 | ||
|
|
100e006218 | ||
|
|
609d434976 | ||
|
|
35fe5703fd | ||
|
|
e200923b72 | ||
|
|
c988a79d3b | ||
|
|
9b21af8892 | ||
|
|
582dc75543 | ||
|
|
c6062c4bb9 | ||
|
|
5e28b05770 | ||
|
|
4e24f9bbb0 | ||
|
|
645da3048e | ||
|
|
ae8003c1e4 |
+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.
|
||||
@@ -474,16 +474,14 @@ Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "token_used": 1, "created_at": "…" },
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
|
||||
"coins_remaining": 7,
|
||||
"tokens_remaining": 7
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
|
||||
EnakCoin yang terpotong. Baca `coins_used` dan `coins_remaining`; `token_used` dan
|
||||
`tokens_remaining` hanya salinan untuk versi aplikasi lama.
|
||||
EnakCoin yang terpotong.
|
||||
|
||||
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
|
||||
|
||||
@@ -497,13 +495,14 @@ pindah. Aplikasi baru jangan memakainya.
|
||||
| Lama | Ganti dengan |
|
||||
|---|---|
|
||||
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
|
||||
| `GET /customer/tokens` | `GET /customer/wallet` (`coin_balance`) |
|
||||
| `total_points`, `total_tokens`, `points_history`, `tokens_history`, `last_updated` di `/customer/wallet` | `point_balance`, `coin_balance`, `recent_transactions` |
|
||||
| `token_used`, `tokens_remaining` di respons game | `coins_used`, `coins_remaining` |
|
||||
| `sort_by=token_used` di daftar game play | `sort_by=coins_used` |
|
||||
| `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 dan tabel lama (`customer_points`, `customer_tokens`) bisa dihapus.
|
||||
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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -52,7 +52,6 @@ const (
|
||||
TableEntity = "table"
|
||||
// Gamification entities
|
||||
CustomerPointsEntity = "customer_points"
|
||||
CustomerTokensEntity = "customer_tokens"
|
||||
TierEntity = "tier"
|
||||
GameEntity = "game"
|
||||
GamePrizeEntity = "game_prize"
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -25,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"`
|
||||
@@ -53,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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -25,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"`
|
||||
@@ -66,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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +22,7 @@ func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *rep
|
||||
}
|
||||
}
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -42,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 {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -169,7 +169,6 @@ 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)
|
||||
|
||||
@@ -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,7 +10,6 @@ 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)
|
||||
@@ -41,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")
|
||||
|
||||
@@ -276,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,
|
||||
@@ -306,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)))
|
||||
}
|
||||
|
||||
@@ -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,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