Compare commits
59
Commits
dev
...
e94eb2c26d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e94eb2c26d | ||
|
|
e8b31035f7 | ||
|
|
100e006218 | ||
|
|
609d434976 | ||
|
|
35fe5703fd | ||
|
|
e200923b72 | ||
|
|
c988a79d3b | ||
|
|
9b21af8892 | ||
|
|
582dc75543 | ||
|
|
c6062c4bb9 | ||
|
|
5e28b05770 | ||
|
|
4e24f9bbb0 | ||
|
|
645da3048e | ||
|
|
ae8003c1e4 | ||
|
|
3cd88a55c8 | ||
|
|
d1e543a79f | ||
|
|
550122f29c | ||
|
|
d293786cde | ||
|
|
4d63673a25 | ||
|
|
9ce55e6002 | ||
|
|
4432f0a10d | ||
|
|
bf9651e221 | ||
|
|
8bf1d5c1a8 | ||
|
|
a18bb072f5 | ||
|
|
694d65b6d8 | ||
|
|
ab3425070b | ||
|
|
0b52edf84e | ||
|
|
d7138b8f87 | ||
|
|
43eac0ced4 | ||
|
|
4b3beaed41 | ||
|
|
0c4dd72583 | ||
|
|
cf5332c281 | ||
|
|
fe2f459b03 | ||
|
|
8370851ed2 | ||
|
|
fc97c78300 | ||
|
|
eb5b63677f | ||
|
|
78c0c11774 | ||
|
|
5baed18b22 | ||
|
|
2bd53ee4a4 | ||
|
|
39e47ff0e6 | ||
|
|
040780cd2d | ||
|
|
a6d5a8b056 | ||
|
|
e5db0325cc | ||
|
|
41b75810fd | ||
|
|
6af97f5696 | ||
|
|
2eb590caab | ||
|
|
fc5eecb68a | ||
|
|
b107f4ef04 | ||
|
|
84401cc708 | ||
|
|
f0ff59fea7 | ||
|
|
923c108690 | ||
|
|
1edeaa0a48 | ||
|
|
f2701882dc | ||
|
|
c9311997a7 | ||
|
|
ebf666c004 | ||
|
|
d3987c7114 | ||
|
|
992bb04816 | ||
|
|
1e5573af75 | ||
|
|
b42d141927 |
+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"]
|
||||
|
||||
@@ -42,6 +42,7 @@ help:
|
||||
@echo " migration-up Up migrations"
|
||||
@echo " migration-up ENV=production Up migrations (production DB)"
|
||||
@echo " migration-down Down last migration"
|
||||
@echo " wallet-migrate args=-dry-run Move legacy points/tokens into the wallet"
|
||||
@echo
|
||||
@echo " docker-up Up docker services"
|
||||
@echo " docker-down Down docker services"
|
||||
@@ -105,6 +106,13 @@ migration-down:
|
||||
migration-force:
|
||||
@migrate -database $(DB_URL) -path ./migrations force $(version)
|
||||
|
||||
# Move customer_points / customer_tokens into the wallet. Safe to run again.
|
||||
# Pass args=-dry-run to only report.
|
||||
|
||||
.SILENT: wallet-migrate
|
||||
wallet-migrate:
|
||||
ENV_MODE=$(ENV) go run ./cmd/wallet-migrate $(args)
|
||||
|
||||
.SILENT: seeder-create
|
||||
seeder-create:
|
||||
@migrate create -ext sql -dir ./seeders -seq $(name)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Command wallet-migrate moves customer_points and customer_tokens balances into the
|
||||
// EnakPoint & EnakCoin wallet (docs/prd-point-coin.md §10). Safe to run more than once:
|
||||
// each run credits only what earlier runs have not.
|
||||
//
|
||||
// ENV_MODE=staging go run ./cmd/wallet-migrate -dry-run
|
||||
// ENV_MODE=staging go run ./cmd/wallet-migrate
|
||||
//
|
||||
// It exits non-zero when the legacy totals and the migrated totals disagree.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"apskel-pos-be/config"
|
||||
"apskel-pos-be/internal/db"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dryRun := flag.Bool("dry-run", false, "report what would be credited without writing anything")
|
||||
batchSize := flag.Int("batch", 500, "customers read per batch")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.LoadConfig()
|
||||
pg, err := db.NewPostgres(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
migrator := processor.NewWalletMigrationProcessor(
|
||||
repository.NewWalletMigrationRepository(pg),
|
||||
processor.NewWalletProcessor(repository.NewWalletRepository(pg)),
|
||||
repository.NewTxManager(pg),
|
||||
)
|
||||
report, err := migrator.Run(context.Background(), *dryRun, *batchSize)
|
||||
if err != nil {
|
||||
log.Fatalf("wallet migration failed: %v", err)
|
||||
}
|
||||
|
||||
verb := "credited"
|
||||
if report.DryRun {
|
||||
verb = "would credit"
|
||||
}
|
||||
t := report.Totals
|
||||
fmt.Printf("customers scanned: %d\n", report.CustomersScanned)
|
||||
fmt.Printf("EnakPoint: %s %d in %d rows\n", verb, report.PointsCredited, report.PointCredits)
|
||||
fmt.Printf("EnakCoin: %s %d in %d rows\n", verb, report.CoinsCredited, report.CoinCredits)
|
||||
fmt.Printf("\n%-10s %15s %15s %15s\n", "", "legacy", "migrated", "wallet balance")
|
||||
fmt.Printf("%-10s %15d %15d %15d\n", "EnakPoint", t.LegacyPoints, t.MigratedPoints, t.WalletPoints)
|
||||
fmt.Printf("%-10s %15d %15d %15d\n", "EnakCoin", t.LegacyCoins, t.MigratedCoins, t.WalletCoins)
|
||||
|
||||
if len(report.Discrepancies) > 0 {
|
||||
fmt.Printf("\n%d balances shrank in the legacy tables after they were migrated.\n", len(report.Discrepancies))
|
||||
fmt.Println("They were left as they are in the wallet; correct them with an adjustment if needed:")
|
||||
for _, d := range report.Discrepancies {
|
||||
fmt.Printf(" customer %s %s: legacy %d, migrated %d\n", d.CustomerID, d.Currency, d.Legacy, d.Migrated)
|
||||
}
|
||||
}
|
||||
|
||||
if report.DryRun {
|
||||
return
|
||||
}
|
||||
if !report.Balanced() {
|
||||
fmt.Println("\nNOT BALANCED: legacy and migrated totals differ")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\nbalanced: legacy totals = migrated totals")
|
||||
}
|
||||
+142
-27
@@ -1,62 +1,177 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage:
|
||||
# ./deployment.sh staging deploy staging (branch staging, port 4001)
|
||||
# ./deployment.sh production deploy production (branch main, port 4000)
|
||||
# ./deployment.sh production --rollback kembali ke image sebelum deploy terakhir
|
||||
# Tambahkan --yes untuk melewati konfirmasi production.
|
||||
|
||||
APP_NAME="apskel-pos"
|
||||
LEGACY_CONTAINER="apskel-pos" # nama lama sebelum nama container memakai environment
|
||||
|
||||
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
usage() {
|
||||
echo "Usage: $0 <staging|production> [--rollback] [--yes]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$CURRENT_BRANCH" in
|
||||
main)
|
||||
ENV_MODE="production"
|
||||
fail() {
|
||||
echo "❌ $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Argumen ──────────────────────────────────────────────────────────────────
|
||||
ENV_MODE="${1:-}"
|
||||
shift || true
|
||||
|
||||
ROLLBACK=false
|
||||
ASSUME_YES=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--rollback) ROLLBACK=true ;;
|
||||
--yes|-y) ASSUME_YES=true ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Environment wajib disebut eksplisit, tidak lagi ditebak dari branch aktif
|
||||
case "$ENV_MODE" in
|
||||
production)
|
||||
EXPECTED_BRANCH="main"
|
||||
PORT="4000"
|
||||
;;
|
||||
staging)
|
||||
ENV_MODE="staging"
|
||||
EXPECTED_BRANCH="staging"
|
||||
PORT="4001"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
||||
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
||||
exit 1
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
CONTAINER_NAME="$APP_NAME"
|
||||
CONTAINER_NAME="$APP_NAME-$ENV_MODE"
|
||||
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
||||
PREVIOUS_IMAGE="$APP_NAME:$ENV_MODE-previous"
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
|
||||
# ─── Validasi repo ────────────────────────────────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
if [ "$CURRENT_BRANCH" = "HEAD" ]; then
|
||||
fail "Repo sedang detached HEAD. Jalankan 'git checkout $EXPECTED_BRANCH' dulu."
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
|
||||
fail "Deploy $ENV_MODE harus dari branch '$EXPECTED_BRANCH', folder ini di branch '$CURRENT_BRANCH'.
|
||||
Pakai folder terpisah per environment, jangan pindah branch di folder yang sedang dipakai container lain."
|
||||
fi
|
||||
|
||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||
fail "Ada perubahan yang belum di-commit di folder ini. Bersihkan dulu sebelum deploy."
|
||||
fi
|
||||
|
||||
[ -f "$CONFIG_FILE" ] || fail "Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
|
||||
# ─── Pastikan port tidak dipakai container environment lain ──────────────────
|
||||
OTHER_ON_PORT=$(docker ps --filter "publish=$PORT" --format '{{.Names}}' \
|
||||
| grep -vx "$CONTAINER_NAME" \
|
||||
| { if [ "$ENV_MODE" = "production" ]; then grep -vx "$LEGACY_CONTAINER"; else cat; fi; } \
|
||||
|| true)
|
||||
if [ -n "$OTHER_ON_PORT" ]; then
|
||||
fail "Port $PORT sedang dipakai container lain: $OTHER_ON_PORT"
|
||||
fi
|
||||
|
||||
echo "📦 Environment : $ENV_MODE"
|
||||
echo "🌿 Branch : $CURRENT_BRANCH"
|
||||
echo "🐳 Container : $CONTAINER_NAME"
|
||||
echo "🔌 Port : $PORT"
|
||||
echo "📁 Folder : $(pwd)"
|
||||
[ "$ROLLBACK" = true ] && echo "⏪ Mode : ROLLBACK ke $PREVIOUS_IMAGE"
|
||||
echo ""
|
||||
|
||||
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
exit 1
|
||||
# ─── Konfirmasi production ────────────────────────────────────────────────────
|
||||
if [ "$ENV_MODE" = "production" ] && [ "$ASSUME_YES" != true ]; then
|
||||
read -r -p "⚠️ Ini PRODUCTION. Ketik 'production' untuk lanjut: " CONFIRM
|
||||
[ "$CONFIRM" = "production" ] || fail "Dibatalkan."
|
||||
fi
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
run_container() {
|
||||
local image="$1"
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
# Container production lama masih bernama 'apskel-pos'; staging tidak boleh menyentuhnya
|
||||
if [ "$ENV_MODE" = "production" ]; then
|
||||
docker rm -f "$LEGACY_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "🚀 Running container from $image..."
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
--restart unless-stopped \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
"$image" >/dev/null
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
echo "🩺 Waiting for healthcheck..."
|
||||
for _ in $(seq 1 36); do
|
||||
local status
|
||||
status=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER_NAME" 2>/dev/null || echo "missing")
|
||||
case "$status" in
|
||||
healthy) return 0 ;;
|
||||
unhealthy|exited|dead|missing) return 1 ;;
|
||||
esac
|
||||
sleep 5
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ─── Rollback ─────────────────────────────────────────────────────────────────
|
||||
if [ "$ROLLBACK" = true ]; then
|
||||
docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1 || fail "Image '$PREVIOUS_IMAGE' tidak ada, tidak bisa rollback."
|
||||
run_container "$PREVIOUS_IMAGE"
|
||||
wait_healthy || fail "Container hasil rollback tidak healthy. Cek: docker logs $CONTAINER_NAME"
|
||||
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
|
||||
echo "✅ Rollback $ENV_MODE complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─── Deploy ───────────────────────────────────────────────────────────────────
|
||||
echo "🔄 Pulling latest code (origin/$EXPECTED_BRANCH)..."
|
||||
git fetch origin "$EXPECTED_BRANCH"
|
||||
git merge --ff-only "origin/$EXPECTED_BRANCH"
|
||||
echo " Commit: $(git log -1 --format='%h %s')"
|
||||
|
||||
# Simpan image yang sedang jalan untuk rollback
|
||||
if docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
|
||||
docker tag "$IMAGE_NAME" "$PREVIOUS_IMAGE"
|
||||
fi
|
||||
|
||||
echo "🐳 Building Docker image ($ENV_MODE)..."
|
||||
docker build --target production -t "$IMAGE_NAME" .
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
run_container "$IMAGE_NAME"
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
"$IMAGE_NAME"
|
||||
if ! wait_healthy; then
|
||||
echo "❌ Container baru tidak healthy. Log terakhir:"
|
||||
docker logs --tail 50 "$CONTAINER_NAME" || true
|
||||
if docker image inspect "$PREVIOUS_IMAGE" >/dev/null 2>&1; then
|
||||
echo "⏪ Rolling back ke $PREVIOUS_IMAGE..."
|
||||
run_container "$PREVIOUS_IMAGE"
|
||||
wait_healthy || fail "Rollback juga tidak healthy. Cek manual: docker logs $CONTAINER_NAME"
|
||||
# Jangan biarkan image rusak jadi 'previous' di deploy berikutnya
|
||||
docker tag "$PREVIOUS_IMAGE" "$IMAGE_NAME"
|
||||
fail "Deploy gagal, $ENV_MODE sudah dikembalikan ke image sebelumnya."
|
||||
fi
|
||||
fail "Deploy gagal dan tidak ada image sebelumnya untuk rollback."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment $ENV_MODE complete."
|
||||
echo " Container : $CONTAINER_NAME"
|
||||
echo " Port : $PORT"
|
||||
echo " Rollback : $0 $ENV_MODE --rollback"
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
# API EnakPoint & EnakCoin
|
||||
|
||||
30 Sep 2026
|
||||
|
||||
Semua endpoint EnakPoint (`POINT`, bisa bayar order) dan EnakCoin (`COIN`, untuk game dan ditukar ke EnakPoint) ada di bawah base URL `/api/v1`, memakai satu format response, dan semua jumlah berupa bilangan bulat.
|
||||
|
||||
## Konvensi umum
|
||||
|
||||
| Klien | Autentikasi | Prefix |
|
||||
| --- | --- | --- |
|
||||
| Customer app / self-order | `Authorization: Bearer <token customer>` | `/api/v1/customer` |
|
||||
| POS | Token user (kasir/manager) | `/api/v1` |
|
||||
| Dashboard | Token user, role Admin atau Manager | `/api/v1/marketing`, `/api/v1/outlets` |
|
||||
|
||||
**Format response.** Sukses: `{"success": true, "data": {…}, "errors": null}`. Gagal: `{"success": false, "data": null, "errors": [{"code": "304", "entity": "wallet_service", "cause": "…"}]}`. Tampilkan `cause` sebagai alasan penolakan.
|
||||
|
||||
| `code` | HTTP | Arti |
|
||||
| --- | --- | --- |
|
||||
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
|
||||
| `304` | 400 | Ditolak aturan bisnis (saldo kurang, di luar batas, dst.) |
|
||||
| `404` | 404 | Tidak ditemukan, juga untuk data milik customer atau organisasi lain |
|
||||
| `429` | 429 | OTP diminta ulang terlalu cepat |
|
||||
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
|
||||
| `PIN_INVALID` | 400 | PIN salah |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci 30 menit setelah 5 kali salah |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan 24 jam setelah reset PIN |
|
||||
| `900` | 500 | Kesalahan server |
|
||||
|
||||
**Error PIN** membawa `data` yang tidak `null`: `{"code": "PIN_INVALID", "remaining_attempts": 3}`, `{"code": "PIN_LOCKED", "locked_until": "…"}`, atau `{"code": "TRANSFER_BLOCKED", "transfer_blocked_until": "…"}`. Endpoint yang menerima `pin` bisa mengembalikan salah satunya. PIN selalu dikirim sebagai string 6 digit.
|
||||
|
||||
**Idempotency.** Exchange dan transfer wajib header `Idempotency-Key` (maks. 50 karakter, `X-Idempotency-Key` juga diterima): satu key per percobaan, dan key yang sama dipakai ulang saat retry. Retry mengembalikan hasil pertama dengan `replayed: true`. `POST /payments` wajib `X-Idempotency-Key` seperti pembayaran lain.
|
||||
|
||||
**Waktu.** Tanggal kedaluwarsa dan filter tanggal memakai WIB. Saldo berlaku sampai 23:59:59 WIB pada tanggal kedaluwarsanya.
|
||||
|
||||
## Customer app: saldo & riwayat
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET | `/customer/wallet` | Saldo, nilai rupiah, kedaluwarsa terdekat, 5 mutasi terakhir |
|
||||
| GET | `/customer/wallet/transactions` | Riwayat mutasi, dengan pagination dan filter |
|
||||
| GET | `/customer/wallet/expiring` | Saldo yang akan kedaluwarsa, per currency dan tanggal |
|
||||
| PUT | `/customer/devices` | Daftarkan token FCM device |
|
||||
| DELETE | `/customer/devices/:device_id` | Hapus device saat logout |
|
||||
|
||||
### GET /customer/wallet
|
||||
|
||||
```json
|
||||
{
|
||||
"point_balance": 12500,
|
||||
"coin_balance": 8,
|
||||
"point_value": 1,
|
||||
"point_discount_value": 12500,
|
||||
"nearest_expiring": {
|
||||
"point": { "amount": 150, "date": "2026-12-31" },
|
||||
"coin": null
|
||||
},
|
||||
"recent_transactions": [ "… sama seperti item riwayat …" ]
|
||||
}
|
||||
```
|
||||
|
||||
- `point_balance` / `coin_balance` = saldo yang bisa dipakai sekarang.
|
||||
- `point_discount_value` = `point_balance × point_value`; tampilkan sebagai "setara potongan Rp …", bukan saldo uang.
|
||||
- `nearest_expiring.point` / `.coin` bernilai `null` bila tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### GET /customer/wallet/transactions
|
||||
|
||||
| Query | Tipe | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| `page` | int | Default 1 |
|
||||
| `limit` | int | 1–100, default 20 |
|
||||
| `currency` | `POINT` \| `COIN` | Opsional |
|
||||
| `type` | string | Satu tipe atau beberapa dipisah koma, mis. `EARN,PAYMENT` |
|
||||
| `from`, `to` | `YYYY-MM-DD` | Tanggal WIB, inklusif |
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"currency": "POINT",
|
||||
"type": "EARN",
|
||||
"amount": 875,
|
||||
"balance_after": 12500,
|
||||
"description": "Belanja #ORD-0123 di Outlet Kemang",
|
||||
"source": { "type": "ORDER", "id": "…" },
|
||||
"outlet_id": "…",
|
||||
"group_id": null,
|
||||
"expires_at": "2026-12-31T23:59:59+07:00",
|
||||
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
`amount` bertanda (+ menambah, − mengurangi). Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`. Dua baris exchange atau transfer berbagi `group_id`. Daftar tipe ada di bagian Referensi.
|
||||
|
||||
### GET /customer/wallet/expiring
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Terurut dari tanggal terdekat. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### PUT /customer/devices
|
||||
|
||||
```json
|
||||
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
Panggil setelah login dan setiap kali FCM memberi token baru. `device_id` dan `fcm_token` wajib; `platform` = `android` | `ios` | `web`. Satu token hanya milik satu customer: customer lain yang mendaftarkan token yang sama mengambil alih HP itu. Response: `{ "device_id": "a1b2c3" }`.
|
||||
|
||||
## Customer app: PIN
|
||||
|
||||
PIN 6 digit wajib untuk bayar, kode bayar, exchange, dan transfer; minta customer membuatnya saat pertama kali melakukan aksi itu.
|
||||
|
||||
| Method | Path | Body | Response |
|
||||
| --- | --- | --- | --- |
|
||||
| GET | `/customer/pin/status` | – | `{ "has_pin", "locked_until", "transfer_blocked_until" }` |
|
||||
| POST | `/customer/pin/otp` | `{ "purpose": "pin_setup" }` atau `"pin_reset"` | `{ "purpose", "otp_token", "expires_at" }` |
|
||||
| POST | `/customer/pin` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
|
||||
| PUT | `/customer/pin` | `{ "old_pin", "pin", "confirm_pin" }` | Status PIN |
|
||||
| POST | `/customer/pin/reset` | `{ "otp_token", "otp_code", "pin", "confirm_pin" }` | Status PIN |
|
||||
|
||||
1. **Buat PIN:** minta OTP dengan `purpose: "pin_setup"` (dikirim lewat WhatsApp), lalu `POST /customer/pin` dengan `otp_token` dari response OTP dan kode yang diterima customer.
|
||||
2. **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu `POST /customer/pin/reset`. Reset membuka kunci PIN, tapi transfer keluar ditahan 24 jam; pembayaran dan exchange tetap bisa.
|
||||
3. **Ganti PIN:** `PUT /customer/pin` dengan PIN lama.
|
||||
|
||||
PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama (`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir (`DDMMYY` / `YYMMDD`). OTP yang diminta terlalu cepat dijawab `429`. Penanganan `PIN_INVALID`, `PIN_LOCKED`, dan `TRANSFER_BLOCKED` ada di Konvensi umum.
|
||||
|
||||
## Customer app: bayar, exchange, transfer, game
|
||||
|
||||
| Method | Path | PIN | Idempotency-Key |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/customer/wallet/payment-code` | Ya | – |
|
||||
| POST | `/customer/orders/:id/pay-with-points` | Ya | – |
|
||||
| GET | `/customer/wallet/exchange/preview?coins=` | – | – |
|
||||
| POST | `/customer/wallet/exchange` | Ya | Wajib |
|
||||
| GET | `/customer/wallet/transfer/recipient?phone=` | – | – |
|
||||
| POST | `/customer/wallet/transfer` | Ya | Wajib |
|
||||
| POST | `/customer/spin` | – | – |
|
||||
|
||||
### POST /customer/wallet/payment-code
|
||||
|
||||
Body `{ "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
|
||||
```
|
||||
|
||||
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR untuk kasir. Berlaku 2 menit, sekali pakai, hanya untuk customer ini; kode baru membatalkan kode lama.
|
||||
|
||||
### POST /customer/orders/:id/pay-with-points
|
||||
|
||||
Body `{ "points": 12500, "pin": "482913" }`. Hanya untuk order milik customer yang login (order lain `404`). Response sama dengan pembayaran POS (bagian POS). Batas dan aturan penolakan juga sama.
|
||||
|
||||
### GET /customer/wallet/exchange/preview?coins=30
|
||||
|
||||
```json
|
||||
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
|
||||
```
|
||||
|
||||
Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1). Bila `valid: false`, tampilkan `reason`.
|
||||
|
||||
### POST /customer/wallet/exchange
|
||||
|
||||
Body `{ "coins": 30, "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"coins": 30,
|
||||
"points": 9,
|
||||
"coin_amount": 10,
|
||||
"point_amount": 3,
|
||||
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"coin_balance": 5,
|
||||
"point_balance": 9,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
`coins` harus kelipatan `coin_amount`; jumlah yang salah ditolak `304` sebelum PIN dicek. Exchange tidak bisa dibatalkan. EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (lihat `lots`).
|
||||
|
||||
### GET /customer/wallet/transfer/recipient?phone=081234561234
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
Nomor di luar organisasi atau tidak terdaftar → `404`. Diri sendiri, customer walk-in, atau nonaktif → `304`.
|
||||
|
||||
### POST /customer/wallet/transfer
|
||||
|
||||
Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }`. Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"currency": "POINT",
|
||||
"amount": 120,
|
||||
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
|
||||
"lots": [
|
||||
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
|
||||
{ "amount": 20, "expires_at": null }
|
||||
],
|
||||
"balance": 30,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
`currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`.
|
||||
|
||||
### POST /customer/spin
|
||||
|
||||
Body `{ "spin_id": "<id game>" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb" },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong.
|
||||
|
||||
## POS: pembayaran EnakPoint
|
||||
|
||||
Kasir memakai endpoint pembayaran yang sudah ada dengan payment method bertipe `point`, disetujui customer lewat kode bayar dari aplikasinya; PIN tidak pernah diketik di perangkat kasir.
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET | `/orders/:id/point-payment/preview` | Batas pembayaran EnakPoint untuk order ini |
|
||||
| POST | `/payments` | Bayar dengan method EnakPoint (`points` + `payment_code`) |
|
||||
| POST | `/payments/:id/refund` | Refund pembayaran EnakPoint, kembali sebagai EnakPoint |
|
||||
|
||||
1. Customer membuat kode di aplikasi (`POST /customer/wallet/payment-code`) dan menunjukkan angka atau QR-nya.
|
||||
2. POS memanggil preview untuk tombol "pakai maksimal".
|
||||
3. POS memanggil `POST /payments` dengan kode tersebut. Sisa tagihan dibayar dengan method lain seperti biasa.
|
||||
|
||||
### GET /orders/:id/point-payment/preview
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"customer_id": "…",
|
||||
"eligible": true,
|
||||
"point_balance": 12500,
|
||||
"point_value": 1,
|
||||
"remaining_amount": 87500,
|
||||
"min_payment_points": 1,
|
||||
"max_payment_percent": 100,
|
||||
"max_points": 12500,
|
||||
"max_amount": 12500
|
||||
}
|
||||
```
|
||||
|
||||
Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak menerima EnakPoint, saldo di bawah minimal, dst.). Batas yang dipakai:
|
||||
|
||||
```
|
||||
batas_rupiah = min(sisa_tagihan, total × max_payment_percent / 100 − sudah_dibayar_EnakPoint)
|
||||
maks_point = min(saldo, floor(batas_rupiah / point_value))
|
||||
```
|
||||
|
||||
### POST /payments
|
||||
|
||||
Header `X-Idempotency-Key` wajib.
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"payment_method_id": "<id method EnakPoint>",
|
||||
"points": 12500,
|
||||
"payment_code": "482913"
|
||||
}
|
||||
```
|
||||
|
||||
- `amount` tidak perlu dikirim; backend menghitung `points × point_value` dan tidak pernah melebihi sisa tagihan (tidak ada kembalian).
|
||||
- `payment_code` boleh angka yang diketik atau hasil scan QR apa adanya (`enakpoint:482913`).
|
||||
- Response pembayaran membawa `points_used` dan `point_value` untuk struk; response order membawa `points_earned` dan `coins_earned`.
|
||||
- Ditolak `304` bila: order tanpa customer atau walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar batas, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method EnakPoint dipakai sebagai split. Kode terpakai begitu diterima; bila pembayaran lalu ditolak, minta kode baru.
|
||||
- Method EnakPoint dibuat otomatis per organisasi, tidak bisa dihapus atau diubah tipenya, dan tidak muncul di daftar method `?outlet_id=` bila outlet tidak menerima EnakPoint.
|
||||
|
||||
### Void dan refund
|
||||
|
||||
- **Void order:** semua EnakPoint yang dipakai kembali sebagai EnakPoint.
|
||||
- **`POST /payments/:id/refund` pada pembayaran EnakPoint:** kembali `floor(rupiah_direfund / point_value_saat_bayar)`; sisa di bawah 1 EnakPoint hangus.
|
||||
- **Refund order ke tunai/method lain** hanya sebesar bagian non-EnakPoint; mencoba merefund bagian EnakPoint secara tunai ditolak `304`.
|
||||
- EnakPoint yang kembali memakai tanggal kedaluwarsa asal, minimal 7 hari sejak refund. Earning order ikut ditarik; bila saldo sudah terpakai, ditarik sebanyak yang ada dan refund tetap jalan.
|
||||
|
||||
## Dashboard
|
||||
|
||||
Semua endpoint dashboard butuh role Admin atau Manager, dan semuanya dibatasi ke organisasi user yang login. Rincian layar ada di [`backoffice-enakpoint.md`](./backoffice-enakpoint.md).
|
||||
|
||||
| Method | Path | Keterangan |
|
||||
| --- | --- | --- |
|
||||
| GET, PUT | `/outlets/:outlet_id/loyalty-settings` | Earning dan penerimaan EnakPoint per outlet |
|
||||
| GET, PUT | `/marketing/loyalty-settings` | Nilai EnakPoint, kurs, transfer, kedaluwarsa (`?dry_run=true` untuk preview) |
|
||||
| GET | `/marketing/loyalty-settings/history` | Riwayat perubahan setting (`page`, `limit`, `outlet_id`) |
|
||||
| GET | `/marketing/customers/:id/wallet` | Saldo, lot aktif, riwayat dengan nama asli |
|
||||
| POST | `/marketing/customers/:id/wallet/adjust` | Koreksi saldo manual |
|
||||
| GET | `/marketing/wallet-transactions/:id/trace` | Telusuri asal saldo per butir |
|
||||
| DELETE | `/marketing/customers/:id/pin` | Hapus PIN customer |
|
||||
| GET | `/marketing/customers/:id/security-events` | Log keamanan PIN (`page`, `limit`) |
|
||||
|
||||
Pada kedua `PUT` setting, field yang tidak dikirim tetap memakai nilai sekarang; field yang tidak dikenal ditolak.
|
||||
|
||||
### /outlets/:outlet_id/loyalty-settings
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `max_payment_percent` 0–100.
|
||||
|
||||
### /marketing/loyalty-settings
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": {
|
||||
"enabled": false,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { "…": "sama dengan point_expiry" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field kedaluwarsa | Dipakai mode | Nilai |
|
||||
| --- | --- | --- |
|
||||
| `mode` | – | `FIXED_DATE` (hangus di tanggal tetap tiap tahun) atau `ROLLING` (umur sejak didapat) |
|
||||
| `fixed_dates` | `FIXED_DATE` | `MM-DD`, boleh lebih dari satu; `02-29` ditolak |
|
||||
| `grace_months` | `FIXED_DATE` | 0–24; saldo yang didapat kurang dari ini sebelum tanggal hangus ikut ke tanggal berikutnya |
|
||||
| `period`, `unit` | `ROLLING` | ≥ 1, `DAY` atau `MONTH` |
|
||||
| `end_of_month` | `ROLLING` | Dibulatkan ke akhir bulan |
|
||||
| `reminder_days` | keduanya | Hari sebelum hangus untuk pengingat; 0 = tanpa pengingat |
|
||||
|
||||
Response menambahkan:
|
||||
|
||||
- `impact`: saldo beredar dan nilai rupiahnya sebelum/sesudah perubahan `point_value` atau kurs.
|
||||
- `expiry_preview`: `{ "point", "coin" }`, kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak).
|
||||
- `expiry_activations`: bila perubahan ini menyalakan kedaluwarsa pertama kali, `[{ "currency", "lots", "amount", "expires_at" }]` saldo lama yang ikut diberi tanggal.
|
||||
- `changes` dan `dry_run`.
|
||||
|
||||
### POST /marketing/customers/:id/wallet/adjust
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
|
||||
```
|
||||
|
||||
`amount` bertanda dan tidak boleh 0; `reason` wajib. Pengurangan yang melebihi saldo ditolak `304`. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`.
|
||||
|
||||
### GET /marketing/wallet-transactions/:id/trace
|
||||
|
||||
```json
|
||||
{
|
||||
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "type": "PAYMENT", "amount": -30, "…": "…" },
|
||||
"lots": [
|
||||
{
|
||||
"amount": 30,
|
||||
"chain": [
|
||||
{ "lot": { "id": "…", "expires_at": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" } } },
|
||||
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "reference_type": "ORDER", "description": "Belanja #ORD-1", "customer": { "name": "Anita" } } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Pengurangan menampilkan lot yang dipakai; penambahan menampilkan lot yang dibuat. Tiap `chain` mundur lewat transfer, exchange, atau refund sampai lot pertama dari `EARN`, `ADJUSTMENT`, atau `MIGRATION`.
|
||||
|
||||
### PIN customer
|
||||
|
||||
`DELETE /marketing/customers/:id/pin` dengan `{ "reason": "…" }` memaksa customer membuat PIN baru lewat OTP; admin tidak bisa membuat, mengganti, atau melihat PIN. `security-events` mengembalikan `PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`, `PIN_REMOVED_BY_ADMIN` beserta waktu, IP, dan perangkat.
|
||||
|
||||
## Referensi
|
||||
|
||||
### Tipe mutasi (`type`)
|
||||
|
||||
| `type` | Arah | Arti | `source` / `destination` |
|
||||
| --- | --- | --- | --- |
|
||||
| `EARN` | + | Didapat dari order lunas | `ORDER` |
|
||||
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
|
||||
| `PAYMENT` | − | Membayar order (EnakPoint saja) | `PAYMENT` |
|
||||
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
|
||||
| `EXCHANGE_OUT` | − | EnakCoin ditukar | `WALLET_TX` (baris `EXCHANGE_IN`) |
|
||||
| `EXCHANGE_IN` | + | EnakPoint hasil tukar | `WALLET_TX` (baris `EXCHANGE_OUT`) |
|
||||
| `TRANSFER_OUT` | − | Dikirim ke customer lain | `WALLET_TX` (baris `TRANSFER_IN`) |
|
||||
| `TRANSFER_IN` | + | Diterima dari customer lain | `WALLET_TX` (baris `TRANSFER_OUT`) |
|
||||
| `GAME_SPEND` | − | Main game (EnakCoin saja) | `GAME_PLAY` |
|
||||
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
|
||||
| `ADJUSTMENT` | + / − | Koreksi admin | `USER` |
|
||||
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
|
||||
|
||||
### Notifikasi push (FCM)
|
||||
|
||||
Semua nilai `data` berupa string. Push hanya sampai ke device yang terdaftar lewat `PUT /customer/devices`.
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lainnya |
|
||||
| --- | --- | --- |
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
|
||||
| `WALLET_EXPIRING` | `reminder_days` hari sebelum hangus, sekali per tanggal | `currency`, `amount`, `expiry_date` |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
|
||||
|
||||
### Endpoint dan field deprecated
|
||||
|
||||
Masih jalan dan membaca wallet, tapi akan dihapus setelah semua versi aplikasi pindah. Semua yang bernama token (`/customer/tokens`, `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining`, campaign `TOKENS`) sudah dihapus; pakai `coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
|
||||
|
||||
| Lama | Pengganti |
|
||||
| --- | --- |
|
||||
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
|
||||
@@ -0,0 +1,305 @@
|
||||
# Backoffice EnakPoint & EnakCoin
|
||||
|
||||
30 Sep 2026
|
||||
|
||||
Backoffice perlu tujuh layar untuk mengelola program loyalitas: setting per outlet, setting per organisasi (termasuk kedaluwarsa), wallet customer, telusuri mutasi, PIN customer, riwayat setting, dan biaya main game.
|
||||
|
||||
## Layar yang perlu dibuat
|
||||
|
||||
Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin atau Manager, dan otomatis dibatasi ke organisasi user tersebut. Data customer atau outlet organisasi lain dijawab `404`.
|
||||
|
||||
| Layar | Endpoint | Tempat di menu |
|
||||
| --- | --- | --- |
|
||||
| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail outlet → tab Loyalitas |
|
||||
| Setting loyalitas organisasi | `GET` / `PUT /marketing/loyalty-settings` (+ `?dry_run=true`) | Marketing → Loyalitas → Pengaturan |
|
||||
| Riwayat perubahan setting | `GET /marketing/loyalty-settings/history` | Marketing → Loyalitas → Riwayat |
|
||||
| Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet |
|
||||
| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet |
|
||||
| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan |
|
||||
| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game |
|
||||
|
||||
Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada.
|
||||
|
||||
**Istilah di layar.** EnakPoint (`POINT`) adalah saldo yang bisa membayar order; EnakCoin (`COIN`) untuk main game dan bisa ditukar ke EnakPoint. Nilai rupiah EnakPoint selalu ditulis "setara potongan Rp …", tidak pernah "saldo Rp …", karena saldo tidak bisa dicairkan.
|
||||
|
||||
**Format response.** Sukses `{ "success": true, "data": … }`; gagal `{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Tampilkan `cause` sebagai pesan (lihat bagian Pesan error).
|
||||
|
||||
## Setting loyalitas outlet
|
||||
|
||||
Tiap outlet mengatur sendiri berapa EnakPoint dan EnakCoin yang didapat dari order, dan apakah outlet menerima pembayaran EnakPoint. Semua nilai default mati sampai owner menyalakannya.
|
||||
|
||||
`GET /outlets/:outlet_id/loyalty-settings` → isi form. `PUT` ke path yang sama dengan objek yang sama untuk menyimpan; field yang tidak dikirim tetap, field tak dikenal ditolak.
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Tipe | Default | Validasi |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
||||
| `earn_per_amount` | Setiap belanja Rp … | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat | angka | 1 | ≥ 0 |
|
||||
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
|
||||
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
|
||||
| `point_payment.accept_payment` | Terima pembayaran EnakPoint | toggle | mati | – |
|
||||
| `min_payment_points` | Minimal EnakPoint per pembayaran | angka | 1 | ≥ 1 |
|
||||
| `max_payment_percent` | Maksimal porsi order dibayar EnakPoint | % | 100 | 0–100 |
|
||||
|
||||
**Cashback efektif.** Response membawa `point_cashback_percent` dan `point_value`. Tampilkan persentase di samping field earning EnakPoint, mis. "setara cashback 1%", dan hitung ulang di sisi klien saat owner mengetik: `earn_value × point_value ÷ earn_per_amount × 100`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||
|
||||
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak, dan bagian yang dibayar EnakPoint tidak ikut dihitung.
|
||||
|
||||
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan". Mematikan `accept_payment` langsung menyembunyikan method EnakPoint di kasir outlet itu.
|
||||
|
||||
## Setting loyalitas organisasi
|
||||
|
||||
Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan kedaluwarsa berlaku sama untuk semua outlet, jadi diatur sekali per organisasi. Mengubah nilai EnakPoint atau kurs langsung mengubah daya beli semua saldo customer, jadi layar ini wajib menampilkan dampaknya sebelum disimpan.
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": { "…": "lihat bagian kedaluwarsa" },
|
||||
"coin_expiry": { "…": "lihat bagian kedaluwarsa" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Default | Validasi |
|
||||
| --- | --- | --- | --- |
|
||||
| `point_value` | Nilai 1 EnakPoint (Rp) | 1 | ≥ 1 |
|
||||
| `exchange.coin_amount` : `exchange.point_amount` | Kurs tukar: … EnakCoin = … EnakPoint | 1 : 1 | keduanya ≥ 1 |
|
||||
| `transfer.enabled` | Izinkan transfer antar customer | aktif | – |
|
||||
| `transfer.min_amount` | Minimal per transfer | 1 | ≥ 1 |
|
||||
| `transfer.max_per_transaction` | Maksimal per transfer | kosong = tanpa batas | ≥ 1 |
|
||||
| `transfer.daily_limit` | Batas harian per customer | kosong = tanpa batas | ≥ 1, dihitung per currency, reset tengah malam WIB |
|
||||
|
||||
### Alur simpan
|
||||
|
||||
1. Owner mengubah form.
|
||||
2. Tombol Simpan memanggil `PUT /marketing/loyalty-settings?dry_run=true` dengan objek yang diubah. Tidak ada yang tersimpan.
|
||||
3. Bila `changes` kosong, beri tahu "tidak ada perubahan" dan berhenti.
|
||||
4. Tampilkan dialog konfirmasi berisi `changes`, `impact` (bila `point_value` atau kurs berubah), dan `expiry_activations` (bila ada, lihat bagian kedaluwarsa).
|
||||
5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`.
|
||||
|
||||
### Dialog dampak
|
||||
|
||||
`impact` berisi saldo beredar organisasi dan nilainya sebelum/sesudah:
|
||||
|
||||
| Field `impact` | Tampilkan sebagai |
|
||||
| --- | --- |
|
||||
| `outstanding_points` | EnakPoint beredar |
|
||||
| `point_rupiah_before` → `point_rupiah_after` | Setara potongan Rp … → Rp … |
|
||||
| `outstanding_coins` | EnakCoin beredar |
|
||||
| `coins_as_points_before` → `coins_as_points_after` | Bila semua ditukar: … EnakPoint → … EnakPoint |
|
||||
| `coin_rupiah_before` → `coin_rupiah_after` | Setara potongan Rp … → Rp … |
|
||||
|
||||
Contoh kalimat: "Menaikkan nilai EnakPoint dari Rp 1 ke Rp 2 membuat 1.250.000 EnakPoint yang beredar setara potongan Rp 2.500.000 (sebelumnya Rp 1.250.000)." Perubahan hanya berlaku ke depan: pembayaran, refund, dan exchange yang sudah terjadi memakai nilai saat itu.
|
||||
|
||||
## Pengaturan kedaluwarsa
|
||||
|
||||
Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin (`coin_expiry`) dengan salah satu dari dua model; defaultnya mati, dan bila dinyalakan defaultnya hangus setiap 31 Desember.
|
||||
|
||||
```json
|
||||
"point_expiry": {
|
||||
"enabled": true,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tampil saat | Label usulan | Validasi |
|
||||
| --- | --- | --- | --- |
|
||||
| `enabled` | selalu | Saldo bisa kedaluwarsa | – |
|
||||
| `mode` | aktif | Model: Tanggal tetap / Sejak didapat | `FIXED_DATE` atau `ROLLING` |
|
||||
| `fixed_dates` | `FIXED_DATE` | Tanggal hangus setiap tahun | minimal satu, format `MM-DD`, `02-29` ditolak |
|
||||
| `grace_months` | `FIXED_DATE` | Periode tanggung (bulan) | 0–24, default 3 |
|
||||
| `period` + `unit` | `ROLLING` | Berlaku selama … hari/bulan | period ≥ 1, `DAY` atau `MONTH` |
|
||||
| `end_of_month` | `ROLLING` | Bulatkan ke akhir bulan | – |
|
||||
| `reminder_days` | aktif | Ingatkan customer … hari sebelumnya | ≥ 0, 0 = tanpa pengingat |
|
||||
|
||||
**Tanggal tetap (`FIXED_DATE`).** Semua saldo hangus di tanggal yang sama, mis. 31 Desember, atau 30 Juni dan 31 Desember untuk dua kali setahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya, jadi saldo yang didapat 1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Untuk input `fixed_dates`, pakai pemilih tanggal+bulan tanpa tahun.
|
||||
|
||||
**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk, mis. 12 bulan. Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027.
|
||||
|
||||
**Preview.** Response `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`: kapan saldo yang didapat sekarang akan kedaluwarsa (`null` = tidak). Tampilkan di bawah form: "EnakPoint yang didapat hari ini kedaluwarsa pada 31 Des 2026." Karena dihitung dari nilai yang dikirim, dry run bisa dipakai untuk memperbarui preview saat owner mengubah pilihan.
|
||||
|
||||
**Menyalakan pertama kali.** Saldo lama yang belum punya tanggal ikut diberi tanggal, dengan masa berlaku penuh: tanggal hangus kedua berikutnya (`FIXED_DATE`) atau satu periode sejak hari ini (`ROLLING`). Dry run mengembalikan `expiry_activations`; tampilkan di dialog konfirmasi dengan kalimat tegas, mis. "1.250.000 EnakPoint milik customer yang ada sekarang akan kedaluwarsa pada 31 Des 2027. Tindakan ini tidak bisa dibatalkan dengan mematikan kedaluwarsa."
|
||||
|
||||
| Field `expiry_activations[]` | Arti |
|
||||
| --- | --- |
|
||||
| `currency` | `POINT` atau `COIN` |
|
||||
| `lots` | Jumlah paket saldo yang diberi tanggal |
|
||||
| `amount` | Total saldo yang diberi tanggal |
|
||||
| `expires_at` | Tanggal kedaluwarsanya |
|
||||
|
||||
**Aturan lain yang perlu dijelaskan di layar:**
|
||||
|
||||
- Mengubah model atau masa berlaku hanya berlaku untuk saldo yang masuk setelahnya.
|
||||
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
|
||||
- Saldo yang ditransfer atau ditukar membawa tanggal kedaluwarsa aslinya.
|
||||
- Saldo hangus tanpa kompensasi apa pun. Customer mendapat pengingat push `reminder_days` hari sebelumnya dan notifikasi saat hangus.
|
||||
|
||||
## Wallet customer
|
||||
|
||||
Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke order asalnya.
|
||||
|
||||
### Saldo, lot, dan riwayat
|
||||
|
||||
`GET /marketing/customers/:id/wallet?page=1&limit=20¤cy=POINT&type=PAYMENT,EARN&from=2026-09-01&to=2026-09-30` (semua query opsional, sama seperti riwayat di aplikasi customer)
|
||||
|
||||
```json
|
||||
{
|
||||
"customer": { "id": "…", "name": "Budi Santoso", "phone": "081234561234" },
|
||||
"point_balance": 12650,
|
||||
"coin_balance": 8,
|
||||
"spendable_point_balance": 12500,
|
||||
"spendable_coin_balance": 8,
|
||||
"lots": [
|
||||
{ "id": "…", "currency": "POINT", "original_amount": 875, "remaining_amount": 875, "expires_at": "2026-12-31T23:59:59+07:00", "expired": false, "source_transaction_id": "…", "origin_lot_id": null, "created_at": "…" }
|
||||
],
|
||||
"transactions": {
|
||||
"data": [
|
||||
{
|
||||
"id": "…", "currency": "POINT", "type": "TRANSFER_OUT", "amount": -120, "balance_after": 12650,
|
||||
"description": "Transfer ke An*** (08**-****-5678)",
|
||||
"destination": { "type": "WALLET_TX", "id": "…" },
|
||||
"counterparty": { "id": "…", "name": "Anita Rahma" },
|
||||
"created_by": null, "outlet": null, "reason": null, "metadata": {},
|
||||
"created_at": "…"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **Saldo:** tampilkan `spendable_*` sebagai saldo utama. `point_balance` / `coin_balance` bisa sedikit lebih besar selama ada lot yang sudah lewat tanggal tapi belum diproses job kedaluwarsa (paling lama sekitar 15 menit).
|
||||
- **Lot:** tabel paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Beri tanda untuk `expired: true`.
|
||||
- **Riwayat:** sama dengan riwayat customer, ditambah nama asli yang disamarkan untuk customer: `counterparty` (lawan transfer), `created_by` (admin pelaku adjustment atau kasir penerima pembayaran), `outlet`, `reason`, dan `metadata` (kurs, nilai EnakPoint yang dibekukan, shortfall).
|
||||
|
||||
### Adjustment manual
|
||||
|
||||
`POST /marketing/customers/:id/wallet/adjust`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-7f3c" }
|
||||
```
|
||||
|
||||
| Field | Aturan |
|
||||
| --- | --- |
|
||||
| `currency` | `POINT` atau `COIN` |
|
||||
| `amount` | Bertanda, tidak boleh 0. Positif menambah, negatif mengurangi |
|
||||
| `reason` | Wajib; tampil di riwayat customer sebagai "Koreksi oleh admin: …" |
|
||||
| `idempotency_key` | Opsional tapi disarankan: buat satu nilai saat dialog dibuka, supaya klik ganda tidak mengoreksi dua kali |
|
||||
|
||||
Pengurangan yang melebihi saldo yang bisa dipakai ditolak `304`. Adjustment tambah mengikuti aturan kedaluwarsa organisasi. Response: `{ "transaction", "spendable_point_balance", "spendable_coin_balance", "replayed" }`. Beri catatan di dialog bahwa adjustment tidak disertai pembayaran uang, sehingga alasan tidak boleh "pencairan".
|
||||
|
||||
### Telusuri mutasi
|
||||
|
||||
Dari baris riwayat mana pun, tombol Telusuri memanggil `GET /marketing/wallet-transactions/:id/trace`.
|
||||
|
||||
```json
|
||||
{
|
||||
"transaction": { "id": "…", "customer": { "id": "…", "name": "Budi Santoso" }, "currency": "POINT", "type": "PAYMENT", "amount": -30, "description": "Bayar #ORD-0456 di Outlet Kemang (Rp 30)", "reference_type": "PAYMENT", "reference_id": "…", "created_at": "…" },
|
||||
"lots": [
|
||||
{
|
||||
"amount": 30,
|
||||
"chain": [
|
||||
{ "lot": { "id": "…", "expires_at": "…", "origin_lot_id": "…" }, "source": { "type": "TRANSFER_IN", "customer": { "name": "Budi Santoso" }, "description": "Transfer dari An*** (08**-****-5678)" } },
|
||||
{ "lot": { "id": "…", "origin_lot_id": null }, "source": { "type": "EARN", "customer": { "name": "Anita Rahma" }, "reference_type": "ORDER", "reference_id": "…", "description": "Belanja #ORD-1 di Outlet Kemang" } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Tampilkan tiap `lots[]` sebagai rantai dari atas ke bawah: jumlah yang lewat lot itu, lalu setiap langkah `chain` dengan pemilik, tipe, dan deskripsinya. Langkah terakhir selalu `EARN`, `ADJUSTMENT`, atau `MIGRATION`; bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya.
|
||||
|
||||
## PIN, riwayat setting, game, dan method EnakPoint
|
||||
|
||||
### PIN & keamanan customer
|
||||
|
||||
Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah menghapusnya, misalnya bila customer kehilangan akses, sehingga customer harus membuat PIN baru lewat OTP di aplikasi.
|
||||
|
||||
- `DELETE /marketing/customers/:id/pin` dengan body `{ "reason": "Customer ganti nomor HP" }`. `reason` wajib. Tampilkan dialog konfirmasi dengan input alasan.
|
||||
- `GET /marketing/customers/:id/security-events?page=1&limit=20` untuk tab Keamanan:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{ "id": "…", "event": "PIN_LOCKED", "actor_user": null, "reason": null, "ip_address": "103.10.0.7", "user_agent": "EnakApp/2.4 (Android 14)", "created_at": "…" }
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 5, "total_pages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
| `event` | Label usulan |
|
||||
| --- | --- |
|
||||
| `PIN_SET` | PIN dibuat |
|
||||
| `PIN_CHANGED` | PIN diganti |
|
||||
| `PIN_RESET` | PIN direset lewat OTP (transfer ditahan 24 jam) |
|
||||
| `PIN_FAILED` | PIN salah dimasukkan |
|
||||
| `PIN_LOCKED` | PIN terkunci 30 menit |
|
||||
| `PIN_REMOVED_BY_ADMIN` | PIN dihapus admin (`actor_user`, `reason` terisi) |
|
||||
|
||||
### Riwayat perubahan setting
|
||||
|
||||
`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi; tambah `&outlet_id=…` untuk riwayat satu outlet.
|
||||
|
||||
```json
|
||||
{ "id": "…", "organization_id": "…", "outlet_id": null, "key": "loyalty.point.value", "old_value": "1", "new_value": "2", "changed_by": "…", "created_at": "…" }
|
||||
```
|
||||
|
||||
`old_value` `null` berarti sebelumnya masih nilai default. Tampilkan `key` dengan label yang sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint"), dan `changed_by` sebagai nama user.
|
||||
|
||||
### Biaya main game
|
||||
|
||||
Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint.
|
||||
|
||||
### Method pembayaran EnakPoint
|
||||
|
||||
Method "EnakPoint" (tipe `point`) dibuat otomatis untuk setiap organisasi. Di layar Payment Method (`/payment-methods`):
|
||||
|
||||
- Tampilkan sebagai method sistem: tombol hapus dan pilihan ubah tipe disembunyikan; backend menolaknya (`304`). Nama boleh diganti.
|
||||
- Tipe `point` tidak ditawarkan saat membuat method baru.
|
||||
- Kasir hanya melihatnya di outlet yang menyalakan "Terima pembayaran EnakPoint".
|
||||
|
||||
Di laporan per payment method, EnakPoint tampil terpisah dan **tidak** dihitung sebagai kas masuk.
|
||||
|
||||
## Pesan error dan checklist
|
||||
|
||||
| `code` | HTTP | Kapan terjadi di backoffice | Yang ditampilkan |
|
||||
| --- | --- | --- | --- |
|
||||
| `303`, `310` | 400 | Body tidak valid, field tak dikenal di `PUT` setting, UUID salah | Pesan umum "Data tidak valid" + `cause` untuk developer |
|
||||
| `304` | 400 | Nilai di luar batas, adjustment melebihi saldo, alasan kosong, hapus/ubah method EnakPoint | `cause` di dekat field atau di toast |
|
||||
| `404` | 404 | Customer, outlet, atau mutasi bukan milik organisasi ini | "Data tidak ditemukan" |
|
||||
| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalty.point.earn_per_amount must be at least 1`. Untuk validasi form, lebih baik cek batasnya di sisi klien (tabel di tiap bagian) dan tampilkan `cause` hanya sebagai cadangan.
|
||||
|
||||
### Checklist rilis
|
||||
|
||||
- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning.
|
||||
- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi sebelum disimpan.
|
||||
- [ ] Dialog konfirmasi menampilkan `impact` saat nilai EnakPoint atau kurs berubah.
|
||||
- [ ] Dialog konfirmasi menampilkan `expiry_activations` saat kedaluwarsa dinyalakan pertama kali.
|
||||
- [ ] Preview "yang didapat hari ini kedaluwarsa pada …" tampil di bawah pengaturan kedaluwarsa.
|
||||
- [ ] Wallet customer menampilkan saldo yang bisa dipakai, lot, dan riwayat dengan nama asli.
|
||||
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`.
|
||||
- [ ] Tombol Telusuri ada di setiap baris riwayat.
|
||||
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
|
||||
- [ ] Method EnakPoint tampil sebagai method sistem.
|
||||
- [ ] Form game punya input `coin_cost`.
|
||||
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
|
||||
|
||||
Pembayaran EnakPoint belum boleh dirilis ke outlet sebelum tinjauan keuangan (N2) dan legal (N3) selesai, dan transfer menunggu tinjauan legal (N3). Layar backoffice boleh disiapkan lebih dulu.
|
||||
@@ -0,0 +1,634 @@
|
||||
# Integrasi EnakPoint & EnakCoin — Customer App, POS & Dashboard
|
||||
|
||||
**Migrasi:** `000090`–`000097` · **Base URL:** `/api/v1` · **Kompatibilitas:** endpoint
|
||||
lama tetap jalan sebagai alias (lihat §8)
|
||||
|
||||
Panduan untuk memakai saldo loyalitas dari sisi klien. Alasan di balik setiap aturan
|
||||
ada di [`prd-point-coin.md`](./prd-point-coin.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin |
|
||||
| Dipakai untuk | **Membayar order** | **Main game**, ditukar ke EnakPoint |
|
||||
| Bisa ditransfer | Ya | Ya |
|
||||
| Bisa kedaluwarsa | Ya, bila diaktifkan owner | Ya, bila diaktifkan owner |
|
||||
|
||||
Aturan yang berlaku di seluruh dokumen ini:
|
||||
|
||||
1. **Semua jumlah bilangan bulat.** Tidak ada "setengah EnakPoint".
|
||||
2. **Saldo tidak pernah jadi uang.** Tidak ada pencairan, tidak ada kembalian, dan
|
||||
bagian order yang dibayar EnakPoint hanya bisa kembali sebagai EnakPoint. Tampilkan
|
||||
nilai rupiahnya sebagai **"setara potongan Rp …"**, bukan "saldo Rp …".
|
||||
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): bayar,
|
||||
buat kode bayar, exchange, transfer. Main game tidak butuh PIN.
|
||||
4. **Wallet milik customer di satu organisasi.** Saldo berlaku di semua outlet
|
||||
organisasi itu. Nilai rupiah EnakPoint, kurs exchange, batas transfer, dan
|
||||
kedaluwarsa diatur per organisasi; earning dan penerimaan pembayaran per outlet.
|
||||
5. **Setiap mutasi tercatat** di riwayat beserta asal atau tujuannya, dan tidak pernah
|
||||
dihapus. Koreksi muncul sebagai baris baru.
|
||||
|
||||
### Format response
|
||||
|
||||
Semua endpoint memakai amplop yang sama:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { … }, "errors": null }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }]
|
||||
}
|
||||
```
|
||||
|
||||
| `code` | HTTP | Arti |
|
||||
|---|---|---|
|
||||
| `303`, `310` | 400 | Body atau parameter tidak lengkap / salah format |
|
||||
| `304` | 400 | Permintaan ditolak aturan bisnis; `cause` menjelaskan alasannya |
|
||||
| `404` | 404 | Tidak ditemukan (juga dipakai untuk data milik customer/organisasi lain) |
|
||||
| `429` | 429 | Terlalu cepat meminta ulang (OTP) |
|
||||
| `PIN_NOT_SET` | 403 | Customer belum membuat PIN |
|
||||
| `PIN_INVALID` | 400 | PIN salah |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN |
|
||||
| `900` | 500 | Kesalahan server |
|
||||
|
||||
---
|
||||
|
||||
## 2. Customer app — saldo & riwayat
|
||||
|
||||
Semua endpoint customer memakai header `Authorization: Bearer <token customer>`.
|
||||
|
||||
### 2.1 Saldo
|
||||
|
||||
`GET /api/v1/customer/wallet`
|
||||
|
||||
```json
|
||||
{
|
||||
"point_balance": 12500,
|
||||
"coin_balance": 8,
|
||||
"point_value": 1,
|
||||
"point_discount_value": 12500,
|
||||
"nearest_expiring": {
|
||||
"point": { "amount": 150, "date": "2026-12-31" },
|
||||
"coin": null
|
||||
},
|
||||
"recent_transactions": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
- `point_balance` dan `coin_balance` adalah saldo yang **bisa dipakai sekarang**.
|
||||
- `point_discount_value` = `point_balance × point_value`. Tampilkan sebagai
|
||||
"setara potongan Rp 12.500".
|
||||
- `nearest_expiring` bernilai `null` per currency bila tidak ada yang akan kedaluwarsa.
|
||||
- `recent_transactions` berisi 5 mutasi terakhir dengan bentuk yang sama seperti §2.2.
|
||||
|
||||
### 2.2 Riwayat
|
||||
|
||||
`GET /api/v1/customer/wallet/transactions?page=1&limit=20¤cy=POINT&type=EARN,PAYMENT&from=2026-09-01&to=2026-09-30`
|
||||
|
||||
Semua query opsional. `limit` 1–100 (default 20). `type` boleh beberapa, dipisah koma.
|
||||
`from` / `to` tanggal WIB, inklusif.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"currency": "POINT",
|
||||
"type": "EARN",
|
||||
"amount": 875,
|
||||
"balance_after": 12500,
|
||||
"description": "Belanja #ORD-0123 di Outlet Kemang",
|
||||
"source": { "type": "ORDER", "id": "…" },
|
||||
"outlet_id": "…",
|
||||
"expires_at": "2026-12-31T23:59:59+07:00",
|
||||
"lots": [{ "amount": 875, "remaining": 875, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
- `amount` bertanda: positif menambah saldo, negatif mengurangi.
|
||||
- Penambahan punya `source`, pengurangan punya `destination`. Keduanya berbentuk
|
||||
`{ type, id }` dan menunjuk hal yang bisa dibuka di detail (order, pembayaran, game
|
||||
play, dst.).
|
||||
- `description` sudah siap tampil dan tidak berubah walau nama outlet atau customer
|
||||
berubah belakangan. Nama lawan transfer sudah disamarkan.
|
||||
- Dua baris exchange atau transfer berbagi `group_id` yang sama.
|
||||
|
||||
| `type` | Arah | Arti | `source` / `destination` |
|
||||
|---|---|---|---|
|
||||
| `EARN` | + | Didapat dari order lunas | `ORDER` |
|
||||
| `EARN_REVERSAL` | − | Ditarik karena order di-void/refund | `ORDER` |
|
||||
| `PAYMENT` | − | Membayar order | `PAYMENT` |
|
||||
| `PAYMENT_REFUND` | + | Kembali karena pembayaran di-void/refund | `PAYMENT` |
|
||||
| `EXCHANGE_OUT` / `EXCHANGE_IN` | − / + | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) |
|
||||
| `TRANSFER_OUT` / `TRANSFER_IN` | − / + | Transfer antar customer | `WALLET_TX` (baris pasangannya) |
|
||||
| `GAME_SPEND` | − | Main game | `GAME_PLAY` |
|
||||
| `EXPIRE` | − | Hangus karena kedaluwarsa | `LOT` |
|
||||
| `ADJUSTMENT` | + / − | Koreksi oleh admin | `USER` |
|
||||
| `MIGRATION` | + | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
|
||||
|
||||
### 2.3 Yang akan kedaluwarsa
|
||||
|
||||
`GET /api/v1/customer/wallet/expiring`
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Dikelompokkan per tanggal (WIB), paling dekat lebih dulu. Saldo bisa dipakai sampai
|
||||
akhir hari tanggal itu. Daftar kosong berarti tidak ada yang akan kedaluwarsa.
|
||||
|
||||
### 2.4 Notifikasi push (FCM)
|
||||
|
||||
Aplikasi mendaftarkan token FCM-nya **setelah login dan setiap kali FCM memberi token
|
||||
baru**:
|
||||
|
||||
`PUT /api/v1/customer/devices`
|
||||
|
||||
```json
|
||||
{ "device_id": "a1b2c3", "fcm_token": "…", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
`platform`: `android`, `ios`, atau `web` (opsional). Saat logout, panggil
|
||||
`DELETE /api/v1/customer/devices/:device_id` supaya HP itu tidak lagi menerima
|
||||
notifikasi customer tersebut. Satu token hanya milik satu customer: bila customer lain
|
||||
login di HP yang sama dan mendaftarkan token yang sama, customer sebelumnya otomatis
|
||||
tidak menerima notifikasi di HP itu lagi.
|
||||
|
||||
Push yang dikirim, dibedakan lewat `data.type`:
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lainnya |
|
||||
|---|---|---|
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` |
|
||||
| `WALLET_EXPIRING` | `reminder_days` hari sebelum saldo kedaluwarsa, sekali per tanggal | `currency`, `amount`, `expiry_date` |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339, UTC) |
|
||||
|
||||
Semua nilai di `data` berupa string, sesuai aturan FCM.
|
||||
|
||||
---
|
||||
|
||||
## 3. Customer app — PIN
|
||||
|
||||
PIN 6 digit, terpisah dari password login, dikirim sebagai **string** supaya angka nol
|
||||
di depan tidak hilang. PIN tidak pernah dikembalikan di response.
|
||||
|
||||
### 3.1 Cek status
|
||||
|
||||
`GET /api/v1/customer/pin/status`
|
||||
|
||||
```json
|
||||
{ "has_pin": true, "locked_until": null, "transfer_blocked_until": null }
|
||||
```
|
||||
|
||||
Minta customer membuat PIN saat pertama kali ia melakukan aksi yang butuh PIN
|
||||
(`has_pin: false`), bukan saat registrasi.
|
||||
|
||||
### 3.2 Membuat PIN pertama kali
|
||||
|
||||
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`. OTP dikirim ke
|
||||
nomor customer lewat WhatsApp. Response: `{ "purpose", "otp_token", "expires_at" }`.
|
||||
2. `POST /api/v1/customer/pin` dengan
|
||||
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
|
||||
|
||||
PIN ditolak (`304`) bila bukan 6 digit, konfirmasinya beda, semua digit sama
|
||||
(`111111`), berurutan (`123456`, `654321`), atau sama dengan tanggal lahir
|
||||
(`DDMMYY` / `YYMMDD`). Tampilkan `cause` apa adanya. Meminta OTP terlalu cepat
|
||||
menghasilkan `429`.
|
||||
|
||||
### 3.3 Mengganti dan mereset PIN
|
||||
|
||||
- **Ganti:** `PUT /api/v1/customer/pin` dengan `{ "old_pin", "pin", "confirm_pin" }`.
|
||||
- **Lupa PIN:** minta OTP dengan `purpose: "pin_reset"`, lalu
|
||||
`POST /api/v1/customer/pin/reset` dengan body yang sama seperti §3.2. Reset juga
|
||||
membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
|
||||
pembayaran dan exchange tetap bisa.
|
||||
|
||||
### 3.4 Menangani error PIN
|
||||
|
||||
Setiap endpoint yang menerima `pin` bisa mengembalikan error PIN. Pada error ini `data`
|
||||
**tidak** `null`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": { "code": "PIN_INVALID", "remaining_attempts": 3 },
|
||||
"errors": [{ "code": "PIN_INVALID", "entity": "customer_pin_service", "cause": "wrong PIN, 3 attempts left" }]
|
||||
}
|
||||
```
|
||||
|
||||
| `data.code` | Field tambahan | Yang ditampilkan aplikasi |
|
||||
|---|---|---|
|
||||
| `PIN_NOT_SET` | – | Arahkan ke pembuatan PIN (§3.2) |
|
||||
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa 3 percobaan" |
|
||||
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai 14:30", tawarkan reset PIN |
|
||||
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada …" |
|
||||
|
||||
Lima kali salah berturut-turut mengunci PIN selama 30 menit. Selama terkunci, PIN yang
|
||||
benar pun ditolak. Penghitung disimpan di server, jadi tidak bisa diakali dengan
|
||||
reinstall atau ganti HP.
|
||||
|
||||
---
|
||||
|
||||
## 4. Membayar dengan EnakPoint
|
||||
|
||||
Ada dua jalur. Keduanya memakai logika perhitungan yang sama.
|
||||
|
||||
### 4.1 Batas pembayaran
|
||||
|
||||
EnakPoint maksimal yang bisa dipakai untuk satu order:
|
||||
|
||||
```
|
||||
batas_rupiah = min(sisa_tagihan, total_order × max_payment_percent / 100 − yang_sudah_dibayar_EnakPoint)
|
||||
maks_point = min(saldo_customer, floor(batas_rupiah / point_value))
|
||||
```
|
||||
|
||||
Ditambah minimal `min_payment_points` per pembayaran. Nominal rupiah pembayaran selalu
|
||||
`points × point_value` dan **tidak pernah melebihi sisa tagihan**, jadi tidak ada
|
||||
kembalian. Sisa tagihan dibayar dengan method lain seperti biasa (split).
|
||||
|
||||
### 4.2 POS — kode bayar dari aplikasi customer
|
||||
|
||||
PIN **tidak pernah** diketik di perangkat kasir. Customer menyetujui di HP-nya sendiri:
|
||||
|
||||
1. **Customer app:** `POST /api/v1/customer/wallet/payment-code` dengan `{ "pin": "482913" }`.
|
||||
|
||||
```json
|
||||
{ "code": "482913", "qr_payload": "enakpoint:482913", "expires_at": "2026-09-30T05:02:00Z" }
|
||||
```
|
||||
|
||||
Tampilkan `code` sebagai angka dan `qr_payload` sebagai QR. Kode berlaku **2 menit**,
|
||||
sekali pakai, dan hanya untuk customer itu. Membuat kode baru membatalkan kode lama.
|
||||
|
||||
2. **POS:** tampilkan batas untuk tombol "pakai maksimal":
|
||||
|
||||
`GET /api/v1/orders/:id/point-payment/preview`
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"customer_id": "…",
|
||||
"eligible": true,
|
||||
"point_balance": 12500,
|
||||
"point_value": 1,
|
||||
"remaining_amount": 87500,
|
||||
"min_payment_points": 1,
|
||||
"max_payment_percent": 100,
|
||||
"max_points": 12500,
|
||||
"max_amount": 12500
|
||||
}
|
||||
```
|
||||
|
||||
Bila `eligible: false`, `reason` menjelaskan kenapa (order walk-in, outlet tidak
|
||||
menerima EnakPoint, saldo di bawah minimal, dst.).
|
||||
|
||||
3. **POS:** bayar lewat endpoint pembayaran yang sudah ada, dengan payment method
|
||||
bertipe `point`:
|
||||
|
||||
`POST /api/v1/payments` (header `X-Idempotency-Key` wajib seperti pembayaran lain)
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"payment_method_id": "<id method EnakPoint>",
|
||||
"points": 12500,
|
||||
"payment_code": "482913"
|
||||
}
|
||||
```
|
||||
|
||||
`amount` tidak perlu dikirim; backend menghitungnya. `payment_code` boleh berupa
|
||||
angka yang diketik kasir atau hasil scan QR apa adanya (`enakpoint:482913`).
|
||||
|
||||
Response pembayaran membawa `points_used` dan `point_value` untuk struk, misalnya
|
||||
"EnakPoint: 12.500 (Rp 12.500)". Jika pembayaran ini melunasi order, order menjadi
|
||||
`completed`; jika belum, sisanya dibayar dengan method lain.
|
||||
|
||||
Pembayaran ditolak (`304`, `cause` menjelaskan) bila: order tanpa customer atau
|
||||
customer walk-in, customer nonaktif, outlet tidak menerima EnakPoint, `points` di luar
|
||||
batas §4.1, kode salah/kedaluwarsa/sudah dipakai/milik customer lain, atau method
|
||||
EnakPoint dipakai sebagai split (bayar bagian EnakPoint sebagai pembayaran tersendiri,
|
||||
lalu split sisanya seperti biasa). Kode bayar dipakai habis begitu diterima, sebelum
|
||||
batas dicek ulang; bila pembayaran lalu ditolak (misalnya saldo berubah), minta
|
||||
customer membuat kode baru.
|
||||
|
||||
**Method EnakPoint** dibuat otomatis untuk setiap organisasi dan tidak bisa dihapus
|
||||
atau diubah tipenya (namanya boleh diganti). Daftar payment method yang dikirim
|
||||
`?outlet_id=` tidak menampilkannya bila outlet itu tidak menerima EnakPoint.
|
||||
|
||||
### 4.3 Customer app / self-order — bayar order sendiri
|
||||
|
||||
`POST /api/v1/customer/orders/:id/pay-with-points`
|
||||
|
||||
```json
|
||||
{ "points": 12500, "pin": "482913" }
|
||||
```
|
||||
|
||||
Hanya untuk order milik customer yang login; order lain dijawab `404`. Response sama
|
||||
dengan response pembayaran di §4.2.
|
||||
|
||||
### 4.4 Void dan refund
|
||||
|
||||
- **Void order:** semua EnakPoint yang dipakai kembali ke customer sebagai EnakPoint.
|
||||
- **Refund pembayaran EnakPoint** (`POST /api/v1/payments/:id/refund` pada pembayaran
|
||||
EnakPoint): yang kembali `floor(rupiah_direfund / point_value_saat_bayar)`. Perubahan
|
||||
nilai EnakPoint setelah pembayaran tidak mengubah jumlah yang kembali; sisa di bawah
|
||||
1 EnakPoint hangus.
|
||||
- **Refund order ke tunai / method lain** hanya boleh sebesar bagian yang dibayar
|
||||
dengan method lain. Bagian EnakPoint harus direfund lewat pembayaran EnakPoint-nya
|
||||
sendiri; mencoba lewat tunai dijawab `304`.
|
||||
- EnakPoint yang kembali mengikuti tanggal kedaluwarsa asalnya, tapi minimal 7 hari
|
||||
sejak refund.
|
||||
- EnakPoint dan EnakCoin yang didapat dari order ikut ditarik saat void/refund. Bila
|
||||
saldo customer sudah terpakai, yang ditarik sebanyak yang ada; refund tidak pernah
|
||||
diblokir karena ini.
|
||||
|
||||
### 4.5 Earning di layar order dan struk
|
||||
|
||||
Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak
|
||||
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount − bagian yang
|
||||
dibayar EnakPoint`, sebelum pajak, dan diberikan saat order lunas.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exchange EnakCoin → EnakPoint
|
||||
|
||||
Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default 1 : 1).
|
||||
|
||||
1. **Preview** sebelum minta PIN:
|
||||
|
||||
`GET /api/v1/customer/wallet/exchange/preview?coins=30`
|
||||
|
||||
```json
|
||||
{ "coin_amount": 10, "point_amount": 3, "coin_balance": 35, "coins": 30, "points": 9, "valid": true }
|
||||
```
|
||||
|
||||
Bila `valid: false`, tampilkan `reason` (misalnya harus kelipatan `coin_amount`,
|
||||
atau EnakCoin tidak cukup).
|
||||
|
||||
2. **Tukar:**
|
||||
|
||||
`POST /api/v1/customer/wallet/exchange` dengan header **`Idempotency-Key`** (wajib,
|
||||
maks. 50 karakter, satu key per percobaan tukar)
|
||||
|
||||
```json
|
||||
{ "coins": 30, "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"coins": 30,
|
||||
"points": 9,
|
||||
"coin_amount": 10,
|
||||
"point_amount": 3,
|
||||
"lots": [{ "amount": 9, "expires_at": "2026-12-31T23:59:59+07:00" }],
|
||||
"coin_balance": 5,
|
||||
"point_balance": 9,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
- Jumlah EnakCoin harus kelipatan `coin_amount`. Kesalahan jumlah ditolak **sebelum**
|
||||
PIN dicek, jadi tidak memakan jatah percobaan PIN.
|
||||
- Exchange tidak bisa dibatalkan; tampilkan konfirmasi.
|
||||
- Kirim ulang dengan `Idempotency-Key` yang sama bila koneksi putus: hasil pertama
|
||||
dikembalikan dengan `replayed: true` tanpa menukar lagi, dengan kurs saat itu.
|
||||
`Idempotency-Key` yang sama untuk jumlah berbeda ditolak.
|
||||
- EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin asalnya (`lots`
|
||||
menunjukkan tanggalnya).
|
||||
|
||||
---
|
||||
|
||||
## 6. Transfer ke customer lain
|
||||
|
||||
1. **Cek penerima** sebelum konfirmasi:
|
||||
|
||||
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
Nomor yang tidak terdaftar di organisasi yang sama dijawab `404`. Diri sendiri,
|
||||
customer walk-in, atau customer nonaktif dijawab `304`.
|
||||
|
||||
2. **Kirim:**
|
||||
|
||||
`POST /api/v1/customer/wallet/transfer` dengan header **`Idempotency-Key`** (wajib)
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"group_id": "…",
|
||||
"currency": "POINT",
|
||||
"amount": 120,
|
||||
"recipient": { "name": "Bu*** Sa***", "phone_number": "08**-****-1234" },
|
||||
"lots": [
|
||||
{ "amount": 100, "expires_at": "2026-12-31T23:59:59+07:00" },
|
||||
{ "amount": 20, "expires_at": null }
|
||||
],
|
||||
"balance": 30,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
- `currency`: `POINT` atau `COIN`, satu jenis per transfer.
|
||||
- Batas dari organisasi: transfer bisa dimatikan, ada minimal, maksimal per
|
||||
transaksi, dan batas harian per currency (reset tengah malam WIB). Pelanggaran batas
|
||||
ditolak `304` sebelum PIN dicek.
|
||||
- Transfer final dan tidak bisa dibatalkan customer.
|
||||
- Saldo yang dikirim membawa tanggal kedaluwarsa aslinya ke penerima (`lots`).
|
||||
Tampilkan ini ke pengirim.
|
||||
- Penerima mendapat push `WALLET_TRANSFER_IN` (§2.4).
|
||||
- Retry dengan `Idempotency-Key` yang sama mengembalikan hasil pertama
|
||||
(`replayed: true`) dan tidak dihitung dua kali terhadap batas harian.
|
||||
|
||||
---
|
||||
|
||||
## 7. Game
|
||||
|
||||
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
|
||||
|
||||
Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1).
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb", … },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada
|
||||
EnakCoin yang terpotong.
|
||||
|
||||
Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1.
|
||||
|
||||
---
|
||||
|
||||
## 8. Endpoint lama (deprecated)
|
||||
|
||||
Masih jalan dan membaca saldo wallet, tapi akan dihapus setelah semua versi aplikasi
|
||||
pindah. Aplikasi baru jangan memakainya.
|
||||
|
||||
| Lama | Ganti dengan |
|
||||
|---|---|
|
||||
| `GET /customer/points` | `GET /customer/wallet` (`point_balance`) |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
Beri tahu tim backend setelah aplikasi yang beredar tidak lagi memakai kolom kiri,
|
||||
supaya alias ini bisa dihapus.
|
||||
|
||||
Semua yang bernama token sudah dihapus: `GET /customer/tokens`, `total_tokens`,
|
||||
`tokens_history`, `token_used`, `tokens_remaining`, dan nilai `TOKENS` di campaign. Pakai
|
||||
`coin_balance`, `coins_used`, `coins_remaining`, dan `COINS`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Dashboard
|
||||
|
||||
Semua endpoint di bagian ini butuh login user dengan role Admin atau Manager.
|
||||
|
||||
### 9.1 Pengaturan per outlet
|
||||
|
||||
`GET` / `PUT /api/v1/outlets/:outlet_id/loyalty-settings`
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_per_amount": 100, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_per_amount": 25000, "earn_value": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"point_payment": { "accept_payment": true, "min_payment_points": 1, "max_payment_percent": 100 }
|
||||
}
|
||||
```
|
||||
|
||||
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan
|
||||
`point_value` organisasi dan `point_cashback_percent`
|
||||
(`earn_value × point_value / earn_per_amount × 100`). **Tampilkan persentase ini di
|
||||
samping setting** supaya owner tidak salah membaca skala: default di atas setara
|
||||
cashback 1%.
|
||||
|
||||
### 9.2 Pengaturan organisasi
|
||||
|
||||
`GET` / `PUT /api/v1/marketing/loyalty-settings` (tambah `?dry_run=true` untuk preview
|
||||
tanpa menyimpan)
|
||||
|
||||
```json
|
||||
{
|
||||
"point_value": 1,
|
||||
"exchange": { "coin_amount": 1, "point_amount": 1 },
|
||||
"transfer": { "enabled": true, "min_amount": 1, "max_per_transaction": null, "daily_limit": null },
|
||||
"point_expiry": {
|
||||
"enabled": false,
|
||||
"mode": "FIXED_DATE",
|
||||
"fixed_dates": ["12-31"],
|
||||
"grace_months": 3,
|
||||
"period": 12,
|
||||
"unit": "MONTH",
|
||||
"end_of_month": false,
|
||||
"reminder_days": 7
|
||||
},
|
||||
"coin_expiry": { … sama … }
|
||||
}
|
||||
```
|
||||
|
||||
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. Response menambahkan:
|
||||
|
||||
- `impact`: total saldo beredar dan nilai rupiahnya **sebelum dan sesudah** perubahan
|
||||
`point_value` atau kurs. Tampilkan sebagai peringatan sebelum owner menyimpan.
|
||||
- `expiry_preview`: `{ "point": …, "coin": … }`, kapan saldo yang didapat hari ini
|
||||
akan kedaluwarsa (`null` bila tidak kedaluwarsa). Tampilkan sebagai "EnakPoint yang
|
||||
didapat hari ini kedaluwarsa pada 31 Des 2026".
|
||||
- `expiry_activations`: bila perubahan ini **menyalakan** kedaluwarsa untuk pertama
|
||||
kali, berapa saldo lama yang ikut diberi tanggal (`lots`, `amount`) dan tanggalnya
|
||||
(`expires_at`). Selalu minta konfirmasi dengan `dry_run=true` dulu.
|
||||
- `changes`: key yang berubah.
|
||||
|
||||
**Kedaluwarsa** diatur per currency dengan salah satu model:
|
||||
|
||||
| `mode` | Cara kerja | Field yang dipakai |
|
||||
|---|---|---|
|
||||
| `FIXED_DATE` (default) | Semua saldo hangus di tanggal tetap setiap tahun. Saldo yang didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya | `fixed_dates` (format `MM-DD`, boleh lebih dari satu, `02-29` ditolak), `grace_months` (0–24) |
|
||||
| `ROLLING` | Tiap saldo berlaku sekian lama sejak didapat | `period`, `unit` (`DAY` / `MONTH`), `end_of_month` |
|
||||
|
||||
- `reminder_days` berlaku untuk keduanya: customer diingatkan sekian hari sebelum
|
||||
hangus (0 = tanpa pengingat).
|
||||
- Mengubah pengaturan hanya berlaku untuk saldo yang masuk setelahnya.
|
||||
- Menyalakan kedaluwarsa pertama kali memberi saldo lama masa berlaku penuh: tanggal
|
||||
hangus kedua berikutnya (`FIXED_DATE`) atau satu periode penuh (`ROLLING`).
|
||||
- Mematikan kedaluwarsa tidak membatalkan tanggal yang sudah terjadwal.
|
||||
|
||||
Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit=20`
|
||||
(tambah `outlet_id=` untuk setting outlet).
|
||||
|
||||
### 9.3 Wallet customer
|
||||
|
||||
- `GET /api/v1/marketing/customers/:id/wallet` — saldo buku dan saldo yang bisa
|
||||
dipakai, semua lot yang masih berisi, dan riwayat dengan nama asli (lawan transfer,
|
||||
admin, kasir, outlet). Query riwayat sama seperti §2.2.
|
||||
- `POST /api/v1/marketing/customers/:id/wallet/adjust`
|
||||
|
||||
```json
|
||||
{ "currency": "POINT", "amount": -500, "reason": "Komplain #45", "idempotency_key": "adj-45" }
|
||||
```
|
||||
|
||||
`amount` bertanda. `reason` wajib. Pengurangan yang melebihi saldo ditolak.
|
||||
Adjustment tidak disertai pembayaran uang, jadi jangan pakai alasan "pencairan".
|
||||
|
||||
- `GET /api/v1/marketing/wallet-transactions/:id/trace` — telusuri satu mutasi per
|
||||
butir: lot mana yang dipakai atau dibuat, lalu rantai asalnya lewat transfer,
|
||||
exchange, atau refund sampai ke earning/adjustment/migrasi pertama. Contoh: dari
|
||||
pembayaran B bisa terlihat bahwa EnakPoint-nya berasal dari order #ORD-1 milik A
|
||||
yang mentransfer ke B.
|
||||
|
||||
### 9.4 PIN customer
|
||||
|
||||
- `DELETE /api/v1/marketing/customers/:id/pin` dengan `{ "reason": "…" }` — hapus PIN
|
||||
bila customer kehilangan akses. Customer lalu membuat PIN baru lewat OTP. Admin
|
||||
**tidak bisa** membuat, mengganti, atau melihat PIN.
|
||||
- `GET /api/v1/marketing/customers/:id/security-events?page=1&limit=20` — log keamanan:
|
||||
`PIN_SET`, `PIN_CHANGED`, `PIN_RESET`, `PIN_FAILED`, `PIN_LOCKED`,
|
||||
`PIN_REMOVED_BY_ADMIN`, beserta waktu, IP, dan perangkat.
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist integrasi
|
||||
|
||||
**Customer app**
|
||||
- [ ] Daftarkan token FCM setelah login dan saat token berganti; hapus saat logout.
|
||||
- [ ] Tangani empat kode error PIN (§3.4) di semua layar yang meminta PIN.
|
||||
- [ ] Kirim `Idempotency-Key` baru untuk setiap exchange dan transfer, dan pakai ulang
|
||||
key yang sama saat retry.
|
||||
- [ ] Tampilkan nilai rupiah sebagai "setara potongan", bukan saldo uang.
|
||||
- [ ] Baca `coins_used` / `coins_remaining` dan `/customer/wallet`, bukan field lama.
|
||||
|
||||
**POS**
|
||||
- [ ] Scan QR atau ketik kode bayar, jangan pernah meminta PIN customer di layar kasir.
|
||||
- [ ] Pakai `point-payment/preview` untuk tombol "pakai maksimal".
|
||||
- [ ] Cetak `points_used`, `points_earned`, dan `coins_earned` di struk.
|
||||
- [ ] Refund bagian EnakPoint lewat pembayaran EnakPoint-nya, bukan tunai.
|
||||
|
||||
**Dashboard**
|
||||
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
|
||||
`expiry_activations` sebelum owner menyimpan setting.
|
||||
- [ ] Isi `metadata.coin_cost` untuk setiap game.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Integrasi Produk Timbangan — POS Mobile & Backoffice
|
||||
|
||||
**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
|
||||
|
||||
Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
|
||||
Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
**Satu penimbangan = satu baris order.**
|
||||
|
||||
Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
|
||||
baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
|
||||
|
||||
| Bukan begini | Melainkan begini |
|
||||
|---|---|
|
||||
| `quantity: 9.8` | `quantity: 1, weight: 4.2`<br>`quantity: 1, weight: 5.6` |
|
||||
| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
|
||||
|
||||
Empat aturan yang berlaku di seluruh dokumen ini:
|
||||
|
||||
1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
|
||||
menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
|
||||
2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
|
||||
apa pun yang dipilih saat setup).
|
||||
3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
|
||||
`unit_price` tetap berarti harga per satu satuan (per ons).
|
||||
4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
|
||||
produknya sama.
|
||||
|
||||
Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
|
||||
cacah seperti sekarang.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backoffice — setup produk
|
||||
|
||||
### 2.1 Pastikan satuannya ada
|
||||
|
||||
Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
|
||||
|
||||
`POST /api/v1/units`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ons",
|
||||
"abbreviation": "ons",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
|
||||
pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
|
||||
|
||||
### 2.2 Buat produk sebagai produk timbangan
|
||||
|
||||
`POST /api/v1/products`
|
||||
|
||||
```json
|
||||
{
|
||||
"category_id": "…",
|
||||
"name": "Ikan Tude",
|
||||
"price": 4500,
|
||||
"cost": 3000,
|
||||
"sell_by": "weight",
|
||||
"unit_id": "<id satuan Ons>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
|
||||
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
|
||||
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
|
||||
|
||||
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
|
||||
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
|
||||
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
|
||||
|
||||
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
|
||||
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
|
||||
`/products/:id`).
|
||||
|
||||
### 2.3 Catatan UI
|
||||
|
||||
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
|
||||
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
|
||||
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
|
||||
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
|
||||
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
|
||||
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
|
||||
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
|
||||
dikonsumsi apa pun di POS.
|
||||
|
||||
---
|
||||
|
||||
## 3. POS Mobile — transaksi
|
||||
|
||||
### 3.1 Bentuk input mengikuti `sell_by`
|
||||
|
||||
| `sell_by` | Input di POS | Yang dikirim |
|
||||
|---|---|---|
|
||||
| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
|
||||
| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
|
||||
|
||||
### 3.2 Mengirim order
|
||||
|
||||
`POST /api/v1/orders`
|
||||
|
||||
```json
|
||||
{
|
||||
"outlet_id": "…",
|
||||
"user_id": "…",
|
||||
"order_type": "dine_in",
|
||||
"order_items": [
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
|
||||
{ "product_id": "<nasi-goreng>", "quantity": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
|
||||
mandiri `POST /api/v1/self-order/orders`.
|
||||
|
||||
**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
|
||||
bawah `0.001` membulat ke nol dan ditolak.
|
||||
|
||||
**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
|
||||
dari master produk. Jangan mengirim harga hasil hitungan sendiri.
|
||||
|
||||
---
|
||||
|
||||
## 4. Menampilkan baris
|
||||
|
||||
Setiap `order_items[]` di response membawa empat field tambahan:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Ikan Tude",
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_id": "…",
|
||||
"unit_name": "Ons",
|
||||
"unit_abbreviation": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900
|
||||
}
|
||||
```
|
||||
|
||||
Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
|
||||
|
||||
| Kondisi | Tampilkan |
|
||||
|---|---|
|
||||
| `weight == null` | `2 × Rp 25.000` |
|
||||
| `weight != null` | `4,2 ons × Rp 4.500` |
|
||||
|
||||
**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
|
||||
terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
|
||||
desimal sesuai format Indonesia.
|
||||
|
||||
---
|
||||
|
||||
## 5. Void, refund, split bill
|
||||
|
||||
Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
|
||||
`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
|
||||
rupiahnya dari `weight`.
|
||||
|
||||
| Aksi | Endpoint | Field untuk baris berbobot |
|
||||
|---|---|---|
|
||||
| Void per item | `POST /orders/void` | `items[].quantity: 1` |
|
||||
| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
|
||||
| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"reason": "Salah timbang",
|
||||
"type": "ITEM",
|
||||
"items": [
|
||||
{ "order_item_id": "<baris 4,2 ons>", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
|
||||
ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
|
||||
tombol pilih baris.
|
||||
|
||||
**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
|
||||
tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
|
||||
sehingga jejak auditnya tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 6. Referensi error
|
||||
|
||||
Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{ "code": "900", "entity": "ORDER",
|
||||
"cause": "product Ikan Tude is sold by weight and requires a weight" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
|
||||
| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
|
||||
| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
|
||||
| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
|
||||
|
||||
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
|
||||
|
||||
### Setup produk (Backoffice)
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
|
||||
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
|
||||
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Kompatibilitas mundur
|
||||
|
||||
- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
|
||||
- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
|
||||
produk satuan.
|
||||
- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
|
||||
parser lama tidak terganggu.
|
||||
- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
|
||||
|
||||
Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
|
||||
permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 8. Batasan yang diketahui
|
||||
|
||||
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
|
||||
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
|
||||
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
|
||||
- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
|
||||
produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
|
||||
sisa stok yang mengandaikan itu berjalan.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist per tim
|
||||
|
||||
**Backoffice Website**
|
||||
|
||||
- [ ] CRUD satuan tersedia di menu master data
|
||||
- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
|
||||
- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
|
||||
- [ ] Label harga ikut satuan — "Harga per ons"
|
||||
- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
|
||||
- [ ] Daftar produk menandai mana yang dijual per timbangan
|
||||
- [ ] Laporan harian menampilkan kolom Berat
|
||||
|
||||
**POS Mobile**
|
||||
|
||||
- [ ] Menu membaca `sell_by` tiap produk
|
||||
- [ ] Papan angka desimal untuk produk timbangan
|
||||
- [ ] Kirim `quantity: 1` + `weight`
|
||||
- [ ] Dua penimbangan menjadi dua baris, tidak digabung
|
||||
- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
|
||||
- [ ] Void & refund baris berbobot bersifat utuh
|
||||
- [ ] Split bill: pilih baris, bukan stepper jumlah
|
||||
- [ ] Pesan error validasi ditampilkan ke kasir
|
||||
@@ -0,0 +1,362 @@
|
||||
# RFC: Produk Timbangan (Weight-Based Products)
|
||||
|
||||
**Status:** Diimplementasikan (migrasi `000089`)
|
||||
**Tanggal:** 2026-09-05, diperbarui 2026-09-06
|
||||
**Scope:** Product, Order, Void/Refund, Report
|
||||
**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8)
|
||||
|
||||
---
|
||||
|
||||
## 1. Masalah
|
||||
|
||||
Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity`
|
||||
bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung
|
||||
`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill.
|
||||
|
||||
Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons,
|
||||
lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang
|
||||
terpisah** — bukan satu baris berisi 9,8.
|
||||
|
||||
Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya.
|
||||
|
||||
> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk
|
||||
> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau
|
||||
> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang
|
||||
> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Keputusan Inti
|
||||
|
||||
**Satu penimbangan = satu baris `order_items`.**
|
||||
|
||||
| | Baris 1 | Baris 2 |
|
||||
|---|---|---|
|
||||
| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | |
|
||||
| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` |
|
||||
|
||||
`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke
|
||||
kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua
|
||||
ikan yang berbeda.
|
||||
|
||||
### Kenapa bukan `quantity = 4.2`
|
||||
|
||||
Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi
|
||||
`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan:
|
||||
|
||||
1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan
|
||||
informasi bahwa ada dua ikan. Tidak bisa direkonstruksi.
|
||||
2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan
|
||||
"total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang
|
||||
bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun
|
||||
yang berubah di dunia nyata.
|
||||
3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai
|
||||
di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan
|
||||
`4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang
|
||||
sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer.
|
||||
|
||||
Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan
|
||||
epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prinsip
|
||||
|
||||
**P1 — Baris transaksi adalah snapshot yang beku.**
|
||||
`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join
|
||||
ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh
|
||||
mengubah arti transaksi yang sudah terjadi.
|
||||
|
||||
**P2 — Perhitungan harga baris hanya ada di satu tempat.**
|
||||
Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh
|
||||
ada perkalian harga yang tersebar; semuanya memanggil satu fungsi.
|
||||
|
||||
**P3 — Harga tetap otoritas backend.**
|
||||
Klien tidak pernah mengirim harga. Backend membacanya dari `products` /
|
||||
`product_outlet_prices` seperti sekarang.
|
||||
|
||||
**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.**
|
||||
`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan
|
||||
satuan berbeda tidak bermakna.
|
||||
|
||||
---
|
||||
|
||||
## 4. Perubahan Skema
|
||||
|
||||
```sql
|
||||
-- Products: cara jual
|
||||
ALTER TABLE products
|
||||
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'
|
||||
CHECK (sell_by IN ('unit', 'weight'));
|
||||
|
||||
-- Order items: berat + snapshot satuan
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN weight DECIMAL(12,3),
|
||||
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_positive
|
||||
CHECK (weight IS NULL OR weight > 0),
|
||||
ADD CONSTRAINT chk_order_items_weight_single_line
|
||||
CHECK (weight IS NULL OR quantity = 1);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
|
||||
- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid
|
||||
tanpa backfill, dan perilakunya tidak berubah sama sekali.
|
||||
- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database:
|
||||
baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak
|
||||
ambigu dan membuat void otomatis bersifat utuh (§6).
|
||||
- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap
|
||||
berlaku.
|
||||
- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai
|
||||
presisi sama.
|
||||
- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan
|
||||
"ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Perhitungan Harga
|
||||
|
||||
Satu-satunya tempat yang boleh mengalikan harga (P2):
|
||||
|
||||
```go
|
||||
// BillableQuantity mengembalikan pengali harga untuk baris ini:
|
||||
// berat bila produk dijual per timbangan, jumlah bila dijual per satuan.
|
||||
// Baris berbobot dijamin quantity = 1 oleh constraint DB.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
```
|
||||
|
||||
`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor
|
||||
konversi yang menyelinap ke perhitungan uang.
|
||||
|
||||
### Titik yang harus diganti
|
||||
|
||||
Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan
|
||||
menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang,
|
||||
bukan salah tampilan**, dan tidak memicu error apa pun.
|
||||
|
||||
| Lokasi | Sekarang |
|
||||
|---|---|
|
||||
| `processor/order_processor.go:197-198` | buat order |
|
||||
| `processor/order_processor.go:330-331` | tambah item ke order |
|
||||
| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void |
|
||||
| `processor/order_processor.go:723` | jumlah refund |
|
||||
| `processor/split_bill_processor.go:143` | hitung jumlah split |
|
||||
| `processor/split_bill_processor.go:189` | catat pembayaran |
|
||||
| `processor/split_bill_processor.go:231` | metadata pembayaran |
|
||||
| `repository/order_item_repository.go:113` | jumlah void penuh |
|
||||
|
||||
Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur
|
||||
inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis:
|
||||
|
||||
| Lokasi | Status |
|
||||
|---|---|
|
||||
| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** |
|
||||
| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** |
|
||||
| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** |
|
||||
|
||||
Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan
|
||||
dihitung sebagai 1 satuan resep.
|
||||
|
||||
### Verifikasi
|
||||
|
||||
```bash
|
||||
grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \
|
||||
| grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity
|
||||
```
|
||||
|
||||
Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman:
|
||||
|
||||
- `mappers/inventory_movement_mapper.go:129` dan
|
||||
`processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan
|
||||
baris order.
|
||||
- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian,
|
||||
yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`.
|
||||
|
||||
Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Void, Refund, Split Bill
|
||||
|
||||
**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap
|
||||
integer.
|
||||
|
||||
**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada
|
||||
`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan
|
||||
`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang
|
||||
pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa
|
||||
berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5).
|
||||
|
||||
**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di
|
||||
`order_processor.go:723` yang perlu memakai `BillableQuantity()`.
|
||||
|
||||
**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot
|
||||
nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di
|
||||
`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi
|
||||
float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang
|
||||
berubah.
|
||||
|
||||
**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons
|
||||
dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan,
|
||||
koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya
|
||||
adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat.
|
||||
Ini menjaga jejak audit tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validasi & Tampilan
|
||||
|
||||
### 7.1 Aturan validasi
|
||||
|
||||
Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat:
|
||||
|
||||
| `products.sell_by` | Aturan |
|
||||
|---|---|
|
||||
| `unit` | `weight` harus kosong. Bila dikirim → tolak. |
|
||||
| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. |
|
||||
|
||||
`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) —
|
||||
bukan dibaca lewat join saat ditampilkan.
|
||||
|
||||
Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan
|
||||
nilai yang divalidasi.
|
||||
|
||||
### 7.2 Tampilan
|
||||
|
||||
`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris
|
||||
berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`.
|
||||
|
||||
Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat
|
||||
menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Report
|
||||
|
||||
**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti
|
||||
"berapa banyak barang":
|
||||
|
||||
- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas
|
||||
produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.
|
||||
- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan.
|
||||
- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per
|
||||
ekor", yang tetap merupakan angka bermakna.
|
||||
|
||||
**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk**
|
||||
(P4):
|
||||
|
||||
```sql
|
||||
COALESCE(SUM(oi.weight), 0) AS weight_sold
|
||||
```
|
||||
|
||||
Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang
|
||||
berbeda.
|
||||
|
||||
---
|
||||
|
||||
## 9. Di Luar Scope
|
||||
|
||||
**Pengurangan stok otomatis.** `adjustInventoryWithTransaction`
|
||||
(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction`
|
||||
(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** —
|
||||
sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat
|
||||
penjualan tidak tersambung.
|
||||
|
||||
Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi
|
||||
apa pun.
|
||||
|
||||
Catatan untuk nanti bila jalur stok disambungkan:
|
||||
|
||||
- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau
|
||||
tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1.
|
||||
- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol
|
||||
sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.
|
||||
- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan.
|
||||
Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa
|
||||
diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam.
|
||||
|
||||
Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan
|
||||
menggantung — komentar di dalamnya ditulis seolah-olah aktif.
|
||||
|
||||
---
|
||||
|
||||
## 10. Temuan Sampingan: `unit_price` pada request diabaikan
|
||||
|
||||
`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar
|
||||
*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak
|
||||
pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di
|
||||
`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari
|
||||
`products` / `product_outlet_prices`.
|
||||
|
||||
Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang
|
||||
menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract,
|
||||
atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database.
|
||||
|
||||
Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka
|
||||
berhasil, padahal diabaikan diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 11. Urutan Implementasi
|
||||
|
||||
1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama
|
||||
tidak tersentuh.
|
||||
2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5).
|
||||
3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi.
|
||||
4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada
|
||||
response.
|
||||
5. **Validasi `sell_by`** (§7.1).
|
||||
6. **Template & tampilan struk** (§7.2).
|
||||
7. *(Opsional)* `weight_sold` pada laporan per produk (§8).
|
||||
|
||||
Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5
|
||||
mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiko
|
||||
|
||||
| Risiko | Dampak | Mitigasi |
|
||||
|---|---|---|
|
||||
| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) |
|
||||
| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB |
|
||||
| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB |
|
||||
| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan |
|
||||
| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Pertanyaan Terbuka
|
||||
|
||||
1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney`
|
||||
membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah
|
||||
dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`.
|
||||
|
||||
Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan**
|
||||
pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau
|
||||
kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat,
|
||||
dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan
|
||||
baru akan mengikuti aturan berbeda.
|
||||
2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input
|
||||
harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi,
|
||||
tambahkan `products.min_weight_increment`.
|
||||
3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila
|
||||
tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.
|
||||
@@ -76,6 +76,7 @@ require (
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
||||
@@ -107,6 +108,7 @@ require (
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.19.0
|
||||
github.com/alicebob/miniredis/v2 v2.39.0
|
||||
github.com/aws/aws-sdk-go v1.55.7
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
|
||||
@@ -74,6 +74,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
@@ -349,6 +351,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
|
||||
+126
-24
@@ -31,6 +31,9 @@ type App struct {
|
||||
router *router.Router
|
||||
shutdown chan os.Signal
|
||||
omsetScheduler *service.OmsetMilestoneScheduler
|
||||
walletRecon *service.WalletReconciliationJob
|
||||
earningRetry *service.EarningBackfillJob
|
||||
walletExpiry *service.WalletExpiryJob
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
|
||||
@@ -53,6 +56,18 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
processors.notificationProcessor,
|
||||
)
|
||||
|
||||
// Checks that wallet balances, ledger and lots agree (docs/prd-point-coin.md §7.5)
|
||||
a.walletRecon = service.NewWalletReconciliationJob(
|
||||
repository.NewWalletReconciliationRepository(a.db),
|
||||
repos.userRepo,
|
||||
processors.notificationProcessor,
|
||||
)
|
||||
// Earns for paid orders whose earning failed at payment time (docs/prd-point-coin.md F3)
|
||||
a.earningRetry = service.NewEarningBackfillJob(processors.earningProcessor)
|
||||
// Expires balances whose time is up and reminds customers before (docs/prd-point-coin.md F12)
|
||||
a.walletExpiry = service.NewWalletExpiryJob(processor.NewWalletExpiryProcessor(
|
||||
repository.NewWalletExpiryRepository(a.db), processors.loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, processors.customerDeviceProcessor))
|
||||
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
validators := a.initValidators()
|
||||
middleware := a.initMiddleware(services, cfg)
|
||||
@@ -142,6 +157,14 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.expenseValidator,
|
||||
services.cashAdvanceService,
|
||||
validators.cashAdvanceValidator,
|
||||
services.walletAdminService,
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
services.customerPinService,
|
||||
services.pointPaymentService,
|
||||
services.customerOrderPaymentService,
|
||||
services.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -153,6 +176,15 @@ func (a *App) Start(port string) error {
|
||||
if a.omsetScheduler != nil {
|
||||
a.omsetScheduler.Start(5 * time.Minute)
|
||||
}
|
||||
if a.walletRecon != nil {
|
||||
a.walletRecon.Start(6 * time.Hour)
|
||||
}
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Start(30 * time.Minute)
|
||||
}
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Start(15 * time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@@ -192,6 +224,15 @@ func (a *App) Shutdown() {
|
||||
if a.omsetScheduler != nil {
|
||||
a.omsetScheduler.Stop()
|
||||
}
|
||||
if a.walletRecon != nil {
|
||||
a.walletRecon.Stop()
|
||||
}
|
||||
if a.earningRetry != nil {
|
||||
a.earningRetry.Stop()
|
||||
}
|
||||
if a.walletExpiry != nil {
|
||||
a.walletExpiry.Stop()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -226,7 +267,6 @@ type repositories struct {
|
||||
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
|
||||
accountRepo *repository.AccountRepositoryImpl
|
||||
orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
tierRepo *repository.TierRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
@@ -236,7 +276,6 @@ type repositories struct {
|
||||
campaignRepo repository.CampaignRepository
|
||||
campaignRuleRepo repository.CampaignRuleRepository
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
otpRepo repository.OtpRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
txManager *repository.TxManager
|
||||
@@ -247,6 +286,9 @@ type repositories struct {
|
||||
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
||||
expenseRepo *repository.ExpenseRepositoryImpl
|
||||
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
||||
walletRepo repository.WalletRepository
|
||||
walletQueryRepo repository.WalletQueryRepository
|
||||
loyaltySettingsRepo repository.LoyaltySettingsRepository
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
@@ -281,7 +323,6 @@ func (a *App) initRepositories() *repositories {
|
||||
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
|
||||
accountRepo: repository.NewAccountRepositoryImpl(a.db),
|
||||
orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl),
|
||||
customerTokensRepo: repository.NewCustomerTokensRepository(a.db),
|
||||
tierRepo: repository.NewTierRepository(a.db),
|
||||
gameRepo: repository.NewGameRepository(a.db),
|
||||
gamePrizeRepo: repository.NewGamePrizeRepository(a.db),
|
||||
@@ -291,7 +332,6 @@ func (a *App) initRepositories() *repositories {
|
||||
campaignRepo: repository.NewCampaignRepository(a.db),
|
||||
campaignRuleRepo: repository.NewCampaignRuleRepository(a.db),
|
||||
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
|
||||
customerPointsRepo: repository.NewCustomerPointsRepository(a.db),
|
||||
otpRepo: repository.NewOtpRepository(a.db),
|
||||
sessionRepo: repository.NewSessionRepository(a.redisClient),
|
||||
txManager: repository.NewTxManager(a.db),
|
||||
@@ -302,6 +342,9 @@ func (a *App) initRepositories() *repositories {
|
||||
productOutletPriceRepo: repository.NewProductOutletPriceRepositoryImpl(a.db),
|
||||
expenseRepo: repository.NewExpenseRepositoryImpl(a.db),
|
||||
cashAdvanceRepo: repository.NewCashAdvanceRepositoryImpl(a.db),
|
||||
walletRepo: repository.NewWalletRepository(a.db),
|
||||
walletQueryRepo: repository.NewWalletQueryRepository(a.db),
|
||||
loyaltySettingsRepo: repository.NewLoyaltySettingsRepository(a.db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,7 +374,6 @@ type processors struct {
|
||||
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
|
||||
accountProcessor *processor.AccountProcessorImpl
|
||||
orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl
|
||||
customerTokensProcessor *processor.CustomerTokensProcessor
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
@@ -350,14 +392,44 @@ type processors struct {
|
||||
productOutletPriceProcessor processor.ProductOutletPriceProcessor
|
||||
expenseProcessor *processor.ExpenseProcessorImpl
|
||||
cashAdvanceProcessor *processor.CashAdvanceProcessorImpl
|
||||
walletProcessor *processor.WalletProcessor
|
||||
walletAdminProcessor *processor.WalletAdminProcessor
|
||||
loyaltySettingsProcessor *processor.LoyaltySettingsProcessor
|
||||
earningProcessor *processor.EarningProcessor
|
||||
customerPinProcessor *processor.CustomerPinProcessor
|
||||
paymentCodeProcessor *processor.PaymentCodeProcessor
|
||||
pointPaymentProcessor *processor.PointPaymentProcessor
|
||||
walletExchangeProcessor *processor.WalletExchangeProcessor
|
||||
walletTransferProcessor *processor.WalletTransferProcessor
|
||||
walletTraceProcessor *processor.WalletTraceProcessor
|
||||
customerDeviceProcessor *processor.CustomerDeviceProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
fileClient := client.NewFileClient(cfg.S3Config)
|
||||
fcmClient := buildFCMClient(cfg)
|
||||
// Devices of the customer app, for pushes through FCM
|
||||
customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient)
|
||||
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||
// Customer PIN (docs/prd-point-coin.md F11)
|
||||
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor)
|
||||
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
|
||||
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
|
||||
|
||||
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
|
||||
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
|
||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetLoyalty(earningProcessor)
|
||||
// Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9)
|
||||
pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
|
||||
// Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4)
|
||||
walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
// Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5)
|
||||
walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo),
|
||||
@@ -367,8 +439,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
|
||||
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
|
||||
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
|
||||
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo),
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
||||
orderProcessor: orderProcessor,
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
|
||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
||||
@@ -384,25 +456,35 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
|
||||
accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo),
|
||||
orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl),
|
||||
customerTokensProcessor: processor.NewCustomerTokensProcessor(repos.customerTokensRepo),
|
||||
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
|
||||
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
|
||||
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
|
||||
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo),
|
||||
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager),
|
||||
omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo),
|
||||
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
|
||||
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
||||
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo),
|
||||
otpProcessor: otpProcessor,
|
||||
fileClient: fileClient,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||
notificationProcessor: buildNotificationProcessor(repos, fcmClient),
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
|
||||
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
||||
earningProcessor: earningProcessor,
|
||||
customerPinProcessor: customerPinProcessor,
|
||||
paymentCodeProcessor: paymentCodeProcessor,
|
||||
pointPaymentProcessor: pointPaymentProcessor,
|
||||
walletExchangeProcessor: walletExchangeProcessor,
|
||||
walletTransferProcessor: walletTransferProcessor,
|
||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +527,13 @@ type services struct {
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
customerPinService *service.CustomerPinServiceImpl
|
||||
pointPaymentService *service.PointPaymentServiceImpl
|
||||
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
|
||||
customerWalletService *service.CustomerWalletServiceImpl
|
||||
customerDeviceService *service.CustomerDeviceServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -475,12 +564,12 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
|
||||
accountService := service.NewAccountService(processors.accountProcessor)
|
||||
orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager)
|
||||
gamificationService := service.NewGamificationService(processors.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
|
||||
gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor)
|
||||
rewardService := service.NewRewardService(processors.rewardProcessor)
|
||||
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
|
||||
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
||||
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor)
|
||||
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
||||
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
||||
|
||||
@@ -526,6 +615,13 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
|
||||
expenseService: service.NewExpenseService(processors.expenseProcessor),
|
||||
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
|
||||
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor, processors.walletTraceProcessor),
|
||||
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
|
||||
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
|
||||
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
|
||||
customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor),
|
||||
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
|
||||
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +667,7 @@ type validators struct {
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||
walletValidator *validator.WalletValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -604,22 +701,27 @@ func (a *App) initValidators() *validators {
|
||||
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
|
||||
expenseValidator: validator.NewExpenseValidator(),
|
||||
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
|
||||
walletValidator: validator.NewWalletValidator(),
|
||||
}
|
||||
}
|
||||
|
||||
// buildFCMClient connects to FCM, or returns nil when it is not configured or fails to
|
||||
// start. FCM failing is non-fatal: pushes are skipped and notifications are still
|
||||
// persisted.
|
||||
func buildFCMClient(cfg *config.Config) client.FCMClient {
|
||||
if cfg.FCM.CredentialsFile == "" {
|
||||
return nil
|
||||
}
|
||||
fcmClient, err := client.NewFCMClient(&cfg.FCM)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return fcmClient
|
||||
}
|
||||
|
||||
// buildNotificationProcessor creates the notification processor with FCM integration.
|
||||
// If FCM is not configured, it returns a processor with a nil FCM client (FCM dispatch will be skipped).
|
||||
func buildNotificationProcessor(cfg *config.Config, repos *repositories) *processor.NotificationProcessorImpl {
|
||||
var fcmClient client.FCMClient
|
||||
if cfg.FCM.CredentialsFile != "" {
|
||||
var err error
|
||||
fcmClient, err = client.NewFCMClient(&cfg.FCM)
|
||||
if err != nil {
|
||||
// FCM init failure is non-fatal; notifications will still be persisted.
|
||||
fcmClient = nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildNotificationProcessor(repos *repositories, fcmClient client.FCMClient) *processor.NotificationProcessorImpl {
|
||||
return processor.NewNotificationProcessor(
|
||||
repos.notificationRepo,
|
||||
repos.notificationReceiverRepo,
|
||||
|
||||
@@ -12,6 +12,12 @@ const (
|
||||
ValidationErrorCode = "304"
|
||||
InvalidFieldErrorCode = "305"
|
||||
NotFoundErrorCode = "404"
|
||||
// PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9).
|
||||
PinNotSetErrorCode = "PIN_NOT_SET"
|
||||
PinInvalidErrorCode = "PIN_INVALID"
|
||||
PinLockedErrorCode = "PIN_LOCKED"
|
||||
TransferBlockedErrorCode = "TRANSFER_BLOCKED"
|
||||
TooManyRequestsErrorCode = "429"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -46,7 +52,6 @@ const (
|
||||
TableEntity = "table"
|
||||
// Gamification entities
|
||||
CustomerPointsEntity = "customer_points"
|
||||
CustomerTokensEntity = "customer_tokens"
|
||||
TierEntity = "tier"
|
||||
GameEntity = "game"
|
||||
GamePrizeEntity = "game_prize"
|
||||
@@ -63,15 +68,23 @@ const (
|
||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
WalletServiceEntity = "wallet_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
CustomerPinServiceEntity = "customer_pin_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
PinNotSetErrorCode: http.StatusForbidden,
|
||||
PinInvalidErrorCode: http.StatusBadRequest,
|
||||
PinLockedErrorCode: http.StatusLocked,
|
||||
TransferBlockedErrorCode: http.StatusForbidden,
|
||||
TooManyRequestsErrorCode: http.StatusTooManyRequests,
|
||||
}
|
||||
|
||||
// Error messages
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package constants
|
||||
|
||||
// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in
|
||||
// outlet_settings and organization keys in organization_settings. A key that was
|
||||
// never set takes the default in the PRD.
|
||||
|
||||
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
|
||||
const (
|
||||
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
||||
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
||||
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
||||
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
||||
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
||||
|
||||
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
||||
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
||||
LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order"
|
||||
|
||||
LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment"
|
||||
LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points"
|
||||
LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent"
|
||||
)
|
||||
|
||||
// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and
|
||||
// expiry, which must be the same in every outlet.
|
||||
const (
|
||||
LoyaltyPointValueKey = "loyalty.point.value"
|
||||
|
||||
LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount"
|
||||
LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount"
|
||||
|
||||
LoyaltyTransferEnabledKey = "loyalty.transfer.enabled"
|
||||
LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount"
|
||||
LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction"
|
||||
LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit"
|
||||
|
||||
// Suffixes after "loyalty.point." or "loyalty.coin.".
|
||||
LoyaltyExpiryEnabledSuffix = "expiry_enabled"
|
||||
LoyaltyExpiryPeriodSuffix = "expiry_period"
|
||||
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
||||
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
||||
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
||||
LoyaltyExpiryModeSuffix = "expiry_mode"
|
||||
LoyaltyExpiryFixedDatesSuffix = "expiry_fixed_dates"
|
||||
LoyaltyExpiryGraceMonthsSuffix = "expiry_grace_months"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
const (
|
||||
LoyaltyExpiryUnitDay = "DAY"
|
||||
LoyaltyExpiryUnitMonth = "MONTH"
|
||||
)
|
||||
|
||||
// Modes of loyalty.{point,coin}.expiry_mode (note N4, decided: both are supported).
|
||||
const (
|
||||
// Every balance expires on the same fixed dates of the year, such as 31 Dec.
|
||||
LoyaltyExpiryModeFixedDate = "FIXED_DATE"
|
||||
// Every balance lasts a period from when it was received.
|
||||
LoyaltyExpiryModeRolling = "ROLLING"
|
||||
)
|
||||
|
||||
// Defaults from the PRD.
|
||||
const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
||||
LoyaltyEarnValueDefault = int64(1)
|
||||
|
||||
LoyaltyMinPaymentPointsDefault = int64(1)
|
||||
LoyaltyMaxPaymentPercentDefault = int64(100)
|
||||
|
||||
LoyaltyPointValueDefault = int64(1)
|
||||
LoyaltyExchangeAmountDefault = int64(1)
|
||||
|
||||
LoyaltyTransferEnabledDefault = true
|
||||
LoyaltyTransferMinAmountDefault = int64(1)
|
||||
|
||||
LoyaltyExpiryPeriodDefault = int64(12)
|
||||
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
||||
LoyaltyExpiryReminderDaysDefault = int64(7)
|
||||
LoyaltyExpiryModeDefault = LoyaltyExpiryModeFixedDate
|
||||
// Once a year on 31 December, as MM-DD.
|
||||
LoyaltyExpiryFixedDatesDefault = "12-31"
|
||||
LoyaltyExpiryGraceMonthsDefault = int64(3)
|
||||
)
|
||||
@@ -8,6 +8,9 @@ const (
|
||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
||||
PaymentMethodTypeQR PaymentMethodType = "qr"
|
||||
PaymentMethodTypeEDC PaymentMethodType = "edc"
|
||||
// Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method
|
||||
// type until that phase ships.
|
||||
PaymentMethodTypePoint PaymentMethodType = "point"
|
||||
)
|
||||
|
||||
type PaymentStatus string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package constants
|
||||
|
||||
// SellBy describes how a product's quantity is expressed on an order line.
|
||||
type SellBy = string
|
||||
|
||||
const (
|
||||
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
|
||||
SellByUnit SellBy = "unit"
|
||||
// SellByWeight means the line carries a weighed amount. One weighing is one line,
|
||||
// so quantity stays 1 and the measured value lives in order_items.weight.
|
||||
SellByWeight SellBy = "weight"
|
||||
)
|
||||
|
||||
// IsValidSellBy reports whether s is a supported sell_by value.
|
||||
func IsValidSellBy(s string) bool {
|
||||
return s == SellByUnit || s == SellByWeight
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package constants
|
||||
|
||||
// The two balances a customer wallet holds (docs/prd-point-coin.md). EnakPoint pays
|
||||
// for orders; EnakCoin is spent on games and can be exchanged into EnakPoint.
|
||||
const (
|
||||
WalletCurrencyPoint = "POINT"
|
||||
WalletCurrencyCoin = "COIN"
|
||||
)
|
||||
|
||||
func IsValidWalletCurrency(currency string) bool {
|
||||
return currency == WalletCurrencyPoint || currency == WalletCurrencyCoin
|
||||
}
|
||||
|
||||
// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which
|
||||
// way it moves the balance, and which reference it must carry.
|
||||
const (
|
||||
WalletTxTypeEarn = "EARN"
|
||||
WalletTxTypeEarnReversal = "EARN_REVERSAL"
|
||||
WalletTxTypePayment = "PAYMENT"
|
||||
WalletTxTypePaymentRefund = "PAYMENT_REFUND"
|
||||
WalletTxTypeExchangeOut = "EXCHANGE_OUT"
|
||||
WalletTxTypeExchangeIn = "EXCHANGE_IN"
|
||||
WalletTxTypeTransferOut = "TRANSFER_OUT"
|
||||
WalletTxTypeTransferIn = "TRANSFER_IN"
|
||||
WalletTxTypeGameSpend = "GAME_SPEND"
|
||||
WalletTxTypeExpire = "EXPIRE"
|
||||
WalletTxTypeAdjustment = "ADJUSTMENT"
|
||||
WalletTxTypeMigration = "MIGRATION"
|
||||
WalletTxTypeRewardRedeem = "REWARD_REDEEM"
|
||||
)
|
||||
|
||||
// What a ledger row's reference_id points at: where the value came from for a
|
||||
// credit, or where it went for a debit.
|
||||
const (
|
||||
WalletRefTypeOrder = "ORDER"
|
||||
WalletRefTypePayment = "PAYMENT"
|
||||
WalletRefTypeWalletTx = "WALLET_TX"
|
||||
WalletRefTypeGamePlay = "GAME_PLAY"
|
||||
WalletRefTypeLot = "LOT"
|
||||
WalletRefTypeUser = "USER"
|
||||
WalletRefTypeLegacyPoints = "LEGACY_POINTS"
|
||||
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
||||
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
|
||||
)
|
||||
@@ -28,7 +28,11 @@ type PaymentMethodAnalyticsResponse struct {
|
||||
|
||||
// PaymentMethodSummary represents the summary of payment method analytics
|
||||
type PaymentMethodSummary struct {
|
||||
// Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9).
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
PointAmount float64 `json:"point_amount"`
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
TotalWithPoints float64 `json:"total_with_points"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
TotalPayments int64 `json:"total_payments"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
@@ -42,6 +46,8 @@ type PaymentMethodAnalyticsData struct {
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PaymentCount int64 `json:"payment_count"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
CountsAsCashIn bool `json:"counts_as_cash_in"`
|
||||
}
|
||||
|
||||
type SalesAnalyticsRequest struct {
|
||||
@@ -198,6 +204,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -324,6 +331,7 @@ type ParentCategoryAnalyticsProductData struct {
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
|
||||
@@ -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 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 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 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 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 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"`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package contract
|
||||
|
||||
// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer
|
||||
// app got for this device. Send it after login and whenever FCM refreshes the token.
|
||||
type RegisterCustomerDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
FCMToken string `json:"fcm_token" binding:"required"`
|
||||
// android, ios or web.
|
||||
Platform *string `json:"platform"`
|
||||
AppVersion *string `json:"app_version"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package contract
|
||||
|
||||
// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md
|
||||
// F11). PINs are strings so a leading zero is kept.
|
||||
|
||||
type RequestPinOtpRequest struct {
|
||||
// pin_setup or pin_reset.
|
||||
Purpose string `json:"purpose" binding:"required"`
|
||||
}
|
||||
|
||||
type CreateCustomerPinRequest struct {
|
||||
OtpToken string `json:"otp_token" binding:"required"`
|
||||
OtpCode string `json:"otp_code" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||
}
|
||||
|
||||
type ChangeCustomerPinRequest struct {
|
||||
OldPin string `json:"old_pin" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||
}
|
||||
|
||||
type ResetCustomerPinRequest = CreateCustomerPinRequest
|
||||
|
||||
type RemoveCustomerPinRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// IssuePaymentCodeRequest is POST /customer/wallet/payment-code.
|
||||
type IssuePaymentCodeRequest struct {
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
|
||||
// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points.
|
||||
type PayWithPointsRequest struct {
|
||||
Points int64 `json:"points" binding:"required,min=1"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
@@ -2,66 +2,8 @@ package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Existing gamification contracts
|
||||
type CreateCustomerPointsRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerPointsRequest struct {
|
||||
Points int64 `json:"points" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerPointsRequest struct {
|
||||
Points int64 `json:"points" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerPointsResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerPointsRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedCustomerPointsResponse struct {
|
||||
Data []CustomerPointsResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// New customer API contracts
|
||||
type GetCustomerPointsRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerTokensRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerWalletRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Contracts
|
||||
type GetCustomerPointsResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -83,26 +25,6 @@ type PointsHistoryItem struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetCustomerTokensResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetCustomerTokensResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetCustomerTokensResponseData struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type TokensHistoryItem struct {
|
||||
ID string `json:"id"`
|
||||
Tokens int64 `json:"tokens"`
|
||||
Type string `json:"type"` // EARNED, REDEEMED, EXPIRED
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type GetCustomerWalletResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
@@ -111,8 +33,6 @@ type GetCustomerWalletResponse struct {
|
||||
|
||||
type GetCustomerWalletResponseData struct {
|
||||
TotalPoints int64 `json:"total_points"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCustomerTokensRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerTokensRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerTokensResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerTokensRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
TokenType string `json:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedCustomerTokensResponse struct {
|
||||
Data []CustomerTokensResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
CoinsUsed int `json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type GamePlayResponse struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
TokenUsed int `json:"token_used"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
@@ -33,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 token_used"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
@@ -45,14 +45,15 @@ type PaginatedGamePlaysResponse struct {
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// PlayGameRequest plays a game for a customer. What it costs is the game's
|
||||
// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice.
|
||||
type PlayGameRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
|
||||
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
|
||||
}
|
||||
|
||||
type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
|
||||
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateOrderItemRequest struct {
|
||||
@@ -89,29 +92,38 @@ type OrderResponse struct {
|
||||
TotalPaid float64 `json:"total_paid"`
|
||||
PaymentCount int `json:"payment_count"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
// EnakPoint and EnakCoin the order earned, for the order screen and the receipt
|
||||
// (docs/prd-point-coin.md F3). Zero when it earned nothing.
|
||||
PointsEarned int64 `json:"points_earned"`
|
||||
CoinsEarned int64 `json:"coins_earned"`
|
||||
}
|
||||
|
||||
type OrderItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
UnitName *string `json:"unit_name,omitempty"`
|
||||
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
type ListOrdersQuery struct {
|
||||
@@ -174,9 +186,13 @@ type SetOrderCustomerResponse struct {
|
||||
}
|
||||
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,min=0"`
|
||||
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
||||
// For the EnakPoint method: how many to use and the code the customer shows. The
|
||||
// amount is then computed from them.
|
||||
Points *int64 `json:"points,omitempty"`
|
||||
PaymentCode *string `json:"payment_code,omitempty"`
|
||||
Amount float64 `json:"amount" validate:"min=0"`
|
||||
TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"`
|
||||
SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"`
|
||||
SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"`
|
||||
@@ -192,19 +208,23 @@ type CreatePaymentOrderItemRequest struct {
|
||||
}
|
||||
|
||||
type PaymentResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id"`
|
||||
PaymentMethodName string `json:"payment_method_name"`
|
||||
PaymentMethodType string `json:"payment_method_type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
TransactionID *string `json:"transaction_id,omitempty"`
|
||||
SplitNumber int `json:"split_number"`
|
||||
SplitTotal int `json:"split_total"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
SplitDescription *string `json:"split_description,omitempty"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id"`
|
||||
PaymentMethodName string `json:"payment_method_name"`
|
||||
PaymentMethodType string `json:"payment_method_type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
TransactionID *string `json:"transaction_id,omitempty"`
|
||||
SplitNumber int `json:"split_number"`
|
||||
SplitTotal int `json:"split_total"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
SplitDescription *string `json:"split_description,omitempty"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
// Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the
|
||||
// receipt (docs/prd-point-coin.md F9).
|
||||
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||
PointValue *float64 `json:"point_value,omitempty"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
|
||||
|
||||
@@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct {
|
||||
|
||||
type UpdatePaymentMethodRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
|
||||
Configuration map[string]interface{} `json:"configuration,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
@@ -38,11 +38,13 @@ type PaymentMethodResponse struct {
|
||||
|
||||
type ListPaymentMethodsRequest struct {
|
||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type ListPaymentMethodsResponse struct {
|
||||
|
||||
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,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"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,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"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
@@ -73,6 +77,8 @@ type ProductResponse struct {
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ type SpinGameResponse struct {
|
||||
|
||||
// SpinGameResponseData contains the game play result
|
||||
type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package contract
|
||||
|
||||
// AdjustCustomerWalletRequest is POST /marketing/customers/:id/wallet/adjust
|
||||
// (docs/prd-point-coin.md F7).
|
||||
type AdjustCustomerWalletRequest struct {
|
||||
// POINT or COIN.
|
||||
Currency string `json:"currency"`
|
||||
// Signed: positive adds to the balance, negative takes from it. Never zero.
|
||||
Amount int64 `json:"amount"`
|
||||
Reason string `json:"reason"`
|
||||
// Optional. Sending the same key again returns the first result instead of
|
||||
// adjusting twice, so a retried request is safe.
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
}
|
||||
|
||||
// ExchangeCoinsRequest is POST /customer/wallet/exchange (docs/prd-point-coin.md F4).
|
||||
// The Idempotency-Key header is required.
|
||||
type ExchangeCoinsRequest struct {
|
||||
Coins int64 `json:"coins" binding:"required,min=1"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
|
||||
// TransferWalletRequest is POST /customer/wallet/transfer (docs/prd-point-coin.md F5).
|
||||
// The Idempotency-Key header is required.
|
||||
type TransferWalletRequest struct {
|
||||
// POINT or COIN.
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
Amount int64 `json:"amount" binding:"required,min=1"`
|
||||
RecipientPhone string `json:"recipient_phone" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PaymentCount int64 `json:"payment_count"`
|
||||
// EnakPoint used, for the EnakPoint method.
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
}
|
||||
|
||||
// SalesAnalytics represents sales analytics data
|
||||
@@ -114,6 +116,7 @@ type ProductAnalytics struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -172,15 +175,15 @@ type BudgetCutOffWeek struct {
|
||||
|
||||
// DashboardOverview represents dashboard overview data
|
||||
type DashboardOverview struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
TotalCustomers int64 `json:"total_customers"`
|
||||
VoidedOrders int64 `json:"voided_orders"`
|
||||
RefundedOrders int64 `json:"refunded_orders"`
|
||||
TotalItemSold int64 `json:"total_item_sold"`
|
||||
TotalLowStock int64 `json:"total_low_stock"`
|
||||
TotalProductActive int64 `json:"total_product_active"`
|
||||
}
|
||||
|
||||
type ProfitLossAnalytics struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ type CampaignType string
|
||||
const (
|
||||
CampaignTypeReward CampaignType = "REWARD"
|
||||
CampaignTypePoints CampaignType = "POINTS"
|
||||
CampaignTypeTokens CampaignType = "TOKENS"
|
||||
CampaignTypeCoins CampaignType = "COINS"
|
||||
CampaignTypeMixed CampaignType = "MIXED"
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ type CampaignRewardType string
|
||||
|
||||
const (
|
||||
CampaignRewardTypePoints CampaignRewardType = "POINTS"
|
||||
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
|
||||
CampaignRewardTypeCoins CampaignRewardType = "COINS"
|
||||
CampaignRewardTypeReward CampaignRewardType = "REWARD"
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ func GetAllEntities() []interface{} {
|
||||
&ProductOutletPrice{},
|
||||
&Expense{},
|
||||
&CashAdvance{},
|
||||
// Wallet entities
|
||||
&CustomerWallet{},
|
||||
&WalletTransaction{},
|
||||
&WalletLot{},
|
||||
&WalletLotAllocation{},
|
||||
&LoyaltySettingChange{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ type GamePlay struct {
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"`
|
||||
PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"`
|
||||
TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"`
|
||||
CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoyaltySettingChange records one change to a loyalty setting: which key, from what,
|
||||
// to what, by whom (docs/prd-point-coin.md F2). Append-only.
|
||||
type LoyaltySettingChange struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
// Nil for an organization setting.
|
||||
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
|
||||
Key string `gorm:"not null;size:100" json:"key"`
|
||||
// Nil when the key had no stored value, or has none any more.
|
||||
OldValue *string `gorm:"type:text" json:"old_value"`
|
||||
NewValue *string `gorm:"type:text" json:"new_value"`
|
||||
ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (LoyaltySettingChange) TableName() string {
|
||||
return "loyalty_setting_changes"
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -47,6 +48,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
|
||||
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
|
||||
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
|
||||
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
|
||||
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
|
||||
@@ -68,6 +71,7 @@ type OrderItem struct {
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
|
||||
return "order_items"
|
||||
}
|
||||
|
||||
// IsWeighed reports whether this line was sold by weight rather than by count.
|
||||
func (oi *OrderItem) IsWeighed() bool {
|
||||
return oi.Weight != nil
|
||||
}
|
||||
|
||||
// BillableQuantity is the multiplier for unit price and unit cost on this line:
|
||||
// the weighed amount for weight-based products, the item count otherwise.
|
||||
//
|
||||
// This is the ONLY place that decides between the two. Every price or cost
|
||||
// calculation must go through it — multiplying by Quantity directly would bill a
|
||||
// 4.2 ons fish as a single ons.
|
||||
//
|
||||
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
|
||||
// so there is no ambiguity between counting and weighing.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
|
||||
// voiding or refunding `units` of it.
|
||||
//
|
||||
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
|
||||
// covers the whole weighing — hence the full weight is returned regardless of units.
|
||||
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(units)
|
||||
}
|
||||
|
||||
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
|
||||
// value written back always matches what the database keeps.
|
||||
func RoundMoney(amount float64) float64 {
|
||||
return math.Round(amount*100) / 100
|
||||
}
|
||||
|
||||
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
|
||||
// is identical to the stored one.
|
||||
func RoundWeight(weight float64) float64 {
|
||||
return math.Round(weight*1000) / 1000
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CanBeModified() bool {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package entities
|
||||
|
||||
import "testing"
|
||||
|
||||
func weightPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestBillableQuantity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item OrderItem
|
||||
want float64
|
||||
}{
|
||||
{"unit line counts items", OrderItem{Quantity: 3}, 3},
|
||||
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
|
||||
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.item.BillableQuantity(); got != tt.want {
|
||||
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A weighed line is voided or refunded whole, so the units asked for must not shrink
|
||||
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
|
||||
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
|
||||
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
|
||||
if got := weighed.BillableQuantityFor(1); got != 5.6 {
|
||||
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
|
||||
}
|
||||
|
||||
counted := OrderItem{Quantity: 3}
|
||||
if got := counted.BillableQuantityFor(2); got != 2 {
|
||||
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
|
||||
// must not be billed as a single ons.
|
||||
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 18900 {
|
||||
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 12600 {
|
||||
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 75000 {
|
||||
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 30000 {
|
||||
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rupiah residue must not survive into a decimal(10,2) column.
|
||||
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
|
||||
item.CalculateTotalPrice()
|
||||
|
||||
if item.TotalPrice != 19066.5 {
|
||||
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeighed(t *testing.T) {
|
||||
if (&OrderItem{Quantity: 1}).IsWeighed() {
|
||||
t.Error("unit line reported as weighed")
|
||||
}
|
||||
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
|
||||
t.Error("weighed line reported as unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundWeight(t *testing.T) {
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{4.2, 4.2},
|
||||
{4.2374, 4.237},
|
||||
{4.2375, 4.238},
|
||||
{0.0004, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := RoundWeight(tt.in); got != tt.want {
|
||||
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,16 @@ const (
|
||||
PaymentMethodTypeCash PaymentMethodType = "cash"
|
||||
PaymentMethodTypeCard PaymentMethodType = "card"
|
||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
||||
// The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per
|
||||
// organization; it cannot be created, deleted or retyped through the API.
|
||||
PaymentMethodTypePoint PaymentMethodType = "point"
|
||||
)
|
||||
|
||||
type PaymentMethod struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"`
|
||||
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
|
||||
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"`
|
||||
Processor *string `gorm:"size:100" json:"processor"`
|
||||
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
@@ -69,12 +72,16 @@ type Payment struct {
|
||||
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
|
||||
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
|
||||
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
|
||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
|
||||
// one then, frozen so a refund returns exactly what was used.
|
||||
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
|
||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
|
||||
|
||||
@@ -20,6 +20,7 @@ type Product struct {
|
||||
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"`
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CustomerWallet holds a customer's EnakPoint and EnakCoin balances. The row is also
|
||||
// the lock every wallet operation for the customer takes first, so concurrent
|
||||
// operations on one customer queue up instead of spending the same balance twice.
|
||||
//
|
||||
// Balances are never written directly: they only move together with a ledger row, and
|
||||
// only through the wallet processor.
|
||||
type CustomerWallet struct {
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;primary_key" json:"customer_id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
PointBalance int64 `gorm:"not null;default:0" json:"point_balance"`
|
||||
CoinBalance int64 `gorm:"not null;default:0" json:"coin_balance"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CustomerWallet) TableName() string {
|
||||
return "customer_wallets"
|
||||
}
|
||||
|
||||
// WalletTransaction is one ledger row. The ledger is append-only: a correction is a
|
||||
// new row pointing at the one it corrects, never an update.
|
||||
type WalletTransaction struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
||||
Currency string `gorm:"not null;size:10" json:"currency"`
|
||||
Type string `gorm:"not null;size:30" json:"type"`
|
||||
// Signed: positive credits the wallet, negative debits it.
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
BalanceAfter int64 `gorm:"not null" json:"balance_after"`
|
||||
GroupID *uuid.UUID `gorm:"type:uuid" json:"group_id"`
|
||||
|
||||
// Where the value came from (credit) or went to (debit).
|
||||
ReferenceType string `gorm:"not null;size:30" json:"reference_type"`
|
||||
ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"`
|
||||
|
||||
CounterpartyCustomerID *uuid.UUID `gorm:"type:uuid" json:"counterparty_customer_id"`
|
||||
ReversesTransactionID *uuid.UUID `gorm:"type:uuid" json:"reverses_transaction_id"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
|
||||
CreatedByUser *uuid.UUID `gorm:"type:uuid" json:"created_by_user"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
|
||||
// Frozen at creation, so later renames do not rewrite history.
|
||||
Description string `gorm:"not null;size:255" json:"description"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IdempotencyKey *string `gorm:"size:100;unique" json:"idempotency_key"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (t *WalletTransaction) BeforeCreate(tx *gorm.DB) error {
|
||||
if t.ID == uuid.Nil {
|
||||
t.ID = uuid.New()
|
||||
}
|
||||
// A nil map would be stored as JSON null rather than an empty object.
|
||||
if t.Metadata == nil {
|
||||
t.Metadata = Metadata{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (WalletTransaction) TableName() string {
|
||||
return "wallet_transactions"
|
||||
}
|
||||
|
||||
// WalletLot is one credited piece of balance with its own expiry (K9). Debits draw from
|
||||
// the lots that expire soonest. A lot created by a transfer, exchange or refund carries
|
||||
// the expiry of the lot it came from and points back at it through OriginLotID.
|
||||
type WalletLot struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null" json:"customer_id"`
|
||||
Currency string `gorm:"not null;size:10" json:"currency"`
|
||||
SourceTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"source_transaction_id"`
|
||||
OriginLotID *uuid.UUID `gorm:"type:uuid" json:"origin_lot_id"`
|
||||
OriginalAmount int64 `gorm:"not null" json:"original_amount"`
|
||||
// A cache of OriginalAmount minus the lot's allocations, and the only wallet column
|
||||
// that is ever updated.
|
||||
RemainingAmount int64 `gorm:"not null" json:"remaining_amount"`
|
||||
// Nil means the lot never expires.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (l *WalletLot) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.ID == uuid.Nil {
|
||||
l.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (WalletLot) TableName() string {
|
||||
return "wallet_lots"
|
||||
}
|
||||
|
||||
// WalletLotAllocation records how much a debit ledger row drew from one lot.
|
||||
type WalletLotAllocation struct {
|
||||
TransactionID uuid.UUID `gorm:"type:uuid;primary_key" json:"transaction_id"`
|
||||
LotID uuid.UUID `gorm:"type:uuid;primary_key" json:"lot_id"`
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
}
|
||||
|
||||
func (WalletLotAllocation) TableName() string {
|
||||
return "wallet_lot_allocations"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerDeviceHandler serves /customer/devices, where the customer app registers
|
||||
// the FCM token of each device it runs on.
|
||||
type CustomerDeviceHandler struct {
|
||||
devices service.CustomerDeviceService
|
||||
}
|
||||
|
||||
func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler {
|
||||
return &CustomerDeviceHandler{devices: devices}
|
||||
}
|
||||
|
||||
// Register is PUT /customer/devices.
|
||||
func (h *CustomerDeviceHandler) Register(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RegisterCustomerDeviceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"),
|
||||
}), "CustomerDeviceHandler::Register")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register")
|
||||
}
|
||||
|
||||
// Unregister is DELETE /customer/devices/:device_id, for logout.
|
||||
func (h *CustomerDeviceHandler) Unregister(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points
|
||||
// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged.
|
||||
type CustomerOrderPaymentHandler struct {
|
||||
payments service.CustomerOrderPaymentService
|
||||
}
|
||||
|
||||
func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler {
|
||||
return &CustomerOrderPaymentHandler{payments: payments}
|
||||
}
|
||||
|
||||
func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.PayWithPointsRequest
|
||||
if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints
|
||||
// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body,
|
||||
// and binding errors are reported without the values sent.
|
||||
type CustomerPinHandler struct {
|
||||
pinService service.CustomerPinService
|
||||
}
|
||||
|
||||
func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler {
|
||||
return &CustomerPinHandler{pinService: pinService}
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) Status(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) RequestOtp(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RequestPinOtpRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) CreatePin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.CreateCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) ChangePin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ChangeCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) ResetPin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ResetCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin")
|
||||
}
|
||||
|
||||
// RemovePin is DELETE /marketing/customers/:id/pin.
|
||||
func (h *CustomerPinHandler) RemovePin(c *gin.Context) {
|
||||
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RemoveCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin")
|
||||
}
|
||||
|
||||
// ListSecurityEvents is GET /marketing/customers/:id/security-events.
|
||||
func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) {
|
||||
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents")
|
||||
}
|
||||
|
||||
// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the
|
||||
// values, since those can be PINs.
|
||||
func bindPinRequest(c *gin.Context, req interface{}, method string) bool {
|
||||
if err := c.ShouldBindJSON(req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"),
|
||||
}), method)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// customerIDFromGin reads the customer set by CustomerAuthMiddleware.
|
||||
func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
|
||||
raw, _ := c.Get("customer_id")
|
||||
s, _ := raw.(string)
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
|
||||
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
|
||||
}
|
||||
|
||||
// IssuePaymentCode is POST /customer/wallet/payment-code.
|
||||
func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.IssuePaymentCodeRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode")
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
@@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
@@ -54,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(constants.InternalServerErrorCode, 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()
|
||||
|
||||
@@ -114,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
@@ -151,3 +122,74 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
}
|
||||
|
||||
// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest
|
||||
// first, filterable by currency, type and date (docs/prd-point-coin.md F6).
|
||||
func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
customerID, ok := c.Get("customer_id")
|
||||
customerIDStr, isString := customerID.(string)
|
||||
if !ok || !isString {
|
||||
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::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
}
|
||||
|
||||
// walletErrorCode tells a bad request apart from a server failure on the wallet
|
||||
// endpoints.
|
||||
func walletErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, processor.ErrInvalidWalletQuery):
|
||||
return constants.ValidationErrorCode
|
||||
case errors.Is(err, repository.ErrWalletNotFound):
|
||||
return constants.NotFoundErrorCode
|
||||
default:
|
||||
return constants.InternalServerErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
// GetCustomerWalletExpiring is GET /customer/wallet/expiring: what will expire, per
|
||||
// currency and day (docs/prd-point-coin.md F6).
|
||||
func (h *CustomerPointsHandler) GetCustomerWalletExpiring(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerID, ok := c.Get("customer_id")
|
||||
customerIDStr, isString := customerID.(string)
|
||||
if !ok || !isString {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletExpiring")
|
||||
return
|
||||
}
|
||||
response, err := h.customerPointsService.GetCustomerWalletExpiring(ctx, customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletExpiring -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletExpiring")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletExpiring")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, customer, other := uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{customer, other}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet api test', 'basic')`, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
|
||||
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
|
||||
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
|
||||
// that the expiry job has not processed.
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
next := time.Now().Year() + 1
|
||||
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
|
||||
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
|
||||
Description: "Saldo awal dari sistem lama"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
|
||||
Description: "Bayar #ORD-2"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
|
||||
})
|
||||
return err
|
||||
}))
|
||||
|
||||
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
|
||||
nil,
|
||||
)))
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(id uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
|
||||
}
|
||||
for _, who := range []struct {
|
||||
prefix string
|
||||
id uuid.UUID
|
||||
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
|
||||
g := router.Group(who.prefix, as(who.id))
|
||||
g.GET("/points", h.GetCustomerPoints)
|
||||
g.GET("/wallet", h.GetCustomerWallet)
|
||||
g.GET("/wallet/transactions", h.GetCustomerWalletTransactions)
|
||||
}
|
||||
get := func(path string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
var body map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
|
||||
return rec.Code, body
|
||||
}
|
||||
// The balance endpoints wrap their data in {status, message, data}.
|
||||
inner := func(body map[string]any) map[string]any {
|
||||
return body["data"].(map[string]any)["data"].(map[string]any)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// /wallet: new fields, with the old ones still there.
|
||||
status, body = get("/a/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
w := inner(body)
|
||||
assert.EqualValues(t, 250, w["point_balance"])
|
||||
assert.EqualValues(t, 8, w["coin_balance"])
|
||||
assert.EqualValues(t, 100, w["point_value"])
|
||||
assert.EqualValues(t, 25000, w["point_discount_value"])
|
||||
nearest := w["nearest_expiring"].(map[string]any)
|
||||
// The payment took 50 from the lot expiring first, leaving 50 + 200.
|
||||
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", "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)
|
||||
require.Len(t, recent, 4)
|
||||
for _, raw := range recent {
|
||||
tx := raw.(map[string]any)
|
||||
assert.NotContains(t, tx, "counterparty_customer_id")
|
||||
assert.NotContains(t, tx, "metadata")
|
||||
if tx["amount"].(float64) > 0 {
|
||||
assert.Contains(t, tx, "source", tx["type"])
|
||||
assert.NotContains(t, tx, "destination")
|
||||
} else {
|
||||
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
|
||||
assert.NotContains(t, tx, "source")
|
||||
}
|
||||
}
|
||||
|
||||
// History with filters.
|
||||
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
page := body["data"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
|
||||
rows := page["data"].([]any)
|
||||
require.Len(t, rows, 1)
|
||||
first := rows[0].(map[string]any)
|
||||
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
|
||||
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
|
||||
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
|
||||
assert.Len(t, first["lots"], 2)
|
||||
|
||||
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
|
||||
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
status, _ = get("/a/wallet/transactions?currency=GOLD")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
status, _ = get("/a/wallet/transactions?limit=abc")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// A customer who has never had a wallet sees zeros, not an error.
|
||||
status, body = get("/b/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0, inner(body)["point_balance"])
|
||||
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
|
||||
status, body = get("/b/wallet/transactions")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
|
||||
|
||||
// A token for a customer that no longer exists.
|
||||
status, _ = get("/gone/wallet")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
}
|
||||
|
||||
func parseTime(t *testing.T, v any) time.Time {
|
||||
t.Helper()
|
||||
s, ok := v.(string)
|
||||
require.True(t, ok, "expected a time string, got %v", v)
|
||||
parsed, err := time.Parse(time.RFC3339Nano, s)
|
||||
require.NoError(t, err)
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the
|
||||
// X- form the rest of the API uses, which is accepted too.
|
||||
const (
|
||||
idempotencyKeyHeader = "Idempotency-Key"
|
||||
legacyIdempotencyKeyHeader = "X-Idempotency-Key"
|
||||
)
|
||||
|
||||
// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies
|
||||
// hold the PIN, so they are never logged.
|
||||
type CustomerWalletHandler struct {
|
||||
wallets service.CustomerWalletService
|
||||
}
|
||||
|
||||
func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler {
|
||||
return &CustomerWalletHandler{wallets: wallets}
|
||||
}
|
||||
|
||||
// PreviewExchange is GET /customer/wallet/exchange/preview?coins=.
|
||||
func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var coins int64
|
||||
if raw := c.Query("coins"); raw != "" {
|
||||
var err error
|
||||
coins, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"),
|
||||
}), "CustomerWalletHandler::PreviewExchange")
|
||||
return
|
||||
}
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange")
|
||||
}
|
||||
|
||||
// Exchange is POST /customer/wallet/exchange.
|
||||
func (h *CustomerWalletHandler) Exchange(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ExchangeCoinsRequest
|
||||
if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange")
|
||||
}
|
||||
|
||||
func idempotencyKey(c *gin.Context) string {
|
||||
if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader))
|
||||
}
|
||||
|
||||
// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=.
|
||||
func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient")
|
||||
}
|
||||
|
||||
// Transfer is POST /customer/wallet/transfer.
|
||||
func (h *CustomerWalletHandler) Transfer(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.TransferWalletRequest
|
||||
if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer")
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GamificationHandler struct {
|
||||
@@ -27,239 +26,6 @@ func NewGamificationHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Customer Points Handlers
|
||||
func (h *GamificationHandler) CreateCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var req contract.CreateCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateCustomerPoints(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::CreateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetCustomerPoints(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerPointsByCustomerID(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerPointsByCustomerID")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetCustomerPointsByCustomerID(ctx, customerID)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerPointsByCustomerID -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::GetCustomerPointsByCustomerID")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerPointsByCustomerID")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListCustomerPointsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListCustomerPoints(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::ListCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) UpdateCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.UpdateCustomerPoints(ctx, id, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::UpdateCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeleteCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.gamificationService.DeleteCustomerPoints(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeleteCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) AddCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.AddCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.AddCustomerPoints(ctx, customerID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::AddCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerPoints")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeductCustomerPoints(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerPointsEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.DeductCustomerPointsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerPointsRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerPoints -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.DeductCustomerPoints(ctx, customerID, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerPointsEntity, err.Error())}), "GamificationHandler::DeductCustomerPoints")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerPoints")
|
||||
}
|
||||
|
||||
// Play Game Handler
|
||||
func (h *GamificationHandler) PlayGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
@@ -288,240 +54,3 @@ func (h *GamificationHandler) PlayGame(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame")
|
||||
}
|
||||
|
||||
// Additional handler methods for other gamification features
|
||||
func (h *GamificationHandler) CreateCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.CreateCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateCreateCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.CreateCustomerTokens(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::CreateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.GetCustomerTokens(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) GetCustomerTokensByCustomerIDAndType(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := c.Param("token_type")
|
||||
|
||||
response, err := h.gamificationService.GetCustomerTokensByCustomerIDAndType(ctx, customerID, tokenType)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetCustomerTokensByCustomerIDAndType -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetCustomerTokensByCustomerIDAndType")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) ListCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var req contract.ListCustomerTokensRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateListCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.ListCustomerTokens(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::ListCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) UpdateCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.UpdateCustomerTokens(ctx, id, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::UpdateCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeleteCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
idStr := c.Param("id")
|
||||
id, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> invalid ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.gamificationService.DeleteCustomerTokens(ctx, id)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeleteCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) AddCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := c.Param("token_type")
|
||||
|
||||
var req contract.AddCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateAddCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::AddCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.AddCustomerTokens(ctx, customerID, tokenType, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::AddCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::AddCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::AddCustomerTokens")
|
||||
}
|
||||
|
||||
func (h *GamificationHandler) DeductCustomerTokens(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
customerIDStr := c.Param("customer_id")
|
||||
customerID, err := uuid.Parse(customerIDStr)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> invalid customer ID")
|
||||
validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.CustomerTokensEntity, "Invalid customer ID format")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
tokenType := c.Param("token_type")
|
||||
|
||||
var req contract.DeductCustomerTokensRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidateDeductCustomerTokensRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::DeductCustomerTokens -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.DeductCustomerTokens(ctx, customerID, tokenType, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeductCustomerTokens -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CustomerTokensEntity, err.Error())}), "GamificationHandler::DeductCustomerTokens")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::DeductCustomerTokens")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string]struct {
|
||||
org uuid.UUID
|
||||
role string
|
||||
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
|
||||
g := router.Group(prefix, as(who.org, who.role))
|
||||
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
|
||||
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
|
||||
}
|
||||
call := func(method, path string, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
path := "/outlets/" + outlet.String() + "/loyalty-settings"
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
// An outlet without settings shows every default and 1% cashback.
|
||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
||||
|
||||
// A partial update keeps everything it does not mention.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
|
||||
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
|
||||
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
|
||||
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
|
||||
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
|
||||
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
|
||||
assert.Len(t, got["changes"], 3)
|
||||
|
||||
// The cashback follows the organization's point value.
|
||||
orgSettings, err := settings.Organization(context.Background(), org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 5
|
||||
_, _, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
|
||||
|
||||
// Sending back what GET returned changes nothing.
|
||||
echo, err := json.Marshal(data(body))
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodPut, "/manager"+path, string(echo))
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Empty(t, data(body)["changes"])
|
||||
|
||||
// null clears a limit.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
|
||||
|
||||
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
|
||||
for name, bad := range map[string]string{
|
||||
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
||||
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
||||
"unknown field": `{"point": {"earn_per_amout": 50}}`,
|
||||
"wrong type": `{"point": {"enabled": "yes"}}`,
|
||||
"not json": `enabled=true`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager"+path, bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
|
||||
|
||||
// Purchasing staff can read but not change; another organization sees nothing.
|
||||
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, _ = call(http.MethodGet, "/other"+path, "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Every change is in the history with who made it.
|
||||
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
|
||||
for _, c := range history.Data {
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB.
|
||||
const loyaltySettingsBodyLimit = 64 << 10
|
||||
|
||||
// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2).
|
||||
type LoyaltySettingsHandler struct {
|
||||
loyaltySettingsService service.LoyaltySettingsService
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler {
|
||||
return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService}
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings")
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
|
||||
}), "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
}
|
||||
|
||||
// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one.
|
||||
func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings")
|
||||
}
|
||||
|
||||
// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it
|
||||
// saves nothing and returns what would change and what the balances in circulation
|
||||
// would be worth, for the warning the dashboard shows before saving (F2).
|
||||
func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
|
||||
}), "LoyaltySettingsHandler::UpdateOrganizationSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true")
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings")
|
||||
}
|
||||
|
||||
// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet
|
||||
// (?outlet_id=).
|
||||
func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var outletID *uuid.UUID
|
||||
if raw := c.Query("outlet_id"); raw != "" {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"),
|
||||
}), "LoyaltySettingsHandler::ListHistory")
|
||||
return
|
||||
}
|
||||
outletID = &id
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to
|
||||
// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic')`, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Balances in circulation: 50.000 EnakPoint and 105 EnakCoin.
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
for _, c := range []struct {
|
||||
currency, ref string
|
||||
amount int64
|
||||
}{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} {
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount,
|
||||
ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal",
|
||||
Metadata: map[string]interface{}{"frozen": true}}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
|
||||
role := role
|
||||
g := router.Group(prefix, func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
g.GET("/loyalty-settings", h.GetOrganizationSettings)
|
||||
g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings)
|
||||
g.GET("/loyalty-settings/history", h.ListHistory)
|
||||
}
|
||||
call := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
status, body := call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"])
|
||||
assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"])
|
||||
assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"])
|
||||
impact := got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["outstanding_points"])
|
||||
assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"])
|
||||
|
||||
// A dry run shows what the balances in circulation would be worth, and saves nothing.
|
||||
change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}`
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["dry_run"])
|
||||
impact = got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["point_rupiah_before"])
|
||||
assert.EqualValues(t, 5000000, impact["point_rupiah_after"])
|
||||
assert.EqualValues(t, 105, impact["coins_as_points_before"])
|
||||
assert.EqualValues(t, 10, impact["coins_as_points_after"])
|
||||
assert.Len(t, got["changes"], 2)
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing")
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
// Saving records every change with who made it.
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings", change)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, false, data(body)["dry_run"])
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
history := data(body)["data"].([]any)
|
||||
require.Len(t, history, 2)
|
||||
var keys []string
|
||||
for _, raw := range history {
|
||||
row := raw.(map[string]any)
|
||||
keys = append(keys, row["key"].(string))
|
||||
assert.Equal(t, admin.String(), row["changed_by"])
|
||||
assert.Nil(t, row["old_value"], "was on its default")
|
||||
assert.Nil(t, row["outlet_id"])
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys)
|
||||
|
||||
// Nothing already written changes: balances and ledger rows keep their numbers.
|
||||
var rows []struct {
|
||||
Amount int64
|
||||
Metadata string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error)
|
||||
require.Len(t, rows, 2)
|
||||
assert.Equal(t, int64(105), rows[0].Amount)
|
||||
assert.Equal(t, int64(50000), rows[1].Amount)
|
||||
for _, r := range rows {
|
||||
assert.Contains(t, r.Metadata, `"frozen": true`)
|
||||
}
|
||||
|
||||
// Out of bounds, unknown fields and the wrong role are refused.
|
||||
for name, bad := range map[string]string{
|
||||
"point value 0": `{"point_value": 0}`,
|
||||
"exchange 0": `{"exchange": {"coin_amount": 0}}`,
|
||||
"expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`,
|
||||
"unknown field": `{"point_valeu": 5}`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)")
|
||||
}
|
||||
status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/transformer"
|
||||
"apskel-pos-be/internal/util"
|
||||
@@ -202,6 +205,11 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
|
||||
// Refusing to hand EnakPoint back as cash is a bad request, not a server fault.
|
||||
if errors.Is(err, processor.ErrPointPaymentRejected) {
|
||||
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
|
||||
return
|
||||
}
|
||||
@@ -225,6 +233,11 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) {
|
||||
|
||||
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq)
|
||||
if err != nil {
|
||||
// A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault.
|
||||
if errors.Is(err, processor.ErrPointPaymentRejected) {
|
||||
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,6 +113,16 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) {
|
||||
|
||||
req.OrganizationID = &contextInfo.OrganizationID
|
||||
|
||||
// At the cashier, EnakPoint is listed only where the outlet accepts it (F9).
|
||||
if outletStr := c.Query("outlet_id"); outletStr != "" {
|
||||
if outletID, err := uuid.Parse(outletStr); err == nil {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
} else if contextInfo.OutletID != uuid.Nil {
|
||||
outletID := contextInfo.OutletID
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
|
||||
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
req.IsActive = &isActive
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// PointPaymentHandler serves GET /orders/:id/point-payment/preview
|
||||
// (docs/prd-point-coin.md F9).
|
||||
type PointPaymentHandler struct {
|
||||
pointPaymentService service.PointPaymentService
|
||||
}
|
||||
|
||||
func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler {
|
||||
return &PointPaymentHandler{pointPaymentService: pointPaymentService}
|
||||
}
|
||||
|
||||
func (h *PointPaymentHandler) Preview(c *gin.Context) {
|
||||
orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview")
|
||||
}
|
||||
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
Notes: item.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
@@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) {
|
||||
// Play the spin game
|
||||
response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq)
|
||||
if err != nil {
|
||||
// Not enough EnakCoin, an inactive game or a prize that just ran out is the
|
||||
// customer's to fix, not a server failure.
|
||||
code := constants.InternalServerErrorCode
|
||||
if errors.Is(err, processor.ErrGamePlayRefused) {
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()),
|
||||
contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()),
|
||||
}), "SpinGameHandler::PlaySpinGame")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
orgA, orgB := uuid.New(), uuid.New()
|
||||
adminA, adminB := uuid.New(), uuid.New()
|
||||
customer := uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
|
||||
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
|
||||
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
|
||||
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
|
||||
})
|
||||
|
||||
walletRepo := repository.NewWalletRepository(db)
|
||||
queryRepo := repository.NewWalletQueryRepository(db)
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(walletRepo)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
outlet := uuid.New()
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
|
||||
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Belanja #ORD-1"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
h := NewWalletAdminHandler(
|
||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm), txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
|
||||
validator.NewWalletValidator(),
|
||||
)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(org, user uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
|
||||
g := router.Group(prefix, as(who[0], who[1]))
|
||||
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
|
||||
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
|
||||
}
|
||||
call := func(method, path string, body any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
require.NoError(t, json.NewEncoder(&buf).Encode(body))
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
|
||||
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
|
||||
}
|
||||
spendable := func() float64 {
|
||||
t.Helper()
|
||||
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
|
||||
}
|
||||
|
||||
// Add 50.
|
||||
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
|
||||
assert.Equal(t, "ADJUSTMENT", tx["type"])
|
||||
assert.EqualValues(t, 50, tx["amount"])
|
||||
assert.Equal(t, "komplain #45", tx["reason"])
|
||||
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
|
||||
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
|
||||
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
|
||||
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
|
||||
|
||||
// Taking more than the customer can spend is refused and changes nothing.
|
||||
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
assert.Equal(t, float64(150), spendable())
|
||||
|
||||
// Taking away, retried with the same key, happens once.
|
||||
for i, wantReplayed := range []bool{false, true} {
|
||||
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
|
||||
}
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// Bad requests.
|
||||
for name, req := range map[string]map[string]any{
|
||||
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
|
||||
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
|
||||
"no reason": {"currency": "POINT", "amount": 10},
|
||||
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
|
||||
} {
|
||||
status, _ = adjust("/a", req)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// Another organization's admin cannot see or touch this customer.
|
||||
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// The dashboard view.
|
||||
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
w := body["data"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
|
||||
assert.EqualValues(t, 120, w["point_balance"])
|
||||
assert.EqualValues(t, 120, w["spendable_point_balance"])
|
||||
lots := w["lots"].([]any)
|
||||
var lotTotal float64
|
||||
for _, l := range lots {
|
||||
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
|
||||
}
|
||||
assert.Equal(t, float64(120), lotTotal)
|
||||
|
||||
rows := w["transactions"].(map[string]any)["data"].([]any)
|
||||
require.Len(t, rows, 2, "the two adjustments, newest first")
|
||||
newest := rows[0].(map[string]any)
|
||||
assert.EqualValues(t, -30, newest["amount"])
|
||||
assert.Equal(t, "salah input", newest["reason"])
|
||||
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
|
||||
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
|
||||
|
||||
// The customer's own history shows the adjustment too, with the reason in the
|
||||
// description and without the admin's identity fields.
|
||||
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
|
||||
crouter := gin.New()
|
||||
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
||||
rec := httptest.NewRecorder()
|
||||
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var cbody map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
|
||||
crows := cbody["data"].(map[string]any)["data"].([]any)
|
||||
require.Len(t, crows, 2)
|
||||
first := crows[0].(map[string]any)
|
||||
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
|
||||
assert.NotContains(t, first, "created_by")
|
||||
assert.NotContains(t, first, "reason")
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7).
|
||||
type WalletAdminHandler struct {
|
||||
walletAdminService service.WalletAdminService
|
||||
walletValidator validator.WalletValidator
|
||||
}
|
||||
|
||||
func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler {
|
||||
return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator}
|
||||
}
|
||||
|
||||
func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
customerID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
|
||||
}), "WalletAdminHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "WalletAdminHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet")
|
||||
}
|
||||
|
||||
func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
customerID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.AdjustCustomerWalletRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.RequestEntity, validationError.Error()),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
|
||||
}
|
||||
|
||||
// TraceTransaction is GET /marketing/wallet-transactions/:id/trace.
|
||||
func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction")
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToCustomerPointsResponse converts a customer points entity to a customer points response
|
||||
func ToCustomerPointsResponse(customerPoints *entities.CustomerPoints) *models.CustomerPointsResponse {
|
||||
if customerPoints == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CustomerPointsResponse{
|
||||
ID: customerPoints.ID,
|
||||
CustomerID: customerPoints.CustomerID,
|
||||
Balance: customerPoints.Balance,
|
||||
Customer: ToCustomerResponse(&customerPoints.Customer),
|
||||
CreatedAt: customerPoints.CreatedAt,
|
||||
UpdatedAt: customerPoints.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToCustomerPointsResponses converts a slice of customer points entities to customer points responses
|
||||
func ToCustomerPointsResponses(customerPoints []entities.CustomerPoints) []models.CustomerPointsResponse {
|
||||
responses := make([]models.CustomerPointsResponse, len(customerPoints))
|
||||
for i, cp := range customerPoints {
|
||||
responses[i] = *ToCustomerPointsResponse(&cp)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToCustomerPointsEntity converts a create customer points request to a customer points entity
|
||||
func ToCustomerPointsEntity(req *models.CreateCustomerPointsRequest) *entities.CustomerPoints {
|
||||
return &entities.CustomerPoints{
|
||||
CustomerID: req.CustomerID,
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCustomerPointsEntity updates a customer points entity with update request data
|
||||
func UpdateCustomerPointsEntity(customerPoints *entities.CustomerPoints, req *models.UpdateCustomerPointsRequest) {
|
||||
if req.Balance >= 0 {
|
||||
customerPoints.Balance = req.Balance
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// ToCustomerTokensResponse converts a customer tokens entity to a customer tokens response
|
||||
func ToCustomerTokensResponse(customerTokens *entities.CustomerTokens) *models.CustomerTokensResponse {
|
||||
if customerTokens == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CustomerTokensResponse{
|
||||
ID: customerTokens.ID,
|
||||
CustomerID: customerTokens.CustomerID,
|
||||
TokenType: string(customerTokens.TokenType),
|
||||
Balance: customerTokens.Balance,
|
||||
Customer: ToCustomerResponse(&customerTokens.Customer),
|
||||
CreatedAt: customerTokens.CreatedAt,
|
||||
UpdatedAt: customerTokens.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToCustomerTokensResponses converts a slice of customer tokens entities to customer tokens responses
|
||||
func ToCustomerTokensResponses(customerTokens []entities.CustomerTokens) []models.CustomerTokensResponse {
|
||||
responses := make([]models.CustomerTokensResponse, len(customerTokens))
|
||||
for i, ct := range customerTokens {
|
||||
responses[i] = *ToCustomerTokensResponse(&ct)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
// ToCustomerTokensEntity converts a create customer tokens request to a customer tokens entity
|
||||
func ToCustomerTokensEntity(req *models.CreateCustomerTokensRequest) *entities.CustomerTokens {
|
||||
return &entities.CustomerTokens{
|
||||
CustomerID: req.CustomerID,
|
||||
TokenType: entities.TokenType(req.TokenType),
|
||||
Balance: req.Balance,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateCustomerTokensEntity updates a customer tokens entity with update request data
|
||||
func UpdateCustomerTokensEntity(customerTokens *entities.CustomerTokens, req *models.UpdateCustomerTokensRequest) {
|
||||
if req.Balance >= 0 {
|
||||
customerTokens.Balance = req.Balance
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse {
|
||||
GameID: gamePlay.GameID,
|
||||
CustomerID: gamePlay.CustomerID,
|
||||
PrizeID: gamePlay.PrizeID,
|
||||
TokenUsed: gamePlay.TokenUsed,
|
||||
CoinsUsed: gamePlay.CoinsUsed,
|
||||
RandomSeed: gamePlay.RandomSeed,
|
||||
CreatedAt: gamePlay.CreatedAt,
|
||||
Game: ToGameResponse(&gamePlay.Game),
|
||||
@@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay {
|
||||
return &entities.GamePlay{
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
TokenUsed: req.TokenUsed,
|
||||
CoinsUsed: req.CoinsUsed,
|
||||
RandomSeed: req.RandomSeed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,12 +115,23 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
||||
}
|
||||
}
|
||||
|
||||
// Unit is only meaningful for weighed lines, but carry it whenever it was captured.
|
||||
var unitName, unitAbbreviation *string
|
||||
if item.Unit != nil {
|
||||
unitName = &item.Unit.Name
|
||||
unitAbbreviation = item.Unit.Abbreviation
|
||||
}
|
||||
|
||||
response := &models.OrderItemResponse{
|
||||
ID: item.ID,
|
||||
OrderID: item.OrderID,
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
UnitID: item.UnitID,
|
||||
UnitName: unitName,
|
||||
UnitAbbreviation: unitAbbreviation,
|
||||
UnitPrice: item.UnitPrice,
|
||||
TotalPrice: item.TotalPrice,
|
||||
UnitCost: item.UnitCost,
|
||||
@@ -178,6 +189,8 @@ func PaymentEntityToResponse(payment *entities.Payment) *models.PaymentResponse
|
||||
SplitType: (*string)(payment.SplitType),
|
||||
SplitDescription: payment.SplitDescription,
|
||||
RefundAmount: payment.RefundAmount,
|
||||
PointsUsed: payment.PointsUsed,
|
||||
PointValue: payment.PointValue,
|
||||
RefundReason: payment.RefundReason,
|
||||
RefundedAt: payment.RefundedAt,
|
||||
RefundedBy: payment.RefundedBy,
|
||||
|
||||
@@ -134,6 +134,7 @@ func ListPaymentMethodsContractToModel(req *contract.ListPaymentMethodsRequest)
|
||||
|
||||
return &models.ListPaymentMethodsRequest{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
Type: paymentMethodType,
|
||||
IsActive: req.IsActive,
|
||||
Search: req.Search,
|
||||
|
||||
@@ -25,6 +25,9 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
HasIngredients: entity.HasIngredients,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
@@ -49,6 +52,9 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
||||
BusinessType: string(model.BusinessType),
|
||||
ImageURL: model.ImageURL,
|
||||
PrinterType: model.PrinterType,
|
||||
UnitID: model.UnitID,
|
||||
SellBy: model.SellBy,
|
||||
HasIngredients: model.HasIngredients,
|
||||
Metadata: entities.Metadata(model.Metadata),
|
||||
IsActive: model.IsActive,
|
||||
CreatedAt: model.CreatedAt,
|
||||
@@ -76,6 +82,11 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
printerType = *req.PrinterType
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if constants.IsValidSellBy(req.SellBy) {
|
||||
sellBy = req.SellBy
|
||||
}
|
||||
|
||||
metadata := entities.Metadata{}
|
||||
if req.Metadata != nil {
|
||||
metadata = entities.Metadata(req.Metadata)
|
||||
@@ -92,6 +103,8 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: printerType,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
@@ -140,6 +153,8 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
IsActive: entity.IsActive,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
@@ -185,6 +200,14 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
||||
entity.PrinterType = *req.PrinterType
|
||||
}
|
||||
|
||||
if req.UnitID != nil {
|
||||
entity.UnitID = req.UnitID
|
||||
}
|
||||
|
||||
if req.SellBy != nil && constants.IsValidSellBy(*req.SellBy) {
|
||||
entity.SellBy = *req.SellBy
|
||||
}
|
||||
|
||||
if req.Metadata != nil {
|
||||
if entity.Metadata == nil {
|
||||
entity.Metadata = make(entities.Metadata)
|
||||
|
||||
@@ -36,9 +36,9 @@ func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *con
|
||||
}
|
||||
|
||||
return &contract.SpinGameResponseData{
|
||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
||||
TokensRemaining: data.TokensRemaining,
|
||||
GamePlay: GamePlayResponseModelToContract(&data.GamePlay),
|
||||
PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon),
|
||||
CoinsRemaining: data.CoinsRemaining,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.Gam
|
||||
GameID: resp.GameID,
|
||||
CustomerID: resp.CustomerID,
|
||||
PrizeID: resp.PrizeID,
|
||||
TokenUsed: resp.TokenUsed,
|
||||
CoinsUsed: resp.CoinsUsed,
|
||||
RandomSeed: resp.RandomSeed,
|
||||
CreatedAt: resp.CreatedAt,
|
||||
Game: nil, // Optional field - can be populated separately if needed
|
||||
|
||||
@@ -85,6 +85,14 @@ func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
// RequireLoyaltyManager guards what moves or prices EnakPoint and EnakCoin: loyalty
|
||||
// settings and manual wallet adjustments (docs/prd-point-coin.md F1, F2, F7). Only
|
||||
// admins and managers may do these; unlike RequireAdminOrManager it does not let the
|
||||
// purchasing role through.
|
||||
func (m *AuthMiddleware) RequireLoyaltyManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner")
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,14 @@ type PaymentMethodAnalyticsResponse struct {
|
||||
|
||||
// PaymentMethodSummary represents the summary of payment method analytics
|
||||
type PaymentMethodSummary struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
// Money actually received. EnakPoint is not money in (docs/prd-point-coin.md F9,
|
||||
// K7) and is reported apart; its accounting treatment waits on note N2.
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
// Rupiah paid with EnakPoint, and how many EnakPoint that was.
|
||||
PointAmount float64 `json:"point_amount"`
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
// TotalAmount plus PointAmount: the value of the orders paid.
|
||||
TotalWithPoints float64 `json:"total_with_points"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
TotalPayments int64 `json:"total_payments"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
@@ -47,7 +54,11 @@ type PaymentMethodAnalyticsData struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PaymentCount int64 `json:"payment_count"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
// Share of the money received; 0 for EnakPoint, which is not money in.
|
||||
Percentage float64 `json:"percentage"`
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
// False for EnakPoint.
|
||||
CountsAsCashIn bool `json:"counts_as_cash_in"`
|
||||
}
|
||||
|
||||
// SalesAnalyticsRequest represents the request for sales analytics
|
||||
@@ -233,6 +244,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -359,6 +371,7 @@ type ParentCategoryAnalyticsProductData struct {
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -462,12 +475,12 @@ type ProfitLossAnalyticsResponse struct {
|
||||
}
|
||||
|
||||
type ProfitLossPurchasing struct {
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
TodayTotal float64 `json:"today_total"`
|
||||
MtdTotal float64 `json:"mtd_total"`
|
||||
TodayRawMaterial float64 `json:"today_raw_material"`
|
||||
MtdRawMaterial float64 `json:"mtd_raw_material"`
|
||||
TodayExpense float64 `json:"today_expense"`
|
||||
MtdExpense float64 `json:"mtd_expense"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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 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 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 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 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"`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CustomerPinStatus is GET /customer/pin/status.
|
||||
type CustomerPinStatus struct {
|
||||
HasPin bool `json:"has_pin"`
|
||||
LockedUntil *time.Time `json:"locked_until"`
|
||||
TransferBlockedUntil *time.Time `json:"transfer_blocked_until"`
|
||||
}
|
||||
|
||||
// CustomerPinOtp is what POST /customer/pin/otp returns: the token to send back with
|
||||
// the code the customer received.
|
||||
type CustomerPinOtp struct {
|
||||
Purpose string `json:"purpose"`
|
||||
OtpToken string `json:"otp_token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// CustomerSecurityEventView is one row of GET /marketing/customers/:id/security-events.
|
||||
type CustomerSecurityEventView struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Event string `json:"event"`
|
||||
ActorUser *uuid.UUID `json:"actor_user,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
IPAddress *string `json:"ip_address,omitempty"`
|
||||
UserAgent *string `json:"user_agent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerPinRequestInfo is where a PIN request came from, for the security log.
|
||||
type CustomerPinRequestInfo struct {
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// PaymentCode is what POST /customer/wallet/payment-code returns: a one-time code the
|
||||
// customer shows the cashier, as digits or as a QR of QRPayload.
|
||||
type PaymentCode struct {
|
||||
Code string `json:"code"`
|
||||
QRPayload string `json:"qr_payload"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
@@ -2,66 +2,8 @@ package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Existing gamification models
|
||||
type CreateCustomerPointsRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerPointsRequest struct {
|
||||
Balance int64 `json:"balance" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerPointsResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerPointsQuery struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedCustomerPointsResponse struct {
|
||||
Data []CustomerPointsResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// New customer API models
|
||||
type GetCustomerPointsRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerTokensRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetCustomerWalletRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Models
|
||||
type GetCustomerPointsResponse struct {
|
||||
Status string `json:"status"`
|
||||
@@ -83,36 +25,27 @@ 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"`
|
||||
Data *GetCustomerWalletResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// GetCustomerWalletResponseData is GET /customer/wallet (docs/prd-point-coin.md F6).
|
||||
type GetCustomerWalletResponseData struct {
|
||||
// Balances the customer can spend now.
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
// Rupiah value of one EnakPoint, and of the whole point balance. EnakPoint only
|
||||
// ever pays for orders, so the app shows this as "setara potongan Rp …" (K7).
|
||||
PointValue int64 `json:"point_value"`
|
||||
PointDiscountValue int64 `json:"point_discount_value"`
|
||||
NearestExpiring CustomerWalletNearestExpiring `json:"nearest_expiring"`
|
||||
RecentTransactions []CustomerWalletTransaction `json:"recent_transactions"`
|
||||
|
||||
// Deprecated: the shape of this endpoint before the wallet, kept for app versions
|
||||
// that still read it. Filled from the wallet like the fields above.
|
||||
TotalPoints int64 `json:"total_points"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
PointsHistory []PointsHistoryItem `json:"points_history,omitempty"`
|
||||
TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCustomerTokensRequest struct {
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenType string `json:"token_type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type UpdateCustomerTokensRequest struct {
|
||||
Balance int64 `json:"balance" validate:"min=0"`
|
||||
}
|
||||
|
||||
type AddCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type DeductCustomerTokensRequest struct {
|
||||
Tokens int64 `json:"tokens" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type CustomerTokensResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
TokenType string `json:"token_type"`
|
||||
Balance int64 `json:"balance"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCustomerTokensQuery struct {
|
||||
Page int `query:"page" validate:"min=1"`
|
||||
Limit int `query:"limit" validate:"min=1,max=100"`
|
||||
Search string `query:"search"`
|
||||
TokenType string `query:"token_type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=balance token_type created_at updated_at"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
CoinsUsed int `json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type GamePlayResponse struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
TokenUsed int `json:"token_used"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
@@ -33,18 +33,18 @@ type ListGamePlaysQuery struct {
|
||||
GameID *uuid.UUID `query:"game_id"`
|
||||
CustomerID *uuid.UUID `query:"customer_id"`
|
||||
PrizeID *uuid.UUID `query:"prize_id"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at token_used"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost.
|
||||
type PlayGameRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
||||
type OutletLoyaltySettings struct {
|
||||
Point LoyaltyEarnSettings `json:"point"`
|
||||
Coin LoyaltyEarnSettings `json:"coin"`
|
||||
// Paying with EnakPoint. EnakCoin cannot pay, so it has no counterpart.
|
||||
PointPayment LoyaltyPointPaymentSettings `json:"point_payment"`
|
||||
}
|
||||
|
||||
// LoyaltyEarnSettings is how much of one currency an order earns:
|
||||
// floor(basis / EarnPerAmount) × EarnValue, nothing below MinOrderAmount, and at most
|
||||
// MaxPerOrder when set.
|
||||
type LoyaltyEarnSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||
EarnValue int64 `json:"earn_value"`
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
MaxPerOrder *int64 `json:"max_per_order"`
|
||||
}
|
||||
|
||||
type LoyaltyPointPaymentSettings struct {
|
||||
AcceptPayment bool `json:"accept_payment"`
|
||||
MinPaymentPoints int64 `json:"min_payment_points"`
|
||||
// Largest share of the order total, 0–100, that EnakPoint may pay.
|
||||
MaxPaymentPercent int64 `json:"max_payment_percent"`
|
||||
}
|
||||
|
||||
// OrganizationLoyaltySettings are the loyalty settings shared by every outlet of an
|
||||
// organization (docs/prd-point-coin.md F2, F12).
|
||||
type OrganizationLoyaltySettings struct {
|
||||
// Rupiah value of one EnakPoint when paying.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// CoinAmount EnakCoin exchange into PointAmount EnakPoint.
|
||||
Exchange LoyaltyExchangeSettings `json:"exchange"`
|
||||
Transfer LoyaltyTransferSettings `json:"transfer"`
|
||||
PointExpiry LoyaltyExpirySettings `json:"point_expiry"`
|
||||
CoinExpiry LoyaltyExpirySettings `json:"coin_expiry"`
|
||||
}
|
||||
|
||||
type LoyaltyExchangeSettings struct {
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
}
|
||||
|
||||
type LoyaltyTransferSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MinAmount int64 `json:"min_amount"`
|
||||
MaxPerTransaction *int64 `json:"max_per_transaction"`
|
||||
DailyLimit *int64 `json:"daily_limit"`
|
||||
}
|
||||
|
||||
// LoyaltyExpirySettings is when one currency expires once received (F12). Both
|
||||
// models of note N4 are supported, and the owner picks one:
|
||||
//
|
||||
// - FIXED_DATE: everything expires on the next of FixedDates falling on or after
|
||||
// the day received + GraceMonths, so a balance received just before a date moves
|
||||
// on to the one after.
|
||||
// - ROLLING: everything lasts Period Units from the day received, to the end of
|
||||
// that month when EndOfMonth is set.
|
||||
type LoyaltyExpirySettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// FIXED_DATE or ROLLING.
|
||||
Mode string `json:"mode"`
|
||||
// FIXED_DATE: the days of the year balances expire on, as MM-DD, sorted.
|
||||
FixedDates []string `json:"fixed_dates"`
|
||||
// FIXED_DATE: how many months a balance lasts at least before a fixed date takes it.
|
||||
GraceMonths int64 `json:"grace_months"`
|
||||
// ROLLING: how long a balance lasts.
|
||||
Period int64 `json:"period"`
|
||||
// ROLLING: DAY or MONTH.
|
||||
Unit string `json:"unit"`
|
||||
EndOfMonth bool `json:"end_of_month"`
|
||||
// Days before expiry the customer is reminded; 0 for no reminder.
|
||||
ReminderDays int64 `json:"reminder_days"`
|
||||
}
|
||||
|
||||
// LoyaltySettingChange is one row of the loyalty settings history.
|
||||
type LoyaltySettingChange struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Key string `json:"key"`
|
||||
// Nil when the key had no stored value, that is it was on its default.
|
||||
OldValue *string `json:"old_value"`
|
||||
NewValue *string `json:"new_value"`
|
||||
ChangedBy uuid.UUID `json:"changed_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// OutletLoyaltySettingsView is GET and PUT /outlets/:id/loyalty-settings.
|
||||
type OutletLoyaltySettingsView struct {
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletLoyaltySettings
|
||||
// The organization's rupiah value of one EnakPoint, which the cashback depends on.
|
||||
PointValue int64 `json:"point_value"`
|
||||
// Effective EnakPoint cashback in percent: earn_value × point_value /
|
||||
// earn_per_amount × 100. Shown next to the setting so an owner cannot misread the
|
||||
// scale (F1).
|
||||
PointCashbackPercent float64 `json:"point_cashback_percent"`
|
||||
// Set on PUT: the keys that changed.
|
||||
Changes []LoyaltySettingChange `json:"changes,omitempty"`
|
||||
}
|
||||
|
||||
// LoyaltyCashbackPercent is earnValue × pointValue / earnPerAmount as a percentage,
|
||||
// rounded to two decimals.
|
||||
func LoyaltyCashbackPercent(earnValue, pointValue, earnPerAmount int64) float64 {
|
||||
if earnPerAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round(float64(earnValue)*float64(pointValue)*10000/float64(earnPerAmount)) / 100
|
||||
}
|
||||
|
||||
// OrganizationLoyaltySettingsView is GET and PUT /marketing/loyalty-settings.
|
||||
type OrganizationLoyaltySettingsView struct {
|
||||
OrganizationLoyaltySettings
|
||||
// What the balances in circulation are worth, before and after the change.
|
||||
Impact LoyaltySettingsImpact `json:"impact"`
|
||||
// When a balance received now would expire under these settings (F12).
|
||||
ExpiryPreview LoyaltyExpiryPreview `json:"expiry_preview"`
|
||||
// The currencies this change turns expiry on for, and the balances affected.
|
||||
ExpiryActivations []LoyaltyExpiryActivation `json:"expiry_activations"`
|
||||
// On PUT, the keys that changed; on a dry run, the keys that would.
|
||||
Changes []LoyaltySettingChange `json:"changes"`
|
||||
// True when nothing was saved.
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
|
||||
// LoyaltyExpiryActivation is expiry being turned on for a currency: the balances that
|
||||
// had no expiry and the expiry they get (F12). On a dry run nothing is dated yet.
|
||||
type LoyaltyExpiryActivation struct {
|
||||
Currency string `json:"currency"`
|
||||
Lots int64 `json:"lots"`
|
||||
Amount int64 `json:"amount"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// LoyaltyExpiryPreview is what the dashboard shows next to the expiry settings: "the
|
||||
// EnakPoint received today expire on …". Nil means they never expire.
|
||||
type LoyaltyExpiryPreview struct {
|
||||
Point *time.Time `json:"point"`
|
||||
Coin *time.Time `json:"coin"`
|
||||
}
|
||||
|
||||
// LoyaltySettingsImpact shows how a change of point value or exchange rate changes what
|
||||
// the balances in circulation are worth (F2). Before and after are equal when neither
|
||||
// changes.
|
||||
type LoyaltySettingsImpact struct {
|
||||
OutstandingPoints int64 `json:"outstanding_points"`
|
||||
OutstandingCoins int64 `json:"outstanding_coins"`
|
||||
|
||||
PointValueBefore int64 `json:"point_value_before"`
|
||||
PointValueAfter int64 `json:"point_value_after"`
|
||||
PointRupiahBefore int64 `json:"point_rupiah_before"`
|
||||
PointRupiahAfter int64 `json:"point_rupiah_after"`
|
||||
|
||||
// The coins in circulation exchanged at the rate, in EnakPoint and in rupiah.
|
||||
CoinsAsPointsBefore int64 `json:"coins_as_points_before"`
|
||||
CoinsAsPointsAfter int64 `json:"coins_as_points_after"`
|
||||
CoinRupiahBefore int64 `json:"coin_rupiah_before"`
|
||||
CoinRupiahAfter int64 `json:"coin_rupiah_after"`
|
||||
}
|
||||
|
||||
// NewLoyaltySettingsImpact computes the impact of moving from one organization setting
|
||||
// to another on the balances in circulation.
|
||||
func NewLoyaltySettingsImpact(points, coins int64, before, after OrganizationLoyaltySettings) LoyaltySettingsImpact {
|
||||
asPoints := func(s OrganizationLoyaltySettings) int64 {
|
||||
if s.Exchange.CoinAmount <= 0 {
|
||||
return 0
|
||||
}
|
||||
return coins * s.Exchange.PointAmount / s.Exchange.CoinAmount
|
||||
}
|
||||
impact := LoyaltySettingsImpact{
|
||||
OutstandingPoints: points,
|
||||
OutstandingCoins: coins,
|
||||
PointValueBefore: before.PointValue,
|
||||
PointValueAfter: after.PointValue,
|
||||
PointRupiahBefore: points * before.PointValue,
|
||||
PointRupiahAfter: points * after.PointValue,
|
||||
CoinsAsPointsBefore: asPoints(before),
|
||||
CoinsAsPointsAfter: asPoints(after),
|
||||
}
|
||||
impact.CoinRupiahBefore = impact.CoinsAsPointsBefore * before.PointValue
|
||||
impact.CoinRupiahAfter = impact.CoinsAsPointsAfter * after.PointValue
|
||||
return impact
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLoyaltyCashbackPercent(t *testing.T) {
|
||||
assert.Equal(t, 1.0, LoyaltyCashbackPercent(1, 1, 100))
|
||||
assert.Equal(t, 0.1, LoyaltyCashbackPercent(1, 1, 1000))
|
||||
assert.Equal(t, 0.33, LoyaltyCashbackPercent(1, 1, 300), "rounded to two decimals")
|
||||
assert.Equal(t, 0.67, LoyaltyCashbackPercent(2, 1, 300), "rounded, not cut")
|
||||
assert.Equal(t, 0.0, LoyaltyCashbackPercent(1, 1, 0))
|
||||
}
|
||||
|
||||
func TestNewLoyaltySettingsImpact(t *testing.T) {
|
||||
before := OrganizationLoyaltySettings{PointValue: 1, Exchange: LoyaltyExchangeSettings{CoinAmount: 1, PointAmount: 1}}
|
||||
after := OrganizationLoyaltySettings{PointValue: 100, Exchange: LoyaltyExchangeSettings{CoinAmount: 10, PointAmount: 1}}
|
||||
|
||||
got := NewLoyaltySettingsImpact(50000, 105, before, after)
|
||||
assert.Equal(t, LoyaltySettingsImpact{
|
||||
OutstandingPoints: 50000, OutstandingCoins: 105,
|
||||
PointValueBefore: 1, PointValueAfter: 100,
|
||||
PointRupiahBefore: 50000, PointRupiahAfter: 5000000,
|
||||
CoinsAsPointsBefore: 105, CoinsAsPointsAfter: 10, // floor(105 / 10)
|
||||
CoinRupiahBefore: 105, CoinRupiahAfter: 1000,
|
||||
}, got)
|
||||
|
||||
same := NewLoyaltySettingsImpact(10, 10, before, before)
|
||||
assert.Equal(t, same.PointRupiahBefore, same.PointRupiahAfter)
|
||||
}
|
||||
@@ -44,6 +44,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID
|
||||
ProductVariantID *uuid.UUID
|
||||
Quantity int
|
||||
Weight *float64
|
||||
UnitID *uuid.UUID
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
UnitCost float64
|
||||
@@ -99,7 +101,8 @@ type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `validate:"required"`
|
||||
ProductVariantID *uuid.UUID `validate:"omitempty"`
|
||||
Quantity int `validate:"required,min=1"`
|
||||
UnitPrice *float64 `validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
Weight *float64 `validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `validate:"omitempty"`
|
||||
Notes *string `validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `validate:"omitempty"`
|
||||
@@ -179,6 +182,9 @@ type OrderResponse struct {
|
||||
TotalPaid float64
|
||||
PaymentCount int
|
||||
SplitType *string
|
||||
// What the order earned (docs/prd-point-coin.md F3), from its EARN rows.
|
||||
PointsEarned int64
|
||||
CoinsEarned int64
|
||||
}
|
||||
|
||||
type OrderItemResponse struct {
|
||||
@@ -191,6 +197,10 @@ type OrderItemResponse struct {
|
||||
CategoryID *uuid.UUID
|
||||
CategoryName *string
|
||||
Quantity int
|
||||
Weight *float64
|
||||
UnitID *uuid.UUID
|
||||
UnitName *string
|
||||
UnitAbbreviation *string
|
||||
UnitPrice float64
|
||||
TotalPrice float64
|
||||
UnitCost float64
|
||||
|
||||
@@ -28,8 +28,10 @@ type Payment struct {
|
||||
}
|
||||
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID uuid.UUID `validate:"required"`
|
||||
PaymentMethodID uuid.UUID `validate:"required"`
|
||||
OrderID uuid.UUID `validate:"required"`
|
||||
PaymentMethodID uuid.UUID `validate:"required"`
|
||||
Points *int64
|
||||
PaymentCode *string
|
||||
Amount float64 `validate:"required,min=0"`
|
||||
TransactionID *string `validate:"omitempty"`
|
||||
SplitNumber int `validate:"omitempty,min=1"`
|
||||
@@ -60,6 +62,9 @@ type PaymentResponse struct {
|
||||
SplitType *string
|
||||
SplitDescription *string
|
||||
RefundAmount float64
|
||||
// Set for a payment with EnakPoint.
|
||||
PointsUsed *int64
|
||||
PointValue *float64
|
||||
RefundReason *string
|
||||
RefundedAt *time.Time
|
||||
RefundedBy *uuid.UUID
|
||||
|
||||
@@ -51,11 +51,13 @@ type PaymentMethodResponse struct {
|
||||
|
||||
type ListPaymentMethodsRequest struct {
|
||||
OrganizationID *uuid.UUID
|
||||
Type *constants.PaymentMethodType
|
||||
IsActive *bool
|
||||
Search string
|
||||
Page int `validate:"min=1"`
|
||||
Limit int `validate:"min=1,max=100"`
|
||||
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||
OutletID *uuid.UUID
|
||||
Type *constants.PaymentMethodType
|
||||
IsActive *bool
|
||||
Search string
|
||||
Page int `validate:"min=1"`
|
||||
Limit int `validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type ListPaymentMethodsResponse struct {
|
||||
|
||||
@@ -19,6 +19,7 @@ type Product struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
SellBy string
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
Metadata map[string]interface{}
|
||||
@@ -52,6 +53,7 @@ type CreateProductRequest struct {
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||
HasIngredients bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
Variants []CreateProductVariantRequest `validate:"omitempty,dive"`
|
||||
@@ -73,6 +75,7 @@ type UpdateProductRequest struct {
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||
HasIngredients *bool `validate:"omitempty"`
|
||||
Metadata map[string]interface{}
|
||||
IsActive *bool
|
||||
@@ -110,6 +113,7 @@ type ProductResponse struct {
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
SellBy string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
|
||||
@@ -20,9 +20,9 @@ type SpinGameResponse struct {
|
||||
|
||||
// SpinGameResponseData contains the game play result
|
||||
type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
}
|
||||
|
||||
// SpinGamePrizeResponse represents a prize won in the spin game
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CustomerWalletTransaction is one ledger row as the customer app shows it
|
||||
// (docs/prd-point-coin.md F6).
|
||||
type CustomerWalletTransaction struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
// Signed: positive added to the balance, negative taken from it.
|
||||
Amount int64 `json:"amount"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
Description string `json:"description"`
|
||||
// Where the value came from, set on additions.
|
||||
Source *CustomerWalletTransactionRef `json:"source,omitempty"`
|
||||
// Where the value went, set on deductions.
|
||||
Destination *CustomerWalletTransactionRef `json:"destination,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ReversesTransactionID *uuid.UUID `json:"reverses_transaction_id,omitempty"`
|
||||
// Shared by the two rows of an exchange or a transfer.
|
||||
GroupID *uuid.UUID `json:"group_id,omitempty"`
|
||||
// Additions only: the earliest expiry among the lots it created, nil when none of
|
||||
// them expire, and the lots themselves.
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
Lots []CustomerWalletLot `json:"lots,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerWalletTransactionRef points at what a ledger row came from or went to, as
|
||||
// listed in §8.1: ORDER, PAYMENT, WALLET_TX, GAME_PLAY, LOT, USER and so on.
|
||||
type CustomerWalletTransactionRef struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
type CustomerWalletLot struct {
|
||||
Amount int64 `json:"amount"`
|
||||
Remaining int64 `json:"remaining"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// CustomerWalletExpiring is how much expires on one day.
|
||||
type CustomerWalletExpiring struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// YYYY-MM-DD, Asia/Jakarta.
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
// CustomerWalletNearestExpiring is the next day each currency loses balance, nil when
|
||||
// nothing is due to expire.
|
||||
type CustomerWalletNearestExpiring struct {
|
||||
Point *CustomerWalletExpiring `json:"point"`
|
||||
Coin *CustomerWalletExpiring `json:"coin"`
|
||||
}
|
||||
|
||||
// ListCustomerWalletTransactionsQuery is GET /customer/wallet/transactions.
|
||||
type ListCustomerWalletTransactionsQuery struct {
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
Currency string `form:"currency"`
|
||||
// One type, or several separated by commas.
|
||||
Type string `form:"type"`
|
||||
// Inclusive calendar dates, YYYY-MM-DD, Asia/Jakarta.
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
}
|
||||
|
||||
// AdminCustomerWallet is GET /marketing/customers/:id/wallet (docs/prd-point-coin.md
|
||||
// F7). Unlike the customer's own view it shows the raw balances next to the spendable
|
||||
// ones, every lot that still holds something, and the real names behind each row.
|
||||
type AdminCustomerWallet struct {
|
||||
Customer AdminWalletCustomer `json:"customer"`
|
||||
// Balances as the ledger has them.
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
// What can be spent now. Lower than the ledger balance only while lots that have
|
||||
// expired wait for the expiry job.
|
||||
SpendablePointBalance int64 `json:"spendable_point_balance"`
|
||||
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
|
||||
Lots []AdminWalletLot `json:"lots"`
|
||||
Transactions PaginatedResponse[AdminWalletTransaction] `json:"transactions"`
|
||||
}
|
||||
|
||||
type AdminWalletCustomer struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Phone *string `json:"phone,omitempty"`
|
||||
}
|
||||
|
||||
type AdminWalletLot struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Currency string `json:"currency"`
|
||||
OriginalAmount int64 `json:"original_amount"`
|
||||
RemainingAmount int64 `json:"remaining_amount"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Expired bool `json:"expired"`
|
||||
SourceTransactionID uuid.UUID `json:"source_transaction_id"`
|
||||
OriginLotID *uuid.UUID `json:"origin_lot_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AdminWalletTransaction is a ledger row with the names the customer does not see:
|
||||
// the real counterparty of a transfer, the admin behind an adjustment, the cashier who
|
||||
// took a payment, and the outlet.
|
||||
type AdminWalletTransaction struct {
|
||||
CustomerWalletTransaction
|
||||
Counterparty *AdminWalletNamedRef `json:"counterparty,omitempty"`
|
||||
CreatedBy *AdminWalletNamedRef `json:"created_by,omitempty"`
|
||||
Outlet *AdminWalletNamedRef `json:"outlet,omitempty"`
|
||||
Reason *string `json:"reason,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type AdminWalletNamedRef struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// WalletAdjustment is a manual correction by an admin.
|
||||
type WalletAdjustment struct {
|
||||
Currency string
|
||||
// Signed: positive adds, negative takes away.
|
||||
Amount int64
|
||||
Reason string
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
// AdminWalletAdjustmentResult is what POST /marketing/customers/:id/wallet/adjust returns.
|
||||
type AdminWalletAdjustmentResult struct {
|
||||
Transaction AdminWalletTransaction `json:"transaction"`
|
||||
SpendablePointBalance int64 `json:"spendable_point_balance"`
|
||||
SpendableCoinBalance int64 `json:"spendable_coin_balance"`
|
||||
// True when the idempotency key had been used before and nothing changed.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// PointPaymentPreview is GET /orders/:id/point-payment/preview (docs/prd-point-coin.md
|
||||
// F9): whether the order can be paid with EnakPoint and at most how much, for the
|
||||
// cashier's "use maximum" button.
|
||||
type PointPaymentPreview struct {
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
CustomerID *uuid.UUID `json:"customer_id"`
|
||||
Eligible bool `json:"eligible"`
|
||||
// Why not, when not eligible.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
PointValue int64 `json:"point_value"`
|
||||
RemainingAmount float64 `json:"remaining_amount"`
|
||||
MinPaymentPoints int64 `json:"min_payment_points"`
|
||||
MaxPaymentPercent int64 `json:"max_payment_percent"`
|
||||
MaxPoints int64 `json:"max_points"`
|
||||
// Rupiah covered by MaxPoints.
|
||||
MaxAmount int64 `json:"max_amount"`
|
||||
}
|
||||
|
||||
// CustomerWalletExpiringList is GET /customer/wallet/expiring (docs/prd-point-coin.md
|
||||
// F6): everything that will expire, per currency and day, soonest first.
|
||||
type CustomerWalletExpiringList struct {
|
||||
Point []CustomerWalletExpiring `json:"point"`
|
||||
Coin []CustomerWalletExpiring `json:"coin"`
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// WalletExchangePreview is GET /customer/wallet/exchange/preview
|
||||
// (docs/prd-point-coin.md F4): the rate, and what exchanging Coins would give.
|
||||
type WalletExchangePreview struct {
|
||||
// The rate: CoinAmount EnakCoin exchange into PointAmount EnakPoint.
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
Coins int64 `json:"coins"`
|
||||
Points int64 `json:"points"`
|
||||
// Whether Coins can be exchanged now, and why not when it cannot.
|
||||
Valid bool `json:"valid"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// WalletMovedLot is part of what an exchange or a transfer delivered, with the
|
||||
// expiry it carried over from the lot it came from (K9).
|
||||
type WalletMovedLot struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// Nil when it never expires.
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// WalletExchangeResult is POST /customer/wallet/exchange.
|
||||
type WalletExchangeResult struct {
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
Coins int64 `json:"coins"`
|
||||
Points int64 `json:"points"`
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PointAmount int64 `json:"point_amount"`
|
||||
// The EnakPoint received, split by expiry.
|
||||
Lots []WalletMovedLot `json:"lots"`
|
||||
CoinBalance int64 `json:"coin_balance"`
|
||||
PointBalance int64 `json:"point_balance"`
|
||||
// True when this was a retry of an exchange already made; nothing moved again.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
// WalletTransferRecipient is GET /customer/wallet/transfer/recipient: who a phone
|
||||
// number belongs to, masked, so the sender can check before confirming (F5).
|
||||
type WalletTransferRecipient struct {
|
||||
Name string `json:"name"`
|
||||
PhoneNumber string `json:"phone_number"`
|
||||
}
|
||||
|
||||
// WalletTransfer is what a customer asks to send (F5).
|
||||
type WalletTransfer struct {
|
||||
// POINT or COIN.
|
||||
Currency string
|
||||
Amount int64
|
||||
RecipientPhone string
|
||||
}
|
||||
|
||||
// WalletTransferResult is POST /customer/wallet/transfer.
|
||||
type WalletTransferResult struct {
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
Currency string `json:"currency"`
|
||||
Amount int64 `json:"amount"`
|
||||
Recipient WalletTransferRecipient `json:"recipient"`
|
||||
// What the recipient received, split by the expiry it carried over.
|
||||
Lots []WalletMovedLot `json:"lots"`
|
||||
// The sender's balance in the currency sent.
|
||||
Balance int64 `json:"balance"`
|
||||
// True when this was a retry of a transfer already made; nothing moved again.
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AdminWalletTrace is GET /marketing/wallet-transactions/:id/trace
|
||||
// (docs/prd-point-coin.md F7, §8.1): the lots a ledger row took from or created, and
|
||||
// where each of them came from, lot by lot, back to the lot that first brought the
|
||||
// balance into a wallet. It follows balance across transfers, exchanges and refunds.
|
||||
type AdminWalletTrace struct {
|
||||
Transaction AdminWalletTraceRow `json:"transaction"`
|
||||
// One per lot the row took from (a debit) or created (a credit).
|
||||
Lots []AdminWalletTraceLot `json:"lots"`
|
||||
}
|
||||
|
||||
// AdminWalletTraceLot is how much of a row went through one lot, and that lot's chain.
|
||||
type AdminWalletTraceLot struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// The lot itself first, then the lot it was carried over from, and so on. The
|
||||
// last step is the lot an EARN, ADJUSTMENT or MIGRATION created.
|
||||
Chain []AdminWalletTraceStep `json:"chain"`
|
||||
}
|
||||
|
||||
// AdminWalletTraceStep is one lot on a chain and the row that created it.
|
||||
type AdminWalletTraceStep struct {
|
||||
Lot AdminWalletLot `json:"lot"`
|
||||
Source AdminWalletTraceRow `json:"source"`
|
||||
}
|
||||
|
||||
// AdminWalletTraceRow is a ledger row as a trace shows it, with the real name of the
|
||||
// customer it belongs to.
|
||||
type AdminWalletTraceRow struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Customer AdminWalletNamedRef `json:"customer"`
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
Amount int64 `json:"amount"`
|
||||
Description string `json:"description"`
|
||||
ReferenceType string `json:"reference_type"`
|
||||
ReferenceID uuid.UUID `json:"reference_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -63,27 +63,37 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
return nil, fmt.Errorf("failed to get payment method analytics: %w", err)
|
||||
}
|
||||
|
||||
var totalAmount float64
|
||||
// EnakPoint is not money in (docs/prd-point-coin.md F9, K7): it is listed as its own
|
||||
// method but left out of the money received and of the shares. How it is booked
|
||||
// waits on note N2.
|
||||
var cashAmount, pointAmount float64
|
||||
var pointsUsed int64
|
||||
var totalOrders int64
|
||||
var totalPayments int64
|
||||
|
||||
for _, data := range analyticsData {
|
||||
totalAmount += data.TotalAmount
|
||||
if data.PaymentMethodType == string(constants.PaymentMethodTypePoint) {
|
||||
pointAmount += data.TotalAmount
|
||||
pointsUsed += data.PointsUsed
|
||||
} else {
|
||||
cashAmount += data.TotalAmount
|
||||
}
|
||||
totalOrders += data.OrderCount
|
||||
totalPayments += data.PaymentCount
|
||||
}
|
||||
|
||||
// The value of an order includes what EnakPoint paid, so the average does too.
|
||||
var averageOrderValue float64
|
||||
if totalOrders > 0 {
|
||||
averageOrderValue = totalAmount / float64(totalOrders)
|
||||
averageOrderValue = (cashAmount + pointAmount) / float64(totalOrders)
|
||||
}
|
||||
|
||||
// Calculate percentages
|
||||
var resultData []models.PaymentMethodAnalyticsData
|
||||
for _, data := range analyticsData {
|
||||
cashIn := data.PaymentMethodType != string(constants.PaymentMethodTypePoint)
|
||||
var percentage float64
|
||||
if totalAmount > 0 {
|
||||
percentage = (data.TotalAmount / totalAmount) * 100
|
||||
if cashIn && cashAmount > 0 {
|
||||
percentage = (data.TotalAmount / cashAmount) * 100
|
||||
}
|
||||
|
||||
resultData = append(resultData, models.PaymentMethodAnalyticsData{
|
||||
@@ -94,11 +104,16 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
|
||||
OrderCount: data.OrderCount,
|
||||
PaymentCount: data.PaymentCount,
|
||||
Percentage: percentage,
|
||||
PointsUsed: data.PointsUsed,
|
||||
CountsAsCashIn: cashIn,
|
||||
})
|
||||
}
|
||||
|
||||
summary := models.PaymentMethodSummary{
|
||||
TotalAmount: totalAmount,
|
||||
TotalAmount: cashAmount,
|
||||
PointAmount: pointAmount,
|
||||
PointsUsed: pointsUsed,
|
||||
TotalWithPoints: cashAmount + pointAmount,
|
||||
TotalOrders: totalOrders,
|
||||
TotalPayments: totalPayments,
|
||||
AverageOrderValue: averageOrderValue,
|
||||
@@ -322,6 +337,7 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
|
||||
CategoryName: data.CategoryName,
|
||||
CategoryOrder: data.CategoryOrder,
|
||||
QuantitySold: data.QuantitySold,
|
||||
WeightSold: data.WeightSold,
|
||||
Revenue: data.Revenue,
|
||||
AveragePrice: data.AveragePrice,
|
||||
OrderCount: data.OrderCount,
|
||||
@@ -447,6 +463,7 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
ProductSku: product.ProductSku,
|
||||
ProductPrice: product.ProductPrice,
|
||||
QuantitySold: product.QuantitySold,
|
||||
WeightSold: product.WeightSold,
|
||||
Revenue: product.Revenue,
|
||||
AveragePrice: product.AveragePrice,
|
||||
OrderCount: product.OrderCount,
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -24,10 +25,11 @@ type analyticsRepositoryStub struct {
|
||||
exclusiveSummaryCalls int
|
||||
exclusiveSummaryFrom []time.Time
|
||||
exclusiveSummaryTo []time.Time
|
||||
paymentMethods []*entities.PaymentMethodAnalytics
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||
return nil, nil
|
||||
func (s analyticsRepositoryStub) GetPaymentMethodAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.PaymentMethodAnalytics, error) {
|
||||
return s.paymentMethods, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetSalesAnalytics(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time, string) ([]*entities.SalesAnalytics, error) {
|
||||
@@ -570,3 +572,35 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t
|
||||
require.Len(t, result.DailySummary, 1)
|
||||
require.Len(t, result.DailyTransactions, 2)
|
||||
}
|
||||
|
||||
// EnakPoint is listed as its own method but is not money in (F9, K7).
|
||||
func TestPaymentMethodAnalytics_EnakPointIsNotCashIn(t *testing.T) {
|
||||
repo := &analyticsRepositoryStub{paymentMethods: []*entities.PaymentMethodAnalytics{
|
||||
{PaymentMethodName: "Tunai", PaymentMethodType: "cash", TotalAmount: 70000, OrderCount: 2, PaymentCount: 2},
|
||||
{PaymentMethodName: "Kartu", PaymentMethodType: "card", TotalAmount: 20000, OrderCount: 1, PaymentCount: 1},
|
||||
{PaymentMethodName: "EnakPoint", PaymentMethodType: "point", TotalAmount: 30000, OrderCount: 1, PaymentCount: 1, PointsUsed: 30000},
|
||||
}}
|
||||
p := NewAnalyticsProcessorImpl(repo, nil)
|
||||
|
||||
got, err := p.GetPaymentMethodAnalytics(context.Background(), &models.PaymentMethodAnalyticsRequest{
|
||||
OrganizationID: uuid.New(), DateFrom: time.Now().Add(-time.Hour), DateTo: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 90000.0, got.Summary.TotalAmount, "money in leaves EnakPoint out")
|
||||
assert.Equal(t, 30000.0, got.Summary.PointAmount)
|
||||
assert.Equal(t, int64(30000), got.Summary.PointsUsed)
|
||||
assert.Equal(t, 120000.0, got.Summary.TotalWithPoints)
|
||||
assert.Equal(t, int64(4), got.Summary.TotalOrders)
|
||||
assert.Equal(t, 30000.0, got.Summary.AverageOrderValue, "the value of an order includes what EnakPoint paid")
|
||||
|
||||
byType := map[string]models.PaymentMethodAnalyticsData{}
|
||||
for _, d := range got.Data {
|
||||
byType[d.PaymentMethodType] = d
|
||||
}
|
||||
assert.True(t, byType["cash"].CountsAsCashIn)
|
||||
assert.False(t, byType["point"].CountsAsCashIn)
|
||||
assert.InDelta(t, 77.78, byType["cash"].Percentage, 0.01, "shares are of the money received")
|
||||
assert.InDelta(t, 22.22, byType["card"].Percentage, 0.01)
|
||||
assert.Zero(t, byType["point"].Percentage)
|
||||
assert.Equal(t, int64(30000), byType["point"].PointsUsed)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ErrInvalidCustomerDevice wraps every rejection of a device registration.
|
||||
var ErrInvalidCustomerDevice = errors.New("invalid customer device")
|
||||
|
||||
type customerPushSender interface {
|
||||
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
|
||||
}
|
||||
|
||||
// CustomerDeviceProcessor keeps the customer app's FCM tokens and sends push
|
||||
// notifications to a customer's devices.
|
||||
type CustomerDeviceProcessor struct {
|
||||
repo repository.CustomerDeviceRepository
|
||||
// Nil when FCM is not configured; notifications are then skipped.
|
||||
fcm customerPushSender
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewCustomerDeviceProcessor(repo repository.CustomerDeviceRepository, fcm customerPushSender) *CustomerDeviceProcessor {
|
||||
return &CustomerDeviceProcessor{repo: repo, fcm: fcm, now: time.Now}
|
||||
}
|
||||
|
||||
// Register saves the FCM token the app got for this device. The app calls it after
|
||||
// login and whenever FCM gives it a new token.
|
||||
func (p *CustomerDeviceProcessor) Register(ctx context.Context, device repository.CustomerDevice) error {
|
||||
device.DeviceID = strings.TrimSpace(device.DeviceID)
|
||||
device.FCMToken = strings.TrimSpace(device.FCMToken)
|
||||
switch {
|
||||
case device.DeviceID == "" || len(device.DeviceID) > 255:
|
||||
return fmt.Errorf("%w: device_id is required, at most 255 characters", ErrInvalidCustomerDevice)
|
||||
case device.FCMToken == "" || len(device.FCMToken) > 512:
|
||||
return fmt.Errorf("%w: fcm_token is required, at most 512 characters", ErrInvalidCustomerDevice)
|
||||
}
|
||||
if device.Platform != nil {
|
||||
platform := strings.ToLower(strings.TrimSpace(*device.Platform))
|
||||
if platform != "android" && platform != "ios" && platform != "web" {
|
||||
return fmt.Errorf("%w: platform must be android, ios or web", ErrInvalidCustomerDevice)
|
||||
}
|
||||
device.Platform = &platform
|
||||
}
|
||||
return p.repo.Register(ctx, device, p.now())
|
||||
}
|
||||
|
||||
// Unregister forgets a device, so it stops getting the customer's notifications.
|
||||
func (p *CustomerDeviceProcessor) Unregister(ctx context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
return p.repo.Unregister(ctx, customerID, strings.TrimSpace(deviceID))
|
||||
}
|
||||
|
||||
// Notify pushes a notification to every device of the customer through FCM. A
|
||||
// customer without a registered device gets nothing, which is not an error.
|
||||
func (p *CustomerDeviceProcessor) Notify(ctx context.Context, customerID uuid.UUID, title, body string, data map[string]string) error {
|
||||
if p.fcm == nil {
|
||||
logger.NonContext.Info(fmt.Sprintf("FCM is not configured; not notifying customer %s", customerID))
|
||||
return nil
|
||||
}
|
||||
tokens, err := p.repo.ListTokens(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p.fcm.SendMulticastNotification(ctx, tokens, title, body, data)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// customerDeviceRepoFake keeps devices the way the table does: one row per customer
|
||||
// and device, and a token belongs to one row only.
|
||||
type customerDeviceRepoFake struct{ devices []repository.CustomerDevice }
|
||||
|
||||
func (f *customerDeviceRepoFake) Register(_ context.Context, d repository.CustomerDevice, _ time.Time) error {
|
||||
kept := f.devices[:0]
|
||||
for _, existing := range f.devices {
|
||||
sameRow := existing.CustomerID == d.CustomerID && existing.DeviceID == d.DeviceID
|
||||
if !sameRow && existing.FCMToken != d.FCMToken {
|
||||
kept = append(kept, existing)
|
||||
}
|
||||
}
|
||||
f.devices = append(kept, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *customerDeviceRepoFake) Unregister(_ context.Context, customerID uuid.UUID, deviceID string) error {
|
||||
kept := f.devices[:0]
|
||||
for _, d := range f.devices {
|
||||
if d.CustomerID != customerID || d.DeviceID != deviceID {
|
||||
kept = append(kept, d)
|
||||
}
|
||||
}
|
||||
f.devices = kept
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *customerDeviceRepoFake) ListTokens(_ context.Context, customerID uuid.UUID) ([]string, error) {
|
||||
var tokens []string
|
||||
for _, d := range f.devices {
|
||||
if d.CustomerID == customerID {
|
||||
tokens = append(tokens, d.FCMToken)
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
type fcmFake struct {
|
||||
tokens [][]string
|
||||
title string
|
||||
body string
|
||||
data map[string]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fcmFake) SendMulticastNotification(_ context.Context, tokens []string, title, body string, data map[string]string) error {
|
||||
f.tokens = append(f.tokens, tokens)
|
||||
f.title, f.body, f.data = title, body, data
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestCustomerDevice_NotifiesEveryDeviceOfTheCustomer(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
budi, anita := uuid.New(), uuid.New()
|
||||
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1", Platform: ptr("Android")}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "tablet", FCMToken: "t2"}))
|
||||
// A refreshed token replaces the old one of the same device.
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "t1b"}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "t3"}))
|
||||
|
||||
require.NoError(t, p.Notify(ctx, budi, "EnakPoint masuk", "Kamu menerima 10 EnakPoint", map[string]string{"type": "X"}))
|
||||
assert.Equal(t, [][]string{{"t2", "t1b"}}, fcm.tokens)
|
||||
assert.Equal(t, "EnakPoint masuk", fcm.title)
|
||||
assert.Equal(t, map[string]string{"type": "X"}, fcm.data)
|
||||
}
|
||||
|
||||
func TestCustomerDevice_TokenMovesToWhoeverLogsInOnThePhone(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
budi, anita := uuid.New(), uuid.New()
|
||||
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: budi, DeviceID: "phone", FCMToken: "shared"}))
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: anita, DeviceID: "phone", FCMToken: "shared"}))
|
||||
|
||||
// Budi's notifications no longer reach the phone Anita is now logged in on.
|
||||
require.NoError(t, p.Notify(ctx, budi, "t", "b", nil))
|
||||
require.NoError(t, p.Notify(ctx, anita, "t", "b", nil))
|
||||
assert.Equal(t, [][]string{{"shared"}}, fcm.tokens)
|
||||
}
|
||||
|
||||
func TestCustomerDevice_NothingToSend(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{}
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
|
||||
// No device registered, or logged out: nothing is sent and nothing fails.
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Unregister(ctx, customer, "phone"))
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, fcm).Notify(ctx, customer, "t", "b", nil))
|
||||
assert.Empty(t, fcm.tokens)
|
||||
|
||||
// FCM not configured.
|
||||
require.NoError(t, NewCustomerDeviceProcessor(repo, nil).Notify(ctx, customer, "t", "b", nil))
|
||||
}
|
||||
|
||||
func TestCustomerDevice_FCMFailureIsReported(t *testing.T) {
|
||||
repo, fcm := &customerDeviceRepoFake{}, &fcmFake{err: errors.New("unavailable")}
|
||||
p := NewCustomerDeviceProcessor(repo, fcm)
|
||||
ctx := context.Background()
|
||||
customer := uuid.New()
|
||||
require.NoError(t, p.Register(ctx, repository.CustomerDevice{CustomerID: customer, DeviceID: "phone", FCMToken: "t1"}))
|
||||
|
||||
assert.Error(t, p.Notify(ctx, customer, "t", "b", nil))
|
||||
}
|
||||
|
||||
func TestCustomerDevice_RejectsIncompleteRegistrations(t *testing.T) {
|
||||
p := NewCustomerDeviceProcessor(&customerDeviceRepoFake{}, nil)
|
||||
ctx := context.Background()
|
||||
for name, d := range map[string]repository.CustomerDevice{
|
||||
"no device": {FCMToken: "t"},
|
||||
"no token": {DeviceID: "phone", FCMToken: " "},
|
||||
"bad platform": {DeviceID: "phone", FCMToken: "t", Platform: ptr("symbian")},
|
||||
} {
|
||||
assert.ErrorIs(t, p.Register(ctx, d), ErrInvalidCustomerDevice, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// PIN rules (docs/prd-point-coin.md F11, Q16, Q17).
|
||||
const (
|
||||
pinLength = 6
|
||||
pinMaxAttempts = 5
|
||||
pinLockDuration = 30 * time.Minute
|
||||
pinTransferHold = 24 * time.Hour
|
||||
pinSecurityReasonN = 255
|
||||
|
||||
PinOtpPurposeSetup = "pin_setup"
|
||||
PinOtpPurposeReset = "pin_reset"
|
||||
)
|
||||
|
||||
// Security log events.
|
||||
const (
|
||||
PinEventSet = "PIN_SET"
|
||||
PinEventChanged = "PIN_CHANGED"
|
||||
PinEventReset = "PIN_RESET"
|
||||
PinEventFailed = "PIN_FAILED"
|
||||
PinEventLocked = "PIN_LOCKED"
|
||||
PinEventRemovedByAdmin = "PIN_REMOVED_BY_ADMIN"
|
||||
)
|
||||
|
||||
// What a PIN approves. Only a transfer is held after a reset.
|
||||
type PinAction string
|
||||
|
||||
const (
|
||||
PinActionPay PinAction = "PAY"
|
||||
PinActionExchange PinAction = "EXCHANGE"
|
||||
PinActionTransfer PinAction = "TRANSFER"
|
||||
)
|
||||
|
||||
// Codes of PinError, which the apps tell apart (docs/prd-point-coin.md §9).
|
||||
const (
|
||||
PinErrNotSet = "PIN_NOT_SET"
|
||||
PinErrInvalid = "PIN_INVALID"
|
||||
PinErrLocked = "PIN_LOCKED"
|
||||
PinErrTransferBlocked = "TRANSFER_BLOCKED"
|
||||
)
|
||||
|
||||
// PinError is why a PIN did not approve an action.
|
||||
type PinError struct {
|
||||
Code string
|
||||
// Set for PIN_INVALID: attempts left before the PIN locks.
|
||||
RemainingAttempts int
|
||||
// Set for PIN_LOCKED and TRANSFER_BLOCKED.
|
||||
Until *time.Time
|
||||
}
|
||||
|
||||
func (e *PinError) Error() string {
|
||||
switch e.Code {
|
||||
case PinErrNotSet:
|
||||
return "PIN has not been set"
|
||||
case PinErrInvalid:
|
||||
return fmt.Sprintf("wrong PIN, %d attempts left", e.RemainingAttempts)
|
||||
case PinErrLocked:
|
||||
return fmt.Sprintf("PIN is locked until %s", e.Until.Format(time.RFC3339))
|
||||
case PinErrTransferBlocked:
|
||||
return fmt.Sprintf("transfers are on hold after a PIN reset until %s", e.Until.Format(time.RFC3339))
|
||||
}
|
||||
return e.Code
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrInvalidPinInput wraps a PIN that is malformed, weak, or not confirmed. The
|
||||
// message never contains the PIN.
|
||||
ErrInvalidPinInput = errors.New("invalid PIN")
|
||||
// ErrPinAlreadySet means a first PIN was requested for a customer who has one.
|
||||
ErrPinAlreadySet = errors.New("PIN has already been set")
|
||||
// ErrPinOtpInvalid means the OTP was wrong, expired, used, for another purpose, or
|
||||
// sent to another number.
|
||||
ErrPinOtpInvalid = errors.New("invalid or expired OTP")
|
||||
// ErrPinOtpTooSoon means an OTP was requested again too quickly.
|
||||
ErrPinOtpTooSoon = errors.New("an OTP was sent recently; wait before asking again")
|
||||
// ErrPinNoPhone means the customer has no phone number to send an OTP to.
|
||||
ErrPinNoPhone = errors.New("customer has no phone number")
|
||||
)
|
||||
|
||||
type pinOtpSender interface {
|
||||
CanResendOtp(ctx context.Context, phoneNumber string, purpose string) (bool, int, error)
|
||||
CreateOtpSession(ctx context.Context, phoneNumber string, purpose string) (*entities.OtpSession, error)
|
||||
SendOtpViaWhatsApp(phoneNumber string, otpCode string, purpose string) error
|
||||
ValidateOtpSession(ctx context.Context, token string, code string) (*entities.OtpSession, error)
|
||||
}
|
||||
|
||||
// NotificationTypePinLocked is the data type of the push a customer gets when their
|
||||
// PIN locks, so the app can offer the PIN reset.
|
||||
const NotificationTypePinLocked = "PIN_LOCKED"
|
||||
|
||||
// CustomerPinProcessor manages customer PINs (docs/prd-point-coin.md F11). Every flow
|
||||
// that moves balance on the customer's request calls VerifyPin first (K8).
|
||||
type CustomerPinProcessor struct {
|
||||
repo repository.CustomerPinRepository
|
||||
otp pinOtpSender
|
||||
notifier customerNotifier
|
||||
now func() time.Time
|
||||
cost int
|
||||
}
|
||||
|
||||
func NewCustomerPinProcessor(repo repository.CustomerPinRepository, otp pinOtpSender, notifier customerNotifier) *CustomerPinProcessor {
|
||||
return &CustomerPinProcessor{repo: repo, otp: otp, notifier: notifier, now: time.Now, cost: bcrypt.DefaultCost}
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) Status(ctx context.Context, customerID uuid.UUID) (*models.CustomerPinStatus, error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := p.now()
|
||||
status := &models.CustomerPinStatus{HasPin: state.PinHash != nil}
|
||||
if state.LockedUntil != nil && state.LockedUntil.After(now) {
|
||||
status.LockedUntil = state.LockedUntil
|
||||
}
|
||||
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
|
||||
status.TransferBlockedUntil = state.TransferBlockedUntil
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// RequestOtp sends an OTP to the customer's own phone number, for creating a first PIN
|
||||
// (pin_setup) or resetting a forgotten one (pin_reset).
|
||||
func (p *CustomerPinProcessor) RequestOtp(ctx context.Context, customerID uuid.UUID, purpose string) (*models.CustomerPinOtp, error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch purpose {
|
||||
case PinOtpPurposeSetup:
|
||||
if state.PinHash != nil {
|
||||
return nil, ErrPinAlreadySet
|
||||
}
|
||||
case PinOtpPurposeReset:
|
||||
if state.PinHash == nil {
|
||||
return nil, &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: purpose must be %s or %s", ErrInvalidPinInput, PinOtpPurposeSetup, PinOtpPurposeReset)
|
||||
}
|
||||
if state.PhoneNumber == nil || *state.PhoneNumber == "" {
|
||||
return nil, ErrPinNoPhone
|
||||
}
|
||||
|
||||
canSend, _, err := p.otp.CanResendOtp(ctx, *state.PhoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canSend {
|
||||
return nil, ErrPinOtpTooSoon
|
||||
}
|
||||
session, err := p.otp.CreateOtpSession(ctx, *state.PhoneNumber, purpose)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.otp.SendOtpViaWhatsApp(*state.PhoneNumber, session.Code, purpose); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.CustomerPinOtp{Purpose: purpose, OtpToken: session.Token, ExpiresAt: session.ExpiresAt}, nil
|
||||
}
|
||||
|
||||
// CreatePin sets a customer's first PIN, approved by an OTP to their phone so it is set
|
||||
// by the owner of the number and not by whoever holds a logged-in phone.
|
||||
func (p *CustomerPinProcessor) CreatePin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.PinHash != nil {
|
||||
return ErrPinAlreadySet
|
||||
}
|
||||
// Check the PIN before spending the OTP, so a weak PIN does not cost a new code.
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeSetup); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventSet, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePin replaces the PIN after checking the old one, which counts toward the lock
|
||||
// like any other attempt. A transfer hold from an earlier reset stays.
|
||||
func (p *CustomerPinProcessor) ChangePin(ctx context.Context, customerID uuid.UUID, oldPin, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.verify(ctx, state, oldPin, PinActionPay, info); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, p.activeHold(state)); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventChanged, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetPin sets a new PIN for a customer who forgot theirs, approved by an OTP. It also
|
||||
// lifts a lock, and holds outgoing transfers for 24 hours in case the phone number was
|
||||
// taken over (Q16).
|
||||
func (p *CustomerPinProcessor) ResetPin(ctx context.Context, customerID uuid.UUID, otpToken, otpCode, pin, confirmPin string, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
if err := checkNewPin(pin, confirmPin, state.BirthDate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.checkOtp(ctx, state, otpToken, otpCode, PinOtpPurposeReset); err != nil {
|
||||
return err
|
||||
}
|
||||
hash, err := p.hash(pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hold := p.now().Add(pinTransferHold)
|
||||
if err := p.repo.SetPin(ctx, customerID, hash, &hold); err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, customerID, PinEventReset, nil, nil, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyPin checks the PIN before an action that moves balance. It returns a *PinError
|
||||
// with the code the apps act on: PIN_NOT_SET, PIN_INVALID (with the attempts left),
|
||||
// PIN_LOCKED or TRANSFER_BLOCKED (with until when).
|
||||
func (p *CustomerPinProcessor) VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.verify(ctx, state, pin, action, info)
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) verify(ctx context.Context, state *repository.CustomerPinState, pin string, action PinAction, info models.CustomerPinRequestInfo) error {
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
now := p.now()
|
||||
// A locked PIN is refused before it is compared, even when it is right.
|
||||
if state.LockedUntil != nil && state.LockedUntil.After(now) {
|
||||
until := *state.LockedUntil
|
||||
return &PinError{Code: PinErrLocked, Until: &until}
|
||||
}
|
||||
// A held transfer is refused before the PIN is compared, so it costs no attempt.
|
||||
if action == PinActionTransfer && state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(now) {
|
||||
until := *state.TransferBlockedUntil
|
||||
return &PinError{Code: PinErrTransferBlocked, Until: &until}
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(*state.PinHash), []byte(pin)) != nil {
|
||||
attempts, lockedUntil, err := p.repo.RecordFailure(ctx, state.CustomerID, pinMaxAttempts, now, now.Add(pinLockDuration))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.logEvent(ctx, state.CustomerID, PinEventFailed, nil, nil, info)
|
||||
if lockedUntil != nil && lockedUntil.After(now) {
|
||||
// Only the attempt that reached the limit logs the lock and tells the
|
||||
// customer; attempts racing it just see the lock.
|
||||
if attempts == pinMaxAttempts {
|
||||
p.logEvent(ctx, state.CustomerID, PinEventLocked, nil, nil, info)
|
||||
p.alertLocked(ctx, state, *lockedUntil)
|
||||
}
|
||||
return &PinError{Code: PinErrLocked, Until: lockedUntil}
|
||||
}
|
||||
return &PinError{Code: PinErrInvalid, RemainingAttempts: pinMaxAttempts - attempts}
|
||||
}
|
||||
if state.FailedAttempts > 0 || state.LockedUntil != nil {
|
||||
if err := p.repo.ClearFailures(ctx, state.CustomerID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemovePinByAdmin deletes a customer's PIN, for example when they lost access to it,
|
||||
// so they have to create a new one through OTP. Admins can never set or read a PIN.
|
||||
func (p *CustomerPinProcessor) RemovePinByAdmin(ctx context.Context, organizationID, customerID, adminID uuid.UUID, reason string, info models.CustomerPinRequestInfo) error {
|
||||
reason = strings.TrimSpace(reason)
|
||||
if reason == "" {
|
||||
return fmt.Errorf("%w: a reason is required", ErrInvalidPinInput)
|
||||
}
|
||||
if adminID == uuid.Nil {
|
||||
return fmt.Errorf("%w: the admin is unknown", ErrInvalidPinInput)
|
||||
}
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.OrganizationID != organizationID {
|
||||
return repository.ErrPinCustomerNotFound
|
||||
}
|
||||
if state.PinHash == nil {
|
||||
return &PinError{Code: PinErrNotSet}
|
||||
}
|
||||
if err := p.repo.RemovePin(ctx, customerID); err != nil {
|
||||
return err
|
||||
}
|
||||
reason = truncateRunes(reason, pinSecurityReasonN)
|
||||
p.logEvent(ctx, customerID, PinEventRemovedByAdmin, &adminID, &reason, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListEvents returns a page of a customer's PIN security log for the dashboard.
|
||||
func (p *CustomerPinProcessor) ListEvents(ctx context.Context, organizationID, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerSecurityEventView], error) {
|
||||
state, err := p.repo.GetState(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if state.OrganizationID != organizationID {
|
||||
return nil, repository.ErrPinCustomerNotFound
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
rows, total, err := p.repo.ListEvents(ctx, customerID, (page-1)*limit, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events := make([]models.CustomerSecurityEventView, 0, len(rows))
|
||||
for _, e := range rows {
|
||||
events = append(events, models.CustomerSecurityEventView{
|
||||
ID: e.ID, Event: e.Event, ActorUser: e.ActorUser, Reason: e.Reason,
|
||||
IPAddress: e.IPAddress, UserAgent: e.UserAgent, CreatedAt: e.CreatedAt,
|
||||
})
|
||||
}
|
||||
return &models.PaginatedResponse[models.CustomerSecurityEventView]{
|
||||
Data: events,
|
||||
Pagination: models.Pagination{
|
||||
Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkOtp validates an OTP and that it was issued for this purpose to this customer's
|
||||
// own phone number. Without those checks an OTP from the login flow, or one sent to
|
||||
// another number, could approve a PIN change.
|
||||
func (p *CustomerPinProcessor) checkOtp(ctx context.Context, state *repository.CustomerPinState, token, code, purpose string) error {
|
||||
if token == "" || code == "" || state.PhoneNumber == nil {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
session, err := p.otp.ValidateOtpSession(ctx, token, code)
|
||||
if err != nil || session == nil {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
if session.Purpose != purpose || session.PhoneNumber != *state.PhoneNumber {
|
||||
return ErrPinOtpInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) hash(pin string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pin), p.cost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash PIN: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func (p *CustomerPinProcessor) activeHold(state *repository.CustomerPinState) *time.Time {
|
||||
if state.TransferBlockedUntil != nil && state.TransferBlockedUntil.After(p.now()) {
|
||||
return state.TransferBlockedUntil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logEvent records a security event. The log is best effort: failing to write it must
|
||||
// not undo what the customer just did, so a failure is logged instead.
|
||||
func (p *CustomerPinProcessor) logEvent(ctx context.Context, customerID uuid.UUID, event string, actor *uuid.UUID, reason *string, info models.CustomerPinRequestInfo) {
|
||||
e := repository.CustomerSecurityEvent{CustomerID: customerID, Event: event, ActorUser: actor, Reason: reason}
|
||||
if info.IPAddress != "" {
|
||||
ip := truncateRunes(info.IPAddress, 45)
|
||||
e.IPAddress = &ip
|
||||
}
|
||||
if info.UserAgent != "" {
|
||||
ua := truncateRunes(info.UserAgent, 255)
|
||||
e.UserAgent = &ua
|
||||
}
|
||||
if err := p.repo.InsertEvent(ctx, e); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not record %s for customer %s", event, customerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// alertLocked pushes the lock to the customer's app through FCM (F11). It is best
|
||||
// effort: the lock stands whether or not the push goes out.
|
||||
func (p *CustomerPinProcessor) alertLocked(ctx context.Context, state *repository.CustomerPinState, until time.Time) {
|
||||
if p.notifier == nil {
|
||||
return
|
||||
}
|
||||
body := fmt.Sprintf("PIN EnakPoint kamu terkunci sampai %s karena salah dimasukkan %d kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.",
|
||||
until.In(walletDisplayLocation).Format("02 Jan 2006 15:04 WIB"), pinMaxAttempts)
|
||||
data := map[string]string{
|
||||
"type": NotificationTypePinLocked,
|
||||
"locked_until": until.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if err := p.notifier.Notify(ctx, state.CustomerID, "PIN terkunci", body, data); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Could not tell customer %s their PIN is locked", state.CustomerID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNewPin rejects a PIN that is not 6 digits, does not match its confirmation, or
|
||||
// is easy to guess: one digit repeated, a run up or down, or the birth date as DDMMYY
|
||||
// or YYMMDD.
|
||||
func checkNewPin(pin, confirm string, birthDate *time.Time) error {
|
||||
if len(pin) != pinLength {
|
||||
return fmt.Errorf("%w: a PIN is %d digits", ErrInvalidPinInput, pinLength)
|
||||
}
|
||||
for _, r := range pin {
|
||||
if r < '0' || r > '9' {
|
||||
return fmt.Errorf("%w: a PIN is digits only", ErrInvalidPinInput)
|
||||
}
|
||||
}
|
||||
if pin != confirm {
|
||||
return fmt.Errorf("%w: the PIN and its confirmation differ", ErrInvalidPinInput)
|
||||
}
|
||||
same, up, down := true, true, true
|
||||
for i := 1; i < len(pin); i++ {
|
||||
d := int(pin[i]) - int(pin[i-1])
|
||||
same = same && d == 0
|
||||
up = up && d == 1
|
||||
down = down && d == -1
|
||||
}
|
||||
if same || up || down {
|
||||
return fmt.Errorf("%w: the PIN is too easy to guess", ErrInvalidPinInput)
|
||||
}
|
||||
if birthDate != nil {
|
||||
for _, layout := range []string{"020106", "060102"} {
|
||||
if pin == birthDate.Format(layout) {
|
||||
return fmt.Errorf("%w: the PIN must not be your birth date", ErrInvalidPinInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// otpFake keeps OTP sessions in memory with the checks the real one makes.
|
||||
type otpFake struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]*entities.OtpSession
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (f *otpFake) CanResendOtp(context.Context, string, string) (bool, int, error) {
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func (f *otpFake) CreateOtpSession(_ context.Context, phone, purpose string) (*entities.OtpSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := &entities.OtpSession{Token: uuid.NewString(), Code: "246810", PhoneNumber: phone, Purpose: purpose, ExpiresAt: time.Now().Add(5 * time.Minute)}
|
||||
f.sessions[s.Token] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (f *otpFake) SendOtpViaWhatsApp(phone, code, purpose string) error {
|
||||
f.sent = append(f.sent, purpose)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *otpFake) ValidateOtpSession(_ context.Context, token, code string) (*entities.OtpSession, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
s := f.sessions[token]
|
||||
if s == nil || s.IsUsed || s.Code != code {
|
||||
return nil, errors.New("invalid OTP")
|
||||
}
|
||||
s.IsUsed = true
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// issue creates a session as if it had been sent, for any purpose and number.
|
||||
func (f *otpFake) issue(phone, purpose string) *entities.OtpSession {
|
||||
s, _ := f.CreateOtpSession(context.Background(), phone, purpose)
|
||||
return s
|
||||
}
|
||||
|
||||
type alerterFake struct {
|
||||
mu sync.Mutex
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *alerterFake) Notify(_ context.Context, _ uuid.UUID, _, body string, _ map[string]string) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.messages = append(f.messages, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestCustomerPin_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, otherOrg, customer, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
phone := "0812" + customer.String()[:8]
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'pin test', 'basic'), (?, 'other', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO customers (id, organization_id, name, phone_number, birth_date) VALUES (?, ?, 'Budi', ?, '1990-03-14')`, customer, org, phone)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM customer_security_events WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
otp := &otpFake{sessions: map[string]*entities.OtpSession{}}
|
||||
alerts := &alerterFake{}
|
||||
p := NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), otp, alerts)
|
||||
p.cost = bcrypt.MinCost
|
||||
clock := time.Now()
|
||||
var clockMu sync.Mutex
|
||||
p.now = func() time.Time { clockMu.Lock(); defer clockMu.Unlock(); return clock }
|
||||
advance := func(d time.Duration) { clockMu.Lock(); clock = clock.Add(d); clockMu.Unlock() }
|
||||
info := models.CustomerPinRequestInfo{IPAddress: "10.0.0.7", UserAgent: "EnakApp/2.0"}
|
||||
const pin, newPin, resetPin = "482913", "572039", "613408"
|
||||
|
||||
pinErr := func(err error) *PinError {
|
||||
t.Helper()
|
||||
var pe *PinError
|
||||
require.True(t, errors.As(err, &pe), "want a PinError, got %v", err)
|
||||
for _, secret := range []string{pin, newPin, resetPin} {
|
||||
assert.NotContains(t, err.Error(), secret, "an error must never contain a PIN")
|
||||
}
|
||||
return pe
|
||||
}
|
||||
events := func() []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
require.NoError(t, db.Raw(`SELECT event FROM customer_security_events WHERE customer_id = ? ORDER BY created_at, id`, customer).Scan(&out).Error)
|
||||
return out
|
||||
}
|
||||
|
||||
// No PIN yet: nothing can be approved.
|
||||
status, err := p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.HasPin)
|
||||
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info)).Code)
|
||||
|
||||
// Creating the first PIN takes an OTP sent to the customer's own number, for this
|
||||
// purpose.
|
||||
sent, err := p.RequestOtp(ctx, customer, PinOtpPurposeSetup)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []string{PinOtpPurposeSetup}, otp.sent)
|
||||
|
||||
loginOtp := otp.issue(phone, "login")
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, loginOtp.Token, loginOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP for another purpose")
|
||||
strangerOtp := otp.issue("0899999999", PinOtpPurposeSetup)
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, strangerOtp.Token, strangerOtp.Code, pin, pin, info), ErrPinOtpInvalid, "an OTP sent to another number")
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "000000", pin, pin, info), ErrPinOtpInvalid, "a wrong code")
|
||||
|
||||
// A weak PIN is refused before the OTP is used, so the same OTP still works after.
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "123456", "123456", info), ErrInvalidPinInput)
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", "140390", "140390", info), ErrInvalidPinInput, "birth date")
|
||||
require.NoError(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info))
|
||||
assert.ErrorIs(t, p.CreatePin(ctx, customer, sent.OtpToken, "246810", pin, pin, info), ErrPinAlreadySet)
|
||||
|
||||
var stored string
|
||||
require.NoError(t, db.Raw(`SELECT pin_hash FROM customers WHERE id = ?`, customer).Scan(&stored).Error)
|
||||
assert.NotContains(t, stored, pin, "only a hash is stored")
|
||||
assert.True(t, strings.HasPrefix(stored, "$2"), "bcrypt")
|
||||
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
|
||||
// Four wrong attempts count down; the fifth locks for 30 minutes.
|
||||
for left := 4; left >= 1; left-- {
|
||||
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, left, pe.RemainingAttempts)
|
||||
}
|
||||
pe := pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
assert.WithinDuration(t, clock.Add(30*time.Minute), *pe.Until, time.Second)
|
||||
assert.Len(t, alerts.messages, 1, "the customer is told the PIN locked")
|
||||
|
||||
// While locked even the right PIN is refused.
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, status.LockedUntil)
|
||||
|
||||
// Once the lock runs out a wrong PIN starts a new series of five.
|
||||
advance(31 * time.Minute)
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, PinErrInvalid, pe.Code)
|
||||
assert.Equal(t, 4, pe.RemainingAttempts)
|
||||
// The right PIN resets the count.
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, "000001", PinActionPay, info))
|
||||
assert.Equal(t, 4, pe.RemainingAttempts)
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
|
||||
// Wrong attempts made at once all count: none slips past the lock.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); _ = p.VerifyPin(ctx, customer, "000001", PinActionPay, info) }()
|
||||
}
|
||||
wg.Wait()
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, pin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrLocked, pe.Code)
|
||||
|
||||
// Resetting through OTP lifts the lock and holds transfers for 24 hours.
|
||||
_, err = p.RequestOtp(ctx, customer, PinOtpPurposeReset)
|
||||
require.NoError(t, err)
|
||||
setupOtp := otp.issue(phone, PinOtpPurposeSetup)
|
||||
assert.ErrorIs(t, p.ResetPin(ctx, customer, setupOtp.Token, setupOtp.Code, resetPin, resetPin, info), ErrPinOtpInvalid, "a setup OTP cannot reset")
|
||||
resetOtp := otp.issue(phone, PinOtpPurposeReset)
|
||||
require.NoError(t, p.ResetPin(ctx, customer, resetOtp.Token, resetOtp.Code, resetPin, resetPin, info))
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, status.LockedUntil, "the lock is lifted")
|
||||
require.NotNil(t, status.TransferBlockedUntil)
|
||||
assert.WithinDuration(t, clock.Add(24*time.Hour), *status.TransferBlockedUntil, time.Second)
|
||||
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionPay, info), "paying still works")
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, resetPin, PinActionExchange, info), "exchanging still works")
|
||||
pe = pinErr(p.VerifyPin(ctx, customer, resetPin, PinActionTransfer, info))
|
||||
assert.Equal(t, PinErrTransferBlocked, pe.Code)
|
||||
var failed int
|
||||
require.NoError(t, db.Raw(`SELECT pin_failed_attempts FROM customers WHERE id = ?`, customer).Scan(&failed).Error)
|
||||
assert.Zero(t, failed, "a held transfer costs no attempt")
|
||||
|
||||
// Changing the PIN needs the old one and keeps the transfer hold.
|
||||
assert.Equal(t, PinErrInvalid, pinErr(p.ChangePin(ctx, customer, "000001", newPin, newPin, info)).Code)
|
||||
require.NoError(t, p.ChangePin(ctx, customer, resetPin, newPin, newPin, info))
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionPay, info))
|
||||
assert.Equal(t, PinErrTransferBlocked, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info)).Code)
|
||||
advance(25 * time.Hour)
|
||||
require.NoError(t, p.VerifyPin(ctx, customer, newPin, PinActionTransfer, info), "the hold ends after 24 hours")
|
||||
|
||||
// An admin can remove the PIN, only in their own organization and with a reason.
|
||||
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, otherOrg, customer, admin, "hilang HP", info), repository.ErrPinCustomerNotFound)
|
||||
assert.ErrorIs(t, p.RemovePinByAdmin(ctx, org, customer, admin, " ", info), ErrInvalidPinInput)
|
||||
require.NoError(t, p.RemovePinByAdmin(ctx, org, customer, admin, "hilang HP", info))
|
||||
status, err = p.Status(ctx, customer)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, status.HasPin)
|
||||
assert.Equal(t, PinErrNotSet, pinErr(p.VerifyPin(ctx, customer, newPin, PinActionPay, info)).Code)
|
||||
|
||||
// Every event is in the security log, with where it came from.
|
||||
got := events()
|
||||
for _, want := range []string{PinEventSet, PinEventFailed, PinEventLocked, PinEventReset, PinEventChanged, PinEventRemovedByAdmin} {
|
||||
assert.Contains(t, got, want)
|
||||
}
|
||||
page, err := p.ListEvents(ctx, org, customer, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, len(got), page.Pagination.Total)
|
||||
removed := page.Data[0]
|
||||
assert.Equal(t, PinEventRemovedByAdmin, removed.Event)
|
||||
assert.Equal(t, &admin, removed.ActorUser)
|
||||
assert.Equal(t, "hilang HP", *removed.Reason)
|
||||
assert.Equal(t, "10.0.0.7", *removed.IPAddress)
|
||||
_, err = p.ListEvents(ctx, otherOrg, customer, 1, 10)
|
||||
assert.ErrorIs(t, err, repository.ErrPinCustomerNotFound)
|
||||
|
||||
var locked int
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM customer_security_events WHERE customer_id = ? AND event = ?`, customer, PinEventLocked).Scan(&locked).Error)
|
||||
assert.Equal(t, locked, len(alerts.messages), "one alert per lock")
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func TestCheckNewPin(t *testing.T) {
|
||||
birth := time.Date(1990, 3, 14, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
for _, ok := range []string{"482913", "019283", "135790", "112233"} {
|
||||
assert.NoError(t, checkNewPin(ok, ok, &birth), ok)
|
||||
}
|
||||
for name, c := range map[string][2]string{
|
||||
"too short": {"12345", "12345"},
|
||||
"too long": {"1234567", "1234567"},
|
||||
"not digits": {"12a456", "12a456"},
|
||||
"confirmation": {"482913", "482914"},
|
||||
"one digit": {"111111", "111111"},
|
||||
"zeros": {"000000", "000000"},
|
||||
"run up": {"123456", "123456"},
|
||||
"run up from 4": {"456789", "456789"},
|
||||
"run down": {"654321", "654321"},
|
||||
"run down from 9": {"987654", "987654"},
|
||||
"birth date DDMMYY": {"140390", "140390"},
|
||||
"birth date YYMMDD": {"900314", "900314"},
|
||||
} {
|
||||
err := checkNewPin(c[0], c[1], &birth)
|
||||
assert.ErrorIs(t, err, ErrInvalidPinInput, name)
|
||||
assert.NotContains(t, err.Error(), c[0], "%s: the message must not echo the PIN", name)
|
||||
}
|
||||
// Without a birth date only the other rules apply.
|
||||
assert.NoError(t, checkNewPin("140390", "140390", nil))
|
||||
}
|
||||
|
||||
// pinRepoFake holds one customer's PIN state, with the lock rules of RecordFailure.
|
||||
type pinRepoFake struct {
|
||||
repository.CustomerPinRepository
|
||||
state repository.CustomerPinState
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) GetState(context.Context, uuid.UUID) (*repository.CustomerPinState, error) {
|
||||
s := f.state
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) RecordFailure(_ context.Context, _ uuid.UUID, maxAttempts int, now, lockUntil time.Time) (int, *time.Time, error) {
|
||||
if f.state.LockedUntil != nil && !f.state.LockedUntil.After(now) {
|
||||
f.state.FailedAttempts, f.state.LockedUntil = 1, nil
|
||||
} else {
|
||||
f.state.FailedAttempts++
|
||||
if f.state.FailedAttempts >= maxAttempts {
|
||||
f.state.LockedUntil = &lockUntil
|
||||
}
|
||||
}
|
||||
return f.state.FailedAttempts, f.state.LockedUntil, nil
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) ClearFailures(context.Context, uuid.UUID) error {
|
||||
f.state.FailedAttempts, f.state.LockedUntil = 0, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *pinRepoFake) InsertEvent(context.Context, repository.CustomerSecurityEvent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCustomerPin_LockIsPushedThroughFCM(t *testing.T) {
|
||||
customer := uuid.New()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
h := string(hash)
|
||||
repo := &pinRepoFake{state: repository.CustomerPinState{CustomerID: customer, PinHash: &h}}
|
||||
notifier := ¬ifierFake{}
|
||||
p := NewCustomerPinProcessor(repo, nil, notifier)
|
||||
now := time.Date(2026, 9, 30, 3, 0, 0, 0, time.UTC)
|
||||
p.now = func() time.Time { return now }
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
_ = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{})
|
||||
}
|
||||
assert.Empty(t, notifier.pushes[customer], "no push before the PIN locks")
|
||||
|
||||
err = p.VerifyPin(ctx, customer, "000000", PinActionPay, models.CustomerPinRequestInfo{})
|
||||
var pinErr *PinError
|
||||
require.ErrorAs(t, err, &pinErr)
|
||||
assert.Equal(t, PinErrLocked, pinErr.Code)
|
||||
|
||||
// Attempts while locked do not push again.
|
||||
_ = p.VerifyPin(ctx, customer, "482913", PinActionPay, models.CustomerPinRequestInfo{})
|
||||
|
||||
require.Len(t, notifier.pushes[customer], 1)
|
||||
push := notifier.pushes[customer][0]
|
||||
assert.Equal(t, "PIN terkunci", push.title)
|
||||
assert.Equal(t, "PIN EnakPoint kamu terkunci sampai 30 Sep 2026 10:30 WIB karena salah dimasukkan 5 kali. Jika ini bukan kamu, segera reset PIN lewat aplikasi.", push.body)
|
||||
assert.Equal(t, map[string]string{"type": NotificationTypePinLocked, "locked_until": "2026-09-30T03:30:00Z"}, push.data)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
@@ -12,217 +11,69 @@ import (
|
||||
)
|
||||
|
||||
type CustomerPointsProcessor struct {
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
gameRepo *repository.GameRepository
|
||||
walletQuery *WalletQueryProcessor
|
||||
gameRepo *repository.GameRepository
|
||||
}
|
||||
|
||||
func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor {
|
||||
return &CustomerPointsProcessor{
|
||||
customerPointsRepo: customerPointsRepo,
|
||||
gameRepo: gameRepo,
|
||||
walletQuery: walletQuery,
|
||||
gameRepo: gameRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// Existing gamification methods - placeholder implementations
|
||||
func (p *CustomerPointsProcessor) CreateCustomerPoints(ctx context.Context, req *models.CreateCustomerPointsRequest) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedCustomerPointsResponse, error) {
|
||||
// Return empty paginated response for now
|
||||
return &models.PaginatedCustomerPointsResponse{
|
||||
Data: []models.CustomerPointsResponse{},
|
||||
TotalCount: 0,
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
TotalPages: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error {
|
||||
// TODO: Implement this method
|
||||
return fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) {
|
||||
// TODO: Implement this method
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
// The 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).
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) {
|
||||
// Get total points
|
||||
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get points history (last 10 records)
|
||||
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10)
|
||||
data, err := p.walletQuery.LegacyPoints(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer points: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var historyItems []models.PointsHistoryItem
|
||||
|
||||
for _, point := range pointsHistory {
|
||||
historyItems = append(historyItems, models.PointsHistoryItem{
|
||||
ID: point.ID.String(),
|
||||
Points: point.Balance,
|
||||
Type: "BALANCE",
|
||||
Description: "Points balance",
|
||||
CreatedAt: point.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var lastUpdated time.Time
|
||||
if len(pointsHistory) > 0 {
|
||||
lastUpdated = pointsHistory[0].CreatedAt
|
||||
}
|
||||
|
||||
return &models.GetCustomerPointsResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer points retrieved successfully.",
|
||||
Data: &models.GetCustomerPointsResponseData{
|
||||
TotalPoints: totalPoints,
|
||||
PointsHistory: historyItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) {
|
||||
// Get total tokens
|
||||
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get tokens history (last 10 records)
|
||||
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var historyItems []models.TokensHistoryItem
|
||||
|
||||
for _, token := range tokensHistory {
|
||||
historyItems = append(historyItems, models.TokensHistoryItem{
|
||||
ID: token.ID.String(),
|
||||
Tokens: token.Balance,
|
||||
Type: string(token.TokenType),
|
||||
Description: "Tokens balance",
|
||||
CreatedAt: token.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
var lastUpdated time.Time
|
||||
if len(tokensHistory) > 0 {
|
||||
lastUpdated = tokensHistory[0].CreatedAt
|
||||
}
|
||||
|
||||
return &models.GetCustomerTokensResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer tokens retrieved successfully.",
|
||||
Data: &models.GetCustomerTokensResponseData{
|
||||
TotalTokens: totalTokens,
|
||||
TokensHistory: historyItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) {
|
||||
// Get total points
|
||||
totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID)
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total points: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get total tokens
|
||||
totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID)
|
||||
data, err := p.walletQuery.Summary(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer total tokens: %w", err)
|
||||
return nil, fmt.Errorf("failed to get customer wallet: %w", err)
|
||||
}
|
||||
|
||||
// Get points history (last 5 records)
|
||||
pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer points history: %w", err)
|
||||
}
|
||||
|
||||
// Get tokens history (last 5 records)
|
||||
tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens history: %w", err)
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
var pointsHistoryItems []models.PointsHistoryItem
|
||||
var tokensHistoryItems []models.TokensHistoryItem
|
||||
var lastUpdated time.Time
|
||||
|
||||
for _, point := range pointsHistory {
|
||||
pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{
|
||||
ID: point.ID.String(),
|
||||
Points: point.Balance,
|
||||
Type: "BALANCE",
|
||||
Description: "Points balance",
|
||||
CreatedAt: point.CreatedAt,
|
||||
})
|
||||
if point.CreatedAt.After(lastUpdated) {
|
||||
lastUpdated = point.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
for _, token := range tokensHistory {
|
||||
tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{
|
||||
ID: token.ID.String(),
|
||||
Tokens: token.Balance,
|
||||
Type: string(token.TokenType),
|
||||
Description: "Tokens balance",
|
||||
CreatedAt: token.CreatedAt,
|
||||
})
|
||||
if token.CreatedAt.After(lastUpdated) {
|
||||
lastUpdated = token.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
return &models.GetCustomerWalletResponse{
|
||||
Status: "SUCCESS",
|
||||
Message: "Customer wallet retrieved successfully.",
|
||||
Data: &models.GetCustomerWalletResponseData{
|
||||
TotalPoints: totalPoints,
|
||||
TotalTokens: totalTokens,
|
||||
PointsHistory: pointsHistoryItems,
|
||||
TokensHistory: tokensHistoryItems,
|
||||
LastUpdated: lastUpdated,
|
||||
},
|
||||
Data: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletTransactionsAPI(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.walletQuery.Transactions(ctx, id, query)
|
||||
}
|
||||
|
||||
func parseWalletCustomerID(customerID string) (uuid.UUID, error) {
|
||||
id, err := uuid.Parse(customerID)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("%w: invalid customer id", ErrInvalidWalletQuery)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// GetCustomerGamesAPI gets active SPIN games for customers
|
||||
func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) {
|
||||
// Get active SPIN games
|
||||
@@ -310,3 +161,11 @@ func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*m
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) {
|
||||
id, err := parseWalletCustomerID(customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.walletQuery.Expiring(ctx, id)
|
||||
}
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CustomerTokensProcessor struct {
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
}
|
||||
|
||||
func NewCustomerTokensProcessor(customerTokensRepo *repository.CustomerTokensRepository) *CustomerTokensProcessor {
|
||||
return &CustomerTokensProcessor{
|
||||
customerTokensRepo: customerTokensRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCustomerTokens creates a new customer tokens record
|
||||
func (p *CustomerTokensProcessor) CreateCustomerTokens(ctx context.Context, req *models.CreateCustomerTokensRequest) (*models.CustomerTokensResponse, error) {
|
||||
// Convert request to entity
|
||||
customerTokens := mappers.ToCustomerTokensEntity(req)
|
||||
|
||||
// Create customer tokens
|
||||
err := p.customerTokensRepo.Create(ctx, customerTokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// GetCustomerTokens retrieves customer tokens by ID
|
||||
func (p *CustomerTokensProcessor) GetCustomerTokens(ctx context.Context, id uuid.UUID) (*models.CustomerTokensResponse, error) {
|
||||
customerTokens, err := p.customerTokensRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// GetCustomerTokensByCustomerIDAndType retrieves customer tokens by customer ID and token type
|
||||
func (p *CustomerTokensProcessor) GetCustomerTokensByCustomerIDAndType(ctx context.Context, customerID uuid.UUID, tokenType string) (*models.CustomerTokensResponse, error) {
|
||||
customerTokens, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// ListCustomerTokens retrieves customer tokens with pagination and filtering
|
||||
func (p *CustomerTokensProcessor) ListCustomerTokens(ctx context.Context, query *models.ListCustomerTokensQuery) (*models.PaginatedResponse[models.CustomerTokensResponse], error) {
|
||||
// Set default values
|
||||
if query.Page <= 0 {
|
||||
query.Page = 1
|
||||
}
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 10
|
||||
}
|
||||
if query.Limit > 100 {
|
||||
query.Limit = 100
|
||||
}
|
||||
|
||||
offset := (query.Page - 1) * query.Limit
|
||||
|
||||
// Get customer tokens from repository
|
||||
customerTokens, total, err := p.customerTokensRepo.List(
|
||||
ctx,
|
||||
offset,
|
||||
query.Limit,
|
||||
query.Search,
|
||||
query.TokenType,
|
||||
query.SortBy,
|
||||
query.SortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list customer tokens: %w", err)
|
||||
}
|
||||
|
||||
// Convert to responses
|
||||
responses := mappers.ToCustomerTokensResponses(customerTokens)
|
||||
|
||||
// Calculate pagination info
|
||||
totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit))
|
||||
|
||||
return &models.PaginatedResponse[models.CustomerTokensResponse]{
|
||||
Data: responses,
|
||||
Pagination: models.Pagination{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
Total: total,
|
||||
TotalPages: totalPages,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateCustomerTokens updates an existing customer tokens record
|
||||
func (p *CustomerTokensProcessor) UpdateCustomerTokens(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerTokensRequest) (*models.CustomerTokensResponse, error) {
|
||||
// Get existing customer tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
// Update customer tokens fields
|
||||
mappers.UpdateCustomerTokensEntity(customerTokens, req)
|
||||
|
||||
// Save updated customer tokens
|
||||
err = p.customerTokensRepo.Update(ctx, customerTokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// DeleteCustomerTokens deletes a customer tokens record
|
||||
func (p *CustomerTokensProcessor) DeleteCustomerTokens(ctx context.Context, id uuid.UUID) error {
|
||||
// Get existing customer tokens
|
||||
_, err := p.customerTokensRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
// Delete customer tokens
|
||||
err = p.customerTokensRepo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTokens adds tokens to a customer's balance
|
||||
func (p *CustomerTokensProcessor) AddTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) {
|
||||
if tokens <= 0 {
|
||||
return nil, errors.New("tokens must be greater than 0")
|
||||
}
|
||||
|
||||
// Ensure customer tokens record exists
|
||||
_, err := p.customerTokensRepo.EnsureCustomerTokens(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to ensure customer tokens: %w", err)
|
||||
}
|
||||
|
||||
// Add tokens
|
||||
err = p.customerTokensRepo.AddTokens(ctx, customerID, entities.TokenType(tokenType), tokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get updated customer tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(customerTokens), nil
|
||||
}
|
||||
|
||||
// DeductTokens deducts tokens from a customer's balance
|
||||
func (p *CustomerTokensProcessor) DeductTokens(ctx context.Context, customerID uuid.UUID, tokenType string, tokens int64) (*models.CustomerTokensResponse, error) {
|
||||
if tokens <= 0 {
|
||||
return nil, errors.New("tokens must be greater than 0")
|
||||
}
|
||||
|
||||
// Get current customer tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
}
|
||||
|
||||
if customerTokens.Balance < tokens {
|
||||
return nil, errors.New("insufficient tokens balance")
|
||||
}
|
||||
|
||||
// Deduct tokens
|
||||
err = p.customerTokensRepo.DeductTokens(ctx, customerID, entities.TokenType(tokenType), tokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get updated customer tokens
|
||||
updatedCustomerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, customerID, entities.TokenType(tokenType))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated customer tokens: %w", err)
|
||||
}
|
||||
|
||||
return mappers.ToCustomerTokensResponse(updatedCustomerTokens), nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// EarningLine is what an order earns in one currency, and the settings that produced
|
||||
// it. The settings are kept so the ledger row can freeze them: a later change of
|
||||
// setting must not change what an earning meant, and a reversal is computed with the
|
||||
// same numbers (docs/prd-point-coin.md F3).
|
||||
type EarningLine struct {
|
||||
Amount int64
|
||||
Settings models.LoyaltyEarnSettings
|
||||
// True when max_per_order cut the amount down.
|
||||
Capped bool
|
||||
}
|
||||
|
||||
// EarningResult is what an order earns.
|
||||
type EarningResult struct {
|
||||
// subtotal − discount − the part paid with EnakPoint, in rupiah, never negative.
|
||||
// Tax and anything else added on top of the subtotal are not part of it (Q1).
|
||||
Basis float64
|
||||
Point EarningLine
|
||||
Coin EarningLine
|
||||
}
|
||||
|
||||
// Metadata is the snapshot stored on the currency's EARN ledger row.
|
||||
func (r EarningResult) Metadata(line EarningLine) entities.Metadata {
|
||||
m := entities.Metadata{
|
||||
"basis": r.Basis,
|
||||
"earn_per_amount": line.Settings.EarnPerAmount,
|
||||
"earn_value": line.Settings.EarnValue,
|
||||
"min_order_amount": line.Settings.MinOrderAmount,
|
||||
"capped": line.Capped,
|
||||
}
|
||||
if line.Settings.MaxPerOrder != nil {
|
||||
m["max_per_order"] = *line.Settings.MaxPerOrder
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// CalculateEarning applies the earning formula of docs/prd-point-coin.md F1:
|
||||
//
|
||||
// basis = subtotal − discount_amount − paid with EnakPoint
|
||||
// amount = 0 if basis < min_order_amount
|
||||
// amount = floor(basis / earn_per_amount) × earn_value
|
||||
// amount = min(amount, max_per_order) if max_per_order is set
|
||||
//
|
||||
// The part paid with EnakPoint earns nothing (Q10). Money is handled in whole cents so
|
||||
// floor never lands one short on a value like 87500.00 that float64 cannot hold
|
||||
// exactly. It has no side effects.
|
||||
func CalculateEarning(order *entities.Order, pointPaidAmount float64, settings models.OutletLoyaltySettings) EarningResult {
|
||||
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount) - toCents(pointPaidAmount)
|
||||
if basisCents < 0 {
|
||||
basisCents = 0
|
||||
}
|
||||
return EarningResult{
|
||||
Basis: float64(basisCents) / 100,
|
||||
Point: earnLine(basisCents, settings.Point),
|
||||
Coin: earnLine(basisCents, settings.Coin),
|
||||
}
|
||||
}
|
||||
|
||||
func earnLine(basisCents int64, s models.LoyaltyEarnSettings) EarningLine {
|
||||
line := EarningLine{Settings: s}
|
||||
if !s.Enabled || s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
return line
|
||||
}
|
||||
if basisCents < s.MinOrderAmount*100 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents / (s.EarnPerAmount * 100) * s.EarnValue
|
||||
if s.MaxPerOrder != nil && line.Amount > *s.MaxPerOrder {
|
||||
line.Amount = *s.MaxPerOrder
|
||||
line.Capped = true
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func toCents(rupiah float64) int64 {
|
||||
return int64(math.Round(rupiah * 100))
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// 1 EnakPoint per Rp 100 and 1 EnakCoin per Rp 25.000, as in the F1 example.
|
||||
func prdEarningSettings() models.OutletLoyaltySettings {
|
||||
return models.OutletLoyaltySettings{
|
||||
Point: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 100, EarnValue: 1},
|
||||
Coin: models.LoyaltyEarnSettings{Enabled: true, EarnPerAmount: 25000, EarnValue: 1},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateEarning_PRDExample(t *testing.T) {
|
||||
// Subtotal after discount Rp 87.500, paid in full in cash.
|
||||
order := &entities.Order{Subtotal: 97500, DiscountAmount: 10000, TaxAmount: 9625, TotalAmount: 97125}
|
||||
|
||||
got := CalculateEarning(order, 0, prdEarningSettings())
|
||||
assert.Equal(t, 87500.0, got.Basis)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
|
||||
// Rp 20.000 of it paid with EnakPoint earns nothing.
|
||||
got = CalculateEarning(order, 20000, prdEarningSettings())
|
||||
assert.Equal(t, 67500.0, got.Basis)
|
||||
assert.Equal(t, int64(675), got.Point.Amount)
|
||||
assert.Equal(t, int64(2), got.Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_TaxIsNotPartOfTheBasis(t *testing.T) {
|
||||
withoutTax := &entities.Order{Subtotal: 50000}
|
||||
withTax := &entities.Order{Subtotal: 50000, TaxAmount: 5500, TotalAmount: 55500}
|
||||
assert.Equal(t, CalculateEarning(withoutTax, 0, prdEarningSettings()), CalculateEarning(withTax, 0, prdEarningSettings()))
|
||||
assert.Equal(t, int64(500), CalculateEarning(withTax, 0, prdEarningSettings()).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_BelowMinimum(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.MinOrderAmount = 50000
|
||||
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Point.Amount)
|
||||
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, 0, s).Point.Amount, "the minimum itself earns")
|
||||
// The minimum applies to the basis, after discount and EnakPoint.
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000}, 15000, s).Point.Amount)
|
||||
// Coin has its own minimum.
|
||||
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, 0, s).Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MaxPerOrder(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
max := int64(300)
|
||||
s.Point.MaxPerOrder = &max
|
||||
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(300), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
assert.Equal(t, int64(300), got.Metadata(got.Point)["max_per_order"])
|
||||
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 20000}, 0, s)
|
||||
assert.Equal(t, int64(200), got.Point.Amount)
|
||||
assert.False(t, got.Point.Capped)
|
||||
|
||||
zero := int64(0)
|
||||
s.Point.MaxPerOrder = &zero
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.Enabled = false
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount, "each currency is switched on its own")
|
||||
|
||||
s.Coin.Enabled = false
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s)
|
||||
assert.Equal(t, int64(0), got.Coin.Amount)
|
||||
assert.Equal(t, 87500.0, got.Basis, "the basis is still reported")
|
||||
|
||||
// The defaults of an outlet that never set anything earn nothing.
|
||||
var defaults models.OutletLoyaltySettings
|
||||
loadLoyaltyFields(outletLoyaltyFields(&defaults), nil, "test")
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, 0, defaults)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
assert.Equal(t, int64(0), got.Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_EdgeCases(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
|
||||
// floor, not round.
|
||||
assert.Equal(t, int64(875), CalculateEarning(&entities.Order{Subtotal: 87599.99}, 0, s).Point.Amount)
|
||||
// Values float64 cannot hold exactly do not lose a point: computed in float64 this
|
||||
// basis divides to 4956.999…, which a naive floor turns into 4956.
|
||||
per250 := prdEarningSettings()
|
||||
per250.Point.EarnPerAmount = 250
|
||||
assert.Equal(t, int64(4957), CalculateEarning(&entities.Order{Subtotal: 1240155.48, DiscountAmount: 749.11}, 156.37, per250).Point.Amount)
|
||||
// Paying more with EnakPoint than the basis leaves nothing, never a negative amount.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 10000}, 15000, s)
|
||||
assert.Equal(t, 0.0, got.Basis)
|
||||
assert.Equal(t, int64(0), got.Point.Amount)
|
||||
// earn_value multiplies.
|
||||
s.Point.EarnValue = 5
|
||||
assert.Equal(t, int64(4375), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
// A zero earn_value earns nothing even when enabled.
|
||||
s.Point.EarnValue = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, 0, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 0, prdEarningSettings())
|
||||
assert.Equal(t, entities.Metadata{
|
||||
"basis": 87500.0, "earn_per_amount": int64(100), "earn_value": int64(1), "min_order_amount": int64(0), "capped": false,
|
||||
}, got.Metadata(got.Point))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Why an order earned nothing.
|
||||
const (
|
||||
EarningSkipNotPaid = "NOT_PAID"
|
||||
EarningSkipVoid = "VOID"
|
||||
EarningSkipNoCustomer = "NO_CUSTOMER"
|
||||
EarningSkipDefaultCustomer = "DEFAULT_CUSTOMER"
|
||||
EarningSkipInactiveCustomer = "INACTIVE_CUSTOMER"
|
||||
EarningSkipNothingToEarn = "NOTHING_TO_EARN"
|
||||
)
|
||||
|
||||
// EarningOutcome is what earning did for one order.
|
||||
type EarningOutcome struct {
|
||||
Points int64
|
||||
Coins int64
|
||||
// Set when the order earned nothing, to say why.
|
||||
Skipped string
|
||||
}
|
||||
|
||||
type outletSettingsReader interface {
|
||||
Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error)
|
||||
}
|
||||
|
||||
// earningSettingsReader gives the outlet's earning settings and the organization's
|
||||
// expiry. LoyaltySettingsProcessor is one.
|
||||
type earningSettingsReader interface {
|
||||
outletSettingsReader
|
||||
organizationSettingsReader
|
||||
}
|
||||
|
||||
// EarningProcessor credits EnakPoint and EnakCoin for paid orders
|
||||
// (docs/prd-point-coin.md F3).
|
||||
type EarningProcessor struct {
|
||||
orders repository.EarningRepository
|
||||
settings earningSettingsReader
|
||||
now func() time.Time
|
||||
wallet *WalletProcessor
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewEarningProcessor(orders repository.EarningRepository, settings earningSettingsReader, wallet *WalletProcessor, tx TxRunner) *EarningProcessor {
|
||||
return &EarningProcessor{orders: orders, settings: settings, wallet: wallet, tx: tx, now: time.Now}
|
||||
}
|
||||
|
||||
// OnOrderPaid is called once an order has become fully paid and the payment has
|
||||
// committed. It never fails the caller: a failed earning is logged and picked up later
|
||||
// by EarnMissing, and the idempotency keys make that retry safe.
|
||||
func (p *EarningProcessor) OnOrderPaid(ctx context.Context, orderID uuid.UUID) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning for order %s panicked; it will be retried", orderID), fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := p.EarnForOrder(ctx, orderID); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning for order %s failed; it will be retried", orderID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// EarnForOrder credits what a paid order earns. Calling it again for the same order
|
||||
// credits nothing more.
|
||||
func (p *EarningProcessor) EarnForOrder(ctx context.Context, orderID uuid.UUID) (*EarningOutcome, error) {
|
||||
order, err := p.orders.GetOrderForEarning(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if skip := earningSkipReason(order); skip != "" {
|
||||
return &EarningOutcome{Skipped: skip}, nil
|
||||
}
|
||||
|
||||
settings, err := p.settings.Outlet(ctx, order.OutletID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pointPaid, err := p.orders.PointPaidAmount(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := CalculateEarning(&entities.Order{Subtotal: order.Subtotal, DiscountAmount: order.DiscountAmount}, pointPaid, *settings)
|
||||
if result.Point.Amount == 0 && result.Coin.Amount == 0 {
|
||||
return &EarningOutcome{Skipped: EarningSkipNothingToEarn}, nil
|
||||
}
|
||||
|
||||
orgSettings, err := p.settings.Organization(ctx, order.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receivedAt := p.now()
|
||||
|
||||
outcome := &EarningOutcome{}
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, c := range []struct {
|
||||
currency string
|
||||
line EarningLine
|
||||
total *int64
|
||||
}{
|
||||
{constants.WalletCurrencyPoint, result.Point, &outcome.Points},
|
||||
{constants.WalletCurrencyCoin, result.Coin, &outcome.Coins},
|
||||
} {
|
||||
if c.line.Amount == 0 {
|
||||
continue
|
||||
}
|
||||
outletID := order.OutletID
|
||||
res, err := p.wallet.Credit(ctx, WalletCreditInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: *order.CustomerID,
|
||||
Currency: c.currency,
|
||||
Type: constants.WalletTxTypeEarn,
|
||||
Amount: c.line.Amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
OutletID: &outletID,
|
||||
Description: earningDescription(order),
|
||||
Metadata: result.Metadata(c.line),
|
||||
IdempotencyKey: fmt.Sprintf("earn:%s:%s", order.ID, c.currency),
|
||||
},
|
||||
Lots: []WalletLotInput{{
|
||||
Amount: c.line.Amount,
|
||||
ExpiresAt: ComputeExpiry(ExpirySettings(orgSettings, c.currency), receivedAt),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("crediting %s: %w", c.currency, err)
|
||||
}
|
||||
*c.total = res.Transaction.Amount
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// EarnMissing is the safety net behind OnOrderPaid: it looks for orders paid since the
|
||||
// given time that should have earned and have no EARN row, and earns for them. It
|
||||
// returns how many orders it looked at and how many now earned. One order failing does
|
||||
// not stop the others.
|
||||
func (p *EarningProcessor) EarnMissing(ctx context.Context, since time.Time, maxOrders int) (checked, earned int, err error) {
|
||||
const page = 200
|
||||
var after *repository.EarningCursor
|
||||
for checked < maxOrders {
|
||||
batch, err := p.orders.ListPaidOrdersWithoutEarning(ctx, since, after, page)
|
||||
if err != nil {
|
||||
return checked, earned, err
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
for _, candidate := range batch {
|
||||
checked++
|
||||
outcome, err := p.EarnForOrder(ctx, candidate.ID)
|
||||
if err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning retry for order %s failed", candidate.ID), err)
|
||||
continue
|
||||
}
|
||||
if outcome.Skipped == "" {
|
||||
earned++
|
||||
}
|
||||
}
|
||||
last := batch[len(batch)-1]
|
||||
after = &last
|
||||
}
|
||||
return checked, earned, nil
|
||||
}
|
||||
|
||||
func earningSkipReason(order *repository.EarningOrder) string {
|
||||
switch {
|
||||
case order.PaymentStatus != string(entities.PaymentStatusCompleted):
|
||||
return EarningSkipNotPaid
|
||||
case order.IsVoid:
|
||||
return EarningSkipVoid
|
||||
case order.CustomerID == nil || order.CustomerIsDefault == nil:
|
||||
return EarningSkipNoCustomer
|
||||
case *order.CustomerIsDefault:
|
||||
return EarningSkipDefaultCustomer
|
||||
case order.CustomerIsActive == nil || !*order.CustomerIsActive:
|
||||
return EarningSkipInactiveCustomer
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func earningDescription(order *repository.EarningOrder) string {
|
||||
description := "Belanja #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// EarnedByOrders returns what each order earned, as written in its EARN rows, for the
|
||||
// order screen and the receipt (F3). Orders that earned nothing are absent.
|
||||
func (p *EarningProcessor) EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
|
||||
rows, err := p.orders.EarnedByOrders(ctx, orderIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uuid.UUID]OrderEarned, len(rows))
|
||||
for id, byCurrency := range rows {
|
||||
out[id] = OrderEarned{Points: byCurrency[constants.WalletCurrencyPoint], Coins: byCurrency[constants.WalletCurrencyCoin]}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// failingSettings fails for the outlet settings until healed, to stand in for the
|
||||
// database being unreachable right after a payment.
|
||||
type failingSettings struct {
|
||||
real earningSettingsReader
|
||||
mu sync.Mutex
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *failingSettings) Outlet(ctx context.Context, outletID uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
f.mu.Lock()
|
||||
fail := f.fail
|
||||
f.mu.Unlock()
|
||||
if fail {
|
||||
return nil, errors.New("connection refused")
|
||||
}
|
||||
return f.real.Outlet(ctx, outletID)
|
||||
}
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEarningProcessor_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user := uuid.New(), uuid.New()
|
||||
earningOutlet, quietOutlet := uuid.New(), uuid.New()
|
||||
regular, inactive := uuid.New(), uuid.New()
|
||||
var walkIn uuid.UUID
|
||||
var customers []uuid.UUID
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'earning test', 'basic')`, org)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang'), (?, ?, 'Tanpa Poin')`, earningOutlet, org, quietOutlet, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name, is_default, is_active) VALUES
|
||||
(?, ?, 'Budi', false, true), (?, ?, 'Nonaktif', false, false)`, regular, org, inactive, org)
|
||||
// Creating the organization created its walk-in customer (trigger_create_default_customer).
|
||||
var walkInID string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM customers WHERE organization_id = ? AND is_default`, org).Scan(&walkInID).Error)
|
||||
walkIn = uuid.MustParse(walkInID)
|
||||
customers = []uuid.UUID{regular, walkIn, inactive}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM outlets WHERE id IN ?`, []uuid.UUID{earningOutlet, quietOutlet})
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settingsProcessor := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
outletSettings, err := settingsProcessor.Outlet(ctx, earningOutlet)
|
||||
require.NoError(t, err)
|
||||
outletSettings.Point.Enabled = true
|
||||
outletSettings.Coin.Enabled = true
|
||||
_, err = settingsProcessor.UpdateOutlet(ctx, org, earningOutlet, user, *outletSettings)
|
||||
require.NoError(t, err)
|
||||
|
||||
settings := &failingSettings{real: settingsProcessor}
|
||||
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, NewWalletProcessor(repository.NewWalletRepository(db)), txm)
|
||||
|
||||
orderNo := 0
|
||||
newOrder := func(outlet uuid.UUID, customer *uuid.UUID, paymentStatus string, isVoid bool, subtotal, discount float64) uuid.UUID {
|
||||
t.Helper()
|
||||
orderNo++
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, discount_amount, tax_amount, total_amount, payment_status, is_void)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', ?, ?, 0, ?, ?, ?)`,
|
||||
id, org, outlet, user, customer, id.String()[:8]+"-"+string(rune('A'+orderNo)), subtotal, discount, subtotal-discount, paymentStatus, isVoid)
|
||||
return id
|
||||
}
|
||||
earnRows := func(orderID uuid.UUID) map[string]int64 {
|
||||
t.Helper()
|
||||
var rows []struct {
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT currency, amount FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN'`, orderID).Scan(&rows).Error)
|
||||
out := map[string]int64{}
|
||||
for _, r := range rows {
|
||||
out[r.Currency] += r.Amount
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Paid in full: the PRD example, 875 EnakPoint and 3 EnakCoin.
|
||||
paid := newOrder(earningOutlet, ®ular, "completed", false, 97500, 10000)
|
||||
earning.OnOrderPaid(ctx, paid)
|
||||
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
||||
|
||||
var row struct {
|
||||
Description string
|
||||
Metadata string
|
||||
OutletID string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT description, metadata::text AS metadata, outlet_id::text AS outlet_id FROM wallet_transactions
|
||||
WHERE reference_id = ? AND currency = 'POINT'`, paid).Scan(&row).Error)
|
||||
assert.Contains(t, row.Description, "di Kemang")
|
||||
assert.Contains(t, row.Metadata, `"earn_per_amount": 100`, "the settings used are frozen on the row")
|
||||
assert.Contains(t, row.Metadata, `"basis": 87500`)
|
||||
assert.Equal(t, earningOutlet.String(), row.OutletID)
|
||||
|
||||
// Called again, and five times at once, it is still one earning.
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); earning.OnOrderPaid(ctx, paid) }()
|
||||
}
|
||||
wg.Wait()
|
||||
outcome, err := earning.EarnForOrder(ctx, paid)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(875), outcome.Points, "a repeat reports the first earning")
|
||||
assert.Equal(t, map[string]int64{"POINT": 875, "COIN": 3}, earnRows(paid))
|
||||
|
||||
// A self-order goes through the same payment path and the same rules.
|
||||
selfOrder := newOrder(earningOutlet, ®ular, "completed", false, 25000, 0)
|
||||
earning.OnOrderPaid(ctx, selfOrder)
|
||||
assert.Equal(t, map[string]int64{"POINT": 250, "COIN": 1}, earnRows(selfOrder))
|
||||
|
||||
// A split bill earns once, on the payment that settles it: while partial, nothing.
|
||||
split := newOrder(earningOutlet, ®ular, "partial", false, 60000, 0)
|
||||
earning.OnOrderPaid(ctx, split)
|
||||
assert.Empty(t, earnRows(split))
|
||||
exec(`UPDATE orders SET payment_status = 'completed' WHERE id = ?`, split)
|
||||
earning.OnOrderPaid(ctx, split)
|
||||
assert.Equal(t, map[string]int64{"POINT": 600, "COIN": 2}, earnRows(split))
|
||||
|
||||
// Orders that must not earn.
|
||||
for name, c := range map[string]struct {
|
||||
id uuid.UUID
|
||||
skip string
|
||||
}{
|
||||
"walk-in customer": {newOrder(earningOutlet, &walkIn, "completed", false, 50000, 0), EarningSkipDefaultCustomer},
|
||||
"inactive customer": {newOrder(earningOutlet, &inactive, "completed", false, 50000, 0), EarningSkipInactiveCustomer},
|
||||
"no customer": {newOrder(earningOutlet, nil, "completed", false, 50000, 0), EarningSkipNoCustomer},
|
||||
"void": {newOrder(earningOutlet, ®ular, "completed", true, 50000, 0), EarningSkipVoid},
|
||||
"unpaid": {newOrder(earningOutlet, ®ular, "pending", false, 50000, 0), EarningSkipNotPaid},
|
||||
"outlet not earning": {newOrder(quietOutlet, ®ular, "completed", false, 50000, 0), EarningSkipNothingToEarn},
|
||||
} {
|
||||
outcome, err := earning.EarnForOrder(ctx, c.id)
|
||||
require.NoError(t, err, name)
|
||||
assert.Equal(t, c.skip, outcome.Skipped, name)
|
||||
assert.Empty(t, earnRows(c.id), name)
|
||||
}
|
||||
|
||||
// An earning that fails does not surface to the payment, and the job picks it up.
|
||||
settings.mu.Lock()
|
||||
settings.fail = true
|
||||
settings.mu.Unlock()
|
||||
missed := newOrder(earningOutlet, ®ular, "completed", false, 40000, 0)
|
||||
assert.NotPanics(t, func() { earning.OnOrderPaid(ctx, missed) })
|
||||
assert.Empty(t, earnRows(missed))
|
||||
|
||||
settings.mu.Lock()
|
||||
settings.fail = false
|
||||
settings.mu.Unlock()
|
||||
since := time.Now().Add(-time.Hour)
|
||||
checked, earned, err := earning.EarnMissing(ctx, since, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, earned, 1)
|
||||
assert.GreaterOrEqual(t, checked, earned)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
// The job only looks at orders that could earn and have not.
|
||||
candidates, err := repository.NewEarningRepository(db).ListPaidOrdersWithoutEarning(ctx, since, nil, 1000)
|
||||
require.NoError(t, err)
|
||||
var ours []uuid.UUID
|
||||
for _, c := range candidates {
|
||||
var n int64
|
||||
db.Raw(`SELECT COUNT(*) FROM orders WHERE id = ? AND organization_id = ?`, c.ID, org).Scan(&n)
|
||||
if n > 0 {
|
||||
ours = append(ours, c.ID)
|
||||
}
|
||||
}
|
||||
assert.Empty(t, ours, "every eligible order of ours has earned; walk-in, inactive, void, unpaid and non-earning outlets are never candidates")
|
||||
|
||||
// A second run finds nothing more to do for these orders.
|
||||
_, _, err = earning.EarnMissing(ctx, since, 1000)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]int64{"POINT": 400, "COIN": 1}, earnRows(missed))
|
||||
|
||||
// What the order screen and receipt show is what the ledger has.
|
||||
shown, err := earning.EarnedByOrders(ctx, []uuid.UUID{paid, split, missed, uuid.New()})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OrderEarned{Points: 875, Coins: 3}, shown[paid])
|
||||
assert.Equal(t, OrderEarned{Points: 600, Coins: 2}, shown[split])
|
||||
assert.Equal(t, OrderEarned{Points: 400, Coins: 1}, shown[missed])
|
||||
assert.Len(t, shown, 3, "an order without earning is absent, so it shows 0")
|
||||
|
||||
balance := struct{ PointBalance, CoinBalance int64 }{}
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, regular).Scan(&balance).Error)
|
||||
assert.Equal(t, int64(875+250+600+400), balance.PointBalance)
|
||||
assert.Equal(t, int64(3+1+2+1), balance.CoinBalance)
|
||||
}
|
||||
|
||||
func (f *failingSettings) Organization(ctx context.Context, organizationID uuid.UUID) (*models.OrganizationLoyaltySettings, error) {
|
||||
return f.real.Organization(ctx, organizationID)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// ReversalOutcome is what a reversal took back, and what it could not.
|
||||
type ReversalOutcome struct {
|
||||
Points int64
|
||||
Coins int64
|
||||
PointShortfall int64
|
||||
CoinShortfall int64
|
||||
}
|
||||
|
||||
// OnOrderRefunded is called after an order was voided or (partly) refunded and that
|
||||
// has committed. It never fails the caller: a refund is never blocked by the loyalty
|
||||
// balance (Q3), so errors are logged.
|
||||
func (p *EarningProcessor) OnOrderRefunded(ctx context.Context, orderID uuid.UUID) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s panicked", orderID), fmt.Errorf("%v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := p.ReverseForOrder(ctx, orderID); err != nil {
|
||||
logger.NonContext.Error(fmt.Sprintf("Earning reversal for order %s failed", orderID), err)
|
||||
}
|
||||
}
|
||||
|
||||
// ReverseForOrder takes back what an order earned, as far as it has been voided or
|
||||
// refunded (docs/prd-point-coin.md F10):
|
||||
//
|
||||
// - void: everything the order earned;
|
||||
// - refund: floor(earned × refunded / basis), never more than was earned, with the
|
||||
// refunded amount being the order's cumulative refund.
|
||||
//
|
||||
// Only the part not asked back yet is taken, so calling it again, or after each of
|
||||
// several partial refunds, never takes more than the order earned. It draws from the
|
||||
// lots the EARN created first, then from the others in K9 order, and takes what the
|
||||
// balance has when it is short, recording the rest as shortfall (Q3).
|
||||
func (p *EarningProcessor) ReverseForOrder(ctx context.Context, orderID uuid.UUID) (*ReversalOutcome, error) {
|
||||
order, err := p.orders.GetOrderForEarning(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
earns, err := p.orders.ListEarnTransactions(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outcome := &ReversalOutcome{}
|
||||
if len(earns) == 0 {
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
for _, earn := range earns {
|
||||
target := earningReversalTarget(order, earn)
|
||||
requested, err := p.orders.ReversalRequested(ctx, earn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
amount := target - requested
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
lots, err := p.orders.LotIDsCreatedBy(ctx, earn.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reason := "REFUND"
|
||||
if order.IsVoid {
|
||||
reason = "VOID"
|
||||
}
|
||||
earnID := earn.ID
|
||||
res, err := p.wallet.DebitUpTo(ctx, WalletDebitInput{
|
||||
WalletEntry: WalletEntry{
|
||||
CustomerID: earn.CustomerID,
|
||||
Currency: earn.Currency,
|
||||
Type: constants.WalletTxTypeEarnReversal,
|
||||
Amount: amount,
|
||||
ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order.ID,
|
||||
ReversesTransactionID: &earnID,
|
||||
OutletID: earn.OutletID,
|
||||
Description: earningReversalDescription(order),
|
||||
Metadata: entities.Metadata{
|
||||
"reason": reason,
|
||||
"refund_amount": order.RefundAmount,
|
||||
"target": target,
|
||||
},
|
||||
// The target only grows with each refund, so each refund gets its own
|
||||
// key while a retry of the same one replays.
|
||||
IdempotencyKey: fmt.Sprintf("reverse:%s:%d", earn.ID, target),
|
||||
},
|
||||
PreferredLotIDs: lots,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reversing %s: %w", earn.Currency, err)
|
||||
}
|
||||
|
||||
var taken int64
|
||||
if res.Transaction != nil {
|
||||
taken = -res.Transaction.Amount
|
||||
} else {
|
||||
// Nothing to take: the ledger has no row to carry the shortfall.
|
||||
logger.NonContext.WarnWithFields("Earning reversal found an empty balance; the whole amount is shortfall", map[string]interface{}{
|
||||
"order_id": order.ID.String(), "customer_id": earn.CustomerID.String(),
|
||||
"currency": earn.Currency, "shortfall": res.Shortfall,
|
||||
}, nil)
|
||||
}
|
||||
switch earn.Currency {
|
||||
case constants.WalletCurrencyPoint:
|
||||
outcome.Points += taken
|
||||
outcome.PointShortfall += res.Shortfall
|
||||
case constants.WalletCurrencyCoin:
|
||||
outcome.Coins += taken
|
||||
outcome.CoinShortfall += res.Shortfall
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
// earningReversalTarget is how much of an EARN row should have been taken back in
|
||||
// total, given the order's void flag and cumulative refund. It works in cents like
|
||||
// CalculateEarning, with the basis frozen on the EARN row.
|
||||
func earningReversalTarget(order *repository.EarningOrder, earn entities.WalletTransaction) int64 {
|
||||
earned := earn.Amount
|
||||
if order.IsVoid {
|
||||
return earned
|
||||
}
|
||||
refundCents := toCents(order.RefundAmount)
|
||||
if refundCents <= 0 {
|
||||
return 0
|
||||
}
|
||||
basis, _ := earn.Metadata["basis"].(float64)
|
||||
basisCents := toCents(basis)
|
||||
// A refund can include tax, which the basis does not, so it can reach past it.
|
||||
if basisCents <= 0 || refundCents >= basisCents {
|
||||
return earned
|
||||
}
|
||||
return earned * refundCents / basisCents
|
||||
}
|
||||
|
||||
func earningReversalDescription(order *repository.EarningOrder) string {
|
||||
verb := "Refund"
|
||||
if order.IsVoid {
|
||||
verb = "Batal"
|
||||
}
|
||||
description := verb + " #" + order.OrderNumber
|
||||
if order.OutletName != "" {
|
||||
description += " di " + order.OutletName
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEarningReversal_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
|
||||
org, user, outlet := uuid.New(), uuid.New(), uuid.New()
|
||||
voider, refunder, spender, loyal := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{voider, refunder, spender, loyal}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'reversal test', 'basic')`, org)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES (?, ?, 'Kasir', ?, 'x', 'cashier')`, user, org, user.String()+"@test")
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
for _, c := range customers {
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'c')`, c, org)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM orders WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM users WHERE id = ?`, user)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
settings := NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
s, err := settings.Outlet(ctx, outlet)
|
||||
require.NoError(t, err)
|
||||
s.Point.Enabled, s.Coin.Enabled = true, true
|
||||
_, err = settings.UpdateOutlet(ctx, org, outlet, user, *s)
|
||||
require.NoError(t, err)
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
earning := NewEarningProcessor(repository.NewEarningRepository(db), settings, wallet, txm)
|
||||
|
||||
// Each order is the PRD example: basis Rp 87.500, 875 EnakPoint and 3 EnakCoin.
|
||||
paidOrder := func(customer uuid.UUID) uuid.UUID {
|
||||
t.Helper()
|
||||
id := uuid.New()
|
||||
exec(`INSERT INTO orders (id, organization_id, outlet_id, user_id, customer_id, order_number, order_type,
|
||||
subtotal, discount_amount, tax_amount, total_amount, payment_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'dine_in', 97500, 10000, 8750, 96250, 'completed')`,
|
||||
id, org, outlet, user, customer, "ORD-"+id.String()[:8])
|
||||
outcome, err := earning.EarnForOrder(ctx, id)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(875), outcome.Points)
|
||||
return id
|
||||
}
|
||||
balance := func(customer uuid.UUID) (points, coins int64) {
|
||||
t.Helper()
|
||||
var b struct{ PointBalance, CoinBalance int64 }
|
||||
require.NoError(t, db.Raw(`SELECT point_balance, coin_balance FROM customer_wallets WHERE customer_id = ?`, customer).Scan(&b).Error)
|
||||
return b.PointBalance, b.CoinBalance
|
||||
}
|
||||
reversed := func(order uuid.UUID, currency string) int64 {
|
||||
t.Helper()
|
||||
var total int64
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(-amount), 0) FROM wallet_transactions
|
||||
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = ?`, order, currency).Scan(&total).Error)
|
||||
return total
|
||||
}
|
||||
|
||||
// Void: everything comes back.
|
||||
voided := paidOrder(voider)
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, voided)
|
||||
earning.OnOrderRefunded(ctx, voided)
|
||||
assert.Equal(t, int64(875), reversed(voided, "POINT"))
|
||||
assert.Equal(t, int64(3), reversed(voided, "COIN"))
|
||||
p, c := balance(voider)
|
||||
assert.Equal(t, [2]int64{0, 0}, [2]int64{p, c})
|
||||
|
||||
var rev struct {
|
||||
ReversesTransactionID string
|
||||
Description string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reverses_transaction_id::text AS reverses_transaction_id, description FROM wallet_transactions
|
||||
WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, voided).Scan(&rev).Error)
|
||||
var earnID string
|
||||
require.NoError(t, db.Raw(`SELECT id::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN' AND currency = 'POINT'`, voided).Scan(&earnID).Error)
|
||||
assert.Equal(t, earnID, rev.ReversesTransactionID, "the reversal points at the EARN it reverses")
|
||||
assert.Contains(t, rev.Description, "Batal #ORD-")
|
||||
|
||||
// Repeating it takes nothing more.
|
||||
earning.OnOrderRefunded(ctx, voided)
|
||||
assert.Equal(t, int64(875), reversed(voided, "POINT"))
|
||||
|
||||
// Partial refunds are proportional and add up to at most what was earned.
|
||||
refunded := paidOrder(refunder)
|
||||
for _, step := range []struct {
|
||||
refundAmount float64
|
||||
wantTotal int64
|
||||
}{
|
||||
{20000, 200}, // floor(875 × 20000 / 87500)
|
||||
{20000, 200}, // same refund again: nothing more
|
||||
{50000, 500}, // cumulative refund grew
|
||||
{96250, 875}, // the whole total, tax included, cannot exceed the earning
|
||||
{96250, 875},
|
||||
} {
|
||||
exec(`UPDATE orders SET refund_amount = ? WHERE id = ?`, step.refundAmount, refunded)
|
||||
outcome, err := earning.ReverseForOrder(ctx, refunded)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, outcome.PointShortfall)
|
||||
assert.Equal(t, step.wantTotal, reversed(refunded, "POINT"), "after a refund of %.0f", step.refundAmount)
|
||||
}
|
||||
assert.Equal(t, int64(3), reversed(refunded, "COIN"))
|
||||
|
||||
// Balance already spent: what is left is taken, the rest recorded as shortfall,
|
||||
// and the refund is not blocked.
|
||||
spent := paidOrder(spender)
|
||||
committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
|
||||
return wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: spender, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 800, ReferenceType: constants.WalletRefTypePayment, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Bayar"}})
|
||||
})
|
||||
exec(`UPDATE orders SET is_void = true WHERE id = ?`, spent)
|
||||
outcome, err := earning.ReverseForOrder(ctx, spent)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(75), outcome.Points)
|
||||
assert.Equal(t, int64(800), outcome.PointShortfall)
|
||||
p, _ = balance(spender)
|
||||
assert.Equal(t, int64(0), p, "never negative")
|
||||
var metadata string
|
||||
require.NoError(t, db.Raw(`SELECT metadata::text FROM wallet_transactions WHERE reference_id = ? AND type = 'EARN_REVERSAL' AND currency = 'POINT'`, spent).Scan(&metadata).Error)
|
||||
assert.Contains(t, metadata, `"shortfall": 800`)
|
||||
assert.Contains(t, metadata, `"requested_amount": 875`)
|
||||
// Asked back in full already, shortfall included: a repeat takes nothing.
|
||||
outcome, err = earning.ReverseForOrder(ctx, spent)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, outcome.Points)
|
||||
|
||||
// The EARN's own lots are drawn first, even when an older lot would come first.
|
||||
older := committed(t, txm, func(ctx context.Context) (*WalletResult, error) {
|
||||
return wallet.Credit(ctx, WalletCreditInput{WalletEntry: WalletEntry{
|
||||
CustomerID: loyal, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 500, ReferenceType: constants.WalletRefTypeLegacyPoints, ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
})
|
||||
loyalOrder := paidOrder(loyal)
|
||||
exec(`UPDATE orders SET refund_amount = 20000 WHERE id = ?`, loyalOrder)
|
||||
_, err = earning.ReverseForOrder(ctx, loyalOrder)
|
||||
require.NoError(t, err)
|
||||
var fromOlder int64
|
||||
require.NoError(t, db.Raw(`SELECT COALESCE(SUM(a.amount), 0) FROM wallet_lot_allocations a
|
||||
JOIN wallet_transactions t ON t.id = a.transaction_id
|
||||
WHERE t.reference_id = ? AND t.type = 'EARN_REVERSAL' AND a.lot_id = ?`, loyalOrder, older.Lots[0].ID).Scan(&fromOlder).Error)
|
||||
assert.Zero(t, fromOlder, "the older lot is untouched")
|
||||
assert.Equal(t, int64(200), reversed(loyalOrder, "POINT"))
|
||||
|
||||
// An order that never earned has nothing to reverse.
|
||||
outcome, err = earning.ReverseForOrder(ctx, uuid.New())
|
||||
assert.ErrorIs(t, err, repository.ErrEarningOrderNotFound)
|
||||
assert.Nil(t, outcome)
|
||||
|
||||
// Every reversal kept balances, ledger and lots in step (§7.5).
|
||||
found, err := repository.NewWalletReconciliationRepository(db).FindDiscrepancies(ctx, 1000)
|
||||
require.NoError(t, err)
|
||||
for _, d := range found {
|
||||
for _, c := range customers {
|
||||
assert.NotEqual(t, c, d.CustomerID, "discrepancy %s for one of our customers", d.Check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// committed runs fn in a transaction that commits before it returns, for engine calls
|
||||
// made directly from a test.
|
||||
func committed[T any](t *testing.T, txm *repository.TxManager, fn func(ctx context.Context) (T, error)) T {
|
||||
t.Helper()
|
||||
var out T
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
var err error
|
||||
out, err = fn(ctx)
|
||||
return err
|
||||
}))
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
func TestEarningReversalTarget(t *testing.T) {
|
||||
earn := entities.WalletTransaction{Amount: 875, Metadata: entities.Metadata{"basis": 87500.0}}
|
||||
target := func(order repository.EarningOrder) int64 { return earningReversalTarget(&order, earn) }
|
||||
|
||||
assert.Equal(t, int64(0), target(repository.EarningOrder{}), "nothing refunded, nothing to take")
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{IsVoid: true}), "void takes everything")
|
||||
assert.Equal(t, int64(200), target(repository.EarningOrder{RefundAmount: 20000}), "floor(875 × 20000 / 87500)")
|
||||
assert.Equal(t, int64(299), target(repository.EarningOrder{RefundAmount: 29999.99}), "floor, not round")
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 87500}))
|
||||
assert.Equal(t, int64(875), target(repository.EarningOrder{RefundAmount: 96250}), "a refund including tax cannot take more than was earned")
|
||||
|
||||
noBasis := entities.WalletTransaction{Amount: 10, Metadata: entities.Metadata{}}
|
||||
assert.Equal(t, int64(10), earningReversalTarget(&repository.EarningOrder{RefundAmount: 1}, noBasis), "without a basis any refund takes it all")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
@@ -8,33 +9,55 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game,
|
||||
// its prizes or their EnakCoin. The message says which.
|
||||
var ErrGamePlayRefused = errors.New("game cannot be played")
|
||||
|
||||
const (
|
||||
gameCoinCostKey = "coin_cost"
|
||||
defaultGameCoinCost = 1
|
||||
)
|
||||
|
||||
type gameReader interface {
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error)
|
||||
}
|
||||
|
||||
type gamePrizeStock interface {
|
||||
GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error)
|
||||
DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error
|
||||
}
|
||||
|
||||
type GamePlayProcessor struct {
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
gameRepo gameReader
|
||||
gamePrizeRepo gamePrizeStock
|
||||
wallet *WalletProcessor
|
||||
spendable spendableReader
|
||||
tx TxRunner
|
||||
}
|
||||
|
||||
func NewGamePlayProcessor(
|
||||
gamePlayRepo repository.GamePlayRepository,
|
||||
gameRepo *repository.GameRepository,
|
||||
gamePrizeRepo *repository.GamePrizeRepository,
|
||||
customerTokensRepo *repository.CustomerTokensRepository,
|
||||
customerPointsRepo repository.CustomerPointsRepository,
|
||||
gameRepo gameReader,
|
||||
gamePrizeRepo gamePrizeStock,
|
||||
wallet *WalletProcessor,
|
||||
spendable spendableReader,
|
||||
tx TxRunner,
|
||||
) *GamePlayProcessor {
|
||||
return &GamePlayProcessor{
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
customerTokensRepo: customerTokensRepo,
|
||||
customerPointsRepo: customerPointsRepo,
|
||||
gamePlayRepo: gamePlayRepo,
|
||||
gameRepo: gameRepo,
|
||||
gamePrizeRepo: gamePrizeRepo,
|
||||
wallet: wallet,
|
||||
spendable: spendable,
|
||||
tx: tx,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,101 +133,140 @@ func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.Lis
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PlayGame handles the game playing logic
|
||||
// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md
|
||||
// F8, K1). Every kind of game spends the same EnakCoin, at the game's
|
||||
// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking
|
||||
// the prize from stock happen in one transaction, so a play that cannot be completed,
|
||||
// for instance because the prize just ran out, takes nothing and records nothing.
|
||||
func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) {
|
||||
// Verify game exists and is active
|
||||
refuse := func(format string, args ...any) error {
|
||||
return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...))
|
||||
}
|
||||
game, err := p.gameRepo.GetByID(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("game not found: %w", err)
|
||||
}
|
||||
|
||||
if !game.IsActive {
|
||||
return nil, errors.New("game is not active")
|
||||
return nil, refuse("game is not active")
|
||||
}
|
||||
|
||||
// Convert GameType to TokenType
|
||||
tokenType := entities.TokenType(game.Type)
|
||||
|
||||
// Check if customer has enough tokens
|
||||
customerTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
||||
cost, err := gameCoinCost(game.Metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("customer tokens not found: %w", err)
|
||||
return nil, refuse("%s", err.Error())
|
||||
}
|
||||
|
||||
if customerTokens.Balance < int64(req.TokenUsed) {
|
||||
return nil, errors.New("insufficient tokens")
|
||||
}
|
||||
|
||||
// Deduct tokens
|
||||
err = p.customerTokensRepo.DeductTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to deduct tokens: %w", err)
|
||||
}
|
||||
|
||||
// Get available prizes
|
||||
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get available prizes: %w", err)
|
||||
}
|
||||
|
||||
if len(availablePrizes) == 0 {
|
||||
return nil, errors.New("no prizes available")
|
||||
}
|
||||
|
||||
// Convert entities to models for prize selection
|
||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||
for i, prize := range availablePrizes {
|
||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||
}
|
||||
|
||||
// Select prize based on weight
|
||||
selectedPrize := p.selectPrizeByWeight(prizeResponses)
|
||||
|
||||
// Generate random seed for audit
|
||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
// Create game play record
|
||||
gamePlay := &models.CreateGamePlayRequest{
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
TokenUsed: req.TokenUsed,
|
||||
RandomSeed: &randomSeed,
|
||||
}
|
||||
|
||||
gamePlayEntity := mappers.ToGamePlayEntity(gamePlay)
|
||||
if selectedPrize != nil {
|
||||
gamePlayEntity.PrizeID = &selectedPrize.ID
|
||||
}
|
||||
|
||||
err = p.gamePlayRepo.Create(ctx, gamePlayEntity)
|
||||
if err != nil {
|
||||
// Rollback token deduction
|
||||
p.customerTokensRepo.AddTokens(ctx, req.CustomerID, tokenType, int64(req.TokenUsed))
|
||||
return nil, fmt.Errorf("failed to create game play: %w", err)
|
||||
}
|
||||
|
||||
// Decrease prize stock if prize was won
|
||||
if selectedPrize != nil {
|
||||
err = p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||
var gamePlay *entities.GamePlay
|
||||
var selectedPrize *models.GamePrizeResponse
|
||||
err = p.tx.WithTransaction(ctx, func(ctx context.Context) error {
|
||||
availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID)
|
||||
if err != nil {
|
||||
// Log error but don't fail the transaction
|
||||
fmt.Printf("Warning: failed to decrease prize stock: %v\n", err)
|
||||
return fmt.Errorf("failed to get available prizes: %w", err)
|
||||
}
|
||||
}
|
||||
if len(availablePrizes) == 0 {
|
||||
return refuse("no prizes available")
|
||||
}
|
||||
prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes))
|
||||
for i, prize := range availablePrizes {
|
||||
prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize)
|
||||
}
|
||||
selectedPrize = p.selectPrizeByWeight(prizeResponses)
|
||||
|
||||
// Get updated token balance
|
||||
updatedTokens, err := p.customerTokensRepo.GetByCustomerIDAndType(ctx, req.CustomerID, tokenType)
|
||||
// Random seed kept for audit.
|
||||
randomSeed := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
gamePlay = &entities.GamePlay{
|
||||
ID: uuid.New(),
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
CoinsUsed: int(cost),
|
||||
RandomSeed: &randomSeed,
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
gamePlay.PrizeID = &selectedPrize.ID
|
||||
}
|
||||
|
||||
// The EnakCoin go first: the debit locks the wallet, so plays by the same
|
||||
// customer at the same time queue up instead of spending the same balance.
|
||||
_, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{
|
||||
CustomerID: req.CustomerID,
|
||||
Currency: constants.WalletCurrencyCoin,
|
||||
Type: constants.WalletTxTypeGameSpend,
|
||||
Amount: cost,
|
||||
ReferenceType: constants.WalletRefTypeGamePlay,
|
||||
ReferenceID: gamePlay.ID,
|
||||
Description: gameSpendDescription(game.Name, selectedPrize),
|
||||
Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost},
|
||||
}})
|
||||
if errors.Is(err, repository.ErrWalletInsufficientBalance) {
|
||||
return refuse("not enough EnakCoin: this game costs %d", cost)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil {
|
||||
return err
|
||||
}
|
||||
if selectedPrize != nil {
|
||||
err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1)
|
||||
if errors.Is(err, repository.ErrGamePrizeOutOfStock) {
|
||||
return refuse("the prize just ran out, please play again")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get updated token balance: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &models.PlayGameResponse{
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlayEntity),
|
||||
PrizeWon: selectedPrize,
|
||||
TokensRemaining: updatedTokens.Balance,
|
||||
GamePlay: *mappers.ToGamePlayResponse(gamePlay),
|
||||
PrizeWon: selectedPrize,
|
||||
CoinsRemaining: balances[constants.WalletCurrencyCoin],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a
|
||||
// whole number of EnakCoin, 1 when it is not set (F8).
|
||||
func gameCoinCost(metadata entities.Metadata) (int64, error) {
|
||||
raw, ok := metadata[gameCoinCostKey]
|
||||
if !ok || raw == nil {
|
||||
return defaultGameCoinCost, nil
|
||||
}
|
||||
var cost int64
|
||||
switch v := raw.(type) {
|
||||
case float64:
|
||||
if v != math.Trunc(v) {
|
||||
return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v)
|
||||
}
|
||||
cost = int64(v)
|
||||
case int:
|
||||
cost = int64(v)
|
||||
case int64:
|
||||
cost = v
|
||||
default:
|
||||
return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw)
|
||||
}
|
||||
if cost < 1 {
|
||||
return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost)
|
||||
}
|
||||
return cost, nil
|
||||
}
|
||||
|
||||
// gameSpendDescription is what the customer's history shows for a play, frozen when it
|
||||
// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb".
|
||||
func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string {
|
||||
description := "Main " + gameName
|
||||
if prize != nil {
|
||||
description += ": dapat " + prize.Name
|
||||
}
|
||||
return truncateRunes(description, walletDescriptionLimit)
|
||||
}
|
||||
|
||||
// selectPrizeByWeight selects a prize based on weight distribution
|
||||
func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse {
|
||||
if len(prizes) == 0 {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// Plays against Postgres, so the game repositories really join the transaction: a
|
||||
// refused play leaves no game_plays row and no GAME_SPEND.
|
||||
func TestPlayGame_AgainstPostgres(t *testing.T) {
|
||||
db, _, player, _ := walletMoveDB(t)
|
||||
gameID, prizeID := uuid.New(), uuid.New()
|
||||
require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}')`, gameID).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player)
|
||||
db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID)
|
||||
db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID)
|
||||
db.Exec(`DELETE FROM games WHERE id = ?`, gameID)
|
||||
})
|
||||
|
||||
wallet := NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
in := earn(player, 3, nil)
|
||||
in.Currency = constants.WalletCurrencyCoin
|
||||
_, err := wallet.Credit(ctx, in)
|
||||
return err
|
||||
}))
|
||||
p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db),
|
||||
wallet, repository.NewWalletQueryRepository(db), txm)
|
||||
play := func() (*models.PlayGameResponse, error) {
|
||||
return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player})
|
||||
}
|
||||
|
||||
res, err := play()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), res.CoinsRemaining)
|
||||
assert.Equal(t, 2, res.GamePlay.CoinsUsed)
|
||||
|
||||
var spend struct {
|
||||
ReferenceID uuid.UUID
|
||||
Amount int64
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error)
|
||||
assert.Equal(t, res.GamePlay.ID, spend.ReferenceID)
|
||||
assert.Equal(t, int64(-2), spend.Amount)
|
||||
|
||||
// The only prize is gone, then the EnakCoin are too short: neither play leaves a row.
|
||||
_, err = play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error)
|
||||
_, err = play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
|
||||
var plays, spends int64
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error)
|
||||
require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error)
|
||||
assert.Equal(t, int64(1), plays)
|
||||
assert.Equal(t, int64(1), spends)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// snapshot saves everything the fake wallet holds and returns what puts it back, so
|
||||
// a test transaction can roll back like a real one.
|
||||
func (f *walletRepoFake) snapshot() func() {
|
||||
wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets))
|
||||
for id, w := range f.wallets {
|
||||
c := *w
|
||||
wallets[id] = &c
|
||||
}
|
||||
transactions := make([]*entities.WalletTransaction, len(f.transactions))
|
||||
for i, t := range f.transactions {
|
||||
c := *t
|
||||
transactions[i] = &c
|
||||
}
|
||||
lots := make([]*entities.WalletLot, len(f.lots))
|
||||
for i, l := range f.lots {
|
||||
c := *l
|
||||
lots[i] = &c
|
||||
}
|
||||
allocations := append([]entities.WalletLotAllocation(nil), f.allocations...)
|
||||
return func() {
|
||||
f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations
|
||||
}
|
||||
}
|
||||
|
||||
// rollbackTx runs fn in place and, when it fails, puts back what each save saved.
|
||||
type rollbackTx struct{ saves []func() func() }
|
||||
|
||||
func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
restores := make([]func(), 0, len(r.saves))
|
||||
for _, save := range r.saves {
|
||||
restores = append(restores, save())
|
||||
}
|
||||
err := fn(ctx)
|
||||
if err != nil {
|
||||
for _, restore := range restores {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type gameFake struct{ games map[uuid.UUID]*entities.Game }
|
||||
|
||||
func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) {
|
||||
g, ok := f.games[id]
|
||||
if !ok {
|
||||
return nil, errors.New("record not found")
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
type prizeStockFake struct {
|
||||
prizes []entities.GamePrize
|
||||
stockErr error
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) {
|
||||
var out []entities.GamePrize
|
||||
for _, p := range f.prizes {
|
||||
if p.GameID == gameID && p.Stock > 0 {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error {
|
||||
if f.stockErr != nil {
|
||||
return f.stockErr
|
||||
}
|
||||
for i := range f.prizes {
|
||||
if f.prizes[i].ID == id {
|
||||
if f.prizes[i].Stock < amount {
|
||||
return repository.ErrGamePrizeOutOfStock
|
||||
}
|
||||
f.prizes[i].Stock -= amount
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return repository.ErrGamePrizeOutOfStock
|
||||
}
|
||||
|
||||
func (f *prizeStockFake) snapshot() func() {
|
||||
prizes := append([]entities.GamePrize(nil), f.prizes...)
|
||||
return func() { f.prizes = prizes }
|
||||
}
|
||||
|
||||
// gamePlayRepoFake records plays; the other methods of the interface are not used.
|
||||
type gamePlayRepoFake struct {
|
||||
repository.GamePlayRepository
|
||||
plays []entities.GamePlay
|
||||
}
|
||||
|
||||
func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error {
|
||||
f.plays = append(f.plays, *gp)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *gamePlayRepoFake) snapshot() func() {
|
||||
plays := append([]entities.GamePlay(nil), f.plays...)
|
||||
return func() { f.plays = plays }
|
||||
}
|
||||
|
||||
type gameTestEnv struct {
|
||||
*walletMoveEnv
|
||||
game *entities.Game
|
||||
prize entities.GamePrize
|
||||
stock *prizeStockFake
|
||||
plays *gamePlayRepoFake
|
||||
player uuid.UUID
|
||||
p *GamePlayProcessor
|
||||
}
|
||||
|
||||
func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv {
|
||||
e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)}
|
||||
e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata}
|
||||
e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5}
|
||||
e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}}
|
||||
e.plays = &gamePlayRepoFake{}
|
||||
e.player = e.member("Budi", "081234561234")
|
||||
tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}}
|
||||
e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *gameTestEnv) play() (*models.PlayGameResponse, error) {
|
||||
return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player})
|
||||
}
|
||||
|
||||
func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) {
|
||||
// coin_cost comes back from JSONB as a float64.
|
||||
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
|
||||
e.earnCoins(t, e.player, 10, nil)
|
||||
|
||||
res, err := e.play()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(7), res.CoinsRemaining)
|
||||
require.NotNil(t, res.PrizeWon)
|
||||
assert.Equal(t, e.prize.ID, res.PrizeWon.ID)
|
||||
require.Len(t, e.plays.plays, 1)
|
||||
play := e.plays.plays[0]
|
||||
assert.Equal(t, 3, play.CoinsUsed)
|
||||
assert.Equal(t, e.prize.ID, *play.PrizeID)
|
||||
assert.Equal(t, 4, e.stock.prizes[0].Stock)
|
||||
|
||||
spend := e.repo.transactions[len(e.repo.transactions)-1]
|
||||
assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type)
|
||||
assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency)
|
||||
assert.Equal(t, int64(-3), spend.Amount)
|
||||
assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType)
|
||||
assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play")
|
||||
assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description)
|
||||
}
|
||||
|
||||
func TestPlayGame_CostsOneCoinByDefault(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 2, nil)
|
||||
|
||||
res, err := e.play()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), res.CoinsRemaining)
|
||||
assert.Equal(t, 1, e.plays.plays[0].CoinsUsed)
|
||||
}
|
||||
|
||||
func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)})
|
||||
e.earnCoins(t, e.player, 2, nil)
|
||||
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Empty(t, e.plays.plays)
|
||||
assert.Equal(t, 5, e.stock.prizes[0].Stock)
|
||||
assert.Equal(t, int64(2), e.coinBalance(t, e.player))
|
||||
}
|
||||
|
||||
func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
rows := len(e.repo.transactions)
|
||||
|
||||
// Another play took the last one between reading the prizes and taking it.
|
||||
e.stock.stockErr = repository.ErrGamePrizeOutOfStock
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
|
||||
// Anything else going wrong with the stock cancels it too.
|
||||
e.stock.stockErr = errors.New("connection reset")
|
||||
_, err = e.play()
|
||||
assert.Error(t, err)
|
||||
|
||||
assert.Empty(t, e.plays.plays)
|
||||
assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind")
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
}
|
||||
|
||||
func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) {
|
||||
for name, metadata := range map[string]entities.Metadata{
|
||||
"zero": {"coin_cost": float64(0)},
|
||||
"fraction": {"coin_cost": 1.5},
|
||||
"not number": {"coin_cost": "2"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e := newGameTestEnv(t, metadata)
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("inactive", func(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
e.game.IsActive = false
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
})
|
||||
|
||||
t.Run("no prizes left", func(t *testing.T) {
|
||||
e := newGameTestEnv(t, entities.Metadata{})
|
||||
e.earnCoins(t, e.player, 5, nil)
|
||||
e.stock.prizes[0].Stock = 0
|
||||
_, err := e.play()
|
||||
assert.ErrorIs(t, err, ErrGamePlayRefused)
|
||||
assert.Equal(t, int64(5), e.coinBalance(t, e.player))
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user