Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c9753fae7 | ||
|
|
3ebc09f818 | ||
|
|
117e3529aa | ||
|
|
b59b7b4ddd | ||
|
|
fbe7e97dc7 | ||
|
|
b372821b23 | ||
|
|
d3e3ea6b08 | ||
|
|
b0ef226af1 | ||
|
|
79226bd891 | ||
|
|
306eabb1d9 | ||
|
|
892575202b | ||
|
|
c9654a387a | ||
|
|
e94eb2c26d | ||
|
|
e8b31035f7 | ||
|
|
100e006218 | ||
|
|
f16a5da951 | ||
|
|
a24d5f0561 | ||
|
|
c1ec6a3dfd | ||
|
|
8bf2fe5585 | ||
|
|
a3cb7dd5fd | ||
|
|
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 |
+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,380 @@
|
||||
# API EnakPoint & EnakCoin
|
||||
|
||||
30 Sep 2026
|
||||
|
||||
Semua endpoint EnakPoint (`POINT`, hanya untuk ditukar ke voucher) 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.
|
||||
|
||||
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus, karena EnakPoint sekarang hanya bisa ditukar ke voucher: tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus ada di Referensi → Endpoint dan field yang dihapus.
|
||||
|
||||
## 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`.
|
||||
|
||||
**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/outlets` | Outlet aktif di organisasi customer, dengan `earns_points`, `earns_coins` |
|
||||
| GET | `/customer/orders` | Riwayat order customer (`page`, `limit`), dengan `points_earned` / `coins_earned` |
|
||||
| GET | `/customer/orders/:id` | Detail order: item, pembayaran, EnakPoint/EnakCoin yang didapat; order customer lain → `404` |
|
||||
|
||||
Registrasi (`POST /customer-auth/register/start`) menerima `organization_id` opsional: bila tidak dikirim dan hanya ada satu organisasi, customer masuk ke organisasi itu. Contoh request dan response lengkap untuk outlet dan order ada di [`mobile-customer-enakpoint.md`](./mobile-customer-enakpoint.md) §4.4–§4.5.
|
||||
|
||||
### 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,TRANSFER_IN` |
|
||||
| `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 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; 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: exchange, transfer, game
|
||||
|
||||
| Method | Path | PIN | Idempotency-Key |
|
||||
| --- | --- | --- | --- |
|
||||
| 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` | – | – |
|
||||
|
||||
### 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: earning, void, dan refund
|
||||
|
||||
EnakPoint bukan payment method: tidak ada lagi tipe `point`, dan `POST /payments` memakai `amount` seperti pembayaran lain. Response order membawa `points_earned` dan `coins_earned` untuk struk. Saat order di-void atau direfund, EnakPoint dan EnakCoin yang didapat dari order itu ikut ditarik (`EARN_REVERSAL`); 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 EnakPoint dan EnakCoin 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_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
|
||||
}
|
||||
```
|
||||
|
||||
Response menambahkan `outlet_id`, `point_value`, `point_cashback_percent` (default di atas = 1%), dan `changes` pada PUT. `earn_mode` adalah `PER_AMOUNT` (setiap `earn_per_amount` rupiah mendapat `earn_value`) atau `PERCENTAGE` (`earn_percent` persen dari basis). Validasi: `earn_per_amount > 0`, `earn_value ≥ 0`, `earn_percent` 0–100 dengan maks. 2 angka desimal. Objek `point_payment` sudah dihapus; `PUT` yang masih mengirimnya ditolak `310` (field tidak dikenal).
|
||||
|
||||
### /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": "TRANSFER_OUT", "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 atau exchange 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` |
|
||||
| `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` |
|
||||
|
||||
### Endpoint dan field yang dihapus
|
||||
|
||||
Bayar dengan EnakPoint dihapus pada 7 Okt 2026 karena EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Tidak ada penggantinya; jangan dipanggil lagi.
|
||||
|
||||
| Dihapus | Catatan |
|
||||
| --- | --- |
|
||||
| `POST /customer/wallet/payment-code` | Kode bayar untuk kasir |
|
||||
| `POST /customer/orders/:id/pay-with-points` | Bayar order dari app / self-order |
|
||||
| `GET /orders/:id/point-payment/preview` | Batas pembayaran EnakPoint di POS |
|
||||
| Payment method tipe `point`; field `points` dan `payment_code` di `POST /payments` | `amount` kembali wajib seperti pembayaran lain |
|
||||
| `points_used`, `point_value` di response pembayaran dan di `payments` pada `GET /customer/orders/:id` | – |
|
||||
| `accepts_point_payment` di `GET /customer/outlets` | – |
|
||||
| `point_payment` (`accept_payment`, `min_payment_points`, `max_payment_percent`) di `/outlets/:outlet_id/loyalty-settings` | `PUT` yang masih mengirimnya ditolak `310` |
|
||||
| `summary.point_amount`, `summary.points_used`, `summary.total_with_points`, serta `points_used` dan `counts_as_cash_in` per baris di analytics payment method | `summary.total_amount` kembali total semua method; persentase dihitung dari total itu |
|
||||
| Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` | Tidak ditulis lagi |
|
||||
|
||||
Panduan alur lengkap per tim ada di [`integration-enakpoint.md`](./integration-enakpoint.md).
|
||||
@@ -0,0 +1,296 @@
|
||||
# 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.
|
||||
|
||||
> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint sudah dihapus karena EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Akibatnya setting outlet tidak lagi punya `point_payment`, method "EnakPoint" (tipe `point`) tidak ada lagi di Payment Method, dan laporan per payment method tidak lagi membawa `point_amount`, `points_used`, `total_with_points`, atau `counts_as_cash_in`; `summary.total_amount` kembali total semua method.
|
||||
|
||||
## 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 hanya bisa ditukar ke voucher, bukan alat bayar; 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. 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 (termasuk `point_payment` yang sudah dihapus).
|
||||
|
||||
```json
|
||||
{
|
||||
"point": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Tipe | Default | Validasi |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `point.enabled` / `coin.enabled` | Beri EnakPoint / EnakCoin | toggle | mati | – |
|
||||
| `earn_mode` | Cara hitung: per nominal / persentase | pilihan `PER_AMOUNT` / `PERCENTAGE` | `PER_AMOUNT` | salah satu dari keduanya |
|
||||
| `earn_per_amount` | Setiap belanja Rp … (mode `PER_AMOUNT`) | Rp | 100 (point), 25.000 (coin) | > 0 |
|
||||
| `earn_value` | … mendapat (mode `PER_AMOUNT`) | angka | 1 | ≥ 0 |
|
||||
| `earn_percent` | … % dari belanja (mode `PERCENTAGE`) | %, boleh desimal | 1 | 0–100, maks. 2 angka desimal |
|
||||
| `min_order_amount` | Minimal belanja | Rp | 0 | ≥ 0 |
|
||||
| `max_per_order` | Maksimal per order | angka, boleh kosong | kosong = tanpa batas | ≥ 0 |
|
||||
|
||||
**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`, atau pada mode `PERCENTAGE`: `earn_percent × point_value`. Tujuannya agar owner tidak salah membaca skala (1 per Rp 100 bukan 1 per Rp 1).
|
||||
|
||||
**Mode earning.** Tampilkan hanya field mode yang dipilih (`earn_per_amount` + `earn_value`, atau `earn_percent`). Field mode lain tetap tersimpan di server, jadi tidak perlu dikosongkan saat owner berpindah mode. Pada mode `PERCENTAGE` jumlah yang didapat adalah `floor(basis × earn_percent ÷ 100)`, mis. 2,5% dari Rp 87.500 = 2.187 EnakPoint.
|
||||
|
||||
**Contoh di bawah form.** "Belanja Rp 87.500 mendapat 875 EnakPoint dan 3 EnakCoin." Earning dihitung dari subtotal setelah diskon, sebelum pajak.
|
||||
|
||||
Setelah `PUT`, response membawa `changes` (key yang berubah); tampilkan toast singkat, mis. "2 pengaturan disimpan".
|
||||
|
||||
## 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: exchange yang sudah terjadi memakai kurs 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=TRANSFER_OUT,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), `outlet`, `reason`, dan `metadata` (kurs, rumus earning, 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": "TRANSFER_OUT", "amount": -30, "description": "Transfer ke Ri*** (08**-****-9012)", "reference_type": "WALLET_TX", "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, dan game
|
||||
|
||||
### 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.
|
||||
|
||||
## 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 | `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.
|
||||
- [ ] Form game punya input `coin_cost`.
|
||||
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
|
||||
|
||||
Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,551 @@
|
||||
# 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).
|
||||
|
||||
> **Perubahan 7 Okt 2026:** bayar order dengan EnakPoint sudah dihapus (migrasi
|
||||
> `000102`). EnakPoint sekarang hanya bisa ditukar ke voucher, tidak bisa dipakai
|
||||
> sebagai alat bayar dan tidak bisa dicairkan
|
||||
> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Endpoint dan field yang ikut dihapus
|
||||
> ada di §8.
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Order lunas (per outlet), adjustment admin, exchange | Order lunas (per outlet), adjustment admin |
|
||||
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa 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, dan EnakPoint tidak bisa
|
||||
dipakai membayar order. Tampilkan nilai rupiahnya sebagai **"setara potongan
|
||||
Rp …"**, bukan "saldo Rp …".
|
||||
3. **Semua aksi customer yang memindahkan saldo butuh PIN 6 digit** (§3): exchange
|
||||
dan 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 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,TRANSFER_IN&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, 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` |
|
||||
| `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**;
|
||||
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. Earning, void, dan refund
|
||||
|
||||
EnakPoint bukan payment method: tidak ada payment method bertipe `point`, dan
|
||||
`POST /api/v1/payments` memakai `amount` seperti pembayaran lain. Kode bayar,
|
||||
bayar dari aplikasi, dan preview pembayaran EnakPoint sudah dihapus (§8).
|
||||
|
||||
Response order membawa `points_earned` dan `coins_earned` (0 bila order tidak
|
||||
menghasilkan apa-apa). Earning dihitung dari `subtotal − discount`, sebelum pajak, dan
|
||||
diberikan saat order lunas.
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
Bayar dengan EnakPoint juga sudah dihapus (7 Okt 2026, migrasi `000102`) karena
|
||||
EnakPoint sekarang hanya untuk voucher ([`enakgame-prd.md`](./enakgame-prd.md) §3.2).
|
||||
Tidak ada penggantinya:
|
||||
|
||||
- Endpoint `POST /customer/wallet/payment-code`, `POST /customer/orders/:id/pay-with-points`,
|
||||
dan `GET /orders/:id/point-payment/preview`.
|
||||
- Payment method tipe `point`, serta field `points` dan `payment_code` di
|
||||
`POST /payments`; `amount` kembali wajib seperti pembayaran lain.
|
||||
- `points_used` dan `point_value` di response pembayaran dan di `payments` pada
|
||||
`GET /customer/orders/:id`; `accepts_point_payment` di `GET /customer/outlets`.
|
||||
- Objek `point_payment` (`accept_payment`, `min_payment_points`,
|
||||
`max_payment_percent`) di setting outlet (§9.1).
|
||||
- Di analytics payment method: `point_amount`, `points_used`, `total_with_points` di
|
||||
`summary`, serta `points_used` dan `counts_as_cash_in` per baris.
|
||||
`summary.total_amount` kembali total semua method, dan persentase dihitung dari total
|
||||
itu.
|
||||
- Tipe mutasi `PAYMENT` dan `PAYMENT_REFUND` tidak ditulis lagi.
|
||||
|
||||
---
|
||||
|
||||
## 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_mode": "PER_AMOUNT", "earn_per_amount": 100, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null },
|
||||
"coin": { "enabled": true, "earn_mode": "PER_AMOUNT", "earn_per_amount": 25000, "earn_value": 1, "earn_percent": 1, "min_order_amount": 0, "max_per_order": null }
|
||||
}
|
||||
```
|
||||
|
||||
Field yang tidak dikirim di `PUT` tetap memakai nilai sekarang. `PUT` yang masih
|
||||
mengirim `point_payment` ditolak `310` (field tidak dikenal). Response menambahkan
|
||||
`point_value` organisasi dan `point_cashback_percent`
|
||||
(`earn_value × point_value / earn_per_amount × 100`, atau `earn_percent × point_value`
|
||||
pada `earn_mode` `PERCENTAGE`). **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 atau
|
||||
exchange sampai ke earning/adjustment/migrasi pertama. Contoh: dari transfer keluar
|
||||
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**
|
||||
- [ ] Cetak `points_earned` dan `coins_earned` di struk.
|
||||
- [ ] Jangan menampilkan EnakPoint sebagai payment method (§4).
|
||||
|
||||
**Dashboard**
|
||||
- [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan
|
||||
`expiry_activations` sebelum owner menyimpan setting.
|
||||
- [ ] Isi `metadata.coin_cost` untuk setiap game.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Migrasi printer_type ke printer_types
|
||||
|
||||
1 Oktober 2026
|
||||
|
||||
## Ringkasan
|
||||
|
||||
`printer_type` (string) dihapus dan diganti `printer_types` (array string), sehingga satu produk bisa dicetak ke lebih dari satu printer. Contohnya "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di kitchen dan bar sekaligus.
|
||||
|
||||
Ini breaking change. Setelah backend baru dirilis, client yang masih membaca `printer_type` tidak menerima printer apa pun. Karena itu app POS dan dashboard harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||
|
||||
Yang perlu bertindak:
|
||||
|
||||
- **App POS**: baca `printer_types` dan cetak tiap item ke semua printer di dalamnya.
|
||||
- **Dashboard admin**: ganti pilihan printer di form produk jadi multi-select yang mengirim `printer_types`.
|
||||
|
||||
## Perubahan response
|
||||
|
||||
`printer_type` hilang dari semua response dan digantikan `printer_types`, yang nilainya selalu array dan tidak pernah `null`.
|
||||
|
||||
| Endpoint | Letak field |
|
||||
| --- | --- |
|
||||
| `POST /api/v1/products`, `PUT /api/v1/products/:id`, `GET /api/v1/products`, `GET /api/v1/products/all`, `GET /api/v1/products/:id` | objek produk |
|
||||
| `POST /api/v1/orders`, `GET /api/v1/orders`, `GET /api/v1/orders/:id`, `PUT /api/v1/orders/:id` | `order_items[]` |
|
||||
| `POST /api/v1/orders/:id/add-items` | `added_items[]` dan `updated_order.order_items[]` |
|
||||
| `POST /api/v1/self-order/orders`, `GET /api/v1/self-order/orders/:session_id` | `order_items[]` |
|
||||
| `/api/v1/product-recipes` (semua yang mengembalikan recipe) | `product` |
|
||||
| `/api/v1/inventory` | `product`, selalu `[]` karena produk di sini hanya berisi id dan nama |
|
||||
|
||||
Contoh satu item di `order_items[]`:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Paket Makan Minum",
|
||||
"printer_types": ["kitchen", "bar"],
|
||||
"print_to_checker": true
|
||||
}
|
||||
```
|
||||
|
||||
Aturan nilainya:
|
||||
|
||||
- `printer_types: []` artinya produk tidak dicetak ke printer mana pun.
|
||||
- Urutan printer sesuai yang disimpan admin, tanpa duplikat.
|
||||
- Void, refund, payment, split bill, dan set customer tidak membawa data printer, sama seperti sebelumnya.
|
||||
|
||||
## Perubahan request produk
|
||||
|
||||
`POST /api/v1/products` dan `PUT /api/v1/products/:id` menerima `printer_types` sebagai pengganti `printer_type`. Kalau `printer_type` masih dikirim, field itu diabaikan tanpa error.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Paket Makan Minum",
|
||||
"category_id": "<uuid>",
|
||||
"price": 25000,
|
||||
"printer_types": ["kitchen", "bar"]
|
||||
}
|
||||
```
|
||||
|
||||
| Request | `printer_types` yang dikirim | Hasil |
|
||||
| --- | --- | --- |
|
||||
| Create | tidak dikirim | `["kitchen"]` |
|
||||
| Create | `["kitchen", "bar"]` | `["kitchen", "bar"]` |
|
||||
| Create | `[]` atau hanya string kosong | `["kitchen"]` |
|
||||
| Update | tidak dikirim | tidak berubah |
|
||||
| Update | `["bar"]` | `["bar"]`, seluruh daftar diganti |
|
||||
| Update | `[]` | `[]`, produk tidak dicetak ke mana pun |
|
||||
|
||||
Sebelum disimpan, spasi di awal dan akhir tiap entri dibuang, lalu entri kosong dan duplikat dihapus. Urutan dipertahankan.
|
||||
|
||||
Tiap entri maksimal 50 karakter. Kalau lebih, request ditolak dengan error code `310` dan pesan `each printer_types entry cannot exceed 50 characters`.
|
||||
|
||||
## Migrasi app POS
|
||||
|
||||
App POS harus mengirim tiap item ke semua printer di `printer_types`, sehingga satu item bisa muncul di lebih dari satu tiket.
|
||||
|
||||
1. Ganti `printer_type` dengan `printer_types` (list string) di model order item dan produk.
|
||||
2. Selama backend lama masih jalan, `printer_types` belum ada di response. Pakai `[printer_type]` kalau `printer_types` tidak ada, supaya app baru bisa dirilis sebelum backend.
|
||||
3. Saat mencetak, kelompokkan item per printer dengan mengulang setiap entri `printer_types` milik item.
|
||||
4. Item dengan `printer_types: []` tidak dicetak ke printer station mana pun.
|
||||
5. `print_to_checker` tidak berubah dan tetap diperlakukan terpisah.
|
||||
6. Untuk tambahan pesanan dari `POST /api/v1/orders/:id/add-items`, cetak dari `added_items[]`.
|
||||
|
||||
```text
|
||||
tiket = {}
|
||||
untuk setiap item di order_items:
|
||||
printers = item.printer_types ?? [item.printer_type] // fallback hanya untuk backend lama
|
||||
untuk setiap printer di printers:
|
||||
tiket[printer].tambah(item)
|
||||
untuk setiap (printer, items) di tiket:
|
||||
cetak items ke printer
|
||||
```
|
||||
|
||||
Perbaikan di `added_items[]`: sebelumnya field ini selalu berisi `product_name` dan printer kosong, serta `print_to_checker: true`. Sekarang isinya lengkap seperti item di `updated_order.order_items[]`, dengan urutan sesuai request. Kalau app selama ini mengakali dengan mencari item baru di `updated_order`, cara itu bisa diganti dengan `added_items[]` langsung.
|
||||
|
||||
## Migrasi dashboard
|
||||
|
||||
Form produk di dashboard harus memakai multi-select printer dan selalu mengirim daftar lengkapnya lewat `printer_types`.
|
||||
|
||||
1. Ganti dropdown printer tunggal dengan multi-select, misalnya checkbox, berisi pilihan printer yang sama.
|
||||
2. Saat membuka form edit, isi pilihan dari `printer_types`. Selama backend lama masih jalan, pakai `[printer_type]` kalau `printer_types` tidak ada.
|
||||
3. Saat menyimpan, kirim `printer_types` berisi semua printer yang dipilih.
|
||||
4. Selama backend lama masih jalan, kirim juga `printer_type` berisi printer pertama. Backend lama hanya membaca `printer_type`, dan backend baru mengabaikannya.
|
||||
5. Di daftar produk, tampilkan semua printer dari `printer_types`.
|
||||
|
||||
Backend tidak membatasi nilai printer. Nilainya harus sama persis dengan nama printer yang dikenal app POS, termasuk huruf besar dan kecilnya.
|
||||
|
||||
Create dengan `printer_types: []` tetap menghasilkan `["kitchen"]`. Produk tanpa printer dibuat dulu, lalu di-update dengan `printer_types: []`.
|
||||
|
||||
## Database dan urutan rilis
|
||||
|
||||
Migration `000099_add_printer_types_to_products` menambah kolom `products.printer_types` (JSONB, `NOT NULL`, default `["kitchen"]`), mengisinya dari `printer_type`, lalu menghapus kolom `printer_type` beserta index-nya.
|
||||
|
||||
- Produk dengan `printer_type` berisi nilai menjadi `[printer_type]`.
|
||||
- Produk dengan `printer_type` `NULL` atau kosong menjadi `[]`.
|
||||
|
||||
Urutan rilis:
|
||||
|
||||
1. Rilis app POS baru, yang membaca `printer_types` dengan fallback ke `printer_type`, ke semua outlet.
|
||||
2. Rilis dashboard baru, yang mengirim `printer_types` dan `printer_type`.
|
||||
3. Jalankan migration `000099` tepat sebelum deploy backend baru. Di antara keduanya, backend lama gagal menyimpan produk karena kolom `printer_type` sudah tidak ada.
|
||||
4. Setelah backend baru jalan, dashboard boleh berhenti mengirim `printer_type`, dan fallback di app POS boleh dihapus.
|
||||
5. Atur produk multi-printer, misalnya "Paket Makan Minum" ke `["kitchen", "bar"]`.
|
||||
|
||||
Outlet yang masih memakai app POS lama setelah langkah 3 tidak menerima printer untuk semua item. Pastikan langkah 1 sudah selesai di semua outlet.
|
||||
|
||||
Rollback: jalankan down migration dan deploy backend lama bersamaan. Down migration membuat ulang kolom `printer_type` berisi printer pertama, lalu menghapus `printer_types`, jadi yang hilang hanya printer tambahan.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] App POS baru (dengan fallback) terpasang di semua outlet
|
||||
- [ ] Dashboard baru mengirim `printer_types` dan `printer_type`
|
||||
- [ ] Migration `000099` dan backend baru dirilis di staging
|
||||
- [ ] Uji "Paket Makan Minum" dengan `["kitchen", "bar"]` tercetak di dua printer, saat order baru dan saat tambah pesanan
|
||||
- [ ] Migration `000099` dan backend baru dirilis di production
|
||||
- [ ] Dashboard berhenti mengirim `printer_type`
|
||||
- [ ] Fallback `printer_type` di app POS dihapus
|
||||
|
||||
## FAQ
|
||||
|
||||
**Kenapa `printer_type` dihapus, bukan dipertahankan?** Supaya hanya ada satu sumber data printer. Dua field yang menyimpan hal yang sama bisa saling berbeda.
|
||||
|
||||
**Apakah kitchen dan bar menerima tiket yang sama?** Ya. Keduanya mencetak "Paket Makan Minum" lengkap dengan varian dan modifier-nya. Memecah isi paket per station butuh fitur bundle, yang di luar rilis ini.
|
||||
|
||||
**Apakah nilai printer dibatasi?** Tidak. Nilainya string bebas sampai 50 karakter, dan harus sama dengan nama printer di app POS.
|
||||
@@ -0,0 +1,289 @@
|
||||
# Migrasi profit sharing
|
||||
|
||||
4 Oktober 2026
|
||||
|
||||
## Ringkasan
|
||||
|
||||
Ada tiga perubahan di backend:
|
||||
|
||||
1. **Parent category bisa ditandai bukan team.** Kategori punya field baru `is_team`. Parent category dengan `is_team: false` tidak bisa dipilih sebagai team di purchase order dan cash advance, dan tidak ikut laporan profit sharing.
|
||||
2. **Endpoint laporan pindah path.** `/api/v1/analytics/parent-categories` menjadi `/api/v1/analytics/profit-sharing`.
|
||||
3. **Pembagian revenue tinggal dua porsi.** Porsi purchase (60%) dihapus. Revenue sekarang dibagi ke owner (SDL) dan team, dengan porsi team = 100% − fee owner.
|
||||
|
||||
Nomor 2 dan 3 adalah breaking change. Setelah backend baru dirilis, client yang masih memanggil path lama mendapat 404, dan `limit_purchase` serta `percentages.purchase` tidak ada lagi di response. Karena itu client harus diupdate lebih dulu, lihat [Urutan rilis](#database-dan-urutan-rilis).
|
||||
|
||||
Yang perlu bertindak, di dashboard maupun app mobile, mana pun yang punya layarnya:
|
||||
|
||||
- **Form kategori**: tambah toggle team untuk parent category.
|
||||
- **Laporan profit sharing**: ganti path, hapus porsi purchase, tampilkan dua porsi.
|
||||
- **Form purchase order dan cash advance**: picker team tidak perlu diubah, tapi form edit perlu menyesuaikan, lihat [Purchase order dan cash advance](#purchase-order-dan-cash-advance).
|
||||
|
||||
## Kategori: field `is_team`
|
||||
|
||||
### Response
|
||||
|
||||
Semua response kategori membawa `is_team` (boolean, tidak pernah `null`): `POST /api/v1/categories`, `PUT /api/v1/categories/:id`, `GET /api/v1/categories`, dan `GET /api/v1/categories/:id`.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"name": "Merchandise",
|
||||
"parent_id": null,
|
||||
"owner_fee_percent": null,
|
||||
"is_team": false
|
||||
}
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
`POST /api/v1/categories` dan `PUT /api/v1/categories/:id` menerima `is_team`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Merchandise",
|
||||
"is_team": false
|
||||
}
|
||||
```
|
||||
|
||||
| Request | `is_team` yang dikirim | Hasil |
|
||||
| --- | --- | --- |
|
||||
| Create | tidak dikirim | `true` |
|
||||
| Create | `false` | `false` |
|
||||
| Update | tidak dikirim atau `null` | tidak berubah |
|
||||
| Update | `true` atau `false` | diganti |
|
||||
|
||||
Update yang hanya berisi `is_team` diterima.
|
||||
|
||||
Aturan nilainya:
|
||||
|
||||
- `is_team` hanya berpengaruh di parent category, yaitu kategori dengan `parent_id: null`. Di sub-category nilainya disimpan tapi tidak dipakai, jadi tampilkan toggle hanya untuk parent category.
|
||||
- Semua kategori yang sudah ada sebelum rilis bernilai `true`. Tidak ada yang berubah sampai admin mematikannya.
|
||||
- Flag dibaca saat request, bukan saat transaksi. Kalau parent category dimatikan, penjualannya di periode lampau juga hilang dari laporan profit sharing. Kalau dinyalakan lagi, semuanya muncul kembali.
|
||||
|
||||
### Efek `is_team: false`
|
||||
|
||||
| Endpoint | Efek |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/purchase-orders/teams`, `GET /api/v1/cash-advances/teams` | Kategori tidak muncul. Pusat tetap ada. |
|
||||
| Create dan update purchase order dan cash advance | `team_scope: "category"` dengan `team_category_id` kategori ini ditolak. |
|
||||
| `GET /api/v1/analytics/profit-sharing` | Kategori tidak muncul di `data`, dan revenue-nya tidak dihitung di `budget`. |
|
||||
| `GET /api/v1/analytics/profit-sharing/:parent_category_id` | Ditolak. |
|
||||
|
||||
Data yang sudah ada tidak diubah. Purchase order dan cash advance yang sudah tercatat ke kategori itu tetap menyimpan team-nya. Laporan purchasing (`GET /api/v1/analytics/purchasing`) masih menampilkannya di `team_data`, dan filter `team=<category_id>` tetap bisa dipakai.
|
||||
|
||||
Laporan lain yang tidak menyaring `is_team`, misalnya `GET /api/v1/analytics/categories`, tetap menampilkan penjualan kategori itu.
|
||||
|
||||
## Purchase order dan cash advance
|
||||
|
||||
Picker team sudah mengambil dari `GET /api/v1/purchase-orders/teams` dan `GET /api/v1/cash-advances/teams`, jadi kategori non-team otomatis tidak muncul tanpa perubahan di client.
|
||||
|
||||
Yang perlu diubah ada di form edit. Purchase order atau cash advance lama bisa tercatat ke kategori yang sekarang sudah non-team. Kalau form edit mengirim ulang `team_scope` dan `team_category_id` yang sama, request ditolak, walaupun user tidak mengubah team-nya.
|
||||
|
||||
1. Kirim `team_scope` dan `team_category_id` hanya kalau user mengganti team. Kalau `team_scope` tidak dikirim, team yang tersimpan tidak berubah.
|
||||
2. Kalau team yang tersimpan tidak ada di daftar `/teams`, tampilkan namanya dari field `team` di response apa adanya, dan jangan memilihkan team lain secara otomatis.
|
||||
|
||||
Contoh error saat team ditolak, dengan HTTP status 500:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "900",
|
||||
"entity": "purchase_order_service",
|
||||
"cause": "category Merchandise is not a team"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk cash advance, `entity` bernilai `cash_advance_service`. Cocokkan dengan teks `is not a team` di `cause` kalau perlu menampilkan pesan khusus.
|
||||
|
||||
## Laporan profit sharing
|
||||
|
||||
### Path baru
|
||||
|
||||
| Lama | Baru |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/analytics/parent-categories` | `GET /api/v1/analytics/profit-sharing` |
|
||||
| `GET /api/v1/analytics/parent-categories/:parent_category_id` | `GET /api/v1/analytics/profit-sharing/:parent_category_id` |
|
||||
|
||||
Query parameter dan role tidak berubah:
|
||||
|
||||
- `date_from` dan `date_to` wajib, dengan format `DD-MM-YYYY`, misalnya `28-09-2026`.
|
||||
- `outlet_id` opsional.
|
||||
- Hanya bisa diakses superadmin, admin, manager, owner, dan purchasing.
|
||||
|
||||
Path lama sudah tidak ada dan mengembalikan 404.
|
||||
|
||||
### Pembagian revenue
|
||||
|
||||
Revenue tiap parent category yang team dibagi dua:
|
||||
|
||||
- **SDL (fee owner)** = revenue × `owner_fee_percent` / 100. Default-nya 20%, dan bisa diganti per parent category lewat `owner_fee_percent` di kategori.
|
||||
- **Team** = revenue − SDL.
|
||||
|
||||
Contoh dengan tiga parent category dalam satu minggu:
|
||||
|
||||
| Parent category | `is_team` | Fee owner | Revenue | SDL | Team |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| Food | `true` | 20% (default) | 1.000.000 | 200.000 | 800.000 |
|
||||
| Drink | `true` | 35% | 2.000.000 | 700.000 | 1.300.000 |
|
||||
| Merchandise | `false` | - | 500.000 | tidak dihitung | tidak dihitung |
|
||||
| **Budget minggu ini** | | | **3.000.000** | **900.000** | **2.100.000** |
|
||||
|
||||
### Perubahan field
|
||||
|
||||
| Field | Sebelum | Sesudah |
|
||||
| --- | --- | --- |
|
||||
| `data[]` | semua parent category | hanya parent category team |
|
||||
| `budget.percentages.purchase` | `60` | dihapus |
|
||||
| `budget.percentages.owner` | `20`, atau fee parent itu di endpoint detail | tidak berubah |
|
||||
| `budget.percentages.team` | `20` | `100 − owner`: `80` di list, `100 − fee parent` di detail |
|
||||
| `limit_purchase` di `budget.total`, `budget.weekly[]`, `budget.monthly[]` | 60% revenue | dihapus |
|
||||
| `limit_team` di tempat yang sama | 20% revenue | `revenue − sdl` |
|
||||
| `revenue` dan `sdl` di `budget` | semua parent category | hanya parent category team |
|
||||
|
||||
Di endpoint list, `budget.percentages` selalu berisi default `20` dan `80`, walaupun ada parent dengan fee berbeda. Angka `sdl` dan `limit_team` dihitung per parent dengan fee masing-masing, jadi `limit_team / revenue` bisa tidak persis 80%. Tampilkan angka rupiah dari response, jangan dihitung ulang dari persentase.
|
||||
|
||||
Baris di `data[]` membawa `sdl` tapi tidak membawa porsi team. Kalau porsi team per parent perlu ditampilkan, hitung dari `total_revenue − sdl`.
|
||||
|
||||
Contoh response list, dipotong:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"date_from": "2026-09-28T00:00:00+07:00",
|
||||
"date_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"data": [
|
||||
{
|
||||
"parent_category_id": "<uuid>",
|
||||
"parent_category_name": "Drink",
|
||||
"owner_fee_percent": 35,
|
||||
"sdl": 700000,
|
||||
"total_revenue": 2000000
|
||||
},
|
||||
{
|
||||
"parent_category_id": "<uuid>",
|
||||
"parent_category_name": "Food",
|
||||
"owner_fee_percent": 20,
|
||||
"sdl": 200000,
|
||||
"total_revenue": 1000000
|
||||
}
|
||||
],
|
||||
"budget": {
|
||||
"percentages": { "owner": 20, "team": 80 },
|
||||
"cut_off_from": "2026-09-28T00:00:00+07:00",
|
||||
"cut_off_to": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"total": {
|
||||
"period_start": "2026-09-28T00:00:00+07:00",
|
||||
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"revenue": 3000000,
|
||||
"order_count": 4,
|
||||
"sdl": 900000,
|
||||
"limit_team": 2100000
|
||||
},
|
||||
"weekly": [
|
||||
{
|
||||
"period_start": "2026-09-28T00:00:00+07:00",
|
||||
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"revenue": 3000000,
|
||||
"order_count": 4,
|
||||
"sdl": 900000,
|
||||
"limit_team": 2100000
|
||||
}
|
||||
],
|
||||
"monthly": [
|
||||
{
|
||||
"month": "2026-09",
|
||||
"week_count": 1,
|
||||
"period_start": "2026-09-28T00:00:00+07:00",
|
||||
"period_end": "2026-10-04T23:59:59.999999999+07:00",
|
||||
"revenue": 3000000,
|
||||
"order_count": 4,
|
||||
"sdl": 900000,
|
||||
"limit_team": 2100000
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
Di endpoint detail, `budget` bentuknya sama, tapi `percentages` memakai fee parent itu, misalnya `{ "owner": 35, "team": 65 }` untuk Drink.
|
||||
|
||||
### Detail kategori non-team
|
||||
|
||||
Detail untuk parent category non-team ditolak dengan HTTP status 500:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{
|
||||
"code": "internal_error",
|
||||
"entity": "AnalyticsHandler::GetParentCategoryAnalyticsDetail",
|
||||
"cause": "failed to get parent category analytics detail: failed to get parent category analytics detail: category Merchandise is not a team"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Ini bisa terjadi kalau user membuka link lama atau bookmark ke parent yang baru dimatikan. Cocokkan dengan teks `is not a team` di `cause`, lalu arahkan user kembali ke list.
|
||||
|
||||
## Migrasi client
|
||||
|
||||
### Laporan profit sharing
|
||||
|
||||
1. Ganti path ke `/api/v1/analytics/profit-sharing`. Selama backend lama masih jalan, path baru mengembalikan 404. Kalau dapat 404, panggil path lama `/api/v1/analytics/parent-categories`, supaya client baru bisa dirilis sebelum backend.
|
||||
2. Hapus tampilan limit purchase dan persentase purchase. Jangan menganggap `limit_purchase` atau `percentages.purchase` selalu ada.
|
||||
3. Tampilkan dua porsi dengan label "SDL / Fee owner" dan "Team", ambil angkanya dari `sdl` dan `limit_team`.
|
||||
4. Kalau detail ditolak dengan `is not a team`, arahkan kembali ke list.
|
||||
|
||||
Selama fallback ke backend lama, `limit_team` masih berisi 20% revenue. Angkanya baru jadi `revenue − sdl` setelah backend baru dirilis.
|
||||
|
||||
### Form kategori
|
||||
|
||||
1. Tambah toggle `is_team` untuk parent category, misalnya berlabel "Ikut profit sharing (team)". Untuk kategori baru, toggle menyala secara default.
|
||||
2. Saat membuka form edit, isi toggle dari `is_team`. Selama backend lama masih jalan, field ini tidak ada di response, jadi anggap `true`.
|
||||
3. Saat admin mematikan toggle, tampilkan konfirmasi bahwa kategori itu tidak akan muncul di pilihan team dan di laporan profit sharing, termasuk untuk periode lampau.
|
||||
4. Di daftar kategori, beri penanda untuk parent category dengan `is_team: false`.
|
||||
|
||||
Backend lama mengabaikan `is_team` di request, jadi toggle bisa dirilis lebih dulu, tapi belum berpengaruh sampai backend baru jalan. Pengecualiannya update yang hanya berisi `is_team`: backend lama menolaknya dengan error code `303` dan pesan `at least one field must be provided for update`. Selama masa transisi, kirim `is_team` bersama field form lainnya.
|
||||
|
||||
## Database dan urutan rilis
|
||||
|
||||
Migration `000101_add_is_team_to_categories` menambah kolom `categories.is_team` (`BOOLEAN NOT NULL DEFAULT TRUE`). Semua kategori yang ada otomatis bernilai `true`. Migration ini hanya menambah kolom, jadi backend lama tetap jalan normal setelahnya.
|
||||
|
||||
Urutan rilis:
|
||||
|
||||
1. Rilis client baru: path profit sharing dengan fallback ke path lama, tanpa porsi purchase, dan dengan toggle `is_team`. Untuk app mobile, pastikan versi baru sudah dipakai sebagian besar user sebelum langkah 3, karena versi lama yang memanggil `/parent-categories` mendapat 404 setelah itu.
|
||||
2. Jalankan migration `000101`.
|
||||
3. Deploy backend baru.
|
||||
4. Hapus fallback path lama di client.
|
||||
5. Admin mematikan `is_team` di parent category yang bukan team.
|
||||
|
||||
Rollback: deploy backend lama, lalu jalankan down migration yang menghapus kolom `is_team`. Client dengan fallback tetap jalan di backend lama. Nilai `is_team` yang sudah diatur admin hilang saat kolom dihapus.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Client baru (fallback path, tanpa porsi purchase, toggle `is_team`) dirilis
|
||||
- [ ] Migration `000101` dan backend baru dirilis di staging
|
||||
- [ ] Uji: parent category yang dimatikan hilang dari `/purchase-orders/teams` dan `/cash-advances/teams`
|
||||
- [ ] Uji: purchase order dan cash advance baru dengan kategori itu ditolak, dan edit purchase order lama tanpa mengganti team tetap berhasil
|
||||
- [ ] Uji: kategori itu hilang dari `/analytics/profit-sharing`, dan detailnya ditolak
|
||||
- [ ] Uji: `limit_team = revenue − sdl`, dan `sdl` mengikuti fee masing-masing parent
|
||||
- [ ] Migration `000101` dan backend baru dirilis di production
|
||||
- [ ] Fallback path lama di client dihapus
|
||||
|
||||
## FAQ
|
||||
|
||||
**Kenapa porsi team jadi 100% − fee owner, bukan tetap 20%?** Porsi purchase sudah tidak ada, jadi seluruh revenue dibagi dua. Owner mengambil fee-nya, dan sisanya untuk team. Kalau fee owner sebuah parent dinaikkan, porsi team parent itu turun sebesar yang sama.
|
||||
|
||||
**Apakah sub-category bisa dijadikan non-team sendiri?** Tidak. Team dan profit sharing dihitung per parent category, jadi semua sub-category ikut status parent-nya.
|
||||
|
||||
**Bagaimana dengan kategori top-level yang tidak punya sub-category?** Kategori itu tetap parent category, jadi `is_team` berlaku untuknya.
|
||||
@@ -0,0 +1,585 @@
|
||||
# Prompt: fitur EnakPoint & EnakCoin di Mobile App Customer
|
||||
|
||||
Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice).
|
||||
Tugasmu: membangun fitur loyalitas EnakPoint & EnakCoin di aplikasi, memakai API backend
|
||||
yang sudah jadi dan dijelaskan di dokumen ini. Jangan mengarang endpoint, field, atau
|
||||
aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan dulu.
|
||||
|
||||
---
|
||||
|
||||
## 1. Konteks bisnis
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Belanja (order lunas), koreksi admin, tukar EnakCoin | Belanja, koreksi admin |
|
||||
| Dipakai untuk | **Ditukar ke voucher** (tidak bisa membayar order) | **Main game**, ditukar ke EnakPoint |
|
||||
| Bisa dikirim ke customer lain | Ya | Ya |
|
||||
| Bisa kedaluwarsa | Ya, bila owner mengaktifkan | Ya, bila owner mengaktifkan |
|
||||
|
||||
Tidak ada lagi "token". Semua yang dulu token sekarang EnakCoin, dan endpoint serta
|
||||
field bernama token sudah dihapus dari API.
|
||||
|
||||
> **Perubahan 7 Okt 2026:** bayar dengan EnakPoint (kode bayar di kasir, bayar order
|
||||
> dari app) sudah dihapus dari backend. EnakPoint sekarang hanya bisa ditukar ke
|
||||
> voucher, tidak bisa dipakai sebagai alat bayar dan tidak bisa dicairkan
|
||||
> ([`enakgame-prd.md`](./enakgame-prd.md) §3.2). Jangan membangun layar bayar atau kode
|
||||
> bayar; endpoint dan field yang dihapus ada di §9.
|
||||
|
||||
### Aturan yang wajib dipatuhi di UI
|
||||
|
||||
1. **Semua jumlah bilangan bulat.** Tidak ada desimal pada EnakPoint atau EnakCoin.
|
||||
2. **Saldo bukan uang.** Nilai rupiah EnakPoint selalu ditulis **"setara potongan
|
||||
Rp …"**, tidak pernah "saldo Rp …" atau "uang". Tidak ada fitur tarik tunai, dan
|
||||
EnakPoint tidak bisa dipakai membayar.
|
||||
3. **PIN 6 digit wajib** untuk: tukar EnakCoin dan transfer. **Main game tidak butuh PIN.** Melihat saldo dan riwayat
|
||||
tidak butuh PIN.
|
||||
4. **PIN terpisah dari password login** dan selalu dikirim sebagai **string** (supaya
|
||||
nol di depan tidak hilang). Jangan pernah menyimpan PIN di perangkat, log, atau
|
||||
analytics.
|
||||
5. **Satu akun customer = satu organisasi.** Saldo berlaku di semua outlet organisasi itu.
|
||||
6. **Waktu memakai WIB.** Tanggal kedaluwarsa berarti saldo masih bisa dipakai sampai
|
||||
23:59:59 WIB di tanggal itu.
|
||||
|
||||
---
|
||||
|
||||
## 2. Koneksi ke API
|
||||
|
||||
- Base URL: `/api/v1`
|
||||
- Semua endpoint customer: header `Authorization: Bearer <token login customer>`
|
||||
- Semua jumlah di request dan response berupa integer.
|
||||
|
||||
### Registrasi customer
|
||||
|
||||
`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional):
|
||||
|
||||
```json
|
||||
{ "phone_number": "0812…", "name": "Budi", "birth_date": "2000-01-31", "organization_id": "648b96a0-1d1d-414e-baee-37e9d6317b4e" }
|
||||
```
|
||||
|
||||
- Customer terdaftar di satu organisasi, dan saldonya berlaku di semua outlet organisasi itu.
|
||||
- Bila `organization_id` tidak dikirim dan backend hanya punya satu organisasi, customer
|
||||
otomatis masuk ke organisasi itu. Bila ada lebih dari satu, registrasi ditolak
|
||||
("organization_id is required"), jadi sebaiknya app selalu mengirimnya dari config per
|
||||
environment/brand.
|
||||
- `organization_id` yang dikirim harus ada; bila tidak, registrasi ditolak sebelum OTP dikirim.
|
||||
- Wallet customer baru belum punya baris sampai saldo pertama kali bergerak;
|
||||
`GET /customer/wallet` tetap menjawab saldo 0.
|
||||
|
||||
### Format response
|
||||
|
||||
Sukses:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { … }, "errors": null }
|
||||
```
|
||||
|
||||
Gagal:
|
||||
|
||||
```json
|
||||
{ "success": false, "data": null, "errors": [{ "code": "304", "entity": "wallet_service", "cause": "wallet move refused: not enough EnakCoin" }] }
|
||||
```
|
||||
|
||||
| `errors[0].code` | HTTP | Arti | Yang dilakukan app |
|
||||
|---|---|---|---|
|
||||
| `303`, `310` | 400 | Request tidak lengkap / salah format | Bug di app; tampilkan pesan umum |
|
||||
| `304` | 400 | Ditolak aturan bisnis | Tampilkan pesan yang ramah (lihat tiap fitur); `cause` berbahasa Inggris, jangan tampilkan mentah |
|
||||
| `404` | 404 | Tidak ditemukan | Tampilkan "tidak ditemukan" |
|
||||
| `429` | 429 | Minta OTP terlalu cepat | Tampilkan hitung mundur sebelum boleh minta lagi |
|
||||
| `PIN_NOT_SET` | 403 | Belum punya PIN | Buka alur buat PIN (§6.2) |
|
||||
| `PIN_INVALID` | 400 | PIN salah | §6.5 |
|
||||
| `PIN_LOCKED` | 423 | PIN terkunci | §6.5 |
|
||||
| `TRANSFER_BLOCKED` | 403 | Transfer ditahan setelah reset PIN | §6.5 |
|
||||
| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
### Idempotency-Key
|
||||
|
||||
Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik, maks.
|
||||
50 karakter, mis. UUID v4).
|
||||
|
||||
- Buat **satu key baru saat customer menekan tombol konfirmasi**.
|
||||
- Bila request gagal karena jaringan/timeout, **kirim ulang dengan key yang sama**.
|
||||
Server mengembalikan hasil pertama dengan `"replayed": true` dan tidak memotong saldo
|
||||
dua kali.
|
||||
- Jangan pakai ulang key untuk transaksi yang berbeda; server menolaknya (`304`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Layar yang perlu dibuat
|
||||
|
||||
| Layar | Endpoint utama | Butuh PIN |
|
||||
|---|---|---|
|
||||
| Beranda wallet | `GET /customer/wallet` | – |
|
||||
| Riwayat mutasi | `GET /customer/wallet/transactions` | – |
|
||||
| Saldo akan kedaluwarsa | `GET /customer/wallet/expiring` | – |
|
||||
| Daftar outlet | `GET /customer/outlets` | – |
|
||||
| Riwayat order + detail | `GET /customer/orders`, `GET /customer/orders/:id` | – |
|
||||
| Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya |
|
||||
| Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya |
|
||||
| PIN (buat, ganti, lupa) | `/customer/pin/*` | – |
|
||||
| Game | `POST /customer/spin` | – |
|
||||
| (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – |
|
||||
|
||||
---
|
||||
|
||||
## 4. Beranda wallet, riwayat, kedaluwarsa
|
||||
|
||||
### 4.1 Beranda — `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 dengan item riwayat §4.2, maksimal 5 */ ]
|
||||
}
|
||||
```
|
||||
|
||||
Tampilkan:
|
||||
- Saldo EnakPoint (`point_balance`) dengan keterangan "setara potongan Rp
|
||||
{point_discount_value}" (format ribuan Indonesia: `Rp 12.500`).
|
||||
- Saldo EnakCoin (`coin_balance`).
|
||||
- Bila `nearest_expiring.point` / `.coin` tidak `null`: banner "{amount} EnakPoint akan
|
||||
kedaluwarsa pada {date}" yang membuka layar §4.3.
|
||||
- 5 mutasi terakhir dari `recent_transactions`, dengan tautan "Lihat semua" ke §4.2.
|
||||
- Tombol aksi: Tukar EnakCoin (§7.1), Transfer (§7.2), Main game (§8).
|
||||
|
||||
Muat ulang beranda setelah setiap transaksi dan saat menerima push (§5).
|
||||
|
||||
Field `total_points`, `points_history`, `last_updated` di response ini **deprecated**;
|
||||
jangan dipakai.
|
||||
|
||||
### 4.2 Riwayat — `GET /customer/wallet/transactions`
|
||||
|
||||
Query (semua opsional):
|
||||
|
||||
| Query | Contoh | Keterangan |
|
||||
|---|---|---|
|
||||
| `page` | `1` | Mulai dari 1 |
|
||||
| `limit` | `20` | 1–100, default 20 |
|
||||
| `currency` | `POINT` | `POINT` atau `COIN`; untuk tab EnakPoint / EnakCoin |
|
||||
| `type` | `EARN,TRANSFER_IN` | Satu atau beberapa tipe dipisah koma, untuk filter |
|
||||
| `from`, `to` | `2026-09-01` | 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 }
|
||||
}
|
||||
```
|
||||
|
||||
Aturan tampilan:
|
||||
- `amount` bertanda: positif tampil hijau dengan `+`, negatif merah dengan `−`.
|
||||
- `description` sudah siap tampil (nama lawan transfer sudah disamarkan). Tampilkan apa
|
||||
adanya.
|
||||
- Mutasi masuk yang punya `expires_at` menampilkan "Berlaku sampai {tanggal}".
|
||||
- Infinite scroll memakai `pagination.total_pages`.
|
||||
- Riwayat tidak pernah berubah atau hilang; koreksi muncul sebagai baris baru.
|
||||
|
||||
Label tipe:
|
||||
|
||||
| `type` | Label | Arah |
|
||||
|---|---|---|
|
||||
| `EARN` | Dari belanja | + |
|
||||
| `EARN_REVERSAL` | Dibatalkan (order di-void/refund) | − |
|
||||
| `EXCHANGE_OUT` | Ditukar ke EnakPoint | − |
|
||||
| `EXCHANGE_IN` | Hasil tukar EnakCoin | + |
|
||||
| `TRANSFER_OUT` | Transfer keluar | − |
|
||||
| `TRANSFER_IN` | Transfer masuk | + |
|
||||
| `GAME_SPEND` | Main game | − |
|
||||
| `EXPIRE` | Kedaluwarsa | − |
|
||||
| `ADJUSTMENT` | Koreksi | + / − |
|
||||
| `MIGRATION` | Saldo awal | + |
|
||||
|
||||
### 4.3 Akan kedaluwarsa — `GET /customer/wallet/expiring`
|
||||
|
||||
```json
|
||||
{
|
||||
"point": [
|
||||
{ "amount": 150, "date": "2026-10-31" },
|
||||
{ "amount": 200, "date": "2026-12-31" }
|
||||
],
|
||||
"coin": []
|
||||
}
|
||||
```
|
||||
|
||||
Daftar per tanggal, paling dekat di atas. Daftar kosong: tampilkan "Tidak ada saldo
|
||||
yang akan kedaluwarsa". Saldo yang kedaluwarsa hangus tanpa kompensasi.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Daftar outlet — `GET /customer/outlets`
|
||||
|
||||
Outlet aktif di organisasi customer, tempat saldo EnakPoint & EnakCoin berlaku. Urut
|
||||
berdasarkan nama.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "…",
|
||||
"name": "Gokuna Kemang",
|
||||
"address": "Jl. Kemang Raya 10",
|
||||
"earns_points": true,
|
||||
"earns_coins": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `address` bisa `null`.
|
||||
- `earns_points` / `earns_coins`: belanja di outlet ini memberi EnakPoint / EnakCoin.
|
||||
- Belum ada telepon, koordinat, atau jam buka; data itu belum disimpan di backend.
|
||||
|
||||
|
||||
### 4.5 Riwayat order — `GET /customer/orders` dan `GET /customer/orders/:id`
|
||||
|
||||
Order milik customer yang login di semua outlet organisasinya, terbaru di atas. Order
|
||||
hanya masuk ke sini bila kasir mengaitkannya ke customer.
|
||||
|
||||
`GET /api/v1/customer/orders?page=1&limit=20` (`limit` 1–100, default 20):
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…",
|
||||
"order_number": "ORD-0123",
|
||||
"outlet_id": "…",
|
||||
"outlet_name": "Gokuna 1",
|
||||
"order_type": "dine_in",
|
||||
"status": "completed",
|
||||
"payment_status": "completed",
|
||||
"total_amount": 99000,
|
||||
"item_count": 2,
|
||||
"is_void": false,
|
||||
"is_refund": false,
|
||||
"points_earned": 865,
|
||||
"coins_earned": 3,
|
||||
"created_at": "2026-09-30T12:01:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 42, "total_pages": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/customer/orders/{id}` mengembalikan field yang sama, ditambah:
|
||||
|
||||
```json
|
||||
{
|
||||
"table_number": "A3",
|
||||
"subtotal": 90000,
|
||||
"discount_amount": 0,
|
||||
"tax_amount": 9000,
|
||||
"refund_amount": 0,
|
||||
"items": [
|
||||
{
|
||||
"id": "…",
|
||||
"product_id": "…",
|
||||
"product_name": "Kopi Susu",
|
||||
"variant_name": "Large",
|
||||
"quantity": 2,
|
||||
"unit_price": 25000,
|
||||
"total_price": 50000,
|
||||
"refund_quantity": 0,
|
||||
"modifiers": [],
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"id": "…",
|
||||
"product_id": "…",
|
||||
"product_name": "Ikan Tude",
|
||||
"variant_name": null,
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_name": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900,
|
||||
"refund_quantity": 0,
|
||||
"modifiers": [],
|
||||
"status": "completed"
|
||||
}
|
||||
],
|
||||
"payments": [
|
||||
{ "id": "…", "method_name": "Cash", "method_type": "cash", "amount": 99000, "status": "completed", "refund_amount": 0, "created_at": "…" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- Order customer lain atau yang tidak ada → `404`.
|
||||
- `points_earned` / `coins_earned`: yang didapat dari order ini; 0 bila tidak ada.
|
||||
- Item timbangan membawa `weight` dan `unit_name`; tampilkan "1 × 4,2 ons".
|
||||
- Order yang `is_void` atau `is_refund` tetap tampil, beri label "Dibatalkan" /
|
||||
"Direfund".
|
||||
|
||||
|
||||
## 5. Notifikasi push (FCM)
|
||||
|
||||
### 5.1 Registrasi device
|
||||
|
||||
Setelah login berhasil **dan** setiap kali FCM memberi token baru (`onTokenRefresh`):
|
||||
|
||||
`PUT /api/v1/customer/devices`
|
||||
|
||||
```json
|
||||
{ "device_id": "<id unik & stabil per instalasi>", "fcm_token": "<token FCM>", "platform": "android", "app_version": "2.4.0" }
|
||||
```
|
||||
|
||||
- `device_id` wajib, stabil untuk satu instalasi (simpan di secure storage).
|
||||
- `platform`: `android`, `ios`, atau `web`.
|
||||
- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum
|
||||
menghapus token login, supaya HP itu tidak lagi menerima notifikasi akun ini.
|
||||
|
||||
Tanpa registrasi ini, customer tidak menerima push apa pun.
|
||||
|
||||
### 5.2 Tipe push
|
||||
|
||||
Semua nilai di `data` berupa string.
|
||||
|
||||
| `data.type` | Kapan | Isi `data` lain | Aksi saat di-tap |
|
||||
|---|---|---|---|
|
||||
| `WALLET_TRANSFER_IN` | Menerima transfer | `transaction_id`, `group_id`, `currency`, `amount` | Buka riwayat, sorot transaksi itu |
|
||||
| `WALLET_EXPIRING` | Beberapa hari sebelum saldo hangus | `currency`, `amount`, `expiry_date` | Buka layar kedaluwarsa (§4.3) |
|
||||
| `WALLET_EXPIRED` | Saldo baru saja hangus | `currency`, `amount` | Buka riwayat |
|
||||
| `PIN_LOCKED` | PIN terkunci setelah 5 kali salah | `locked_until` (RFC3339 UTC) | Buka layar lupa PIN (§6.4) |
|
||||
|
||||
Saat app terbuka dan menerima push wallet, muat ulang beranda.
|
||||
|
||||
---
|
||||
|
||||
## 6. PIN
|
||||
|
||||
### 6.1 Kapan diminta
|
||||
|
||||
Jangan minta PIN saat registrasi. Minta saat customer **pertama kali** melakukan aksi
|
||||
yang butuh PIN. Cek dengan:
|
||||
|
||||
`GET /api/v1/customer/pin/status` → `{ "has_pin": false, "locked_until": null, "transfer_blocked_until": null }`
|
||||
|
||||
Bila `has_pin: false`, arahkan ke alur buat PIN, lalu kembali ke aksi semula.
|
||||
|
||||
### 6.2 Buat PIN
|
||||
|
||||
1. `POST /api/v1/customer/pin/otp` dengan `{ "purpose": "pin_setup" }`.
|
||||
Response: `{ "purpose": "pin_setup", "otp_token": "…", "expires_at": "…" }`.
|
||||
OTP dikirim ke WhatsApp customer.
|
||||
2. Customer memasukkan kode OTP, lalu PIN dua kali.
|
||||
3. `POST /api/v1/customer/pin` dengan
|
||||
`{ "otp_token": "…", "otp_code": "123456", "pin": "482913", "confirm_pin": "482913" }`.
|
||||
Response: status PIN.
|
||||
|
||||
Validasi di app sebelum kirim (server juga memeriksa, jawab `304`):
|
||||
- Tepat 6 digit angka, dan konfirmasi sama.
|
||||
- Bukan satu digit berulang (`111111`).
|
||||
- Bukan berurutan naik/turun (`123456`, `654321`).
|
||||
- Bukan tanggal lahir customer (`DDMMYY` atau `YYMMDD`).
|
||||
|
||||
Minta OTP lagi terlalu cepat → `429`: tampilkan hitung mundur.
|
||||
|
||||
### 6.3 Ganti PIN
|
||||
|
||||
`PUT /api/v1/customer/pin` dengan `{ "old_pin": "…", "pin": "…", "confirm_pin": "…" }`.
|
||||
|
||||
### 6.4 Lupa PIN
|
||||
|
||||
1. `POST /customer/pin/otp` dengan `{ "purpose": "pin_reset" }`.
|
||||
2. `POST /customer/pin/reset` dengan `{ "otp_token", "otp_code", "pin", "confirm_pin" }`.
|
||||
|
||||
Reset juga membuka PIN yang terkunci. Setelah reset, **transfer keluar ditahan 24 jam**;
|
||||
tukar tetap bisa. Beri tahu customer hal ini di layar sukses.
|
||||
|
||||
### 6.5 Menangani error PIN
|
||||
|
||||
Semua endpoint yang menerima `pin` bisa menjawab error PIN. Pada error ini **`data`
|
||||
tidak `null`**:
|
||||
|
||||
```json
|
||||
{ "success": false, "data": { "code": "PIN_INVALID", "remaining_attempts": 3 }, "errors": [ … ] }
|
||||
```
|
||||
|
||||
| `data.code` | Field tambahan | Tampilan |
|
||||
|---|---|---|
|
||||
| `PIN_NOT_SET` | – | Buka alur buat PIN (§6.2) |
|
||||
| `PIN_INVALID` | `remaining_attempts` | "PIN salah, sisa {n} percobaan." Kosongkan input PIN |
|
||||
| `PIN_LOCKED` | `locked_until` | "PIN terkunci sampai {jam}." Tombol "Lupa PIN" |
|
||||
| `TRANSFER_BLOCKED` | `transfer_blocked_until` | "Transfer bisa dilakukan lagi pada {waktu}." |
|
||||
|
||||
5 kali salah berturut-turut mengunci PIN 30 menit; selama terkunci PIN yang benar pun
|
||||
ditolak. Penghitung ada di server, jadi jangan membuat penghitung sendiri di app.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tukar dan transfer
|
||||
|
||||
### 7.1 Tukar EnakCoin → EnakPoint
|
||||
|
||||
1. Customer mengetik jumlah EnakCoin. Panggil preview (debounce saat mengetik):
|
||||
|
||||
`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 }
|
||||
```
|
||||
|
||||
- Kurs: `coin_amount` EnakCoin = `point_amount` EnakPoint. Tampilkan "10 EnakCoin =
|
||||
3 EnakPoint".
|
||||
- Bila `valid: false`, tampilkan `reason` sebagai alasan dan nonaktifkan tombol. Jumlah
|
||||
harus kelipatan `coin_amount`.
|
||||
- Tampilkan "Kamu akan mendapat {points} EnakPoint".
|
||||
|
||||
2. Konfirmasi (tukar tidak bisa dibatalkan) → minta PIN →
|
||||
|
||||
`POST /api/v1/customer/wallet/exchange` + header `Idempotency-Key`
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
3. Layar sukses: saldo baru, dan bila `lots[].expires_at` ada, "EnakPoint ini berlaku
|
||||
sampai {tanggal}".
|
||||
|
||||
### 7.2 Transfer
|
||||
|
||||
1. Pilih mata uang (EnakPoint / EnakCoin), isi nomor HP penerima dan jumlah.
|
||||
2. Cek penerima:
|
||||
|
||||
`GET /api/v1/customer/wallet/transfer/recipient?phone=081234561234`
|
||||
|
||||
```json
|
||||
{ "name": "Bu*** Sa***", "phone_number": "08**-****-1234" }
|
||||
```
|
||||
|
||||
| Hasil | Tampilan |
|
||||
|---|---|
|
||||
| Sukses | "Kirim ke Bu*** Sa*** (08**-****-1234)?" |
|
||||
| `404` | "Nomor ini tidak terdaftar" |
|
||||
| `304` | "Tidak bisa mengirim ke nomor ini" (diri sendiri, akun nonaktif) |
|
||||
|
||||
3. Konfirmasi (transfer final, tidak bisa dibatalkan) → minta PIN →
|
||||
|
||||
`POST /api/v1/customer/wallet/transfer` + header `Idempotency-Key`
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
4. Layar sukses: saldo tersisa (`balance`). Bila ada `lots[].expires_at`, tampilkan
|
||||
"Saldo yang dikirim berlaku sampai {tanggal}" (tanggal kedaluwarsa ikut terbawa ke
|
||||
penerima).
|
||||
|
||||
Penolakan `304` yang mungkin: transfer dimatikan owner, di bawah minimal, di atas
|
||||
maksimal per transaksi, melewati batas harian (reset tengah malam WIB), saldo tidak
|
||||
cukup. Tampilkan pesan umum "Transfer tidak bisa diproses" plus alasan yang sesuai
|
||||
bila bisa dikenali. Bila kena `TRANSFER_BLOCKED`, ikuti §6.5.
|
||||
|
||||
Penerima mendapat push `WALLET_TRANSFER_IN`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Game (memakai EnakCoin)
|
||||
|
||||
`POST /api/v1/customer/spin` dengan `{ "spin_id": "<id game>" }`. Tanpa PIN.
|
||||
|
||||
```json
|
||||
{
|
||||
"game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" },
|
||||
"prize_won": { "id": "…", "name": "Voucher 10rb" },
|
||||
"coins_remaining": 7
|
||||
}
|
||||
```
|
||||
|
||||
- Setiap game punya biaya sendiri: `metadata.coin_cost` pada data game dari
|
||||
`GET /api/v1/customer/games` (atau `GET /customer/ferris-wheel`), default 1 bila kosong.
|
||||
Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` kurang.
|
||||
- `304`: EnakCoin kurang, game nonaktif, atau hadiah baru saja habis. Tidak ada
|
||||
EnakCoin yang terpotong; tampilkan pesan dan biarkan customer mencoba lagi.
|
||||
- Setelah main, perbarui saldo EnakCoin dari `coins_remaining`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Yang sudah dihapus / deprecated
|
||||
|
||||
Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` |
|
||||
| `total_tokens`, `tokens_history` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` |
|
||||
| `token_used`, `tokens_remaining` di response game | `coins_used`, `coins_remaining` |
|
||||
| `POST /customer/wallet/payment-code` | Tidak ada; EnakPoint tidak bisa untuk bayar |
|
||||
| `POST /customer/orders/:id/pay-with-points` | Tidak ada; EnakPoint tidak bisa untuk bayar |
|
||||
| `GET /orders/:id/point-payment/preview` (POS) | Tidak ada; EnakPoint tidak bisa untuk bayar |
|
||||
| `accepts_point_payment` di `GET /customer/outlets` | – |
|
||||
| `points_used`, `point_value` di `payments` pada `GET /customer/orders/:id` | – |
|
||||
| Tipe mutasi `PAYMENT`, `PAYMENT_REFUND` di riwayat | Tidak ditulis lagi |
|
||||
|
||||
Masih ada tapi **deprecated** (akan dihapus, jangan dipakai di kode baru):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `GET /customer/points` | `GET /customer/wallet` → `point_balance` |
|
||||
| `total_points`, `points_history`, `last_updated` di `/customer/wallet` | `point_balance`, `recent_transactions` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Checklist selesai
|
||||
|
||||
- [ ] Beranda menampilkan saldo EnakPoint ("setara potongan Rp …"), EnakCoin, dan banner kedaluwarsa terdekat.
|
||||
- [ ] Riwayat dengan tab per mata uang, filter tipe/tanggal, infinite scroll, label tipe sesuai §4.2.
|
||||
- [ ] Layar saldo akan kedaluwarsa.
|
||||
- [ ] Registrasi device FCM setelah login dan saat token berganti; unregister saat logout.
|
||||
- [ ] Penanganan tap untuk keempat tipe push.
|
||||
- [ ] PIN diminta hanya saat aksi yang membutuhkan; alur buat, ganti, dan lupa PIN lewat OTP.
|
||||
- [ ] Keempat error PIN ditangani di semua layar yang meminta PIN.
|
||||
- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama.
|
||||
- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama.
|
||||
- [ ] Game memakai `coins_used` / `coins_remaining` dan menampilkan biaya per game.
|
||||
- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat).
|
||||
- [ ] Tidak ada pemakaian endpoint atau field di §9.
|
||||
- [ ] PIN tidak pernah disimpan, di-log, atau dikirim ke analytics.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,981 @@
|
||||
# RFC: EnakGame — Game Session, Reward, Voucher & Budget
|
||||
|
||||
**Status:** Draft
|
||||
**Tanggal:** 2026-10-07
|
||||
**PRD:** [enakgame-prd.md](enakgame-prd.md)
|
||||
**Scope:** Game catalog, game session + entry cost + refund, Reward Engine, Economy Guard,
|
||||
voucher & redemption, budget & Budget Controller (recommendation mode), audit
|
||||
**Out of scope:** Mission, leaderboard, reward `TIERED`, Budget Controller automatic mode
|
||||
(lihat §16)
|
||||
|
||||
---
|
||||
|
||||
## 1. Ringkasan
|
||||
|
||||
EnakGame dibangun **di atas wallet EnakPoint/EnakCoin yang sudah ada**
|
||||
([prd-point-coin.md](prd-point-coin.md)), bukan sebagai sistem saldo baru. Ledger, lot,
|
||||
kedaluwarsa, idempotency, exchange Coin → Point, dan PIN sudah tersedia dan sudah
|
||||
teruji. Yang dibangun baru:
|
||||
|
||||
| Komponen PRD | Kondisi sekarang | Rencana |
|
||||
|---|---|---|
|
||||
| Game Management (§9) | `games` ada, tanpa `organization_id`, tanpa status/slug/URL | **Extend** `games` |
|
||||
| Game Session (§10) | Tidak ada. `game_plays` adalah main-instan tanpa session | **Baru**: `game_sessions` |
|
||||
| Entry cost (§10.1) | Ada (`GAME_SPEND`, `metadata.coin_cost`) tapi tanpa idempotency | **Reuse** `GAME_SPEND`, ref baru `GAME_SESSION` |
|
||||
| Refund entry cost (§10.2) | Tidak ada | **Baru**: tipe ledger `GAME_SPEND_REFUND` + job |
|
||||
| Reward Engine (§11–13) | Tidak ada. Hadiah spin tidak memberi apa pun | **Baru**: `game_reward_configs` (versioned) |
|
||||
| Event / campaign (§14–16) | Tidak ada. `campaigns` ada tapi tidak pernah dieksekusi | **Baru**: `game_events`, masing-masing dengan budget sendiri |
|
||||
| Economy Guard & Limits (§17, §35) | Tidak ada | **Baru**: counter harian + settings organisasi |
|
||||
| Coin Wallet & Ledger (§18–20) | **Ada lengkap** | **Reuse**, tambah 3 tipe ledger |
|
||||
| Coin → Point, expiry | **Ada** (F4, F12) | **Reuse** tanpa perubahan |
|
||||
| Voucher & Redemption (§21–28) | `rewards` ada tanpa org, tanpa redemption, tanpa kode | **Baru**: `vouchers`, `voucher_codes`, `voucher_redemptions` |
|
||||
| Budget & Controller (§5–8, §29–34) | Tidak ada | **Baru**: `game_budgets` + atribusi cost per lot |
|
||||
| Audit Log (§37) | Tidak ada yang generik | **Baru**: `audit_logs` |
|
||||
|
||||
Keputusan paling penting ada di §3, terutama **D5**: realized cost voucher diatribusikan ke
|
||||
budget dengan menelusuri lot Point yang dipakai sampai ke asalnya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Kondisi Sekarang
|
||||
|
||||
Temuan yang memengaruhi desain:
|
||||
|
||||
1. **Tabel game, reward, campaign, dan tier tidak punya `organization_id`.** Semua query
|
||||
membaca semua tenant. Tabel wallet (`000090`) sudah punya.
|
||||
2. **Main game sekarang tidak punya session.** `GamePlayProcessor.PlayGame`
|
||||
(`processor/game_play_processor.go:141`) memotong Coin, memilih hadiah, dan mencatat
|
||||
`game_plays` dalam satu request.
|
||||
3. **Hadiah game tidak memberi apa pun.** Prize hanya tercatat sebagai
|
||||
`game_plays.prize_id` dan teks deskripsi ledger. Tidak ada kredit Coin/Point, tidak ada
|
||||
voucher.
|
||||
4. **`GAME_SPEND` tanpa idempotency key** (`game_play_processor.go:186`). Tombol main yang
|
||||
ditekan dua kali memotong Coin dua kali.
|
||||
5. **Tidak ada alur penukaran.** Tipe ledger `REWARD_REDEEM` dan ref
|
||||
`REWARD_REDEMPTION` sudah ada di `walletTypeRules` dan CHECK database, tapi belum pernah
|
||||
dipakai.
|
||||
6. **Wallet sudah mendukung semua kebutuhan dasar:** `Credit` / `Debit` dengan lock per
|
||||
customer, lot FIFO berdasarkan kedaluwarsa, `idempotency_key` UNIQUE dengan replay,
|
||||
`origin_lot_id` untuk menelusuri asal saldo, `RefundExpiry` untuk refund.
|
||||
7. **Tidak ada scheduler library.** Semua job adalah goroutine `time.NewTicker` di
|
||||
`app/app.go`, aman multi-instance lewat lock wallet dan idempotency key.
|
||||
8. **`TxManager.WithTransaction` tidak me-reuse transaksi di context.** Pemanggilan
|
||||
bersarang membuka transaksi baru yang independen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Keputusan Inti
|
||||
|
||||
**D1 — EnakGame memakai wallet yang sudah ada.**
|
||||
Entry cost, reward, refund, dan redemption semuanya lewat `WalletProcessor.Credit` /
|
||||
`Debit`. Tidak ada tabel saldo baru. Konsekuensinya, aturan K5 (setiap mutasi punya asal
|
||||
dan tujuan), K6 (bilangan bulat), dan K9 (lot FIFO) otomatis berlaku untuk EnakGame.
|
||||
|
||||
**D2 — `games` di-extend, game lama diarsipkan, `game_plays` tidak dipakai EnakGame.**
|
||||
`games` sudah dibaca customer app. Kolom yang kurang ditambahkan (§5.1). Session baru masuk
|
||||
ke `game_sessions`. Game lama (spin, ferris wheel) **dihapus dari sisi produk** dan spin
|
||||
dibangun ulang sebagai game EnakGame. Secara data, baris lama diarsipkan, bukan di-`DELETE`
|
||||
(§14).
|
||||
|
||||
**D3 — Coin dipotong saat session dibuat, dalam satu transaksi.**
|
||||
Sesuai PRD §10.1. Idempotency key dari header `Idempotency-Key`, mengikuti pola exchange.
|
||||
|
||||
**D4 — Session punya state machine yang ditegakkan dengan UPDATE bersyarat.**
|
||||
`STARTED → COMPLETED | REFUNDED | EXPIRED`. Setiap transisi adalah
|
||||
`UPDATE ... WHERE id = ? AND status = 'STARTED'`. Complete dan refund tidak mungkin
|
||||
sama-sama berhasil untuk satu session, karena hanya satu yang mendapat baris ter-update.
|
||||
|
||||
**D5 — Realized cost diatribusikan ke budget lewat lot.**
|
||||
Point yang dipakai menukar voucher ditelusuri lewat `wallet_lot_allocations` →
|
||||
`wallet_lots.origin_lot_id` sampai ke lot pertama. Lot pertama menunjuk mutasi asalnya:
|
||||
`GAME_REWARD` (EnakGame, dengan budget yang tercatat), atau `EARN` / `ADJUSTMENT` /
|
||||
`MIGRATION` (bukan dari game). Hasilnya dibekukan per redemption di
|
||||
`voucher_redemption_costs`.
|
||||
|
||||
**Hanya bagian yang berasal dari `GAME_REWARD` yang dihitung ke budget.** Point dari
|
||||
belanja (`EARN`) dan sumber lain tetap dicatat atribusinya (dengan `budget_id` kosong)
|
||||
untuk reporting, tetapi tidak mengurangi budget mana pun.
|
||||
|
||||
Alasannya: Point bersifat fungible. Customer bisa memegang Point dari belanja, dari
|
||||
exchange Coin hasil game, dan dari transfer sekaligus. Tanpa penelusuran lot, sistem tidak
|
||||
bisa tahu berapa bagian voucher yang benar-benar dibiayai budget EnakGame atau budget
|
||||
event tertentu. Lot sudah menyimpan jejak ini sejak PRD point-coin (Q9), jadi tidak ada
|
||||
perubahan struktur wallet.
|
||||
|
||||
**D6 — Reward per budget dicatat sebagai baris ledger terpisah.**
|
||||
Satu session bisa menghasilkan reward dari budget global (reward normal) dan dari budget
|
||||
event (tambahan dari multiplier/bonus event). Masing-masing menjadi satu baris
|
||||
`GAME_REWARD` dengan lot sendiri, dan `game_session_rewards` mencatat budget tiap baris.
|
||||
Ini yang membuat D5 bisa membedakan budget global dan event tanpa menambah kolom di
|
||||
`wallet_lots`.
|
||||
|
||||
Dalam RFC ini **event = campaign**: istilah yang sama untuk hal yang sama.
|
||||
|
||||
**D7 — Reward configuration immutable.**
|
||||
Baris `game_reward_configs` tidak pernah di-UPDATE kecuali kolom `status`. Perubahan
|
||||
reward = baris baru dengan `version + 1`. Session menyimpan `reward_config_id` saat
|
||||
**Start Game**, sehingga perubahan config tidak memengaruhi session yang sedang berjalan.
|
||||
|
||||
**D8 — Semua tabel baru punya `organization_id`.**
|
||||
Satu organisasi = satu ekonomi EnakGame (budget, limit, voucher, game). Org customer dibaca
|
||||
dari tabel `customers` seperti flow wallet sekarang, karena JWT customer tidak membawa org.
|
||||
Game, voucher, dan event milik org lain ditolak.
|
||||
|
||||
**D9 — Budget Controller v1 hanya recommendation mode.**
|
||||
Sesuai default PRD §33. Automatic mode di luar scope RFC ini.
|
||||
|
||||
---
|
||||
|
||||
## 4. Prinsip
|
||||
|
||||
**P1 — Backend satu-satunya penentu reward.** Client hanya mengirim `score`, `outcome`, dan
|
||||
data hasil. Request yang membawa angka reward diabaikan.
|
||||
|
||||
**P2 — Setiap mutasi uang punya idempotency key deterministik.** Diturunkan dari id
|
||||
session/redemption, bukan dari waktu. Retry selalu menghasilkan key yang sama.
|
||||
|
||||
**P3 — Snapshot, bukan join.** Entry cost, reward config, face value voucher, dan point cost
|
||||
dibekukan di baris transaksi saat terjadi, sama seperti `unit_price` di `order_items`.
|
||||
|
||||
**P4 — Lock wallet customer selalu diambil lebih dulu.** Semua alur (start, complete,
|
||||
refund, redeem) mengunci `customer_wallets` sebelum menyentuh tabel lain, supaya urutan
|
||||
lock konsisten dan tidak deadlock.
|
||||
|
||||
---
|
||||
|
||||
## 5. Model Data
|
||||
|
||||
Semua migrasi mengikuti golang-migrate di `migrations/`, nomor lanjut dari `000101`.
|
||||
|
||||
### 5.1 `games` (extend)
|
||||
|
||||
```sql
|
||||
ALTER TABLE games
|
||||
ADD COLUMN organization_id UUID,
|
||||
ADD COLUMN slug VARCHAR(100),
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN thumbnail_url VARCHAR(500),
|
||||
ADD COLUMN game_url VARCHAR(500),
|
||||
ADD COLUMN version VARCHAR(50),
|
||||
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE'
|
||||
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||||
ADD COLUMN entry_cost BIGINT,
|
||||
ADD COLUMN session_ttl_seconds INT NOT NULL DEFAULT 600
|
||||
CHECK (session_ttl_seconds > 0),
|
||||
-- Batas validasi hasil: max_score, min_duration_seconds, max_score_per_second, outcome
|
||||
-- yang valid. Dibaca Result Validator (§7.2).
|
||||
ADD COLUMN result_rules JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Game lama dihapus dari produk: diarsipkan, tidak di-DELETE (§14).
|
||||
UPDATE games SET status = 'ARCHIVED', is_active = FALSE,
|
||||
entry_cost = COALESCE((metadata->>'coin_cost')::bigint, 1);
|
||||
|
||||
ALTER TABLE games
|
||||
ALTER COLUMN entry_cost SET NOT NULL,
|
||||
ADD CONSTRAINT chk_games_entry_cost CHECK (entry_cost >= 1), -- PRD §10.1: tidak ada game gratis
|
||||
-- Semua game EnakGame wajib punya org dan slug. Hanya arsip lama yang boleh kosong.
|
||||
ADD CONSTRAINT chk_games_enakgame_identity CHECK (
|
||||
status = 'ARCHIVED' OR (organization_id IS NOT NULL AND slug IS NOT NULL));
|
||||
|
||||
CREATE UNIQUE INDEX uq_games_org_slug ON games(organization_id, slug) WHERE slug IS NOT NULL;
|
||||
CREATE INDEX idx_games_org_status ON games(organization_id, status);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
|
||||
- Baris lama tidak punya org, sehingga tidak bisa dijadikan game EnakGame. Mereka
|
||||
diarsipkan dan tidak pernah tampil di endpoint EnakGame. Game baru (termasuk spin yang
|
||||
dibangun ulang) dibuat sebagai baris baru dengan org.
|
||||
- `is_active` tidak dipakai EnakGame dan dihapus bersama alur lama (§14). EnakGame hanya
|
||||
membaca `status`.
|
||||
|
||||
### 5.2 `game_reward_configs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE game_reward_configs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
|
||||
version INT NOT NULL,
|
||||
reward_type VARCHAR(30) NOT NULL
|
||||
CHECK (reward_type IN ('FIXED', 'SCORE_BASED', 'OUTCOME_BASED', 'PROBABILITY')),
|
||||
rules JSONB NOT NULL, -- bentuk per tipe di §8
|
||||
max_reward BIGINT NOT NULL CHECK (max_reward >= 0),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (status IN ('DRAFT', 'ACTIVE', 'RETIRED')),
|
||||
effective_at TIMESTAMPTZ,
|
||||
created_by UUID NOT NULL,
|
||||
reason VARCHAR(255),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (game_id, version)
|
||||
);
|
||||
|
||||
-- Satu config aktif per game.
|
||||
CREATE UNIQUE INDEX uq_game_reward_configs_active
|
||||
ON game_reward_configs(game_id) WHERE status = 'ACTIVE';
|
||||
```
|
||||
|
||||
`MULTIPLIER` tidak menjadi `reward_type` karena di PRD ia adalah modifier di atas base
|
||||
reward, bukan cara menghitung base. Multiplier dan bonus hidup di `game_events` (§5.5).
|
||||
|
||||
### 5.3 `game_sessions`
|
||||
|
||||
```sql
|
||||
CREATE TABLE game_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||||
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
|
||||
reward_config_id UUID NOT NULL REFERENCES game_reward_configs(id), -- snapshot (D7)
|
||||
entry_cost BIGINT NOT NULL CHECK (entry_cost >= 1), -- snapshot (P3)
|
||||
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'STARTED'
|
||||
CHECK (status IN ('STARTED', 'COMPLETED', 'REFUNDED', 'EXPIRED')),
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
ended_at TIMESTAMPTZ,
|
||||
|
||||
-- Hasil dari client (P1: hanya data, tanpa angka reward).
|
||||
result JSONB,
|
||||
-- Validasi & perhitungan: base, modifier event, cap guard, alasan penolakan, roll RNG.
|
||||
reward_breakdown JSONB,
|
||||
reward_total BIGINT NOT NULL DEFAULT 0 CHECK (reward_total >= 0),
|
||||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
spend_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
|
||||
refund_transaction_id UUID REFERENCES wallet_transactions(id),
|
||||
refund_reason VARCHAR(30)
|
||||
CHECK (refund_reason IN ('SYSTEM_ERROR', 'GAME_DEACTIVATED')),
|
||||
-- Diisi saat complete gagal karena error sistem (5xx), di transaksi terpisah (§7.3).
|
||||
completion_failed_at TIMESTAMPTZ,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT chk_game_sessions_refund CHECK (
|
||||
(status = 'REFUNDED') = (refund_transaction_id IS NOT NULL AND refund_reason IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_game_sessions_customer ON game_sessions(customer_id, started_at DESC);
|
||||
CREATE INDEX idx_game_sessions_open ON game_sessions(expires_at) WHERE status = 'STARTED';
|
||||
CREATE INDEX idx_game_sessions_game_open ON game_sessions(game_id) WHERE status = 'STARTED';
|
||||
```
|
||||
|
||||
`balance_before` yang diminta PRD §19 tidak perlu kolom: `balance_after - amount` di
|
||||
`wallet_transactions` sudah memberikannya.
|
||||
|
||||
### 5.4 `game_session_rewards`
|
||||
|
||||
```sql
|
||||
-- Satu baris per budget yang membiayai reward session (D6).
|
||||
CREATE TABLE game_session_rewards (
|
||||
session_id UUID NOT NULL REFERENCES game_sessions(id),
|
||||
budget_id UUID NOT NULL REFERENCES game_budgets(id),
|
||||
amount BIGINT NOT NULL CHECK (amount > 0),
|
||||
wallet_transaction_id UUID NOT NULL UNIQUE REFERENCES wallet_transactions(id),
|
||||
PRIMARY KEY (session_id, budget_id)
|
||||
);
|
||||
```
|
||||
|
||||
### 5.5 `game_events` dan `game_event_games`
|
||||
|
||||
```sql
|
||||
CREATE TABLE game_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
description TEXT,
|
||||
banner_url VARCHAR(500),
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
timezone VARCHAR(50) NOT NULL DEFAULT 'Asia/Jakarta',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (status IN ('DRAFT', 'ACTIVE', 'ENDED', 'CANCELLED')),
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
multiplier NUMERIC(5,2) CHECK (multiplier IS NULL OR multiplier > 0),
|
||||
bonus BIGINT CHECK (bonus IS NULL OR bonus > 0),
|
||||
-- Budget event sendiri, terpisah dari global (§5.6). Membiayai tambahan reward
|
||||
-- dari multiplier dan bonus event ini.
|
||||
budget_id UUID NOT NULL REFERENCES game_budgets(id),
|
||||
reward_limit BIGINT, -- PRD §35 Event Limit
|
||||
user_daily_limit BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (organization_id, slug),
|
||||
CHECK (end_at > start_at)
|
||||
);
|
||||
|
||||
CREATE TABLE game_event_games (
|
||||
event_id UUID NOT NULL REFERENCES game_events(id) ON DELETE CASCADE,
|
||||
game_id UUID NOT NULL REFERENCES games(id) ON DELETE RESTRICT,
|
||||
PRIMARY KEY (event_id, game_id)
|
||||
);
|
||||
```
|
||||
|
||||
Event adalah campaign dalam arti PRD §7: setiap event punya budget sendiri. Base reward
|
||||
tetap dibiayai budget global. Hanya selisih yang ditambahkan event (multiplier + bonus)
|
||||
yang dibiayai budget event. Budget event harus ber-`scope = 'EVENT'`, ditegakkan di
|
||||
processor.
|
||||
|
||||
### 5.6 `game_budgets`
|
||||
|
||||
```sql
|
||||
CREATE TABLE game_budgets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
scope VARCHAR(20) NOT NULL CHECK (scope IN ('GLOBAL', 'EVENT')),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
period_start DATE NOT NULL,
|
||||
period_end DATE NOT NULL,
|
||||
amount BIGINT NOT NULL CHECK (amount > 0), -- rupiah
|
||||
-- {"warning": 70, "critical": 90} dalam persen utilisasi/forecast (PRD §8, §32).
|
||||
thresholds JSONB NOT NULL DEFAULT '{}',
|
||||
exhaustion_policy VARCHAR(30), -- PRD §34, menunggu keputusan
|
||||
created_by UUID NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CHECK (period_end >= period_start)
|
||||
);
|
||||
|
||||
-- Budget global tidak boleh tumpang tindih di satu org: satu baris per periode.
|
||||
CREATE UNIQUE INDEX uq_game_budgets_global_period
|
||||
ON game_budgets(organization_id, period_start) WHERE scope = 'GLOBAL';
|
||||
```
|
||||
|
||||
- **Global** default bulanan (PRD §5.2): `period_start` tanggal 1, `period_end` akhir bulan.
|
||||
Periode lain bisa di-configure dengan mengisi rentang sendiri.
|
||||
- **Event** periodenya rentang event. Realized cost dihitung ke budget event **kapan pun
|
||||
Point-nya ditukar**, termasuk setelah event berakhir, karena biaya itu lahir dari reward
|
||||
event tersebut.
|
||||
|
||||
### 5.7 Voucher
|
||||
|
||||
```sql
|
||||
CREATE TABLE vouchers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
image_url VARCHAR(500),
|
||||
voucher_type VARCHAR(30) NOT NULL
|
||||
CHECK (voucher_type IN ('FIXED_VALUE', 'PERCENTAGE', 'FREE_ITEM', 'MERCHANT_BENEFIT')),
|
||||
face_value BIGINT NOT NULL CHECK (face_value > 0), -- rupiah, dasar realized cost
|
||||
point_cost BIGINT NOT NULL CHECK (point_cost > 0), -- boleh beda dari face_value (PRD §22)
|
||||
business_cost BIGINT, -- reporting saja, bukan budget
|
||||
stock_mode VARCHAR(20) NOT NULL
|
||||
CHECK (stock_mode IN ('STATIC', 'CODE_POOL', 'EXTERNAL')),
|
||||
stock BIGINT CHECK (stock IS NULL OR stock >= 0), -- hanya STATIC
|
||||
provider VARCHAR(50), -- hanya EXTERNAL
|
||||
provider_ref VARCHAR(255),
|
||||
max_per_customer INT,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_until TIMESTAMPTZ,
|
||||
terms JSONB NOT NULL DEFAULT '{}',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
|
||||
CHECK (status IN ('DRAFT', 'ACTIVE', 'INACTIVE', 'ARCHIVED')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CHECK ((stock_mode = 'STATIC') = (stock IS NOT NULL)),
|
||||
CHECK ((stock_mode = 'EXTERNAL') = (provider IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE TABLE voucher_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
voucher_id UUID NOT NULL REFERENCES vouchers(id) ON DELETE RESTRICT,
|
||||
code VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE'
|
||||
CHECK (status IN ('AVAILABLE', 'RESERVED', 'REDEEMED', 'EXPIRED', 'CANCELLED')),
|
||||
redemption_id UUID,
|
||||
expires_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (voucher_id, code),
|
||||
CHECK ((status IN ('RESERVED', 'REDEEMED')) = (redemption_id IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_voucher_codes_available ON voucher_codes(voucher_id, created_at)
|
||||
WHERE status = 'AVAILABLE';
|
||||
|
||||
CREATE TABLE voucher_redemptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
|
||||
voucher_id UUID NOT NULL REFERENCES vouchers(id),
|
||||
idempotency_key VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL
|
||||
CHECK (status IN ('PENDING', 'COMPLETED', 'FAILED')),
|
||||
-- Snapshot (P3).
|
||||
face_value BIGINT NOT NULL,
|
||||
point_cost BIGINT NOT NULL,
|
||||
voucher_code_id UUID REFERENCES voucher_codes(id),
|
||||
external_code VARCHAR(255),
|
||||
external_ref VARCHAR(255),
|
||||
debit_transaction_id UUID NOT NULL REFERENCES wallet_transactions(id),
|
||||
refund_transaction_id UUID REFERENCES wallet_transactions(id),
|
||||
failure_reason VARCHAR(255),
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
UNIQUE (customer_id, idempotency_key),
|
||||
CHECK ((status = 'FAILED') = (refund_transaction_id IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_voucher_redemptions_pending ON voucher_redemptions(updated_at)
|
||||
WHERE status = 'PENDING';
|
||||
|
||||
-- Atribusi realized cost per budget, dibekukan saat redemption COMPLETED (D5).
|
||||
CREATE TABLE voucher_redemption_costs (
|
||||
redemption_id UUID NOT NULL REFERENCES voucher_redemptions(id),
|
||||
-- NULL = Point yang bukan berasal dari EnakGame (EARN, ADJUSTMENT, MIGRATION).
|
||||
budget_id UUID REFERENCES game_budgets(id),
|
||||
source_type VARCHAR(30) NOT NULL, -- tipe ledger lot asal
|
||||
points BIGINT NOT NULL CHECK (points > 0),
|
||||
cost BIGINT NOT NULL CHECK (cost >= 0), -- rupiah, bagian dari face_value
|
||||
recognized_at TIMESTAMPTZ NOT NULL, -- = completed_at, dasar periode budget
|
||||
UNIQUE (redemption_id, budget_id, source_type)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_voucher_redemption_costs_budget
|
||||
ON voucher_redemption_costs(budget_id, recognized_at);
|
||||
```
|
||||
|
||||
### 5.8 Counter Economy Guard
|
||||
|
||||
```sql
|
||||
-- Jumlah reward yang sudah diterbitkan per cakupan per hari (Asia/Jakarta).
|
||||
CREATE TABLE game_reward_counters (
|
||||
organization_id UUID NOT NULL,
|
||||
scope_type VARCHAR(20) NOT NULL CHECK (scope_type IN ('USER', 'GAME', 'EVENT', 'GLOBAL')),
|
||||
scope_id UUID NOT NULL, -- customer / game / event / organization
|
||||
day DATE NOT NULL,
|
||||
amount BIGINT NOT NULL DEFAULT 0 CHECK (amount >= 0),
|
||||
PRIMARY KEY (organization_id, scope_type, scope_id, day)
|
||||
);
|
||||
```
|
||||
|
||||
`EVENT` memakai `day = '0001-01-01'` untuk limit seumur event (PRD §35 Event Limit) dan
|
||||
tanggal sebenarnya untuk `game_events.user_daily_limit`.
|
||||
|
||||
### 5.9 `audit_logs`
|
||||
|
||||
```sql
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID NOT NULL,
|
||||
actor_type VARCHAR(20) NOT NULL CHECK (actor_type IN ('USER', 'SYSTEM')),
|
||||
actor_id UUID,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id UUID NOT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
before JSONB,
|
||||
after JSONB,
|
||||
reason VARCHAR(255),
|
||||
source VARCHAR(50) NOT NULL, -- 'admin_api', 'budget_controller', 'session_job', ...
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_logs_entity ON audit_logs(entity_type, entity_id, created_at DESC);
|
||||
```
|
||||
|
||||
### 5.10 Pengaturan organisasi
|
||||
|
||||
Limit global dan per user disimpan di `organization_settings` (key-value, `000091`),
|
||||
dikelola lewat pola `LoyaltySettingsProcessor`: field descriptor dengan default dan
|
||||
min/max, advisory lock, dan riwayat otomatis di `loyalty_setting_changes`. Audit perubahan
|
||||
setting gratis didapat dari situ.
|
||||
|
||||
| Key | Tipe | Default | Arti |
|
||||
|---|---|---|---|
|
||||
| `enakgame.limit.user_daily` | int ≥ 0 | 0 (tanpa batas) | Coin maksimal yang didapat satu customer per hari |
|
||||
| `enakgame.limit.global_daily` | int ≥ 0 | 0 | Coin maksimal yang diterbitkan seluruh org per hari |
|
||||
|
||||
Perilaku saat limit terlampaui tidak di-configure: reward selalu **dipotong ke sisa limit**
|
||||
(§9).
|
||||
|
||||
Limit per game (`Game Daily Limit`) disimpan di `games.result_rules` agar ikut di-configure
|
||||
per game.
|
||||
|
||||
---
|
||||
|
||||
## 6. Ledger
|
||||
|
||||
### 6.1 Tipe baru dan perubahan
|
||||
|
||||
| Tipe | Currency | Arah | Ref | Wajib tambahan | Idempotency key | Status |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `GAME_SPEND` | COIN | keluar | `GAME_PLAY` (lama) **atau `GAME_SESSION`** | – | `game-entry:{customer}:{key}` | ref baru |
|
||||
| `GAME_SPEND_REFUND` | COIN | masuk | `GAME_SESSION` | `reverses_transaction_id` | `game-refund:{session}` | **baru** |
|
||||
| `GAME_REWARD` | COIN | masuk | `GAME_SESSION` | – | `game-reward:{session}:{budget}` | **baru** |
|
||||
| `REWARD_REDEEM` | POINT | keluar | `REWARD_REDEMPTION` → `voucher_redemptions.id` | – | `redeem:{redemption}` | sudah ada, mulai dipakai |
|
||||
| `REWARD_REDEEM_REFUND` | POINT | masuk | `REWARD_REDEMPTION` | `reverses_transaction_id` | `redeem-refund:{redemption}` | **baru** |
|
||||
|
||||
Pemetaan ke konsep PRD §19: `EARNED` = `GAME_REWARD`, `SPENT` = `GAME_SPEND` /
|
||||
`REWARD_REDEEM`, `REFUND` = `GAME_SPEND_REFUND` / `REWARD_REDEEM_REFUND`, `EXPIRED` =
|
||||
`EXPIRE`, `CONVERSION` = `EXCHANGE_OUT` + `EXCHANGE_IN`. `BONUS` dari event tetap
|
||||
`GAME_REWARD`, dibedakan lewat budget dan `reward_breakdown`.
|
||||
|
||||
### 6.2 Perubahan yang harus dilakukan bersamaan
|
||||
|
||||
Menambah tipe berarti mengubah **dua tempat** yang harus sinkron:
|
||||
|
||||
1. `walletTypeRules` di `processor/wallet_processor.go:487`, plus konstanta di
|
||||
`constants/wallet.go` (`WalletRefTypeGameSession`).
|
||||
2. CHECK di `wallet_transactions` (drop + create ulang):
|
||||
- `chk_wallet_transactions_point_only_types` + `REWARD_REDEEM_REFUND`
|
||||
- `chk_wallet_transactions_coin_only_types` + `GAME_SPEND_REFUND`, `GAME_REWARD`
|
||||
- `chk_wallet_transactions_reversal_source` + `GAME_SPEND_REFUND`, `REWARD_REDEEM_REFUND`
|
||||
|
||||
Reconciliation job (`service/wallet_reconciliation_job.go`) harus diperiksa: invariant
|
||||
yang menghitung per tipe perlu mengenali tipe baru.
|
||||
|
||||
### 6.3 Lot
|
||||
|
||||
| Mutasi | Lot yang dibuat |
|
||||
|---|---|
|
||||
| `GAME_REWARD` | Satu lot, `expires_at = ComputeExpiry(CoinExpiry, now)`, tanpa `origin_lot_id` (lot akar) |
|
||||
| `GAME_SPEND_REFUND` | Satu lot per alokasi `GAME_SPEND` asal: `expires_at = RefundExpiry(lot.expires_at, now)`, `origin_lot_id = lot asal`. Sama persis dengan pola `PAYMENT_REFUND` (`point_payment_refund.go`) |
|
||||
| `REWARD_REDEEM_REFUND` | Sama dengan `GAME_SPEND_REFUND`, untuk Point |
|
||||
|
||||
---
|
||||
|
||||
## 7. Alur
|
||||
|
||||
### 7.1 Start Game
|
||||
|
||||
```
|
||||
POST /customer/enakgame/sessions { game_id } Idempotency-Key: <≤50 char>
|
||||
```
|
||||
|
||||
Satu transaksi:
|
||||
|
||||
1. Baca customer → `organization_id`. Tolak bila game bukan milik org tersebut atau
|
||||
`status <> 'ACTIVE'`.
|
||||
2. Baca reward config `ACTIVE` untuk game. Tolak bila tidak ada.
|
||||
3. Pastikan ada budget global untuk periode berjalan. Tolak bila belum diatur, karena
|
||||
reward yang nanti diterbitkan wajib menunjuk budget (D6).
|
||||
4. `LockWallet(customer)`.
|
||||
5. `FindTransaction("game-entry:{customer}:{key}")`. Bila ada → kembalikan session yang
|
||||
menunjuknya (replay, tidak memotong lagi).
|
||||
6. Buat `session_id` baru. `Debit` COIN `GAME_SPEND`, ref `GAME_SESSION → session_id`,
|
||||
amount `games.entry_cost`. Saldo kurang → `ErrWalletInsufficientBalance` → tolak tanpa
|
||||
apa pun tercatat.
|
||||
7. Insert `game_sessions` dengan `entry_cost`, `reward_config_id`,
|
||||
`expires_at = now + session_ttl_seconds`, `spend_transaction_id`.
|
||||
|
||||
Response: `session_id`, `expires_at`, `entry_cost`, `coin_balance`.
|
||||
|
||||
### 7.2 Complete Game
|
||||
|
||||
```
|
||||
POST /customer/enakgame/sessions/:id/complete { score?, outcome?, data? }
|
||||
```
|
||||
|
||||
Satu transaksi:
|
||||
|
||||
1. `LockWallet(customer)`.
|
||||
2. Baca session. Bila `customer_id` beda → 404. Bila status sudah `COMPLETED` → kembalikan
|
||||
hasil yang tersimpan (idempotent, PRD §20). Bila `REFUNDED` / `EXPIRED` → tolak.
|
||||
3. Bila `now > expires_at` → tolak (session dibiarkan untuk job, §7.3).
|
||||
4. Bila game sudah tidak `ACTIVE` → **refund** (`GAME_DEACTIVATED`) di transaksi ini, lalu
|
||||
kembalikan response "game dinonaktifkan, Coin dikembalikan".
|
||||
5. **Result Validator** (`games.result_rules`): durasi minimal sejak `started_at`, skor
|
||||
maksimum, skor per detik, outcome yang dikenal. Gagal → reward 0, `flagged = true`,
|
||||
alasan di `reward_breakdown`. Session tetap `COMPLETED`, tidak ada refund.
|
||||
6. **Reward Engine** (§8): hitung base dari config snapshot.
|
||||
7. **Event modifier**: event `ACTIVE` yang mencakup game, `start_at ≤ now < end_at`.
|
||||
Hasilnya daftar komponen `{budget_id, amount}`.
|
||||
8. **Economy Guard** (§9): naikkan counter dengan UPDATE bersyarat, potong komponen yang
|
||||
melewati limit.
|
||||
9. `UPDATE game_sessions SET status='COMPLETED' ... WHERE id=? AND status='STARTED'`.
|
||||
0 baris → session baru saja di-refund job → rollback, kembalikan status terbaru.
|
||||
10. Untuk tiap komponen > 0: `Credit` COIN `GAME_REWARD` dengan key
|
||||
`game-reward:{session}:{budget}`, lalu insert `game_session_rewards`.
|
||||
|
||||
Response: `reward_total`, rincian yang aman ditampilkan (base, bonus event), `coin_balance`.
|
||||
|
||||
### 7.3 Refund otomatis & kedaluwarsa session
|
||||
|
||||
PRD §10.2: refund otomatis untuk **system error** dan **game dinonaktifkan**; session yang
|
||||
ditinggal user tidak di-refund.
|
||||
|
||||
**Mendefinisikan "system error".** Backend hanya bisa membedakan dua hal ini bila ada jejak.
|
||||
Aturannya:
|
||||
|
||||
- Bila complete (§7.2) gagal dengan error non-bisnis (DB error, panic, timeout → HTTP 5xx),
|
||||
handler menulis `completion_failed_at = now()` di **transaksi terpisah**
|
||||
(`DetachTransaction`, karena transaksi utama sudah rollback).
|
||||
- Error validasi (4xx) dan error yang terjadi di client (Phaser crash, koneksi putus
|
||||
sebelum request sampai) **tidak** meninggalkan jejak, sehingga diperlakukan sebagai
|
||||
ditinggal user dan tidak di-refund (diputuskan).
|
||||
|
||||
**`GameSessionJob`** (ticker, pola `WalletExpiryJob`, per batch, satu transaksi per
|
||||
session):
|
||||
|
||||
| Kondisi session `STARTED` | Aksi |
|
||||
|---|---|
|
||||
| Game tidak lagi `ACTIVE` | Refund `GAME_DEACTIVATED` segera, tanpa menunggu kedaluwarsa |
|
||||
| Lewat `expires_at` dan `completion_failed_at IS NOT NULL` | Refund `SYSTEM_ERROR` |
|
||||
| Lewat `expires_at`, tanpa jejak gagal | `EXPIRED`, tanpa refund |
|
||||
|
||||
Langkah refund: `LockWallet` → `UPDATE ... SET status='REFUNDED' WHERE status='STARTED'`
|
||||
(0 baris = sudah diselesaikan pihak lain, lewati) → `Credit` COIN `GAME_SPEND_REFUND`
|
||||
dengan `reverses_transaction_id = spend_transaction_id`, lot sesuai §6.3, key
|
||||
`game-refund:{session}` → isi `refund_transaction_id`, `refund_reason` → `audit_logs`
|
||||
dengan `actor_type = SYSTEM`.
|
||||
|
||||
Saat admin mengubah game ke `INACTIVE`, handler tidak perlu menyapu session. Job berikutnya
|
||||
menanganinya, dan complete di §7.2 langkah 4 menangani yang lebih cepat.
|
||||
|
||||
### 7.4 Redemption voucher internal (`STATIC`, `CODE_POOL`)
|
||||
|
||||
```
|
||||
POST /customer/enakgame/vouchers/:id/redeem { pin } Idempotency-Key: <≤50 char>
|
||||
```
|
||||
|
||||
Satu transaksi, sehingga state `RESERVED` tidak diperlukan:
|
||||
|
||||
1. Validasi voucher `ACTIVE`, dalam masa berlaku, org sama.
|
||||
2. `VerifyPin(..., PinActionRedeem)` — aksi PIN baru, konsisten dengan K8 (PIN untuk setiap
|
||||
pemakaian saldo). Batas salah 5 kali / kunci 30 menit yang sudah ada ikut berlaku.
|
||||
3. `LockWallet(customer)`.
|
||||
4. Cari `voucher_redemptions (customer_id, idempotency_key)`. Ada → kembalikan (replay).
|
||||
5. Cek `max_per_customer` dari `COUNT(*)` redemption `COMPLETED` / `PENDING`.
|
||||
6. Ambil stok:
|
||||
- `STATIC`: `UPDATE vouchers SET stock = stock - 1 WHERE id = ? AND stock > 0`.
|
||||
- `CODE_POOL`: `SELECT ... FROM voucher_codes WHERE voucher_id = ? AND status = 'AVAILABLE'
|
||||
ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED`, lalu set `REDEEMED`.
|
||||
- Habis → tolak, tidak ada yang tercatat.
|
||||
7. `Debit` POINT `REWARD_REDEEM` sebesar `point_cost`, key `redeem:{redemption}`.
|
||||
8. Insert `voucher_redemptions` `COMPLETED` dengan snapshot `face_value`, `point_cost`.
|
||||
9. **Atribusi cost** (§7.6) → insert `voucher_redemption_costs`.
|
||||
|
||||
### 7.5 Redemption voucher eksternal (`EXTERNAL`)
|
||||
|
||||
Panggilan ke provider tidak boleh berada di dalam transaksi database, jadi alurnya dua
|
||||
tahap dengan pemulihan:
|
||||
|
||||
1. **Transaksi 1:** langkah 1–5 dan 7 di §7.4, lalu insert redemption `PENDING`. Point sudah
|
||||
terpotong.
|
||||
2. **Panggil provider** dengan `redemption_id` sebagai idempotency key provider.
|
||||
3. **Transaksi 2**, tergantung hasil:
|
||||
- Sukses → simpan `external_code` / `external_ref`, `COMPLETED`, atribusi cost (§7.6).
|
||||
- Gagal pasti (provider menolak) → `Credit` POINT `REWARD_REDEEM_REFUND`, `FAILED`.
|
||||
- Timeout / tidak jelas → biarkan `PENDING`, response "sedang diproses".
|
||||
4. **`VoucherRedemptionRecoveryJob`** mengambil `PENDING` yang sudah lewat N menit, bertanya
|
||||
ke provider (atau mengulang dengan key yang sama), lalu menjalankan transaksi 2. Setelah
|
||||
batas percobaan, refund dan `FAILED`.
|
||||
|
||||
Dengan ini syarat PRD §26 terpenuhi: tidak ada keadaan "Point terpotong, voucher tidak
|
||||
datang" yang tidak dipulihkan.
|
||||
|
||||
### 7.6 Atribusi realized cost
|
||||
|
||||
Dijalankan di dalam transaksi redemption, setelah debit `REWARD_REDEEM`:
|
||||
|
||||
```sql
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT a.lot_id AS spent_lot, a.amount AS points, l.origin_lot_id, l.source_transaction_id
|
||||
FROM wallet_lot_allocations a
|
||||
JOIN wallet_lots l ON l.id = a.lot_id
|
||||
WHERE a.transaction_id = :redeem_tx
|
||||
UNION ALL
|
||||
SELECT c.spent_lot, c.points, p.origin_lot_id, p.source_transaction_id
|
||||
FROM chain c
|
||||
JOIN wallet_lots p ON p.id = c.origin_lot_id
|
||||
)
|
||||
SELECT c.points, t.type AS source_type, gsr.budget_id
|
||||
FROM chain c
|
||||
JOIN wallet_transactions t ON t.id = c.source_transaction_id
|
||||
LEFT JOIN game_session_rewards gsr ON gsr.wallet_transaction_id = t.id
|
||||
WHERE c.origin_lot_id IS NULL; -- lot akar
|
||||
```
|
||||
|
||||
Rantai lot akar Point bisa melewati `EXCHANGE_IN` → lot Coin → `TRANSFER_IN` → ... sampai
|
||||
`GAME_REWARD` (EnakGame) atau `EARN` / `ADJUSTMENT` / `MIGRATION` (bukan EnakGame).
|
||||
`exchangeLots` dan transfer sudah mengisi `origin_lot_id`, jadi query ini bekerja dengan
|
||||
data yang sudah ada.
|
||||
|
||||
Hasil dikelompokkan per `(budget_id, source_type)`, lalu `face_value` dibagi
|
||||
proporsional terhadap Point:
|
||||
|
||||
```
|
||||
cost_i = face_value × points_i / point_cost (dibulatkan; selisih pembulatan
|
||||
diberikan ke bagian terbesar, sehingga
|
||||
Σ cost_i = face_value persis)
|
||||
```
|
||||
|
||||
Contoh: voucher face value Rp10.000, point cost 8.000. Point yang dipakai: 6.000 dari
|
||||
`GAME_REWARD` (budget global Oktober), 2.000 dari `EARN`.
|
||||
|
||||
| budget_id | source_type | points | cost |
|
||||
|---|---|---|---|
|
||||
| global-2026-10 | `GAME_REWARD` | 6.000 | 7.500 |
|
||||
| NULL | `EARN` | 2.000 | 2.500 |
|
||||
|
||||
Hanya Rp7.500 yang mengurangi budget global Oktober. Rp2.500 dari Point belanja tetap
|
||||
tercatat untuk reporting Finance, tetapi tidak dihitung ke budget mana pun (D5).
|
||||
|
||||
---
|
||||
|
||||
## 8. Reward Engine
|
||||
|
||||
Satu interface, satu implementasi per tipe:
|
||||
|
||||
```go
|
||||
type RewardCalculator interface {
|
||||
Validate(rules json.RawMessage) error // saat config dibuat
|
||||
Calculate(rules json.RawMessage, result SessionResult, rng RNG) (base int64, detail map[string]any, err error)
|
||||
}
|
||||
```
|
||||
|
||||
| Tipe | `rules` | Catatan |
|
||||
|---|---|---|
|
||||
| `FIXED` | `{"amount": 5}` | |
|
||||
| `SCORE_BASED` | `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}` | Band tidak boleh tumpang tindih atau berlubang; band terakhir boleh tanpa `max` |
|
||||
| `OUTCOME_BASED` | `{"outcomes": {"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0}}` | Outcome di luar daftar → ditolak Result Validator |
|
||||
| `PROBABILITY` | `{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 889, "amount": 0}]}` | Bobot bilangan bulat, bukan persen desimal, supaya validasi "total = 100%" tidak bergantung float |
|
||||
|
||||
**PROBABILITY memakai `crypto/rand`**, bukan `math/rand` yang di-seed ulang dengan waktu
|
||||
seperti `selectPrizeByWeight` sekarang. Angka acak yang ditarik disimpan di
|
||||
`reward_breakdown` untuk audit. Hasil diundi saat **complete**, bukan saat start, supaya
|
||||
client tidak bisa mengetahui hasil lalu meninggalkan session.
|
||||
|
||||
**Modifier event** (urutan tetap): `base × multiplier` → `+ bonus` → cap `max_reward` config.
|
||||
Aturan tumpuk antar event mengikuti default PRD §16 (multiplier terkontrol, bonus dihitung
|
||||
terpisah, cap selalu berlaku) sampai diputuskan (§19.2 #2).
|
||||
|
||||
**Pembulatan:** semua hasil perkalian **dibulatkan ke bawah** ke Coin utuh, mengikuti K6 di
|
||||
PRD point-coin. Ini usulan untuk menutup Open Decision PRD §43 #1.
|
||||
|
||||
`TIERED` di luar scope: tabel `tiers` belum terhubung ke customer, dan aturan tier dibahas
|
||||
di PRD terpisah (point-coin Q8).
|
||||
|
||||
---
|
||||
|
||||
## 9. Economy Guard & Limits
|
||||
|
||||
Pemeriksaan di §7.2 langkah 8, untuk setiap komponen reward:
|
||||
|
||||
1. Session valid, belum rewarded, user & game cocok — sudah dijamin §7.2 langkah 2–4 dan 9.
|
||||
2. Untuk tiap limit yang berlaku (`USER` harian, `GAME` harian, `EVENT` total & per user,
|
||||
`GLOBAL` harian):
|
||||
|
||||
```sql
|
||||
INSERT INTO game_reward_counters (...) VALUES (..., 0) ON CONFLICT DO NOTHING;
|
||||
UPDATE game_reward_counters SET amount = amount + :x
|
||||
WHERE ... AND amount + :x <= :limit
|
||||
RETURNING amount;
|
||||
```
|
||||
|
||||
0 baris → limit terlampaui → `x` diturunkan ke sisa limit dan diulang. Sisa 0 berarti
|
||||
komponen menjadi 0. Batas yang memotong dicatat di `reward_breakdown`, supaya customer
|
||||
bisa diberi tahu kenapa reward-nya lebih kecil.
|
||||
3. Status budget (§10) `EXHAUSTED` → terapkan `exhaustion_policy` (§19.2 #3).
|
||||
|
||||
Counter `USER` aman karena wallet customer sudah dikunci. Counter `GAME` / `GLOBAL` adalah
|
||||
baris panas yang dikunci singkat oleh setiap complete di org tersebut. Untuk volume awal
|
||||
ini dapat diterima. Lihat risiko di §17.
|
||||
|
||||
---
|
||||
|
||||
## 10. Budget & Budget Controller v1
|
||||
|
||||
**Metrik per budget** (PRD §8), dihitung on-read dari tabel yang ada, tanpa tabel agregat:
|
||||
|
||||
| Metrik | Sumber |
|
||||
|---|---|
|
||||
| Realized cost | `SUM(voucher_redemption_costs.cost)` untuk `budget_id`, `recognized_at` dalam periode (global) / tanpa batas waktu (event) |
|
||||
| Coin issued | `SUM(game_session_rewards.amount)` untuk `budget_id` |
|
||||
| Remaining | `amount − realized` |
|
||||
| Utilization | `realized / amount` |
|
||||
| Forecast | `realized + rata-rata realized harian 7 hari terakhir × sisa hari` |
|
||||
| Exposure | Sisa Coin/Point beredar yang berasal dari budget ini (via lot), sebagai batas atas biaya yang masih bisa datang |
|
||||
| Status | Bandingkan utilization dan forecast dengan `thresholds` → `HEALTHY` / `WARNING` / `CRITICAL` / `EXHAUSTED` |
|
||||
|
||||
**Rekomendasi** (recommendation mode, D9): bila forecast > budget, hitung multiplier yang
|
||||
membuat forecast = budget, lalu batasi dengan step maksimum dan min/max multiplier (PRD
|
||||
§31). Rekomendasi ditampilkan ke admin. Bila disetujui, admin membuat **reward config
|
||||
versi baru** (§5.2). Tidak ada perubahan reward tanpa versi baru dan tanpa audit.
|
||||
|
||||
`GET` metrik cukup cepat untuk dashboard selama index di §5.7 ada. Bila nanti lambat,
|
||||
tambahkan snapshot harian, bukan cache yang di-invalidate.
|
||||
|
||||
---
|
||||
|
||||
## 11. API
|
||||
|
||||
### Customer (`/api/v1/customer/enakgame`, `ValidateCustomerToken`)
|
||||
|
||||
| Method | Path | Catatan |
|
||||
|---|---|---|
|
||||
| `GET` | `/games` | Game `ACTIVE` milik org customer, dengan `entry_cost` dan event aktif |
|
||||
| `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` |
|
||||
| `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header |
|
||||
| `GET` | `/sessions/:id` | Status dan hasil |
|
||||
| `GET` | `/sessions` | Riwayat main |
|
||||
| `GET` | `/vouchers` | Katalog `ACTIVE` + stok tersedia |
|
||||
| `POST` | `/vouchers/:id/redeem` | §7.4 / §7.5. Wajib `Idempotency-Key` + PIN |
|
||||
| `GET` | `/redemptions` | Voucher milik customer, termasuk kode |
|
||||
|
||||
Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama.
|
||||
|
||||
### Admin (`/api/v1/marketing/enakgame`, `RequireAdminOrManager`)
|
||||
|
||||
| Resource | Endpoint |
|
||||
|---|---|
|
||||
| Games | CRUD, `PUT /:id/status` |
|
||||
| Reward configs | `POST /games/:id/reward-configs` (versi baru), `POST /reward-configs/:id/activate`, `GET` daftar versi |
|
||||
| Events | CRUD, `PUT /:id/status` |
|
||||
| Budgets | CRUD, `GET /:id/metrics`, `GET /:id/recommendation` |
|
||||
| Vouchers | CRUD, `POST /:id/codes` (impor CSV), `GET /:id/codes` |
|
||||
| Redemptions | `GET` list, `GET /:id` dengan atribusi cost |
|
||||
| Sessions | `GET` list + filter `flagged` |
|
||||
| Settings | Lewat endpoint loyalty settings yang ada, dengan key baru (§5.10) |
|
||||
|
||||
Perubahan budget, voucher, dan reward config memerlukan `RequireLoyaltyManager`, sama
|
||||
seperti adjustment saldo.
|
||||
|
||||
---
|
||||
|
||||
## 12. Background Jobs
|
||||
|
||||
Semua mengikuti pola goroutine + `time.NewTicker` di `app/app.go`, satu transaksi per item,
|
||||
aman dijalankan di beberapa instance karena setiap item dikunci lewat UPDATE bersyarat.
|
||||
|
||||
| Job | Interval | Tugas |
|
||||
|---|---|---|
|
||||
| `GameSessionJob` | 1 menit | §7.3: refund / expire session `STARTED` |
|
||||
| `VoucherRedemptionRecoveryJob` | 1 menit | §7.5: selesaikan `PENDING` eksternal |
|
||||
| `VoucherCodeExpiryJob` | 1 jam | `AVAILABLE → EXPIRED` untuk kode lewat `expires_at` |
|
||||
| `GameBudgetPeriodJob` | 1 hari | Membuat baris budget global bulan berikutnya dari bulan berjalan, bila belum ada |
|
||||
|
||||
---
|
||||
|
||||
## 13. Audit
|
||||
|
||||
`audit_logs` diisi untuk semua perubahan yang disebut PRD §37: reward config (buat,
|
||||
aktifkan, pensiunkan), event, budget, voucher (termasuk impor kode), status game, refund
|
||||
otomatis, dan penerimaan rekomendasi Budget Controller. Ditulis di transaksi yang sama
|
||||
dengan perubahannya, sehingga tidak ada perubahan tanpa jejak.
|
||||
|
||||
Pengaturan limit sudah ter-audit lewat `loyalty_setting_changes` (§5.10). Adjustment saldo
|
||||
sudah ter-audit lewat ledger `ADJUSTMENT`.
|
||||
|
||||
---
|
||||
|
||||
## 14. Legacy & Migrasi
|
||||
|
||||
| Bagian lama | Nasib |
|
||||
|---|---|
|
||||
| Baris `games` lama | Diarsipkan (`status = 'ARCHIVED'`), **tidak di-`DELETE`**. FK `game_plays.game_id` adalah `ON DELETE CASCADE`: menghapus game ikut menghapus `game_plays`, padahal ledger `GAME_SPEND` lama menunjuk ke sana lewat `reference_id`. Jejak asal-tujuan saldo (K5) akan putus |
|
||||
| `POST /customer/spin`, `GET /customer/games`, `GET /customer/ferris-wheel` | Dihapus. Spin dibangun ulang sebagai game EnakGame dengan config `PROBABILITY` dan reward Coin |
|
||||
| `game_prizes`, `game_plays` | Dibekukan (read-only). Data tetap disimpan untuk riwayat ledger `GAME_PLAY`. Kode processor/handler/route-nya dihapus |
|
||||
| `rewards` | Diganti `vouchers`. Tidak punya org, tidak punya redemption, tidak dipakai flow mana pun, dan tidak punya data produksi (dikonfirmasi), sehingga tidak ada migrasi data |
|
||||
| `campaigns`, `campaign_rules` | Tidak dipakai EnakGame. Campaign EnakGame adalah `game_events` |
|
||||
| Bayar order dengan EnakPoint | **Dimatikan sebelum EnakGame rilis** (PRD §3.2). Belum ada order yang dibayar dengan Point, jadi tidak ada data yang dimigrasi. Yang dilepas: route point payment, `payment_methods` tipe `point` beserta trigger pembuatnya (`000094`), dan setting `loyalty.point.accept_payment`. Dikerjakan sebagai task terpisah |
|
||||
|
||||
---
|
||||
|
||||
## 15. Temuan Sampingan
|
||||
|
||||
Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak ke uang:
|
||||
|
||||
1. **Double charge di `/customer/spin`.** `GAME_SPEND` di `PlayGame` tanpa idempotency key.
|
||||
2. **`/customer/spin` menerima game id mana pun**, tanpa cek tipe dan tanpa cek org
|
||||
(`spin_game_service.go:27`). Customer org A bisa memainkan game org B.
|
||||
|
||||
Nomor 1 dan 2 hilang sendiri saat alur lama dihapus (§14). Bila penghapusannya tidak
|
||||
segera, endpoint lama sebaiknya dimatikan lebih dulu daripada ditambal.
|
||||
3. **RNG hadiah** di-seed ulang dengan `UnixNano` setiap panggilan (`game_play_processor.go:287`).
|
||||
4. **`threshold` dan `fallback_prize_id`** di `game_prizes` tidak pernah dipakai.
|
||||
5. **`GET /customer/ferris-wheel`** mengembalikan `First()` dari game SPIN aktif tanpa urutan,
|
||||
sehingga game yang dikembalikan tidak pasti.
|
||||
6. **`rewards`**: create menolak tipe `BALANCE`, update menerimanya, database tidak punya
|
||||
CHECK.
|
||||
7. **`campaigns`**: `GetActiveCampaigns` mengikat string `"now()"` sebagai parameter tanggal
|
||||
(`campaign_repository.go:114`). Belum diverifikasi apakah Postgres menerimanya.
|
||||
8. **`tiers.name` UNIQUE global**, bukan per org.
|
||||
|
||||
---
|
||||
|
||||
## 16. Di Luar Scope
|
||||
|
||||
- **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya.
|
||||
- **Leaderboard** (PRD §15).
|
||||
- **Reward `TIERED`** (§8).
|
||||
- **Budget Controller automatic mode** (D9).
|
||||
- **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game.
|
||||
|
||||
---
|
||||
|
||||
## 17. Urutan Implementasi
|
||||
|
||||
1. **Matikan bayar dengan EnakPoint** (§14). Independen, bisa paralel.
|
||||
2. **Migrasi skema**, dengan urutan mengikuti foreign key: extend `games`,
|
||||
`game_budgets`, `game_reward_configs`, `game_sessions`, `game_session_rewards`,
|
||||
`audit_logs`; tipe ledger baru + CHECK (§6.2).
|
||||
3. **Session + entry cost** (§7.1) dan **refund + `GameSessionJob`** (§7.3).
|
||||
4. **Reward Engine** `FIXED`, `SCORE_BASED`, `OUTCOME_BASED`, `PROBABILITY` + Result
|
||||
Validator + complete (§7.2, §8).
|
||||
5. **Economy Guard + limits** (§9, §5.10).
|
||||
6. **Voucher internal + redemption + atribusi cost** (§7.4, §7.6).
|
||||
7. **Budget metrics + status** (§10).
|
||||
8. **Events / campaign** dengan budget sendiri (§5.5).
|
||||
9. **Voucher eksternal + recovery job** (§7.5). Butuh provider pertama yang konkret.
|
||||
10. **Rekomendasi Budget Controller + analytics** (§10, PRD §36).
|
||||
11. **Spin dibangun ulang sebagai game EnakGame**, lalu hapus kode alur lama dan arsipkan
|
||||
datanya (§14). Endpoint lama bisa dimatikan lebih awal, kapan pun.
|
||||
|
||||
Langkah 2–4 sudah membuat game bisa dimainkan end-to-end dengan Coin. Langkah 6 membuat
|
||||
Point bisa ditukar. Langkah 7 membuat Finance bisa melihat biaya.
|
||||
|
||||
---
|
||||
|
||||
## 18. Risiko
|
||||
|
||||
| Risiko | Dampak | Mitigasi |
|
||||
|---|---|---|
|
||||
| Satu dari dua tempat aturan ledger (§6.2) terlewat | Mutasi ditolak di produksi, atau lolos tanpa validasi | Test per tipe di `wallet_processor_test.go` + test DB yang benar-benar insert ke `wallet_transactions` |
|
||||
| Farming lewat banyak akun + transfer Coin | Limit harian per user dilewati, karena Coin hasil game boleh ditransfer (diputuskan) | Setting transfer yang ada (`Transfer.DailyLimit`, `MaxPerTransaction`); pantau `GAME_REWARD` yang langsung diikuti `TRANSFER_OUT` di analytics |
|
||||
| Counter `GLOBAL` / `GAME` jadi bottleneck | Complete melambat saat ramai | Diukur dulu. Bila perlu, pecah counter per shard dan jumlahkan saat cek |
|
||||
| Rantai `origin_lot_id` panjang | Query atribusi lambat | Rantai praktis pendek (reward → exchange → transfer). Bila perlu, tambah kolom `root_lot_id` di `wallet_lots` |
|
||||
| Provider eksternal tidak idempotent | Voucher terbit dua kali saat recovery | Syarat integrasi: provider wajib menerima idempotency key; bila tidak, recovery hanya boleh *query*, tidak boleh mengulang |
|
||||
| Client mengirim skor palsu | Coin terbit tanpa main | Result Validator (§7.2) + `flagged`; reward tidak pernah dari client (P1) |
|
||||
| Game lama di-`DELETE` alih-alih diarsipkan | `game_plays` ikut terhapus (CASCADE), ledger `GAME_SPEND` lama kehilangan tujuan | Migrasi §5.1 mengarsipkan; `chk_games_enakgame_identity` mencegah arsip lama tampil sebagai game EnakGame |
|
||||
|
||||
---
|
||||
|
||||
## 19. Keputusan & Pertanyaan Terbuka
|
||||
|
||||
### 19.1 Sudah Diputuskan (2026-10-07)
|
||||
|
||||
| # | Pertanyaan | Keputusan | Tercermin di |
|
||||
|---|---|---|---|
|
||||
| Q1 | Point dari belanja (`EARN`) yang ditukar voucher masuk budget EnakGame? | **Tidak.** Hanya Point yang berasal dari `GAME_REWARD` | D5, §7.6 |
|
||||
| Q2 | Campaign itu apa? | **Campaign = event.** Setiap event punya budget sendiri | D6, §5.5, §5.6 |
|
||||
| Q3 | Spin dan game lama? | **Dibangun ulang** sebagai game EnakGame | §14, §17 |
|
||||
| Q4 | Baris `games` lama? | **Dihapus dari produk** (diarsipkan secara data, lihat §14) | §5.1, §14 |
|
||||
| Q5 | Reward melewati limit? | **Dipotong ke sisa limit** | §9 |
|
||||
| Q6 | Coin hasil game boleh ditransfer? | **Boleh** | §18 |
|
||||
| Q7 | PIN untuk redemption voucher? | **Ya** | §7.4 |
|
||||
| Q8 | Definisi "system error" untuk refund? | **Hanya error yang tercatat di server** (5xx saat complete). Crash di client = ditinggal user | §7.3 |
|
||||
|
||||
### 19.2 Masih Terbuka
|
||||
|
||||
Dari PRD §43:
|
||||
|
||||
1. **Pembulatan reward.** Usulan §8: bulatkan ke bawah, mengikuti K6.
|
||||
2. **Event stacking.** Sementara memakai default PRD §16.
|
||||
3. **Budget exhaustion policy**, untuk budget global dan budget event.
|
||||
4. **Threshold Budget Controller.**
|
||||
5. **Timeout reservasi voucher.** Dengan §7.4, hanya relevan untuk voucher eksternal.
|
||||
|
||||
Tidak ada yang memblokir langkah 1–7 di §17. Nomor 3 dan 4 harus diputuskan sebelum langkah
|
||||
7 (budget status) dirilis.
|
||||
@@ -0,0 +1,503 @@
|
||||
# Task Breakdown: EnakGame
|
||||
|
||||
**Sumber:** [RFC EnakGame](rfc-enakgame.md), [PRD EnakGame](enakgame-prd.md)
|
||||
**Tanggal:** 2026-10-07
|
||||
|
||||
Setiap task menyebut bagian RFC yang dikerjakan, lapisan kode yang disentuh, task yang
|
||||
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
|
||||
**L** 4–5 hari.
|
||||
|
||||
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000101`),
|
||||
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
|
||||
`router`, dan wiring di `internal/app/app.go`. Repository **selalu** memakai
|
||||
`DBFromContext`. Semua mutasi saldo **hanya** lewat `WalletProcessor`.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan
|
||||
|
||||
| Fase | Task | Terblokir oleh |
|
||||
|---|---|---|
|
||||
| 0. Matikan bayar dengan EnakPoint | EG-001 – EG-002 | – |
|
||||
| 1. Fondasi | EG-101 – EG-106 | – |
|
||||
| 2. Admin game & session | EG-201 – EG-206 | – |
|
||||
| 3. Reward Engine & complete | EG-301 – EG-303 | Pembulatan (RFC §19.2 #1) sebelum **rilis** |
|
||||
| 4. Economy Guard | EG-401 – EG-402 | – |
|
||||
| 5. Voucher & redemption | EG-501 – EG-507 | – |
|
||||
| 6. Budget metrics | EG-601 – EG-602 | Exhaustion policy & threshold (#3, #4) sebelum **rilis** |
|
||||
| 7. Event / campaign | EG-701 – EG-704 | Event stacking (#2) sebelum **rilis** |
|
||||
| 8. Voucher eksternal | EG-801 – EG-803 | Provider pertama yang konkret sebelum **dikerjakan** |
|
||||
| 9. Budget Controller & analytics | EG-901 – EG-903 | Threshold (#4) sebelum **dikerjakan** |
|
||||
| 10. Spin & bersih-bersih | EG-1001 – EG-1003 | – |
|
||||
|
||||
Fase 0 independen dan bisa jalan paralel dengan semua fase lain, tetapi **harus selesai
|
||||
sebelum EnakGame rilis** (PRD §3.2).
|
||||
|
||||
Fase 1–3 membuat game bisa dimainkan end-to-end dengan Coin. Fase 5 membuat Point bisa
|
||||
ditukar. Fase 6 membuat Finance bisa melihat biaya.
|
||||
|
||||
```
|
||||
Fase 0 EG-001 ── EG-002
|
||||
|
||||
Fondasi EG-101 ── EG-102 ── EG-105
|
||||
EG-103, EG-104, EG-106, EG-301 (independen)
|
||||
|
||||
Game EG-104 + EG-105 ─────────────────── EG-201, EG-203
|
||||
EG-104 + EG-105 + EG-301 ────────── EG-202
|
||||
EG-103 + EG-201 + EG-202 + EG-203 ─ EG-204 ── EG-205
|
||||
EG-204 + EG-205 + EG-301 + EG-302 ─ EG-303
|
||||
EG-106 ── EG-401
|
||||
EG-303 + EG-401 ─────────────────── EG-402
|
||||
|
||||
Voucher EG-501 ── EG-502
|
||||
EG-103 + EG-502 + EG-503 ────────── EG-504
|
||||
EG-303 + EG-504 ─────────────────── EG-505 ── EG-601 ─┬─ EG-602
|
||||
└─ EG-901 ── EG-902
|
||||
EG-504 ── EG-801
|
||||
EG-505 + EG-801 ─────────────────── EG-802 ── EG-803
|
||||
|
||||
Event EG-102 ── EG-701
|
||||
EG-203 + EG-701 ─────────────────── EG-702
|
||||
EG-402 + EG-702 ─────────────────── EG-703
|
||||
|
||||
Akhir EG-402 ── EG-1001 ── EG-1002 ── EG-1003
|
||||
```
|
||||
|
||||
Task kecil yang tidak digambar: EG-206, EG-302 (setelah EG-105), EG-506 (EG-504), EG-507
|
||||
(EG-501), EG-704 (EG-206 + EG-702), EG-903 (EG-303 + EG-505).
|
||||
|
||||
---
|
||||
|
||||
## Fase 0 — Matikan Bayar dengan EnakPoint
|
||||
|
||||
PRD §3.2: Point hanya bisa ditukar ke voucher. Belum ada order yang dibayar dengan Point,
|
||||
jadi tidak ada data yang dimigrasi.
|
||||
|
||||
### EG-001 · Tutup semua jalur pembayaran EnakPoint · M
|
||||
- **RFC:** §14
|
||||
- **Kerjakan:**
|
||||
- Sudah dikonfirmasi belum ada pembayaran dengan EnakPoint di production (2026-10-07).
|
||||
- Migrasi: drop trigger yang membuat payment method `point` untuk organisasi baru
|
||||
(`000094`), lalu nonaktifkan / hapus baris `payment_methods` tipe `point`.
|
||||
- Tolak pembayaran bertipe `point` di jalur POS (`order_handler`, `order_processor`,
|
||||
`payment_method_processor`) dan customer app (`customer_order_payment_service`).
|
||||
- Hapus route `GET /orders/:id/point-payment/preview` dan
|
||||
`POST /customer/wallet/payment-code`.
|
||||
- Hapus `PAYMENT` dan `PAYMENT_REFUND` dari `walletTypeRules`, supaya engine menolak
|
||||
keduanya. CHECK database dibiarkan.
|
||||
- Hapus setting `loyalty.point.accept_payment` dari descriptor
|
||||
`LoyaltySettingsProcessor` dan dari response outlet customer.
|
||||
- **Selesai jika:** pembayaran order dengan method `point` ditolak di POS dan customer app,
|
||||
outlet baru tidak lagi mendapat payment method EnakPoint, dan test yang ada diperbarui.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-002 · Hapus kode point payment · S
|
||||
- **Kerjakan:** hapus `PointPaymentProcessor`, `point_payment_refund.go`,
|
||||
`PointPaymentRepository`, `PointPaymentService`, `PointPaymentHandler`, wiring di
|
||||
`app.go` / `router.go`, dan test-nya (`point_payment_db_test.go`,
|
||||
`point_payment_method_db_test.go`). Pindahkan dulu helper yang ternyata dipakai di
|
||||
tempat lain (mis. pola pembuatan lot refund, yang dipakai ulang di EG-205).
|
||||
- **Selesai jika:** `go build ./...` dan seluruh test lulus, dan
|
||||
`grep -rn "PointPayment" internal/` kosong.
|
||||
- **Bergantung pada:** EG-001
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Fondasi
|
||||
|
||||
### EG-101 · Migrasi extend `games` + arsipkan game lama · S
|
||||
- **RFC:** §5.1, §14
|
||||
- **Kerjakan:** migrasi up & down persis seperti §5.1. Semua baris lama menjadi
|
||||
`ARCHIVED`. **Tidak ada `DELETE`** (FK `game_plays` CASCADE).
|
||||
- **Selesai jika:**
|
||||
- Up/down bersih di database kosong dan di salinan staging.
|
||||
- Jumlah baris `game_plays` sebelum = sesudah.
|
||||
- Ditolak database: game `ACTIVE` tanpa `organization_id` atau `slug`;
|
||||
`entry_cost = 0`; dua game dengan slug sama di satu org.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-102 · Migrasi budget, reward config, session · M
|
||||
- **RFC:** §5.2, §5.3, §5.4, §5.6
|
||||
- **Kerjakan:** `game_budgets`, `game_reward_configs`, `game_sessions`,
|
||||
`game_session_rewards`, dalam urutan foreign key.
|
||||
- **Selesai jika:** up/down bersih. Ditolak database: dua config `ACTIVE` untuk satu game;
|
||||
session `REFUNDED` tanpa `refund_transaction_id`; dua budget `GLOBAL` dengan
|
||||
`period_start` sama di satu org; `game_session_rewards` dengan
|
||||
`wallet_transaction_id` yang sama dua kali.
|
||||
- **Bergantung pada:** EG-101
|
||||
|
||||
### EG-103 · Tipe ledger baru · M
|
||||
- **RFC:** §6
|
||||
- **Kerjakan:**
|
||||
- Konstanta `GAME_SPEND_REFUND`, `GAME_REWARD`, `REWARD_REDEEM_REFUND`, ref
|
||||
`GAME_SESSION` di `constants/wallet.go`.
|
||||
- `walletTypeRules`: tambah tiga tipe, dan izinkan ref `GAME_SESSION` pada
|
||||
`GAME_SPEND`.
|
||||
- Migrasi: drop + create ulang tiga CHECK di `wallet_transactions` (§6.2).
|
||||
- Periksa invariant di `wallet_reconciliation_repository.go` terhadap tipe baru.
|
||||
- **Selesai jika:** test validasi di `wallet_processor_test.go` per tipe baru (currency
|
||||
salah, ref salah, reversal tanpa `reverses_transaction_id` → ditolak), dan test DB yang
|
||||
benar-benar insert ke `wallet_transactions` untuk setiap tipe (lolos engine **dan**
|
||||
CHECK).
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-104 · `audit_logs` + helper · S
|
||||
- **RFC:** §5.9, §13
|
||||
- **Kerjakan:** migrasi, entity, dan `AuditLogger.Record(ctx, entry)` yang menulis lewat
|
||||
`DBFromContext`, sehingga audit ikut commit/rollback bersama perubahannya.
|
||||
- **Selesai jika:** perubahan yang di-rollback tidak meninggalkan baris audit.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-105 · Entities & repository EnakGame · M
|
||||
- **RFC:** §5.1–§5.6
|
||||
- **Kerjakan:** entities dan repository untuk kolom baru `games`, `game_reward_configs`,
|
||||
`game_sessions`, `game_session_rewards`, `game_budgets`. Transisi status session
|
||||
sebagai `UPDATE ... WHERE status = 'STARTED'` yang mengembalikan jumlah baris
|
||||
ter-update (D4). Semua query game/budget memfilter `organization_id`.
|
||||
- **Selesai jika:** test repository: dua transisi bersamaan pada session yang sama, hanya
|
||||
satu yang mendapat 1 baris.
|
||||
- **Bergantung pada:** EG-101, EG-102
|
||||
|
||||
### EG-106 · Setting limit EnakGame · S
|
||||
- **RFC:** §5.10
|
||||
- **Kerjakan:** key `enakgame.limit.user_daily` dan `enakgame.limit.global_daily` sebagai
|
||||
field descriptor di `LoyaltySettingsProcessor` (default 0 = tanpa batas, min 0).
|
||||
- **Selesai jika:** setting terbaca dengan default, bisa diubah lewat endpoint loyalty
|
||||
settings yang ada, dan perubahan tercatat di `loyalty_setting_changes`.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Admin Game & Session
|
||||
|
||||
### EG-201 · Admin game CRUD · M
|
||||
- **RFC:** §11 (admin)
|
||||
- **Kerjakan:** `/marketing/enakgame/games` CRUD + `PUT /:id/status`, org dari user admin.
|
||||
Validasi `slug`, `entry_cost ≥ 1`, `result_rules`. Game `ARCHIVED` tidak bisa diubah.
|
||||
Perubahan status masuk `audit_logs`.
|
||||
- **Selesai jika:** admin org A tidak bisa melihat atau mengubah game org B; game lama
|
||||
(arsip) tidak muncul.
|
||||
- **Bergantung pada:** EG-104, EG-105
|
||||
|
||||
### EG-202 · Admin reward config versioning · M
|
||||
- **RFC:** §5.2, D7
|
||||
- **Kerjakan:** `POST /games/:id/reward-configs` (versi baru, `rules` divalidasi
|
||||
`RewardCalculator.Validate`), `POST /reward-configs/:id/activate` (dalam satu transaksi:
|
||||
config lama → `RETIRED`, yang baru → `ACTIVE`), `GET` daftar versi. Tidak ada endpoint
|
||||
update atau delete. Wajib `RequireLoyaltyManager`. Semua aksi masuk `audit_logs`.
|
||||
- **Selesai jika:** config yang sudah dipakai session tidak bisa diubah lewat jalur apa
|
||||
pun; aktivasi bersamaan dua versi berakhir dengan tepat satu `ACTIVE`.
|
||||
- **Bergantung pada:** EG-104, EG-105, EG-301
|
||||
|
||||
### EG-203 · Admin budget + `GameBudgetPeriodJob` · M
|
||||
- **RFC:** §5.6, §12
|
||||
- **Kerjakan:** CRUD `/marketing/enakgame/budgets` (scope `GLOBAL` / `EVENT`), wajib
|
||||
`RequireLoyaltyManager`, audit. Job harian yang membuat budget global bulan berikutnya
|
||||
dari bulan berjalan bila belum ada.
|
||||
- **Selesai jika:** job aman dijalankan berulang (unique index mencegah duplikat), dan
|
||||
perubahan `amount` tercatat sebelum/sesudah di audit.
|
||||
- **Bergantung pada:** EG-104, EG-105
|
||||
|
||||
### EG-204 · Start Game · M
|
||||
- **RFC:** §7.1
|
||||
- **Kerjakan:** `POST /customer/enakgame/sessions` dengan `Idempotency-Key` wajib (≤ 50
|
||||
karakter, pola `customer_wallet_handler.go`). Langkah persis §7.1.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Key yang sama dua kali → satu debit, session yang sama dikembalikan.
|
||||
- Coin kurang → ditolak, tidak ada ledger maupun session.
|
||||
- Game org lain, game tidak `ACTIVE`, tanpa config aktif, tanpa budget global → ditolak.
|
||||
- `entry_cost` dan `reward_config_id` tersimpan sebagai snapshot: mengubah game setelah
|
||||
start tidak mengubah session.
|
||||
- **Bergantung pada:** EG-103, EG-105, EG-201, EG-202, EG-203
|
||||
|
||||
### EG-205 · Refund otomatis + `GameSessionJob` · M
|
||||
- **RFC:** §7.3, §6.3
|
||||
- **Kerjakan:**
|
||||
- `RefundSession(ctx, sessionID, reason)`: lock wallet → transisi bersyarat ke
|
||||
`REFUNDED` → `Credit` `GAME_SPEND_REFUND` dengan lot per alokasi asal
|
||||
(`RefundExpiry`, `origin_lot_id`) → audit `SYSTEM`.
|
||||
- Job tiap 1 menit, per batch, satu transaksi per session, sesuai tabel §7.3.
|
||||
- Wiring start/stop di `app.go` seperti `WalletExpiryJob`.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Game dinonaktifkan → session `STARTED` di-refund tanpa menunggu kedaluwarsa.
|
||||
- Expired dengan `completion_failed_at` → refund. Expired tanpa → `EXPIRED`, saldo tetap.
|
||||
- Lot refund punya `origin_lot_id` ke lot asal dan `expires_at` minimal 7 hari.
|
||||
- Complete dan refund bersamaan pada session yang sama → tepat satu yang berhasil.
|
||||
- Job dijalankan dua kali → tidak ada refund ganda.
|
||||
- **Bergantung pada:** EG-204
|
||||
|
||||
### EG-206 · Customer: daftar game & riwayat session · S
|
||||
- **RFC:** §11 (customer)
|
||||
- **Kerjakan:** `GET /customer/enakgame/games`, `GET /sessions`, `GET /sessions/:id`. Hanya
|
||||
game `ACTIVE` milik org customer. Rincian internal (`reward_breakdown` lengkap, angka
|
||||
RNG) tidak ditampilkan.
|
||||
- **Bergantung pada:** EG-105
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Reward Engine & Complete
|
||||
|
||||
### EG-301 · `RewardCalculator` + empat tipe · M
|
||||
- **RFC:** §8
|
||||
- **Kerjakan:** interface `Validate` / `Calculate`, implementasi `FIXED`, `SCORE_BASED`,
|
||||
`OUTCOME_BASED`, `PROBABILITY`. RNG lewat interface yang diisi `crypto/rand` di
|
||||
production dan RNG tetap di test. Pembulatan ke bawah.
|
||||
- **Selesai jika:** unit test: contoh tabel PRD §12 untuk setiap tipe; band skor
|
||||
tumpang tindih / berlubang ditolak `Validate`; bobot `PROBABILITY` nol atau negatif
|
||||
ditolak; distribusi `PROBABILITY` pada 100.000 undian berada dalam toleransi bobotnya.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-302 · Result Validator · S
|
||||
- **RFC:** §7.2 langkah 5
|
||||
- **Kerjakan:** validasi `result` terhadap `games.result_rules`: durasi minimal, skor
|
||||
maksimum, skor per detik, outcome yang dikenal. Mengembalikan alasan, bukan error.
|
||||
- **Selesai jika:** unit test per aturan, termasuk game tanpa `result_rules` (semua lolos).
|
||||
- **Bergantung pada:** EG-105
|
||||
|
||||
### EG-303 · Complete Game · L
|
||||
- **RFC:** §7.2, §7.3 (bagian `completion_failed_at`)
|
||||
- **Kerjakan:**
|
||||
- `POST /customer/enakgame/sessions/:id/complete`, langkah §7.2 dengan base reward dari
|
||||
budget global saja. Event menyusul di EG-703, guard di EG-402.
|
||||
- Satu baris `GAME_REWARD` + `game_session_rewards` per budget, key
|
||||
`game-reward:{session}:{budget}`. Lot dengan `ComputeExpiry(CoinExpiry)`.
|
||||
- Game tidak `ACTIVE` saat complete → `RefundSession(GAME_DEACTIVATED)`.
|
||||
- Error non-bisnis → tulis `completion_failed_at` di transaksi terpisah
|
||||
(`DetachTransaction`), lalu kembalikan 5xx.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Complete dua kali → satu reward, response kedua sama dengan yang pertama.
|
||||
- Hasil tidak valid → reward 0, `flagged`, session `COMPLETED`, tanpa refund.
|
||||
- Session milik customer lain → 404. Session expired → ditolak.
|
||||
- Error yang disuntikkan setelah kredit → rollback total, `completion_failed_at` terisi.
|
||||
- Request yang membawa field `reward` diabaikan (P1).
|
||||
- **Bergantung pada:** EG-204, EG-205, EG-301, EG-302
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Economy Guard
|
||||
|
||||
### EG-401 · Counter reward + increment bersyarat · M
|
||||
- **RFC:** §5.8, §9
|
||||
- **Kerjakan:** migrasi `game_reward_counters`; repository `Consume(scope, id, day, x,
|
||||
limit)` yang mengembalikan jumlah yang **benar-benar** diterima (dipotong ke sisa
|
||||
limit, 0 bila habis). Hari dihitung di Asia/Jakarta.
|
||||
- **Selesai jika:** test DB: 20 goroutine menambah counter yang sama dengan limit 100 →
|
||||
total tepat 100, tidak pernah lewat.
|
||||
- **Bergantung pada:** EG-106
|
||||
|
||||
### EG-402 · Guard di complete · S
|
||||
- **RFC:** §9
|
||||
- **Kerjakan:** panggil `Consume` untuk `USER` dan `GLOBAL` harian (dari setting) dan
|
||||
`GAME` harian (dari `result_rules`) sebelum kredit. Batas yang memotong dicatat di
|
||||
`reward_breakdown` dan dikembalikan di response.
|
||||
- **Selesai jika:** reward 30 dengan sisa limit user 10 → kredit 10, breakdown menyebut
|
||||
limit user. Sisa 0 → session `COMPLETED` dengan reward 0.
|
||||
- **Bergantung pada:** EG-303, EG-401
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — Voucher & Redemption
|
||||
|
||||
### EG-501 · Migrasi voucher · M
|
||||
- **RFC:** §5.7
|
||||
- **Kerjakan:** `vouchers`, `voucher_codes`, `voucher_redemptions`,
|
||||
`voucher_redemption_costs`.
|
||||
- **Selesai jika:** up/down bersih. Ditolak database: voucher `STATIC` tanpa `stock`;
|
||||
`EXTERNAL` tanpa `provider`; kode `REDEEMED` tanpa `redemption_id`; dua redemption dengan
|
||||
`(customer_id, idempotency_key)` yang sama.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-502 · Admin voucher + impor kode · M
|
||||
- **RFC:** §11 (admin)
|
||||
- **Kerjakan:** CRUD `/marketing/enakgame/vouchers`, `POST /:id/codes` (CSV, kode duplikat
|
||||
dilewati dan dilaporkan), `GET /:id/codes` dengan jumlah per status. `RequireLoyaltyManager`
|
||||
dan audit.
|
||||
- **Selesai jika:** impor CSV yang sama dua kali tidak menggandakan kode.
|
||||
- **Bergantung pada:** EG-104, EG-501
|
||||
|
||||
### EG-503 · Aksi PIN `REDEEM` · S
|
||||
- **RFC:** §7.4 langkah 2
|
||||
- **Kerjakan:** `PinActionRedeem` di `customer_pin_processor.go`, ikut aturan kunci yang
|
||||
ada (5 salah, 30 menit).
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### EG-504 · Redemption internal · L
|
||||
- **RFC:** §7.4
|
||||
- **Kerjakan:** `POST /customer/enakgame/vouchers/:id/redeem` untuk `STATIC` dan
|
||||
`CODE_POOL`, satu transaksi, langkah persis §7.4. Debit `REWARD_REDEEM` dengan key
|
||||
`redeem:{redemption}`.
|
||||
- **Selesai jika:** test untuk:
|
||||
- Stok tersisa 1, dua customer menukar bersamaan → tepat satu berhasil.
|
||||
- `CODE_POOL`: dua redemption bersamaan mendapat kode berbeda (`SKIP LOCKED`).
|
||||
- Key yang sama dua kali → satu debit, satu kode.
|
||||
- Point kurang, PIN salah, `max_per_customer` tercapai, voucher di luar masa berlaku →
|
||||
ditolak tanpa apa pun tercatat.
|
||||
- **Bergantung pada:** EG-103, EG-501, EG-502, EG-503
|
||||
|
||||
### EG-505 · Atribusi realized cost · M
|
||||
- **RFC:** §7.6, D5
|
||||
- **Kerjakan:** query rekursif §7.6 di dalam transaksi redemption, pembagian
|
||||
`face_value` proporsional dengan sisa pembulatan ke bagian terbesar, insert
|
||||
`voucher_redemption_costs`.
|
||||
- **Selesai jika:** test DB untuk rantai: `GAME_REWARD` → exchange → redeem;
|
||||
`GAME_REWARD` → transfer → exchange → redeem; campuran `GAME_REWARD` + `EARN` (contoh
|
||||
§7.6: Rp7.500 ke budget, Rp2.500 tanpa budget); `Σ cost = face_value` untuk kombinasi
|
||||
angka yang tidak habis dibagi.
|
||||
- **Bergantung pada:** EG-303, EG-504
|
||||
|
||||
### EG-506 · Customer: katalog & voucher saya · S
|
||||
- **RFC:** §11 (customer)
|
||||
- **Kerjakan:** `GET /customer/enakgame/vouchers` (stok tersedia, tanpa membocorkan
|
||||
jumlah kode per status) dan `GET /redemptions` (dengan kode voucher).
|
||||
- **Bergantung pada:** EG-504
|
||||
|
||||
### EG-507 · `VoucherCodeExpiryJob` · S
|
||||
- **RFC:** §12
|
||||
- **Kerjakan:** `AVAILABLE → EXPIRED` untuk kode lewat `expires_at`, per batch.
|
||||
- **Bergantung pada:** EG-501
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Budget Metrics
|
||||
|
||||
### EG-601 · Perhitungan metrik & status budget · M
|
||||
- **RFC:** §10
|
||||
- **Kerjakan:** realized cost, Coin issued, remaining, utilization, forecast, exposure,
|
||||
dan status per budget. Global dibatasi periode; event tanpa batas waktu. Threshold dari
|
||||
`game_budgets.thresholds`.
|
||||
- **Selesai jika:** test dengan data tetap menghasilkan angka contoh PRD §30 (budget
|
||||
Rp100M, realized Rp60M, sisa 10 hari); Point dari `EARN` tidak ikut dihitung.
|
||||
- **Bergantung pada:** EG-505
|
||||
|
||||
### EG-602 · Endpoint metrik budget · S
|
||||
- **Kerjakan:** `GET /marketing/enakgame/budgets/:id/metrics`.
|
||||
- **Bergantung pada:** EG-601
|
||||
|
||||
---
|
||||
|
||||
## Fase 7 — Event / Campaign
|
||||
|
||||
### EG-701 · Migrasi event · S
|
||||
- **RFC:** §5.5
|
||||
- **Kerjakan:** `game_events`, `game_event_games`.
|
||||
- **Selesai jika:** up/down bersih; event tanpa `budget_id` atau dengan
|
||||
`end_at ≤ start_at` ditolak.
|
||||
- **Bergantung pada:** EG-102
|
||||
|
||||
### EG-702 · Admin event · M
|
||||
- **RFC:** §5.5, §11
|
||||
- **Kerjakan:** CRUD + status. Budget yang dipasang harus milik org yang sama dan ber-scope
|
||||
`EVENT`. Audit.
|
||||
- **Bergantung pada:** EG-203, EG-701
|
||||
|
||||
### EG-703 · Modifier event di complete · M
|
||||
- **RFC:** §7.2 langkah 7, §8 (modifier), D6
|
||||
- **Kerjakan:** cari event aktif untuk game, hitung tambahan dari multiplier dan bonus,
|
||||
pisahkan menjadi komponen per budget, terapkan `reward_limit` dan `user_daily_limit`
|
||||
event lewat counter `EVENT`, cap `max_reward`. Aturan tumpuk mengikuti default PRD §16.
|
||||
- **Selesai jika:** contoh PRD §7 (10 Coin + Ramadan 2x) menghasilkan dua baris
|
||||
`GAME_REWARD`: 10 ke budget global, 10 ke budget event; event di luar jam aktif tidak
|
||||
berpengaruh.
|
||||
- **Bergantung pada:** EG-402, EG-702
|
||||
|
||||
### EG-704 · Event di daftar game customer · S
|
||||
- **Kerjakan:** `GET /customer/enakgame/games` menyertakan event aktif per game (nama,
|
||||
banner, multiplier/bonus, berakhir kapan).
|
||||
- **Bergantung pada:** EG-206, EG-702
|
||||
|
||||
---
|
||||
|
||||
## Fase 8 — Voucher Eksternal
|
||||
|
||||
Terblokir sampai provider pertama ditentukan: API-nya menentukan bentuk adapter dan
|
||||
apakah provider menerima idempotency key (RFC §18).
|
||||
|
||||
### EG-801 · Interface provider + adapter pertama · M
|
||||
- **Kerjakan:** `VoucherProvider` (`Issue(ctx, redemptionID, voucher)`,
|
||||
`Lookup(ctx, redemptionID)`), adapter provider pertama, dan klasifikasi hasil: sukses,
|
||||
gagal pasti, tidak jelas.
|
||||
- **Bergantung pada:** EG-504
|
||||
|
||||
### EG-802 · Redemption dua tahap · M
|
||||
- **RFC:** §7.5
|
||||
- **Kerjakan:** jalur `EXTERNAL` di endpoint redeem: transaksi 1 (`PENDING` + debit),
|
||||
panggil provider di luar transaksi, transaksi 2 (`COMPLETED` + atribusi, atau `FAILED` +
|
||||
`REWARD_REDEEM_REFUND`).
|
||||
- **Selesai jika:** test dengan provider palsu untuk ketiga hasil; timeout meninggalkan
|
||||
`PENDING` dengan Point terpotong dan response "sedang diproses".
|
||||
- **Bergantung pada:** EG-505, EG-801
|
||||
|
||||
### EG-803 · `VoucherRedemptionRecoveryJob` · M
|
||||
- **RFC:** §7.5 langkah 4, §12
|
||||
- **Kerjakan:** ambil `PENDING` lewat N menit, `Lookup` ke provider, selesaikan transaksi 2.
|
||||
Setelah batas percobaan → refund + `FAILED`.
|
||||
- **Selesai jika:** tidak ada redemption yang tertinggal `PENDING` melewati batas
|
||||
percobaan; job berjalan dua kali tidak merefund dua kali.
|
||||
- **Bergantung pada:** EG-802
|
||||
|
||||
---
|
||||
|
||||
## Fase 9 — Budget Controller & Analytics
|
||||
|
||||
### EG-901 · Rekomendasi multiplier · M
|
||||
- **RFC:** §10 (rekomendasi), PRD §30–§31
|
||||
- **Kerjakan:** hitung multiplier yang membuat forecast = budget, dibatasi step maksimum dan
|
||||
min/max multiplier. `GET /budgets/:id/recommendation`.
|
||||
- **Bergantung pada:** EG-601
|
||||
|
||||
### EG-902 · Terima rekomendasi · S
|
||||
- **Kerjakan:** admin menerima rekomendasi → reward config versi baru dibuat dari config
|
||||
aktif dengan angka yang disesuaikan, tercatat di audit dengan `source = budget_controller`.
|
||||
- **Bergantung pada:** EG-202, EG-901
|
||||
|
||||
### EG-903 · Analytics · M
|
||||
- **RFC:** PRD §36
|
||||
- **Kerjakan:** endpoint dashboard game (plays, completed, rata-rata skor & reward, Coin
|
||||
issued, entry cost dibayar, Coin di-refund) dan economy (Coin generated / spent /
|
||||
expired / outstanding, Point redeemed), per org dan rentang tanggal.
|
||||
- **Bergantung pada:** EG-303, EG-505
|
||||
|
||||
---
|
||||
|
||||
## Fase 10 — Spin & Bersih-bersih
|
||||
|
||||
### EG-1001 · Spin sebagai game EnakGame · S
|
||||
- **RFC:** §14
|
||||
- **Kerjakan:** seeder / langkah admin untuk game spin baru dengan config `PROBABILITY` dan
|
||||
reward Coin. Client Phaser di luar repo ini.
|
||||
- **Selesai jika:** spin bisa dimainkan lewat `/customer/enakgame/sessions` end-to-end.
|
||||
- **Bergantung pada:** EG-402
|
||||
|
||||
### EG-1002 · Hapus alur game lama · M
|
||||
- **RFC:** §14, §15
|
||||
- **Kerjakan:** hapus route `POST /customer/spin`, `GET /customer/games`,
|
||||
`GET /customer/ferris-wheel`, admin `/marketing/games`, `/marketing/game-prizes`, dan
|
||||
`/marketing/rewards`, beserta handler, service, processor, dan test-nya
|
||||
(`GamePlayProcessor`, `SpinGameService`, dan seterusnya). **Tabel `games`,
|
||||
`game_prizes`, `game_plays` tetap ada** untuk riwayat ledger.
|
||||
- **Selesai jika:** build dan test lulus; aplikasi customer sudah tidak memanggil endpoint
|
||||
lama (konfirmasi tim aplikasi).
|
||||
- **Bergantung pada:** EG-1001. Tabel `rewards` tidak punya data produksi (dikonfirmasi
|
||||
2026-10-07), jadi tidak ada yang dipindah ke `vouchers`.
|
||||
- **Catatan:** endpoint customer lama boleh dimatikan **lebih awal**, kapan pun, untuk
|
||||
menutup temuan RFC §15 nomor 1 dan 2.
|
||||
|
||||
### EG-1003 · Bersihkan kolom lama `games` · S
|
||||
- **Kerjakan:** drop `games.is_active` dan berhenti membaca `metadata.coin_cost`.
|
||||
- **Bergantung pada:** EG-1002
|
||||
|
||||
---
|
||||
|
||||
## Yang Bisa Dimulai Sekarang
|
||||
|
||||
Bisa dikerjakan paralel tanpa menunggu apa pun:
|
||||
|
||||
- **EG-001** (matikan bayar dengan EnakPoint) → **EG-002**
|
||||
- **EG-101** → **EG-102** → **EG-105** (skema & repository)
|
||||
- **EG-103** (tipe ledger)
|
||||
- **EG-104** (audit), **EG-106** (setting limit)
|
||||
- **EG-301** (Reward Engine, kode murni tanpa database)
|
||||
- **EG-501**, **EG-503** (fondasi voucher)
|
||||
|
||||
Jalur kritis: **EG-105 → EG-204 → EG-303 → EG-402**. Hampir semua fase setelahnya
|
||||
menunggu complete game selesai.
|
||||
@@ -0,0 +1,445 @@
|
||||
# Task Breakdown: EnakPoint & EnakCoin
|
||||
|
||||
**Sumber:** [PRD EnakPoint & EnakCoin](prd-point-coin.md)
|
||||
**Tanggal:** 2026-09-29
|
||||
|
||||
> **Catatan 2026-10-07:** fase 3 (Pembayaran EnakPoint) dihapus sesuai
|
||||
> [`enakgame-prd.md`](./enakgame-prd.md) §3.2: EnakPoint hanya bisa ditukar ke voucher,
|
||||
> bukan alat bayar. Payment method EnakPoint, kode bayar, bayar di kasir & app, refund
|
||||
> EnakPoint, dan EnakPoint di laporan (PC-303 – PC-308) sudah dihapus dari backend
|
||||
> (migrasi `000102`). PIN customer (PC-301) dan pengaturan loyalitas organisasi (PC-302)
|
||||
> tetap dipakai. Sisa dokumen ini dibiarkan sebagai riwayat.
|
||||
|
||||
Setiap task menyebut bagian PRD yang dikerjakan, lapisan kode yang disentuh, task yang
|
||||
harus selesai lebih dulu, dan kriteria selesai. Ukuran: **S** ≤ 1 hari, **M** 2–3 hari,
|
||||
**L** 4–5 hari.
|
||||
|
||||
Konvensi kode mengikuti yang sudah ada: `migrations/` (lanjut dari `000089`),
|
||||
`entities` → `repository` → `processor` → `service` → `handler` / `validator` →
|
||||
`router`, dan wiring di `internal/app/app.go`.
|
||||
|
||||
---
|
||||
|
||||
## Ringkasan
|
||||
|
||||
| Fase | Task | Terblokir oleh catatan PRD |
|
||||
|---|---|---|
|
||||
| 1. Fondasi | PC-101 – PC-109 | – |
|
||||
| 2. Earning | PC-201 – PC-205 | – |
|
||||
| 3. Pembayaran EnakPoint | PC-301 – PC-308 | N2 (keuangan), N3 (legal) sebelum **rilis** |
|
||||
| 4. Pergerakan saldo | PC-401 – PC-404 | N3 (legal) sebelum **rilis** |
|
||||
| 5. Kedaluwarsa | PC-501 – PC-504 | N4 (model kedaluwarsa) sebelum **dikerjakan** |
|
||||
| 6. Bersih-bersih | PC-601 – PC-602 | – |
|
||||
|
||||
Catatan N2 dan N3 hanya memblokir **rilis** fase 3–4, bukan pengerjaannya. N4
|
||||
memblokir pengerjaan fase 5, karena rumus kedaluwarsanya belum ditentukan.
|
||||
|
||||
```
|
||||
PC-101 ─┬─ PC-103 ── PC-104 ─┬─ PC-105 ── PC-106
|
||||
│ ├─ PC-107
|
||||
│ ├─ PC-108
|
||||
│ ├─ PC-203 ── PC-204 ── PC-205
|
||||
│ ├─ PC-305 ── PC-306 / PC-307
|
||||
│ ├─ PC-401 / PC-402 / PC-403 / PC-404
|
||||
│ └─ PC-502 ── PC-503
|
||||
PC-102 ── PC-109 ─┬─ PC-201 ── PC-202 ── PC-203
|
||||
└─ PC-302
|
||||
PC-301 ── PC-304 ── PC-305
|
||||
PC-303 ── PC-305
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fase 1 — Fondasi
|
||||
|
||||
Semua fase lain bergantung pada fase ini. **PC-104 (wallet engine) adalah inti.** Tidak
|
||||
ada kode lain yang boleh mengubah saldo tanpa lewat engine ini.
|
||||
|
||||
### PC-101 · Migrasi tabel wallet, ledger, dan lot · M
|
||||
- **PRD:** §8 (`customer_wallets`, `wallet_transactions`, `wallet_lots`,
|
||||
`wallet_lot_allocations`), K5, K9
|
||||
- **Kerjakan:** migrasi `000090_create_wallet_tables` (up & down) dengan semua
|
||||
`CHECK` constraint dan index persis seperti di §8.
|
||||
- **Selesai jika:**
|
||||
- Up dan down berjalan bersih di database kosong dan di salinan staging.
|
||||
- Uji constraint langsung di SQL, masing-masing harus ditolak:
|
||||
`PAYMENT` bercurrency `COIN`; `TRANSFER_IN` tanpa `counterparty_customer_id`;
|
||||
`EARN_REVERSAL` tanpa `reverses_transaction_id`; `ADJUSTMENT` tanpa `reason`;
|
||||
`EXPIRE` dengan `reference_type` selain `LOT`; saldo negatif; lot dengan
|
||||
`remaining_amount > original_amount`.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-102 · Migrasi pengaturan organisasi dan riwayat perubahan setting · S
|
||||
- **PRD:** F2, `loyalty_setting_changes` di §8
|
||||
- **Kerjakan:** tabel `organization_settings` (key–value, pola sama dengan
|
||||
`outlet_settings`, `UNIQUE(organization_id, key)`) dan `loyalty_setting_changes`.
|
||||
Saat ini belum ada tempat menyimpan setting per organisasi.
|
||||
- **Selesai jika:** up/down bersih.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-103 · Entities dan repository wallet · M
|
||||
- **PRD:** §7.1–§7.4
|
||||
- **Kerjakan:**
|
||||
- Entities untuk empat tabel PC-101.
|
||||
- Repository yang **selalu** memakai `DBFromContext` (berbeda dari repository
|
||||
gamification yang sekarang memakai `r.db` langsung).
|
||||
- `LockWallet(ctx, customerID)` dengan `SELECT … FOR UPDATE`. Membuat baris wallet
|
||||
jika belum ada (`INSERT … ON CONFLICT DO NOTHING`, lalu lock).
|
||||
- `LockWallets(ctx, a, b)` yang selalu mengunci berurutan berdasarkan `customer_id`.
|
||||
- Update saldo bersyarat yang **mengembalikan error jika 0 baris ter-update**.
|
||||
- Query lot aktif sesuai urutan K9: `expires_at NULLS LAST, created_at`.
|
||||
- **Selesai jika:** test repository menunjukkan update bersyarat gagal saat saldo
|
||||
kurang, dan dua goroutine yang mengunci wallet yang sama berjalan bergantian.
|
||||
- **Bergantung pada:** PC-101
|
||||
|
||||
### PC-104 · Wallet engine (credit / debit / lot / idempotensi) · L
|
||||
- **PRD:** K5, K6, K9, §7, §8.1
|
||||
- **Kerjakan:** processor `WalletProcessor` sebagai **satu-satunya pintu** perubahan
|
||||
saldo:
|
||||
- `Credit(ctx, CreditInput)`: menulis ledger, membuat lot (dengan `expires_at` dan
|
||||
`origin_lot_id` dari input), dan menambah saldo.
|
||||
- `Debit(ctx, DebitInput)`: mengambil dari lot sesuai urutan K9 (atau dari lot
|
||||
tertentu lebih dulu, untuk reversal di F10), menulis alokasi dan ledger, lalu
|
||||
mengurangi saldo. Mengembalikan daftar alokasi, supaya transfer/exchange bisa
|
||||
membuat lot penerima dengan `expires_at` yang sama.
|
||||
- `DebitUpTo`: mengambil sebanyak yang tersedia, untuk reversal dengan shortfall
|
||||
(F10, Q3).
|
||||
- Idempotensi: jika `idempotency_key` sudah ada, kembalikan hasil pertama tanpa
|
||||
mutasi baru.
|
||||
- Validasi di level kode untuk aturan §8.1 (tipe ↔ currency ↔ referensi wajib),
|
||||
sebagai lapisan kedua di atas constraint database.
|
||||
- Semua method mewajibkan caller sudah berada di dalam transaksi dan wallet sudah
|
||||
dikunci.
|
||||
- **Selesai jika:** unit test mencakup debit yang melewati beberapa lot, urutan lot
|
||||
dengan dan tanpa `expires_at`, debit melebihi saldo (ditolak), `DebitUpTo` dengan
|
||||
shortfall, idempotency key ganda, dan saldo = `SUM(ledger)` = `SUM(lot.remaining)`
|
||||
setelah setiap skenario.
|
||||
- **Bergantung pada:** PC-103
|
||||
|
||||
### PC-105 · Migrasi data Point & Token lama · M
|
||||
- **PRD:** §10, Q6
|
||||
- **Kerjakan:** migrasi data (atau command satu kali) yang:
|
||||
- Membuat wallet dari `customer_points` dan penjumlahan semua jenis
|
||||
`customer_tokens`.
|
||||
- Menulis ledger `MIGRATION` dan lot tanpa `expires_at` per customer per currency,
|
||||
dengan rincian per jenis token di `metadata`.
|
||||
- Mengganti `TOKENS` menjadi `COINS` di `campaigns.type` dan
|
||||
`campaign_rules.reward_type`.
|
||||
- **Selesai jika:** jumlah total Point dan Token sebelum = jumlah total saldo wallet
|
||||
sesudah (diuji pada salinan data staging), dan migrasi aman dijalankan dua kali
|
||||
(tidak menggandakan).
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-106 · Endpoint saldo & riwayat customer · M
|
||||
- **PRD:** F6, §9 (customer app)
|
||||
- **Kerjakan:** `GET /customer/wallet` dan `GET /customer/wallet/transactions`
|
||||
(pagination, filter currency/tipe/tanggal). Ganti isi `/customer/points` dan
|
||||
`/customer/tokens` menjadi alias yang membaca `customer_wallets`. Hapus pemakaian
|
||||
`customer_points_repository` untuk saldo.
|
||||
- **Selesai jika:** response menampilkan asal/tujuan setiap mutasi sesuai §8.1, dan
|
||||
aplikasi lama yang memanggil `/points` / `/tokens` tetap mendapat angka yang benar.
|
||||
- **Bergantung pada:** PC-105
|
||||
|
||||
### PC-107 · Wallet customer dan adjustment di dashboard · M
|
||||
- **PRD:** F7
|
||||
- **Kerjakan:** `GET /marketing/customers/:id/wallet` (saldo, lot aktif, mutasi) dan
|
||||
`POST /marketing/customers/:id/wallet/adjust` (alasan wajib, tidak boleh membuat saldo
|
||||
negatif, tercatat `created_by_user`).
|
||||
- **Selesai jika:** adjustment muncul di riwayat customer dengan nama admin dan alasan.
|
||||
Adjustment yang melebihi saldo ditolak.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-108 · Job rekonsiliasi · S
|
||||
- **PRD:** §7.5
|
||||
- **Kerjakan:** query dan job terjadwal yang memeriksa semua invarian §7.5 dan
|
||||
melaporkan selisih ke log dan notifikasi admin.
|
||||
- **Selesai jika:** job menemukan selisih yang sengaja dibuat di data test, dan diam
|
||||
saat data konsisten.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-109 · Pembaca setting loyalitas dan riwayat perubahan · M
|
||||
- **PRD:** F1, F2 (bagian mekanisme, bukan UI), §8 `loyalty_setting_changes`
|
||||
- **Kerjakan:** service yang membaca setting outlet dan organisasi dengan nilai default
|
||||
PRD bila key belum diisi, mengembalikan struct bertipe (bukan string mentah), dan
|
||||
menulis `loyalty_setting_changes` setiap kali setting loyalitas diubah.
|
||||
- **Selesai jika:** outlet tanpa setting mendapat semua default PRD, dan setiap
|
||||
perubahan tercatat nilai lama, nilai baru, dan pelakunya.
|
||||
- **Bergantung pada:** PC-102
|
||||
|
||||
---
|
||||
|
||||
## Fase 2 — Earning
|
||||
|
||||
### PC-201 · API pengaturan loyalitas outlet · M
|
||||
- **PRD:** F1
|
||||
- **Kerjakan:** `GET/PUT /outlets/:id/loyalty-settings` dengan validasi F1. Response
|
||||
menyertakan **persentase cashback efektif** (`earn_value × nilai EnakPoint /
|
||||
earn_per_amount`).
|
||||
- **Selesai jika:** validasi menolak nilai di luar batas, dan hanya Admin/Manager yang
|
||||
bisa mengubah.
|
||||
- **Bergantung pada:** PC-109
|
||||
|
||||
### PC-202 · Kalkulator earning · S
|
||||
- **PRD:** F1 (rumus), Q1, Q10
|
||||
- **Kerjakan:** fungsi murni `CalculateEarning(order, pointPaidAmount, settings)` yang
|
||||
mengembalikan Point, Coin, basis, dan snapshot setting.
|
||||
- **Selesai jika:** unit test mencakup contoh di PRD (Rp 87.500 → 875 Point, 3 Coin;
|
||||
dengan Rp 20.000 dibayar EnakPoint → 675 Point, 2 Coin), basis di bawah minimum,
|
||||
`max_per_order`, setting nonaktif, dan pajak tidak ikut dihitung.
|
||||
- **Bergantung pada:** PC-201
|
||||
|
||||
### PC-203 · Earning saat order lunas · L
|
||||
- **PRD:** F3
|
||||
- **Kerjakan:**
|
||||
- **Satukan titik "order menjadi lunas".** Saat ini `payment_status = completed`
|
||||
di-set di empat tempat: `OrderProcessorImpl.UpdateOrder`,
|
||||
`OrderProcessorImpl.updateOrderStatus`, dan dua tempat di `split_bill_processor.go`.
|
||||
Buat satu hook `onOrderPaid(orderID)` yang dipanggil dari keempatnya.
|
||||
- Hook menjalankan earning **setelah** transaksi pembayaran commit, sehingga
|
||||
kegagalan earning tidak menggagalkan pembayaran.
|
||||
- Idempotency key `earn:{order_id}:{currency}`.
|
||||
- Lewati order tanpa customer, customer default, atau customer nonaktif.
|
||||
- **Jaring pengaman:** job yang mencari order lunas beberapa hari terakhir yang belum
|
||||
punya `EARN` (dan seharusnya punya), lalu menjalankan ulang earning.
|
||||
- **Selesai jika:** test untuk pembayaran penuh, split bill (lunas di pembayaran
|
||||
terakhir), self-order, dan pemanggilan ganda (hasilnya tetap satu earning). Earning
|
||||
yang sengaja digagalkan tidak menggagalkan pembayaran dan terambil oleh job.
|
||||
- **Bergantung pada:** PC-104, PC-202
|
||||
|
||||
### PC-204 · Reversal earning saat void / refund · M
|
||||
- **PRD:** F10, Q3
|
||||
- **Kerjakan:** panggil reversal dari `VoidOrder`, `RefundOrder`, dan `RefundPayment`.
|
||||
Ambil pertama dari lot `EARN` order tersebut, lalu lot lain. Gunakan `DebitUpTo` dan
|
||||
catat `shortfall`. Refund tidak pernah diblokir.
|
||||
- **Selesai jika:** test untuk void penuh, refund sebagian (proporsional, akumulasi tidak
|
||||
melebihi earning), dan saldo yang sudah terpakai (shortfall tercatat, saldo 0).
|
||||
- **Bergantung pada:** PC-203
|
||||
|
||||
### PC-205 · Tampilkan earning di order dan struk · S
|
||||
- **PRD:** F3
|
||||
- **Kerjakan:** tambah `points_earned` dan `coins_earned` ke response order dan data
|
||||
struk.
|
||||
- **Selesai jika:** nilai sama dengan baris `EARN` di ledger, dan bernilai 0 untuk order
|
||||
tanpa earning.
|
||||
- **Bergantung pada:** PC-203
|
||||
|
||||
---
|
||||
|
||||
## Fase 3 — Pembayaran EnakPoint
|
||||
|
||||
> Boleh dikerjakan sekarang. **Tidak boleh dirilis** sebelum catatan N2 (keuangan) dan
|
||||
> N3 (legal) ditutup.
|
||||
|
||||
### PC-301 · PIN customer · L
|
||||
- **PRD:** K8, F11, Q16, Q17
|
||||
- **Kerjakan:**
|
||||
- Migrasi kolom PIN di `customers` dan tabel `customer_security_events`.
|
||||
- Purpose OTP baru `pin_setup` dan `pin_reset` di `OtpProcessor`.
|
||||
- Endpoint `/customer/pin/*` (status, OTP, buat, ganti, reset).
|
||||
- Hash bcrypt, tolak PIN lemah (digit sama, berurutan, tanggal lahir).
|
||||
- Kunci 30 menit setelah 5 kali salah, dengan penghitung di database.
|
||||
- Tahan transfer keluar 24 jam setelah reset.
|
||||
- `VerifyPin(ctx, customerID, pin)` untuk dipakai task lain, dengan error
|
||||
`PIN_NOT_SET`, `PIN_INVALID`, `PIN_LOCKED`, `TRANSFER_BLOCKED`.
|
||||
- Hapus PIN oleh admin: `DELETE /marketing/customers/:id/pin`, dan
|
||||
`GET /marketing/customers/:id/security-events`.
|
||||
- PIN tidak pernah muncul di log (termasuk log request body).
|
||||
- **Selesai jika:** test untuk semua aturan di atas, termasuk 5 kali salah lalu PIN
|
||||
benar tetap ditolak selama terkunci, dan reset OTP membuka kunci.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-302 · API pengaturan loyalitas organisasi · S
|
||||
- **PRD:** F2
|
||||
- **Kerjakan:** `GET/PUT /marketing/loyalty-settings` untuk nilai EnakPoint, kurs
|
||||
exchange, dan batas transfer, serta `GET /marketing/loyalty-settings/history`.
|
||||
Sebelum menyimpan perubahan nilai EnakPoint atau kurs, response preview menampilkan
|
||||
total saldo beredar dan nilai rupiahnya sebelum/sesudah.
|
||||
- **Selesai jika:** perubahan tercatat di riwayat, dan transaksi lama tetap memakai nilai
|
||||
yang dibekukan.
|
||||
- **Bergantung pada:** PC-109
|
||||
|
||||
### PC-303 · Payment method EnakPoint · M
|
||||
- **PRD:** F9 (payment method), §8 (perubahan `payments`), §10.5
|
||||
- **Kerjakan:**
|
||||
- Tambah tipe `point` ke `PaymentMethodType` dan validator (`oneof=cash card
|
||||
digital_wallet point`).
|
||||
- Migrasi kolom `points_used` dan `point_value` di `payments`.
|
||||
- Buat payment method sistem "EnakPoint" untuk setiap organisasi yang sudah ada, dan
|
||||
otomatis untuk organisasi baru.
|
||||
- Tolak hapus / ubah tipe method sistem.
|
||||
- Sembunyikan method ini di kasir jika outlet tidak mengaktifkan `accept_payment`.
|
||||
- **Selesai jika:** setiap organisasi punya tepat satu method EnakPoint yang tidak bisa
|
||||
dihapus.
|
||||
- **Bergantung pada:** –
|
||||
|
||||
### PC-304 · Kode bayar sekali pakai · M
|
||||
- **PRD:** F9 (persetujuan customer), K8
|
||||
- **Kerjakan:** `POST /customer/wallet/payment-code` (butuh PIN) menghasilkan kode 6
|
||||
digit dan QR yang berlaku 2 menit, disimpan di Redis dengan TTL dan diambil
|
||||
sekali-pakai (`GETDEL`). Kode terikat ke `customer_id`.
|
||||
- **Selesai jika:** kode kedaluwarsa, kode yang sudah dipakai, dan kode milik customer
|
||||
lain semuanya ditolak.
|
||||
- **Bergantung pada:** PC-301
|
||||
|
||||
### PC-305 · Bayar EnakPoint di kasir · L
|
||||
- **PRD:** F9 (perhitungan, pencatatan), K7
|
||||
- **Kerjakan:**
|
||||
- `GET /orders/:id/point-payment/preview`.
|
||||
- Cabang method `point` di `CreatePayment`: validasi kode bayar, cocokkan customer
|
||||
order, hitung `maks_point`, lalu lakukan langkah 1–6 F9 dalam **satu transaksi**
|
||||
bersama pembuatan baris `payments`.
|
||||
- Idempotency `payment:{payment_id}`.
|
||||
- Tolak jika `nominal_rupiah > remaining_amount` (tidak ada kembalian).
|
||||
- **Selesai jika:** test untuk pembayaran penuh, sebagian + tunai (split), batas
|
||||
`max_payment_percent`, order walk-in (ditolak), kode salah (ditolak), dan dua
|
||||
pembayaran bersamaan untuk customer yang sama (saldo tidak terpakai dua kali).
|
||||
Earning (PC-203) menghitung basis tanpa bagian EnakPoint.
|
||||
- **Bergantung pada:** PC-104, PC-303, PC-304
|
||||
|
||||
### PC-306 · Bayar EnakPoint dari app / self-order · M
|
||||
- **PRD:** F9
|
||||
- **Kerjakan:** `POST /customer/orders/:id/pay-with-points` (butuh PIN, hanya untuk
|
||||
order milik customer itu sendiri), memakai logika yang sama dengan PC-305.
|
||||
- **Selesai jika:** customer tidak bisa membayar order milik customer lain.
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
### PC-307 · Refund pembayaran EnakPoint · M
|
||||
- **PRD:** F9 (void/refund), K7, Q13
|
||||
- **Kerjakan:** `PAYMENT_REFUND` di jalur void dan refund memakai `point_value` yang
|
||||
dibekukan, dengan pembulatan ke bawah. Kembalikan ke lot dengan `expires_at` asal
|
||||
(aturan perpanjangan 7 hari mengikuti N4, jadi untuk sekarang cukup pakai tanggal
|
||||
asal). **Tolak** permintaan refund bagian EnakPoint lewat method lain.
|
||||
- **Selesai jika:** test untuk void penuh, refund sebagian, perubahan nilai EnakPoint di
|
||||
antara bayar dan refund (jumlah EnakPoint yang kembali tetap sama), dan percobaan
|
||||
refund tunai atas pembayaran EnakPoint (ditolak).
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
### PC-308 · EnakPoint di laporan · S
|
||||
- **PRD:** F9 (laporan)
|
||||
- **Kerjakan:** laporan per payment method menampilkan EnakPoint terpisah dan tidak
|
||||
menghitungnya sebagai kas masuk. Cek laporan analytics yang menjumlahkan pembayaran.
|
||||
- **Selesai jika:** total kas masuk di laporan tidak berubah saat sebagian order dibayar
|
||||
EnakPoint. Perlakuan akuntansi lanjutan menunggu N2.
|
||||
- **Bergantung pada:** PC-305
|
||||
|
||||
---
|
||||
|
||||
## Fase 4 — Pergerakan Saldo
|
||||
|
||||
> Boleh dikerjakan sekarang. Transfer (PC-402) **tidak boleh dirilis** sebelum N3
|
||||
> (legal) ditutup.
|
||||
|
||||
### PC-401 · Exchange EnakCoin → EnakPoint · M
|
||||
- **PRD:** F4, K3
|
||||
- **Kerjakan:** `GET /customer/wallet/exchange/preview` dan
|
||||
`POST /customer/wallet/exchange` (butuh PIN, `Idempotency-Key`). Jumlah harus
|
||||
kelipatan `coin_amount`. Kurs dibekukan di metadata. Lot EnakPoint kedaluwarsa pada
|
||||
`min(lot EnakCoin asal, sekarang + masa berlaku EnakPoint)`.
|
||||
- **Selesai jika:** test untuk kurs default 1:1, kurs 10:3, jumlah bukan kelipatan
|
||||
(ditolak), dan `expires_at` lot hasil exchange tidak pernah lebih lama dari lot asal.
|
||||
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||
|
||||
### PC-402 · Transfer antar customer · L
|
||||
- **PRD:** F5, Q4, Q16
|
||||
- **Kerjakan:** `GET /customer/wallet/transfer/recipient` (nama disamarkan) dan
|
||||
`POST /customer/wallet/transfer` (butuh PIN, `Idempotency-Key`). Kunci dua wallet
|
||||
berurutan, cek batas organisasi (per transaksi dan harian), cek
|
||||
`transfer_blocked_until`. Lot penerima mewarisi `expires_at` dan `origin_lot_id` dari
|
||||
lot pengirim. Kirim notifikasi push ke penerima.
|
||||
- **Selesai jika:** test untuk transfer ke diri sendiri / customer lain organisasi /
|
||||
customer default (semua ditolak), batas harian, transfer dua arah bersamaan (tidak
|
||||
deadlock), dan `expires_at` di penerima sama persis dengan pengirim.
|
||||
- **Bergantung pada:** PC-104, PC-301, PC-302
|
||||
|
||||
### PC-403 · Semua game memakai EnakCoin · M
|
||||
- **PRD:** F8, K1
|
||||
- **Kerjakan:**
|
||||
- `GamePlayProcessor.PlayGame` memotong EnakCoin lewat wallet engine sesuai
|
||||
`games.metadata.coin_cost`.
|
||||
- Seluruh permainan dalam satu transaksi: ubah repository game, game play, dan game
|
||||
prize ke `DBFromContext`. Gagal mengurangi stok hadiah membatalkan permainan (saat
|
||||
ini hanya di-`Printf`), dan rollback manual `AddTokens` dihapus.
|
||||
- Rename `game_plays.token_used` menjadi `coins_used`.
|
||||
- **Selesai jika:** test untuk saldo kurang (ditolak, tidak ada `game_plays` yang
|
||||
tercatat), stok hadiah gagal (semua dibatalkan), dan `GAME_SPEND` menunjuk
|
||||
`game_plays.id`.
|
||||
- **Bergantung pada:** PC-104
|
||||
|
||||
### PC-404 · Telusuri per butir di dashboard · S
|
||||
- **PRD:** F7, §8.1 (per butir)
|
||||
- **Kerjakan:** `GET /marketing/wallet-transactions/:id/trace` yang mengembalikan
|
||||
alokasi lot dan rantai `origin_lot_id` sampai ke lot pertama.
|
||||
- **Selesai jika:** contoh di §8 (A transfer 120 ke B, B bayar 30) menelusuri ke order
|
||||
#ORD-1 milik A.
|
||||
- **Bergantung pada:** PC-402
|
||||
|
||||
---
|
||||
|
||||
## Fase 5 — Kedaluwarsa
|
||||
|
||||
> **Jangan dikerjakan sebelum catatan N4 (model kedaluwarsa) diputuskan.** Struktur lot
|
||||
> sudah ada sejak PC-101, jadi yang tersisa hanya rumus, job, dan tampilan.
|
||||
|
||||
### PC-501 · Setting kedaluwarsa · M
|
||||
- **PRD:** F12 (pengaturan), N4
|
||||
- **Kerjakan:** key setting sesuai model yang dipilih di N4, dengan validasi dan preview
|
||||
"saldo yang didapat hari ini kedaluwarsa pada …".
|
||||
- **Bergantung pada:** PC-302, **N4**
|
||||
|
||||
### PC-502 · Hitung `expires_at` saat lot dibuat · M
|
||||
- **PRD:** F12 (tabel kedaluwarsa per lot), N4
|
||||
- **Kerjakan:** satu fungsi `ComputeExpiry(currency, receivedAt, settings)` yang dipakai
|
||||
oleh `EARN` dan `ADJUSTMENT`, serta aturan aktivasi pertama untuk lot lama (termasuk
|
||||
lot `MIGRATION`).
|
||||
- **Bergantung pada:** PC-104, PC-501
|
||||
|
||||
### PC-503 · Job kedaluwarsa · M
|
||||
- **PRD:** F12 (proses kedaluwarsa)
|
||||
- **Kerjakan:** job per jam yang memproses lot lewat tanggal dengan `EXPIRE`
|
||||
(idempotency `expire:{lot_id}`) di bawah lock wallet.
|
||||
- **Jangan menyalin pola `OmsetMilestoneScheduler`.** Scheduler itu menyimpan state di
|
||||
memori, dan menurut komentarnya sendiri bisa mengirim ulang notifikasi setelah
|
||||
restart. Job ini harus aman dijalankan di banyak instance sekaligus: pilih lot
|
||||
dengan `FOR UPDATE SKIP LOCKED`, dan andalkan idempotency key.
|
||||
- **Selesai jika:** dua instance yang berjalan bersamaan tidak menghanguskan lot yang
|
||||
sama dua kali, dan tidak ada lot yang lewat tanggal lebih dari 1 jam tanpa diproses.
|
||||
- **Bergantung pada:** PC-502
|
||||
|
||||
### PC-504 · Pengingat dan tampilan saldo yang akan kedaluwarsa · M
|
||||
- **PRD:** F6 (`/wallet/expiring`), F12 (pengingat)
|
||||
- **Kerjakan:** endpoint `GET /customer/wallet/expiring`, field saldo kedaluwarsa
|
||||
terdekat di `/wallet`, dan notifikasi pengingat yang dikelompokkan per tanggal.
|
||||
Jadwal pengingat mengikuti N4.
|
||||
- **Bergantung pada:** PC-503
|
||||
|
||||
---
|
||||
|
||||
## Fase 6 — Bersih-bersih
|
||||
|
||||
### PC-601 · Hapus tabel dan kode lama · S
|
||||
- **PRD:** §10.7
|
||||
- **Kerjakan:** satu rilis setelah PC-105 berjalan di production, drop
|
||||
`customer_points` dan `customer_tokens`. Hapus `CustomerPointsProcessor` dan
|
||||
`CustomerTokensProcessor` beserta stub `not implemented`, rute yang di-comment di
|
||||
`router.go`, dan alias `/customer/points` / `/customer/tokens` setelah aplikasi
|
||||
diperbarui.
|
||||
- **Bergantung pada:** PC-106, PC-403, dan konfirmasi bahwa aplikasi sudah tidak
|
||||
memanggil endpoint lama.
|
||||
|
||||
### PC-602 · Dokumentasi integrasi · S
|
||||
- **Kerjakan:** panduan integrasi untuk tim aplikasi dan POS (seperti
|
||||
`integration-weight-based-products.md`): endpoint, kode error PIN, alur kode bayar, dan
|
||||
contoh request/response.
|
||||
- **Bergantung pada:** PC-305, PC-402
|
||||
|
||||
---
|
||||
|
||||
## Yang Bisa Dimulai Sekarang
|
||||
|
||||
Bisa dikerjakan paralel tanpa menunggu apa pun:
|
||||
- **PC-101** (migrasi wallet) → langsung lanjut **PC-103**, lalu **PC-104**
|
||||
- **PC-102** (setting organisasi) → **PC-109**
|
||||
- **PC-301** (PIN customer)
|
||||
- **PC-303** (payment method EnakPoint)
|
||||
|
||||
PC-104 adalah jalur kritis: hampir semua task lain menunggunya.
|
||||
+121
-23
@@ -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.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
services.customerOutletService,
|
||||
services.customerOrderService,
|
||||
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,40 @@ 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
|
||||
walletExchangeProcessor *processor.WalletExchangeProcessor
|
||||
walletTransferProcessor *processor.WalletTransferProcessor
|
||||
walletTraceProcessor *processor.WalletTraceProcessor
|
||||
customerDeviceProcessor *processor.CustomerDeviceProcessor
|
||||
customerOutletProcessor *processor.CustomerOutletProcessor
|
||||
customerOrderProcessor *processor.CustomerOrderProcessor
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
// 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,7 +435,7 @@ 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),
|
||||
orderProcessor: orderProcessor,
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||
@@ -384,25 +452,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,
|
||||
walletExchangeProcessor: walletExchangeProcessor,
|
||||
walletTransferProcessor: walletTransferProcessor,
|
||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
customerOutletProcessor: processor.NewCustomerOutletProcessor(repository.NewCustomerOutletRepository(a.db), loyaltySettingsProcessor),
|
||||
customerOrderProcessor: processor.NewCustomerOrderProcessor(repository.NewCustomerOrderRepository(a.db), earningProcessor),
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), loyaltySettingsProcessor, repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +523,13 @@ type services struct {
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
customerPinService *service.CustomerPinServiceImpl
|
||||
customerWalletService *service.CustomerWalletServiceImpl
|
||||
customerDeviceService *service.CustomerDeviceServiceImpl
|
||||
customerOutletService *service.CustomerOutletServiceImpl
|
||||
customerOrderService *service.CustomerOrderServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -475,12 +560,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 +611,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),
|
||||
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
|
||||
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
|
||||
customerOutletService: service.NewCustomerOutletService(processors.customerOutletProcessor),
|
||||
customerOrderService: service.NewCustomerOrderService(processors.customerOrderProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +663,7 @@ type validators struct {
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||
walletValidator *validator.WalletValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -604,22 +697,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,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package constants
|
||||
|
||||
// Budget allocation of revenue used by the parent category cut-off report.
|
||||
// The three shares are expected to add up to 100.
|
||||
const (
|
||||
BudgetLimitPurchasePercent = 60.0
|
||||
BudgetLimitOwnerPercent = 20.0
|
||||
BudgetLimitTeamPercent = 20.0
|
||||
)
|
||||
// Revenue split used by the profit sharing report: the owner takes its fee as SDL and
|
||||
// the team takes the rest. BudgetLimitOwnerPercent is only the default: a parent
|
||||
// category can override it with categories.owner_fee_percent.
|
||||
const BudgetLimitOwnerPercent = 20.0
|
||||
|
||||
@@ -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,93 @@
|
||||
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.
|
||||
const (
|
||||
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
||||
LoyaltyPointEarnModeKey = "loyalty.point.earn_mode"
|
||||
LoyaltyPointEarnPercentKey = "loyalty.point.earn_percent"
|
||||
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"
|
||||
LoyaltyCoinEarnModeKey = "loyalty.coin.earn_mode"
|
||||
LoyaltyCoinEarnPercentKey = "loyalty.coin.earn_percent"
|
||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
||||
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
||||
LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order"
|
||||
)
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// Modes of loyalty.{point,coin}.earn_mode.
|
||||
const (
|
||||
// earn_value for every earn_per_amount rupiah of the basis.
|
||||
LoyaltyEarnModePerAmount = "PER_AMOUNT"
|
||||
// earn_percent percent of the basis.
|
||||
LoyaltyEarnModePercentage = "PERCENTAGE"
|
||||
)
|
||||
|
||||
// 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)
|
||||
LoyaltyEarnModeDefault = LoyaltyEarnModePerAmount
|
||||
LoyaltyEarnPercentDefault = float64(1)
|
||||
|
||||
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)
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
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"
|
||||
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"
|
||||
WalletRefTypeWalletTx = "WALLET_TX"
|
||||
WalletRefTypeGamePlay = "GAME_PLAY"
|
||||
WalletRefTypeLot = "LOT"
|
||||
WalletRefTypeUser = "USER"
|
||||
WalletRefTypeLegacyPoints = "LEGACY_POINTS"
|
||||
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
||||
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
|
||||
)
|
||||
@@ -260,16 +260,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||
type ParentCategoryTopProduct struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||
@@ -296,19 +307,25 @@ type ParentCategoryAnalyticsDetailResponse struct {
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
@@ -349,19 +366,17 @@ type BudgetCutOff struct {
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SDL float64 `json:"sdl"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -1,29 +1,81 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCategoryRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||
IsTeam *bool `json:"is_team,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCategoryRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
OwnerFeePercent *float64 `json:"owner_fee_percent,omitempty"`
|
||||
IsTeam *bool `json:"is_team,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
||||
// Set when the field is sent as null (or "" for parent_id), which asks for the
|
||||
// value to be removed. A field that is left out stays unchanged.
|
||||
ClearParentID bool `json:"-"`
|
||||
ClearOwnerFeePercent bool `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON tells an explicit null apart from a field that was left out, so a
|
||||
// category can be detached from its parent and an owner fee override can be removed.
|
||||
func (r *UpdateCategoryRequest) UnmarshalJSON(data []byte) error {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
isEmpty := func(key string, allowEmptyString bool) bool {
|
||||
value, ok := raw[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
value = bytes.TrimSpace(value)
|
||||
return bytes.Equal(value, []byte("null")) || (allowEmptyString && bytes.Equal(value, []byte(`""`)))
|
||||
}
|
||||
|
||||
clearParentID := isEmpty("parent_id", true)
|
||||
clearOwnerFeePercent := isEmpty("owner_fee_percent", false)
|
||||
if clearParentID {
|
||||
// An empty string is not a valid UUID, so keep it away from the decoder
|
||||
delete(raw, "parent_id")
|
||||
cleaned, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = cleaned
|
||||
}
|
||||
|
||||
type plain UpdateCategoryRequest
|
||||
var decoded plain
|
||||
if err := json.Unmarshal(data, &decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*r = UpdateCategoryRequest(decoded)
|
||||
r.ClearParentID = clearParentID
|
||||
r.ClearOwnerFeePercent = clearOwnerFeePercent
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListCategoriesRequest struct {
|
||||
@@ -39,18 +91,20 @@ type ListCategoriesRequest struct {
|
||||
|
||||
// Category Response DTOs
|
||||
type CategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Order int `json:"order"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
Order int `json:"order"`
|
||||
OwnerFeePercent *float64 `json:"owner_fee_percent"`
|
||||
IsTeam bool `json:"is_team"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCategoriesResponse struct {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateCategoryRequestTellsNullFromOmitted(t *testing.T) {
|
||||
parentID := uuid.New()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantParentID *uuid.UUID
|
||||
wantClear bool
|
||||
wantClearFee bool
|
||||
wantFee *float64
|
||||
wantNameIsSet bool
|
||||
}{
|
||||
{name: "omitted leaves parent unchanged", body: `{"name":"Food"}`, wantNameIsSet: true},
|
||||
{name: "null detaches the parent", body: `{"parent_id":null}`, wantClear: true},
|
||||
{name: "empty string detaches the parent", body: `{"name":"Food","parent_id":""}`, wantClear: true, wantNameIsSet: true},
|
||||
{name: "uuid sets the parent", body: `{"parent_id":"` + parentID.String() + `"}`, wantParentID: &parentID},
|
||||
{name: "null removes the owner fee override", body: `{"owner_fee_percent":null}`, wantClearFee: true},
|
||||
{name: "number sets the owner fee", body: `{"owner_fee_percent":35}`, wantFee: func() *float64 { v := 35.0; return &v }()},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var req UpdateCategoryRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(tt.body), &req))
|
||||
|
||||
require.Equal(t, tt.wantParentID, req.ParentID)
|
||||
require.Equal(t, tt.wantClear, req.ClearParentID)
|
||||
require.Equal(t, tt.wantClearFee, req.ClearOwnerFeePercent)
|
||||
require.Equal(t, tt.wantFee, req.OwnerFeePercent)
|
||||
require.Equal(t, tt.wantNameIsSet, req.Name != nil)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCategoryRequestRejectsInvalidParentID(t *testing.T) {
|
||||
var req UpdateCategoryRequest
|
||||
require.Error(t, json.Unmarshal([]byte(`{"parent_id":"not-a-uuid"}`), &req))
|
||||
}
|
||||
@@ -16,6 +16,10 @@ type RegisterStartRequest struct {
|
||||
PhoneNumber string `json:"phone_number" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
BirthDate string `json:"birth_date" binding:"required"`
|
||||
// The organization (brand) the customer registers with. A customer belongs to one
|
||||
// organization. Optional: when it is left out and the database has exactly one
|
||||
// organization, the customer joins that one.
|
||||
OrganizationID string `json:"organization_id,omitempty"`
|
||||
}
|
||||
|
||||
type RegisterVerifyOtpRequest struct {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package contract
|
||||
|
||||
// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer
|
||||
// app got for this device. Send it after login and whenever FCM refreshes the token.
|
||||
type RegisterCustomerDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
FCMToken string `json:"fcm_token" binding:"required"`
|
||||
// android, ios or web.
|
||||
Platform *string `json:"platform"`
|
||||
AppVersion *string `json:"app_version"`
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ 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 {
|
||||
@@ -117,7 +121,7 @@ type OrderItemResponse struct {
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type CreateProductRequest struct {
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
@@ -38,7 +38,7 @@ type UpdateProductRequest struct {
|
||||
Cost *float64 `json:"cost,omitempty" validate:"omitempty,min=0"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `json:"printer_types,omitempty" validate:"omitempty,dive,max=50"` // Replaces the whole list when sent
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
@@ -76,7 +76,7 @@ type ProductResponse struct {
|
||||
Cost float64 `json:"cost"`
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -141,16 +141,29 @@ type ProductAnalyticsPerCategory struct {
|
||||
// ProductAnalyticsPerParentCategory rolls the per-category figures up to the
|
||||
// top-level category. A category without a parent is its own group.
|
||||
type ProductAnalyticsPerParentCategory struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
// OwnerFeePercent is the owner share of this group's revenue, already resolved to the default
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
// TopProduct is filled by a separate query, so it is not a scanned column
|
||||
TopProduct *ParentCategoryTopProduct `gorm:"-" json:"top_product"`
|
||||
}
|
||||
|
||||
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||
type ParentCategoryTopProduct struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetail is the drill-down for a single parent category:
|
||||
@@ -169,19 +182,21 @@ type BudgetCutOffWeek struct {
|
||||
WeekStart time.Time `json:"week_start"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
// SDL is summed per parent category, each at its own owner fee percent
|
||||
SDL float64 `json:"sdl"`
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
|
||||
@@ -41,8 +41,14 @@ type Category struct {
|
||||
Order int `gorm:"default:0" json:"order"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// OwnerFeePercent overrides the owner's share in the parent category budget report; nil uses the default
|
||||
OwnerFeePercent *float64 `gorm:"type:numeric(5,2)" json:"owner_fee_percent"`
|
||||
// IsTeam marks a parent category as a team in the revenue split. It has no gorm
|
||||
// default on purpose: GORM would turn an explicit false into the default on insert,
|
||||
// so callers creating a category set it themselves.
|
||||
IsTeam bool `gorm:"not null" json:"is_team"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -8,24 +9,26 @@ import (
|
||||
)
|
||||
|
||||
type Product struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
CategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"category_id" validate:"required"`
|
||||
SKU *string `gorm:"size:100;index" json:"sku"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Price float64 `gorm:"type:decimal(10,2);not null" json:"price" validate:"required,min=0"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost" validate:"min=0"`
|
||||
BusinessType string `gorm:"size:50;default:'restaurant'" json:"business_type"`
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
// PrinterTypes are the stations the product is printed at. Set them through
|
||||
// SetPrinterTypes.
|
||||
PrinterTypes StringSlice `gorm:"type:jsonb;not null;default:'[\"kitchen\"]'" json:"printer_types"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Category Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
||||
@@ -48,6 +51,32 @@ func (Product) TableName() string {
|
||||
return "products"
|
||||
}
|
||||
|
||||
// SetPrinterTypes records the stations the product is printed at, in order, dropping
|
||||
// blanks and repeats.
|
||||
func (p *Product) SetPrinterTypes(printerTypes []string) {
|
||||
cleaned := StringSlice{}
|
||||
seen := make(map[string]bool, len(printerTypes))
|
||||
for _, printerType := range printerTypes {
|
||||
printerType = strings.TrimSpace(printerType)
|
||||
if printerType == "" || seen[printerType] {
|
||||
continue
|
||||
}
|
||||
seen[printerType] = true
|
||||
cleaned = append(cleaned, printerType)
|
||||
}
|
||||
|
||||
p.PrinterTypes = cleaned
|
||||
}
|
||||
|
||||
// GetPrinterTypes returns the stations the product is printed at, never nil, so a
|
||||
// response always carries a list. A product that was not loaded has none.
|
||||
func (p *Product) GetPrinterTypes() []string {
|
||||
if p.PrinterTypes == nil {
|
||||
return []string{}
|
||||
}
|
||||
return []string(p.PrinterTypes)
|
||||
}
|
||||
|
||||
type ProductVariant struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
|
||||
@@ -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,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// CustomerOrderHandler serves the customer app's order history.
|
||||
type CustomerOrderHandler struct {
|
||||
orders service.CustomerOrderService
|
||||
}
|
||||
|
||||
func NewCustomerOrderHandler(orders service.CustomerOrderService) *CustomerOrderHandler {
|
||||
return &CustomerOrderHandler{orders: orders}
|
||||
}
|
||||
|
||||
// List is GET /customer/orders?page=&limit=.
|
||||
func (h *CustomerOrderHandler) List(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOrderHandler::List")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var query models.ListCustomerOrdersQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "page and limit must be whole numbers"),
|
||||
}), "CustomerOrderHandler::List")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.orders.List(c.Request.Context(), customerID, query), "CustomerOrderHandler::List")
|
||||
}
|
||||
|
||||
// Detail is GET /customer/orders/:id.
|
||||
func (h *CustomerOrderHandler) Detail(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOrderHandler::Detail")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderHandler::Detail")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.orders.Detail(c.Request.Context(), customerID, orderID), "CustomerOrderHandler::Detail")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerOutletHandler serves GET /customer/outlets.
|
||||
type CustomerOutletHandler struct {
|
||||
outlets service.CustomerOutletService
|
||||
}
|
||||
|
||||
func NewCustomerOutletHandler(outlets service.CustomerOutletService) *CustomerOutletHandler {
|
||||
return &CustomerOutletHandler{outlets: outlets}
|
||||
}
|
||||
|
||||
// List returns the active outlets of the customer's organization.
|
||||
func (h *CustomerOutletHandler) List(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOutletHandler::List")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.outlets.List(c.Request.Context(), customerID), "CustomerOutletHandler::List")
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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()}
|
||||
}
|
||||
@@ -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.WalletTxTypeRewardRedeem,
|
||||
Amount: 50, ReferenceType: constants.WalletRefTypeRewardRedemption, ReferenceID: payment, OutletID: &outlet,
|
||||
Description: "Tukar voucher"}}); 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": "REWARD_REDEMPTION", "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,171 @@
|
||||
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_mode": "PER_AMOUNT", "earn_per_amount": float64(100), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_mode": "PER_AMOUNT", "earn_per_amount": float64(25000), "earn_value": float64(1), "earn_percent": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.NotContains(t, got, "point_payment", "EnakPoint cannot pay (docs/enakgame-prd.md §3.2)")
|
||||
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}}`,
|
||||
"earn mode unknown": `{"point": {"earn_mode": "PERCENT"}}`,
|
||||
"earn_percent over 100": `{"coin": {"earn_percent": 101}}`,
|
||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||
"point_payment removed": `{"point_payment": {"accept_payment": true}}`,
|
||||
"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,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")
|
||||
}
|
||||
@@ -58,15 +58,23 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
||||
metadata["image_url"] = *req.ImageURL
|
||||
}
|
||||
|
||||
// A category is a team unless the request says otherwise
|
||||
isTeam := true
|
||||
if req.IsTeam != nil {
|
||||
isTeam = *req.IsTeam
|
||||
}
|
||||
|
||||
return &entities.Category{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
BusinessType: "restaurant",
|
||||
Metadata: metadata,
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
OwnerFeePercent: req.OwnerFeePercent,
|
||||
IsTeam: isTeam,
|
||||
BusinessType: "restaurant",
|
||||
Metadata: metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,18 +102,20 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
||||
}
|
||||
|
||||
return &models.CategoryResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
Order: entity.Order,
|
||||
IsActive: true,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
Order: entity.Order,
|
||||
OwnerFeePercent: entity.OwnerFeePercent,
|
||||
IsTeam: entity.IsTeam,
|
||||
IsActive: true,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,11 +144,25 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
||||
entity.Order = *req.Order
|
||||
}
|
||||
|
||||
if req.ClearOwnerFeePercent {
|
||||
entity.OwnerFeePercent = nil
|
||||
} else if req.OwnerFeePercent != nil {
|
||||
entity.OwnerFeePercent = req.OwnerFeePercent
|
||||
}
|
||||
|
||||
if req.IsTeam != nil {
|
||||
entity.IsTeam = *req.IsTeam
|
||||
}
|
||||
|
||||
if req.OutletID != nil {
|
||||
entity.OutletID = req.OutletID
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
if req.ClearParentID {
|
||||
// Drop the preloaded parent too, so the response stops reporting it
|
||||
entity.ParentID = nil
|
||||
entity.Parent = nil
|
||||
} else if req.ParentID != nil {
|
||||
entity.ParentID = req.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateCategoryRequestToEntityDefaultsToTeam(t *testing.T) {
|
||||
notTeam := false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
isTeam *bool
|
||||
want bool
|
||||
}{
|
||||
{name: "omitted is a team", isTeam: nil, want: true},
|
||||
{name: "false is not a team", isTeam: ¬Team, want: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
entity := CreateCategoryRequestToEntity(&models.CreateCategoryRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
Name: "Merchandise",
|
||||
IsTeam: tt.isTeam,
|
||||
})
|
||||
require.Equal(t, tt.want, entity.IsTeam)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ func OrderItemEntityToResponse(item *entities.OrderItem, outletID uuid.UUID) *mo
|
||||
Status: constants.OrderItemStatus(item.Status),
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
PrinterType: item.Product.PrinterType,
|
||||
PrinterTypes: item.Product.GetPrinterTypes(),
|
||||
PrintToChecker: printToChecker,
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func ProductEntityToModel(entity *entities.Product) *models.Product {
|
||||
Cost: entity.Cost,
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
PrinterTypes: entity.GetPrinterTypes(),
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
HasIngredients: entity.HasIngredients,
|
||||
@@ -51,7 +51,7 @@ func ProductModelToEntity(model *models.Product) *entities.Product {
|
||||
Cost: model.Cost,
|
||||
BusinessType: string(model.BusinessType),
|
||||
ImageURL: model.ImageURL,
|
||||
PrinterType: model.PrinterType,
|
||||
PrinterTypes: entities.StringSlice(model.PrinterTypes),
|
||||
UnitID: model.UnitID,
|
||||
SellBy: model.SellBy,
|
||||
HasIngredients: model.HasIngredients,
|
||||
@@ -77,11 +77,6 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
businessType = string(req.BusinessType)
|
||||
}
|
||||
|
||||
printerType := "kitchen"
|
||||
if req.PrinterType != nil && *req.PrinterType != "" {
|
||||
printerType = *req.PrinterType
|
||||
}
|
||||
|
||||
sellBy := constants.SellByUnit
|
||||
if constants.IsValidSellBy(req.SellBy) {
|
||||
sellBy = req.SellBy
|
||||
@@ -92,7 +87,7 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
metadata = entities.Metadata(req.Metadata)
|
||||
}
|
||||
|
||||
return &entities.Product{
|
||||
product := &entities.Product{
|
||||
OrganizationID: req.OrganizationID,
|
||||
CategoryID: req.CategoryID,
|
||||
SKU: req.SKU,
|
||||
@@ -102,12 +97,18 @@ func CreateProductRequestToEntity(req *models.CreateProductRequest) *entities.Pr
|
||||
Cost: cost,
|
||||
BusinessType: businessType,
|
||||
ImageURL: req.ImageURL,
|
||||
PrinterType: printerType,
|
||||
UnitID: req.UnitID,
|
||||
SellBy: sellBy,
|
||||
Metadata: metadata,
|
||||
IsActive: true, // Default to active
|
||||
}
|
||||
|
||||
product.SetPrinterTypes(req.PrinterTypes)
|
||||
if len(product.PrinterTypes) == 0 {
|
||||
product.SetPrinterTypes([]string{"kitchen"})
|
||||
}
|
||||
|
||||
return product
|
||||
}
|
||||
|
||||
func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
@@ -152,7 +153,7 @@ func ProductEntityToResponse(entity *entities.Product) *models.ProductResponse {
|
||||
Cost: entity.Cost,
|
||||
BusinessType: constants.BusinessType(entity.BusinessType),
|
||||
ImageURL: entity.ImageURL,
|
||||
PrinterType: entity.PrinterType,
|
||||
PrinterTypes: entity.GetPrinterTypes(),
|
||||
UnitID: entity.UnitID,
|
||||
SellBy: entity.SellBy,
|
||||
Metadata: map[string]interface{}(entity.Metadata),
|
||||
@@ -196,8 +197,8 @@ func UpdateProductEntityFromRequest(entity *entities.Product, req *models.Update
|
||||
entity.ImageURL = req.ImageURL
|
||||
}
|
||||
|
||||
if req.PrinterType != nil {
|
||||
entity.PrinterType = *req.PrinterType
|
||||
if req.PrinterTypes != nil {
|
||||
entity.SetPrinterTypes(req.PrinterTypes)
|
||||
}
|
||||
|
||||
if req.UnitID != nil {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateProductRequestToEntityPrinterTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
printerTypes []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "nothing sent prints in the kitchen",
|
||||
want: []string{"kitchen"},
|
||||
},
|
||||
{
|
||||
name: "a meal-and-drink package prints in the kitchen and the bar",
|
||||
printerTypes: []string{"kitchen", "bar"},
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "blanks and repeats are dropped",
|
||||
printerTypes: []string{" kitchen ", "", "kitchen", "bar"},
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "a list of only blanks falls back to the kitchen",
|
||||
printerTypes: []string{" ", ""},
|
||||
want: []string{"kitchen"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
product := CreateProductRequestToEntity(&models.CreateProductRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
CategoryID: uuid.New(),
|
||||
Name: "Paket Makan Minum",
|
||||
PrinterTypes: tt.printerTypes,
|
||||
})
|
||||
|
||||
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProductEntityFromRequestPrinterTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
printerTypes []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "a save that does not send printers keeps them",
|
||||
want: []string{"kitchen", "bar"},
|
||||
},
|
||||
{
|
||||
name: "printer_types replaces the whole list",
|
||||
printerTypes: []string{"bar"},
|
||||
want: []string{"bar"},
|
||||
},
|
||||
{
|
||||
name: "an empty printer_types prints nowhere",
|
||||
printerTypes: []string{},
|
||||
want: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
product := &entities.Product{}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
|
||||
UpdateProductEntityFromRequest(product, &models.UpdateProductRequest{PrinterTypes: tt.printerTypes})
|
||||
|
||||
assert.Equal(t, entities.StringSlice(tt.want), product.PrinterTypes)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderItemEntityToResponsePrinterTypes(t *testing.T) {
|
||||
product := entities.Product{ID: uuid.New(), Name: "Paket Makan Minum"}
|
||||
product.SetPrinterTypes([]string{"kitchen", "bar"})
|
||||
|
||||
response := OrderItemEntityToResponse(&entities.OrderItem{ProductID: product.ID, Product: product}, uuid.New())
|
||||
|
||||
assert.Equal(t, []string{"kitchen", "bar"}, response.PrinterTypes)
|
||||
}
|
||||
|
||||
func TestProductEntityToResponseAlwaysHasAList(t *testing.T) {
|
||||
// The POS reads printer_types as a list, so a product without one sends [] not null.
|
||||
response := ProductEntityToResponse(&entities.Product{})
|
||||
|
||||
assert.Equal(t, []string{}, response.PrinterTypes)
|
||||
}
|
||||
@@ -36,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
|
||||
|
||||
@@ -82,7 +82,15 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
|
||||
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
|
||||
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 {
|
||||
|
||||
@@ -295,16 +295,27 @@ type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
// ParentCategoryTopProduct is the best-selling product of a parent category by revenue.
|
||||
type ParentCategoryTopProduct struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailRequest represents the request for the drill-down of one parent category
|
||||
@@ -331,19 +342,25 @@ type ParentCategoryAnalyticsDetailResponse struct {
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
CategoryCount int64 `json:"category_count"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
OwnerFeePercent float64 `json:"owner_fee_percent"`
|
||||
SDL float64 `json:"sdl"`
|
||||
TopProduct *ParentCategoryTopProduct `json:"top_product"`
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalQuantity int64 `json:"total_quantity"`
|
||||
ProductCount int64 `json:"product_count"`
|
||||
@@ -384,19 +401,17 @@ type BudgetCutOff struct {
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
SDL float64 `json:"sdl"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
@@ -464,12 +479,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"`
|
||||
|
||||
+37
-26
@@ -20,36 +20,47 @@ type Category struct {
|
||||
}
|
||||
|
||||
type CreateCategoryRequest struct {
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order int `validate:"min=0"`
|
||||
OrganizationID uuid.UUID `validate:"required"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Name string `validate:"required,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
Order int `validate:"min=0"`
|
||||
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||
// IsTeam defaults to true when left out
|
||||
IsTeam *bool
|
||||
}
|
||||
|
||||
type UpdateCategoryRequest struct {
|
||||
Name *string `validate:"omitempty,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
IsActive *bool
|
||||
Name *string `validate:"omitempty,min=1,max=255"`
|
||||
Description *string `validate:"omitempty,max=1000"`
|
||||
ImageURL *string `validate:"omitempty,url"`
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
Order *int `validate:"omitempty,min=0"`
|
||||
OwnerFeePercent *float64 `validate:"omitempty,min=0,max=100"`
|
||||
IsTeam *bool
|
||||
IsActive *bool
|
||||
|
||||
// Clear flags remove the value; a nil pointer above only means "leave unchanged"
|
||||
ClearParentID bool
|
||||
ClearOwnerFeePercent bool
|
||||
}
|
||||
|
||||
type CategoryResponse struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
ParentName *string
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
Order int
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *uuid.UUID
|
||||
ParentID *uuid.UUID
|
||||
ParentName *string
|
||||
Name string
|
||||
Description *string
|
||||
ImageURL *string
|
||||
Order int
|
||||
OwnerFeePercent *float64
|
||||
IsTeam bool
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CustomerOrderSummary is one order in GET /customer/orders.
|
||||
type CustomerOrderSummary struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
OutletName string `json:"outlet_name"`
|
||||
OrderType string `json:"order_type"`
|
||||
Status string `json:"status"`
|
||||
PaymentStatus string `json:"payment_status"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
IsVoid bool `json:"is_void"`
|
||||
IsRefund bool `json:"is_refund"`
|
||||
// EnakPoint and EnakCoin the order earned; 0 when it earned nothing.
|
||||
PointsEarned int64 `json:"points_earned"`
|
||||
CoinsEarned int64 `json:"coins_earned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CustomerOrderDetail is GET /customer/orders/:id.
|
||||
type CustomerOrderDetail struct {
|
||||
CustomerOrderSummary
|
||||
TableNumber *string `json:"table_number"`
|
||||
Subtotal float64 `json:"subtotal"`
|
||||
DiscountAmount float64 `json:"discount_amount"`
|
||||
TaxAmount float64 `json:"tax_amount"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
Items []CustomerOrderItem `json:"items"`
|
||||
Payments []CustomerOrderPayment `json:"payments"`
|
||||
}
|
||||
|
||||
type CustomerOrderItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
VariantName *string `json:"variant_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Set for products sold by weight, in UnitName.
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
UnitName *string `json:"unit_name,omitempty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
RefundQuantity int `json:"refund_quantity"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type CustomerOrderPayment struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
MethodName string `json:"method_name"`
|
||||
MethodType string `json:"method_type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListCustomerOrdersQuery is GET /customer/orders.
|
||||
type ListCustomerOrdersQuery struct {
|
||||
Page int `form:"page"`
|
||||
Limit int `form:"limit"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// CustomerOutlet is one outlet in GET /customer/outlets: where the customer can shop,
|
||||
// and what the outlet does with EnakPoint and EnakCoin.
|
||||
type CustomerOutlet struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address *string `json:"address"`
|
||||
// Orders here earn EnakPoint / EnakCoin.
|
||||
EarnsPoints bool `json:"earns_points"`
|
||||
EarnsCoins bool `json:"earns_coins"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
}
|
||||
@@ -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,203 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
)
|
||||
|
||||
// OutletLoyaltySettings are an outlet's loyalty settings (docs/prd-point-coin.md F1).
|
||||
type OutletLoyaltySettings struct {
|
||||
Point LoyaltyEarnSettings `json:"point"`
|
||||
Coin LoyaltyEarnSettings `json:"coin"`
|
||||
}
|
||||
|
||||
// LoyaltyEarnSettings is how much of one currency an order earns, nothing below
|
||||
// MinOrderAmount, and at most MaxPerOrder when set:
|
||||
//
|
||||
// - PER_AMOUNT: floor(basis / EarnPerAmount) × EarnValue;
|
||||
// - PERCENTAGE: floor(basis × EarnPercent / 100).
|
||||
//
|
||||
// The settings of the mode not in use are kept, so switching back restores them.
|
||||
type LoyaltyEarnSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// PER_AMOUNT or PERCENTAGE.
|
||||
EarnMode string `json:"earn_mode"`
|
||||
EarnPerAmount int64 `json:"earn_per_amount"`
|
||||
EarnValue int64 `json:"earn_value"`
|
||||
// PERCENTAGE: percent of the basis earned, 0–100 with at most two decimals.
|
||||
EarnPercent float64 `json:"earn_percent"`
|
||||
MinOrderAmount int64 `json:"min_order_amount"`
|
||||
MaxPerOrder *int64 `json:"max_per_order"`
|
||||
}
|
||||
|
||||
// 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, or earn_percent × point_value in PERCENTAGE mode. 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"`
|
||||
}
|
||||
|
||||
// CashbackPercent is the rupiah value of what an order earns as a percentage of its
|
||||
// basis, in the mode the settings are in, rounded to two decimals.
|
||||
func (s LoyaltyEarnSettings) CashbackPercent(pointValue int64) float64 {
|
||||
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
return math.Round(s.EarnPercent*float64(pointValue)*100) / 100
|
||||
}
|
||||
return LoyaltyCashbackPercent(s.EarnValue, pointValue, s.EarnPerAmount)
|
||||
}
|
||||
|
||||
// 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,40 @@
|
||||
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 TestLoyaltyEarnSettings_CashbackPercent(t *testing.T) {
|
||||
s := LoyaltyEarnSettings{EarnMode: "PER_AMOUNT", EarnPerAmount: 100, EarnValue: 1, EarnPercent: 2.5}
|
||||
assert.Equal(t, 1.0, s.CashbackPercent(1))
|
||||
s.EarnMode = "PERCENTAGE"
|
||||
assert.Equal(t, 2.5, s.CashbackPercent(1))
|
||||
assert.Equal(t, 250.0, s.CashbackPercent(100), "each point earned is worth point_value rupiah")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -182,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 {
|
||||
@@ -215,7 +218,7 @@ type OrderItemResponse struct {
|
||||
Status constants.OrderItemStatus
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
PrintToChecker bool
|
||||
PaidQuantity int
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ type Product struct {
|
||||
Cost float64
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
SellBy string
|
||||
UnitID *uuid.UUID
|
||||
HasIngredients bool
|
||||
@@ -50,7 +50,7 @@ type CreateProductRequest struct {
|
||||
Cost float64 `validate:"min=0"`
|
||||
BusinessType constants.BusinessType `validate:"required"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `validate:"omitempty,dive,max=50"`
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy string `validate:"omitempty,oneof=unit weight"`
|
||||
@@ -72,7 +72,7 @@ type UpdateProductRequest struct {
|
||||
Price *float64 `validate:"omitempty,min=0"`
|
||||
Cost *float64 `validate:"omitempty,min=0"`
|
||||
ImageURL *string `validate:"omitempty,max=500"`
|
||||
PrinterType *string `validate:"omitempty,max=50"`
|
||||
PrinterTypes []string `validate:"omitempty,dive,max=50"` // Replaces the whole list when not nil
|
||||
PrintToChecker *bool `validate:"omitempty"`
|
||||
UnitID *uuid.UUID `validate:"omitempty"`
|
||||
SellBy *string `validate:"omitempty,oneof=unit weight"`
|
||||
@@ -112,7 +112,7 @@ type ProductResponse struct {
|
||||
Cost float64
|
||||
BusinessType constants.BusinessType
|
||||
ImageURL *string
|
||||
PrinterType string
|
||||
PrinterTypes []string
|
||||
SellBy string
|
||||
PrintToChecker bool
|
||||
UnitID *uuid.UUID
|
||||
|
||||
@@ -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,147 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -398,9 +398,22 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerParentCategory(ctx contex
|
||||
// Transform data
|
||||
var resultData []models.ProductAnalyticsPerParentCategoryData
|
||||
for _, data := range analyticsData {
|
||||
var topProduct *models.ParentCategoryTopProduct
|
||||
if data.TopProduct != nil {
|
||||
topProduct = &models.ParentCategoryTopProduct{
|
||||
ProductID: data.TopProduct.ProductID,
|
||||
ProductName: data.TopProduct.ProductName,
|
||||
QuantitySold: data.TopProduct.QuantitySold,
|
||||
Revenue: data.TopProduct.Revenue,
|
||||
}
|
||||
}
|
||||
|
||||
resultData = append(resultData, models.ProductAnalyticsPerParentCategoryData{
|
||||
TopProduct: topProduct,
|
||||
ParentCategoryID: data.ParentCategoryID,
|
||||
ParentCategoryName: data.ParentCategoryName,
|
||||
OwnerFeePercent: data.OwnerFeePercent,
|
||||
SDL: data.TotalRevenue * data.OwnerFeePercent / 100,
|
||||
TotalRevenue: data.TotalRevenue,
|
||||
TotalQuantity: data.TotalQuantity,
|
||||
CategoryCount: data.CategoryCount,
|
||||
@@ -441,7 +454,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
|
||||
// Bucket the product rows by the category they belong to
|
||||
productsByCategory := make(map[uuid.UUID][]models.ParentCategoryAnalyticsProductData)
|
||||
productRowsByCategory := make(map[uuid.UUID][]*entities.ProductAnalytics)
|
||||
for _, product := range detail.Products {
|
||||
productRowsByCategory[product.CategoryID] = append(productRowsByCategory[product.CategoryID], product)
|
||||
productsByCategory[product.CategoryID] = append(productsByCategory[product.CategoryID], models.ParentCategoryAnalyticsProductData{
|
||||
ProductID: product.ProductID,
|
||||
ProductName: product.ProductName,
|
||||
@@ -461,6 +476,12 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
})
|
||||
}
|
||||
|
||||
// Sub-categories take the owner fee of the parent category they sit under
|
||||
var ownerFeePercent float64
|
||||
if detail.Summary != nil {
|
||||
ownerFeePercent = detail.Summary.OwnerFeePercent
|
||||
}
|
||||
|
||||
categories := make([]models.ParentCategoryAnalyticsDetailData, 0, len(detail.Categories))
|
||||
for _, category := range detail.Categories {
|
||||
products := productsByCategory[category.CategoryID]
|
||||
@@ -471,6 +492,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
categories = append(categories, models.ParentCategoryAnalyticsDetailData{
|
||||
CategoryID: category.CategoryID,
|
||||
CategoryName: category.CategoryName,
|
||||
OwnerFeePercent: ownerFeePercent,
|
||||
SDL: category.TotalRevenue * ownerFeePercent / 100,
|
||||
TopProduct: topProductByRevenue(productRowsByCategory[category.CategoryID]),
|
||||
TotalRevenue: category.TotalRevenue,
|
||||
TotalQuantity: category.TotalQuantity,
|
||||
ProductCount: category.ProductCount,
|
||||
@@ -485,6 +509,9 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
summary := models.ParentCategoryAnalyticsDetailSummary{}
|
||||
if detail.Summary != nil {
|
||||
summary = models.ParentCategoryAnalyticsDetailSummary{
|
||||
TopProduct: topProductByRevenue(detail.Products),
|
||||
OwnerFeePercent: detail.Summary.OwnerFeePercent,
|
||||
SDL: detail.Summary.TotalRevenue * detail.Summary.OwnerFeePercent / 100,
|
||||
TotalRevenue: detail.Summary.TotalRevenue,
|
||||
TotalQuantity: detail.Summary.TotalQuantity,
|
||||
CategoryCount: detail.Summary.CategoryCount,
|
||||
@@ -500,6 +527,11 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The block covers a single parent category, so it reports that category's own fee
|
||||
if detail.Summary != nil {
|
||||
budget.Percentages.Owner = detail.Summary.OwnerFeePercent
|
||||
budget.Percentages.Team = 100 - detail.Summary.OwnerFeePercent
|
||||
}
|
||||
|
||||
return &models.ParentCategoryAnalyticsDetailResponse{
|
||||
OrganizationID: req.OrganizationID,
|
||||
@@ -515,6 +547,30 @@ func (p *AnalyticsProcessorImpl) GetParentCategoryAnalyticsDetail(ctx context.Co
|
||||
}, nil
|
||||
}
|
||||
|
||||
// topProductByRevenue picks the best-selling product of a parent category. A product
|
||||
// can span several rows (one per outlet price), so the rows are added up per product
|
||||
// first. Ties keep the product that was seen first.
|
||||
func topProductByRevenue(products []*entities.ProductAnalytics) *models.ParentCategoryTopProduct {
|
||||
totals := make(map[uuid.UUID]*models.ParentCategoryTopProduct)
|
||||
for _, product := range products {
|
||||
total, ok := totals[product.ProductID]
|
||||
if !ok {
|
||||
total = &models.ParentCategoryTopProduct{ProductID: product.ProductID, ProductName: product.ProductName}
|
||||
totals[product.ProductID] = total
|
||||
}
|
||||
total.QuantitySold += product.QuantitySold
|
||||
total.Revenue += product.Revenue
|
||||
}
|
||||
|
||||
var top *models.ParentCategoryTopProduct
|
||||
for _, product := range products {
|
||||
if total := totals[product.ProductID]; top == nil || total.Revenue > top.Revenue {
|
||||
top = total
|
||||
}
|
||||
}
|
||||
return top
|
||||
}
|
||||
|
||||
// startOfWeek returns the Monday 00:00 of the week containing t, in t's own location.
|
||||
func startOfWeek(t time.Time) time.Time {
|
||||
daysSinceMonday := (int(t.Weekday()) + 6) % 7
|
||||
@@ -527,16 +583,17 @@ func endOfWeek(t time.Time) time.Time {
|
||||
return startOfWeek(t).AddDate(0, 0, 7).Add(-time.Nanosecond)
|
||||
}
|
||||
|
||||
// newBudgetPeriod splits a period's revenue into the spending limits.
|
||||
func newBudgetPeriod(start, end time.Time, revenue float64, orderCount int64) models.BudgetPeriod {
|
||||
// newBudgetPeriod splits a period's revenue between the owner and the team. The owner
|
||||
// share is passed in because a parent category can carry its own owner fee percent,
|
||||
// and the team takes whatever the owner does not.
|
||||
func newBudgetPeriod(start, end time.Time, revenue, sdl float64, orderCount int64) models.BudgetPeriod {
|
||||
return models.BudgetPeriod{
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
Revenue: revenue,
|
||||
OrderCount: orderCount,
|
||||
LimitPurchase: revenue * constants.BudgetLimitPurchasePercent / 100,
|
||||
LimitOwner: revenue * constants.BudgetLimitOwnerPercent / 100,
|
||||
LimitTeam: revenue * constants.BudgetLimitTeamPercent / 100,
|
||||
PeriodStart: start,
|
||||
PeriodEnd: end,
|
||||
Revenue: revenue,
|
||||
OrderCount: orderCount,
|
||||
SDL: sdl,
|
||||
LimitTeam: revenue - sdl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,9 +607,8 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
|
||||
budget := models.BudgetCutOff{
|
||||
Percentages: models.BudgetPercentages{
|
||||
Purchase: constants.BudgetLimitPurchasePercent,
|
||||
Owner: constants.BudgetLimitOwnerPercent,
|
||||
Team: constants.BudgetLimitTeamPercent,
|
||||
Owner: constants.BudgetLimitOwnerPercent,
|
||||
Team: 100 - constants.BudgetLimitOwnerPercent,
|
||||
},
|
||||
CutOffFrom: cutOffFrom,
|
||||
CutOffTo: cutOffTo,
|
||||
@@ -573,22 +629,24 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
|
||||
var (
|
||||
totalRevenue float64
|
||||
totalSDL float64
|
||||
totalOrders int64
|
||||
monthOrder []string
|
||||
monthAccumulator = map[string]*models.BudgetMonthPeriod{}
|
||||
)
|
||||
|
||||
for week := cutOffFrom; !week.After(cutOffTo); week = week.AddDate(0, 0, 7) {
|
||||
var revenue float64
|
||||
var revenue, sdl float64
|
||||
var orderCount int64
|
||||
if row, ok := rowsByWeek[week.Format("2006-01-02")]; ok {
|
||||
revenue, orderCount = row.Revenue, row.OrderCount
|
||||
revenue, sdl, orderCount = row.Revenue, row.SDL, row.OrderCount
|
||||
}
|
||||
|
||||
period := newBudgetPeriod(week, endOfWeek(week), revenue, orderCount)
|
||||
period := newBudgetPeriod(week, endOfWeek(week), revenue, sdl, orderCount)
|
||||
budget.Weekly = append(budget.Weekly, period)
|
||||
|
||||
totalRevenue += revenue
|
||||
totalSDL += sdl
|
||||
totalOrders += orderCount
|
||||
|
||||
// A week belongs to the month of its Monday, so every week is counted once
|
||||
@@ -603,6 +661,7 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
month.WeekCount++
|
||||
month.PeriodEnd = period.PeriodEnd
|
||||
month.Revenue += revenue
|
||||
month.SDL += sdl
|
||||
month.OrderCount += orderCount
|
||||
}
|
||||
|
||||
@@ -611,11 +670,11 @@ func (p *AnalyticsProcessorImpl) buildBudgetCutOff(ctx context.Context, organiza
|
||||
budget.Monthly = append(budget.Monthly, models.BudgetMonthPeriod{
|
||||
Month: month.Month,
|
||||
WeekCount: month.WeekCount,
|
||||
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.OrderCount),
|
||||
BudgetPeriod: newBudgetPeriod(month.PeriodStart, month.PeriodEnd, month.Revenue, month.SDL, month.OrderCount),
|
||||
})
|
||||
}
|
||||
|
||||
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalOrders)
|
||||
budget.Total = newBudgetPeriod(cutOffFrom, cutOffTo, totalRevenue, totalSDL, totalOrders)
|
||||
|
||||
return budget, nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type analyticsRepositoryStub struct {
|
||||
purchasingResult *entities.PurchasingAnalytics
|
||||
purchasingTeam *entities.PurchaseTeamFilter
|
||||
budgetCutOffWeeks []*entities.BudgetCutOffWeek
|
||||
parentCategories []*entities.ProductAnalyticsPerParentCategory
|
||||
profitLossResult *entities.ProfitLossAnalytics
|
||||
exclusiveSummaryResults []*entities.ExclusiveSummaryAnalytics
|
||||
bankBalances []entities.ExclusiveSummaryBankBalance
|
||||
@@ -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) {
|
||||
@@ -47,8 +49,8 @@ func (analyticsRepositoryStub) GetProductAnalyticsPerCategory(context.Context, u
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
return nil, nil
|
||||
func (s analyticsRepositoryStub) GetProductAnalyticsPerParentCategory(context.Context, uuid.UUID, *uuid.UUID, time.Time, time.Time) ([]*entities.ProductAnalyticsPerParentCategory, error) {
|
||||
return s.parentCategories, nil
|
||||
}
|
||||
|
||||
func (analyticsRepositoryStub) GetParentCategoryAnalyticsDetail(context.Context, uuid.UUID, *uuid.UUID, uuid.UUID, time.Time, time.Time) (*entities.ParentCategoryAnalyticsDetail, error) {
|
||||
@@ -570,3 +572,61 @@ func TestAnalyticsProcessorGetExclusiveSummaryMTDBuildsMonthToDateBreakdown(t *t
|
||||
require.Len(t, result.DailySummary, 1)
|
||||
require.Len(t, result.DailyTransactions, 2)
|
||||
}
|
||||
|
||||
// A parent category with its own owner fee percent moves the owner limit away from the
|
||||
// default share, and the team limit shrinks by the same amount.
|
||||
func TestAnalyticsProcessorParentCategoryUsesOwnerFeePercent(t *testing.T) {
|
||||
monday := time.Date(2026, 9, 28, 0, 0, 0, 0, time.UTC)
|
||||
processor := NewAnalyticsProcessorImpl(&analyticsRepositoryStub{
|
||||
parentCategories: []*entities.ProductAnalyticsPerParentCategory{
|
||||
{ParentCategoryName: "Food", OwnerFeePercent: constants.BudgetLimitOwnerPercent, TotalRevenue: 1000},
|
||||
{ParentCategoryName: "Drink", OwnerFeePercent: 35, TotalRevenue: 2000},
|
||||
},
|
||||
budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
// 1000 at the default 20% plus 2000 at 35%
|
||||
{WeekStart: monday, Revenue: 3000, OrderCount: 4, SDL: 900},
|
||||
},
|
||||
}, expenseRepositoryStub{})
|
||||
|
||||
result, err := processor.GetProductAnalyticsPerParentCategory(context.Background(), &models.ProductAnalyticsPerParentCategoryRequest{
|
||||
OrganizationID: uuid.New(),
|
||||
DateFrom: monday,
|
||||
DateTo: monday.AddDate(0, 0, 6),
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Data, 2)
|
||||
require.Equal(t, float64(20), result.Data[0].OwnerFeePercent)
|
||||
require.Equal(t, float64(200), result.Data[0].SDL)
|
||||
require.Equal(t, float64(35), result.Data[1].OwnerFeePercent)
|
||||
require.Equal(t, float64(700), result.Data[1].SDL)
|
||||
|
||||
require.Len(t, result.Budget.Weekly, 1)
|
||||
require.Equal(t, float64(900), result.Budget.Weekly[0].SDL)
|
||||
require.Equal(t, float64(2100), result.Budget.Weekly[0].LimitTeam)
|
||||
require.Equal(t, float64(20), result.Budget.Percentages.Owner)
|
||||
require.Equal(t, float64(80), result.Budget.Percentages.Team)
|
||||
require.Len(t, result.Budget.Monthly, 1)
|
||||
require.Equal(t, float64(900), result.Budget.Monthly[0].SDL)
|
||||
require.Equal(t, float64(900), result.Budget.Total.SDL)
|
||||
}
|
||||
|
||||
// A product sold at several outlet prices comes back as several rows, which must be
|
||||
// added up before the best seller is picked.
|
||||
func TestTopProductByRevenueAddsUpRowsOfTheSameProduct(t *testing.T) {
|
||||
split, single := uuid.New(), uuid.New()
|
||||
|
||||
top := topProductByRevenue([]*entities.ProductAnalytics{
|
||||
{ProductID: single, ProductName: "Es Teh", QuantitySold: 5, Revenue: 500},
|
||||
{ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300},
|
||||
{ProductID: split, ProductName: "Nasi Goreng", QuantitySold: 2, Revenue: 300},
|
||||
})
|
||||
|
||||
require.NotNil(t, top)
|
||||
require.Equal(t, split, top.ProductID)
|
||||
require.Equal(t, "Nasi Goreng", top.ProductName)
|
||||
require.Equal(t, int64(4), top.QuantitySold)
|
||||
require.Equal(t, float64(600), top.Revenue)
|
||||
|
||||
require.Nil(t, topProductByRevenue(nil))
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||
weekStart := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120},
|
||||
{WeekStart: weekStart, Revenue: 10_000_000, OrderCount: 120, SDL: 2_500_000},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
@@ -93,16 +93,17 @@ func TestBuildBudgetCutOffAppliesLimits(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Len(t, budget.Weekly, 1)
|
||||
|
||||
// 60 / 20 / 20 of the week's revenue
|
||||
// The owner limit is whatever the repository summed from each parent category's
|
||||
// own fee percent, and the team takes the rest of the week's revenue
|
||||
week := budget.Weekly[0]
|
||||
require.Equal(t, float64(6_000_000), week.LimitPurchase)
|
||||
require.Equal(t, float64(2_000_000), week.LimitOwner)
|
||||
require.Equal(t, float64(2_000_000), week.LimitTeam)
|
||||
require.Equal(t, float64(2_500_000), week.SDL)
|
||||
require.Equal(t, float64(7_500_000), week.LimitTeam)
|
||||
require.Equal(t, int64(120), week.OrderCount)
|
||||
|
||||
// Totals mirror the single week
|
||||
require.Equal(t, week.Revenue, budget.Total.Revenue)
|
||||
require.Equal(t, week.LimitPurchase, budget.Total.LimitPurchase)
|
||||
require.Equal(t, week.SDL, budget.Total.SDL)
|
||||
require.Equal(t, week.LimitTeam, budget.Total.LimitTeam)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
@@ -110,8 +111,8 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
first := time.Date(2026, 8, 3, 0, 0, 0, 0, loc)
|
||||
|
||||
stub := &analyticsRepositoryStub{budgetCutOffWeeks: []*entities.BudgetCutOffWeek{
|
||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100},
|
||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60},
|
||||
{WeekStart: first, Revenue: 10_000_000, OrderCount: 100, SDL: 2_000_000},
|
||||
{WeekStart: first.AddDate(0, 0, 7), Revenue: 5_000_000, OrderCount: 60, SDL: 1_000_000},
|
||||
}}
|
||||
processor := &AnalyticsProcessorImpl{analyticsRepo: stub}
|
||||
|
||||
@@ -125,9 +126,10 @@ func TestBuildBudgetCutOffAccumulatesMonthlyFromWeeks(t *testing.T) {
|
||||
require.Equal(t, float64(15_000_000), month.Revenue)
|
||||
require.Equal(t, int64(160), month.OrderCount)
|
||||
|
||||
// The month limit is the accumulation of its weeks
|
||||
require.Equal(t, float64(9_000_000), month.LimitPurchase)
|
||||
require.Equal(t, budget.Weekly[0].LimitPurchase+budget.Weekly[1].LimitPurchase, month.LimitPurchase)
|
||||
// The month limits are the accumulation of its weeks
|
||||
require.Equal(t, float64(3_000_000), month.SDL)
|
||||
require.Equal(t, float64(12_000_000), month.LimitTeam)
|
||||
require.Equal(t, budget.Weekly[0].LimitTeam+budget.Weekly[1].LimitTeam, month.LimitTeam)
|
||||
}
|
||||
|
||||
func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||
@@ -145,7 +147,7 @@ func TestBuildBudgetCutOffEmitsWeeksWithoutSales(t *testing.T) {
|
||||
|
||||
require.Len(t, budget.Weekly, 3)
|
||||
require.Zero(t, budget.Weekly[0].Revenue)
|
||||
require.Zero(t, budget.Weekly[0].LimitPurchase)
|
||||
require.Zero(t, budget.Weekly[0].LimitTeam)
|
||||
require.Zero(t, budget.Weekly[1].Revenue)
|
||||
require.Equal(t, float64(4_000_000), budget.Weekly[2].Revenue)
|
||||
require.Equal(t, float64(4_000_000), budget.Total.Revenue)
|
||||
|
||||
@@ -3,6 +3,7 @@ package processor
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
@@ -142,6 +143,13 @@ func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *cont
|
||||
return nil, fmt.Errorf("phone number already registered")
|
||||
}
|
||||
|
||||
// Resolve the organization before an OTP is sent, rather than failing on a foreign
|
||||
// key at the last step.
|
||||
organizationID, err := p.registrationOrganization(ctx, req.OrganizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate registration token and create OTP session
|
||||
registrationToken := uuid.New().String()
|
||||
|
||||
@@ -156,6 +164,7 @@ func (p *customerAuthProcessor) StartRegistration(ctx context.Context, req *cont
|
||||
"registration_token": registrationToken,
|
||||
"name": req.Name,
|
||||
"birth_date": req.BirthDate,
|
||||
"organization_id": organizationID.String(),
|
||||
"step": "otp_sent",
|
||||
}
|
||||
|
||||
@@ -294,10 +303,14 @@ func (p *customerAuthProcessor) SetPassword(ctx context.Context, req *contract.R
|
||||
return nil, fmt.Errorf("invalid birth date format: %w", err)
|
||||
}
|
||||
|
||||
defaultOrgID := uuid.MustParse("87bec7c1-e274-4f66-bac5-84e632208470") // This should be configurable
|
||||
orgIDStr, _ := otpSession.Metadata["organization_id"].(string)
|
||||
organizationID, err := uuid.Parse(orgIDStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid registration data: organization not found, start the registration again")
|
||||
}
|
||||
|
||||
customer := &entities.Customer{
|
||||
OrganizationID: defaultOrgID,
|
||||
OrganizationID: organizationID,
|
||||
Name: name,
|
||||
PhoneNumber: &otpSession.PhoneNumber,
|
||||
BirthDate: &birthDate,
|
||||
@@ -438,3 +451,37 @@ func (p *customerAuthProcessor) ResendOtp(ctx context.Context, req *contract.Res
|
||||
}
|
||||
|
||||
// Helper functions - OTP generation is now handled by OtpProcessor
|
||||
|
||||
// registrationOrganization is the organization a new customer joins: the one the app
|
||||
// sent, which must exist, or, when the app sent none, the only organization there is.
|
||||
// With several organizations and none sent there is no way to choose, so it refuses.
|
||||
func (p *customerAuthProcessor) registrationOrganization(ctx context.Context, requested string) (uuid.UUID, error) {
|
||||
requested = strings.TrimSpace(requested)
|
||||
if requested != "" {
|
||||
id, err := uuid.Parse(requested)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("organization_id must be a valid UUID")
|
||||
}
|
||||
exists, err := p.customerAuthRepo.OrganizationExists(ctx, id)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
if !exists {
|
||||
return uuid.Nil, fmt.Errorf("organization not found")
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
ids, err := p.customerAuthRepo.OrganizationIDs(ctx, 2)
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
switch len(ids) {
|
||||
case 1:
|
||||
return ids[0], nil
|
||||
case 0:
|
||||
return uuid.Nil, fmt.Errorf("no organization exists to register customers into")
|
||||
default:
|
||||
return uuid.Nil, fmt.Errorf("organization_id is required: there is more than one organization")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,155 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
customerOrdersPageLimit = 20
|
||||
customerOrdersMaxLimit = 100
|
||||
)
|
||||
|
||||
type orderEarnedReader interface {
|
||||
EarnedByOrders(ctx context.Context, orderIDs []uuid.UUID) (map[uuid.UUID]OrderEarned, error)
|
||||
}
|
||||
|
||||
// CustomerOrderProcessor serves the customer app's order history: the customer's own
|
||||
// orders only, without costs or staff details.
|
||||
type CustomerOrderProcessor struct {
|
||||
repo repository.CustomerOrderRepository
|
||||
earned orderEarnedReader
|
||||
}
|
||||
|
||||
func NewCustomerOrderProcessor(repo repository.CustomerOrderRepository, earned orderEarnedReader) *CustomerOrderProcessor {
|
||||
return &CustomerOrderProcessor{repo: repo, earned: earned}
|
||||
}
|
||||
|
||||
// List is GET /customer/orders: the customer's orders across all outlets, newest first.
|
||||
func (p *CustomerOrderProcessor) List(ctx context.Context, customerID uuid.UUID, query models.ListCustomerOrdersQuery) (*models.PaginatedResponse[models.CustomerOrderSummary], error) {
|
||||
page, limit := query.Page, query.Limit
|
||||
if page == 0 {
|
||||
page = 1
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = customerOrdersPageLimit
|
||||
}
|
||||
if page < 1 || limit < 1 || limit > customerOrdersMaxLimit {
|
||||
return nil, fmt.Errorf("%w: page must be at least 1 and limit between 1 and %d", ErrInvalidWalletQuery, customerOrdersMaxLimit)
|
||||
}
|
||||
|
||||
rows, total, err := p.repo.ListOrders(ctx, customerID, (page-1)*limit, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
earned, err := p.earned.EarnedByOrders(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := make([]models.CustomerOrderSummary, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
data = append(data, customerOrderSummary(r, earned[r.ID]))
|
||||
}
|
||||
return &models.PaginatedResponse[models.CustomerOrderSummary]{
|
||||
Data: data,
|
||||
Pagination: models.Pagination{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
TotalPages: int((total + int64(limit) - 1) / int64(limit)),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Detail is GET /customer/orders/:id. Another customer's order is
|
||||
// repository.ErrCustomerOrderNotFound, like one that does not exist.
|
||||
func (p *CustomerOrderProcessor) Detail(ctx context.Context, customerID, orderID uuid.UUID) (*models.CustomerOrderDetail, error) {
|
||||
row, err := p.repo.GetOrder(ctx, customerID, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := p.repo.ListItems(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payments, err := p.repo.ListPayments(ctx, orderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
earned, err := p.earned.EarnedByOrders(ctx, []uuid.UUID{orderID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
detail := &models.CustomerOrderDetail{
|
||||
CustomerOrderSummary: customerOrderSummary(*row, earned[orderID]),
|
||||
TableNumber: row.TableNumber,
|
||||
Subtotal: row.Subtotal,
|
||||
DiscountAmount: row.DiscountAmount,
|
||||
TaxAmount: row.TaxAmount,
|
||||
RefundAmount: row.RefundAmount,
|
||||
Items: make([]models.CustomerOrderItem, 0, len(items)),
|
||||
Payments: make([]models.CustomerOrderPayment, 0, len(payments)),
|
||||
}
|
||||
for _, it := range items {
|
||||
modifiers := []map[string]interface{}(it.Modifiers)
|
||||
if modifiers == nil {
|
||||
modifiers = []map[string]interface{}{}
|
||||
}
|
||||
detail.Items = append(detail.Items, models.CustomerOrderItem{
|
||||
ID: it.ID,
|
||||
ProductID: it.ProductID,
|
||||
ProductName: it.ProductName,
|
||||
VariantName: it.VariantName,
|
||||
Quantity: it.Quantity,
|
||||
Weight: it.Weight,
|
||||
UnitName: it.UnitName,
|
||||
UnitPrice: it.UnitPrice,
|
||||
TotalPrice: it.TotalPrice,
|
||||
RefundQuantity: it.RefundQuantity,
|
||||
Modifiers: modifiers,
|
||||
Notes: it.Notes,
|
||||
Status: it.Status,
|
||||
})
|
||||
}
|
||||
for _, pay := range payments {
|
||||
detail.Payments = append(detail.Payments, models.CustomerOrderPayment{
|
||||
ID: pay.ID,
|
||||
MethodName: pay.MethodName,
|
||||
MethodType: pay.MethodType,
|
||||
Amount: pay.Amount,
|
||||
Status: pay.Status,
|
||||
RefundAmount: pay.RefundAmount,
|
||||
CreatedAt: pay.CreatedAt,
|
||||
})
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func customerOrderSummary(r repository.CustomerOrderRow, earned OrderEarned) models.CustomerOrderSummary {
|
||||
return models.CustomerOrderSummary{
|
||||
ID: r.ID,
|
||||
OrderNumber: r.OrderNumber,
|
||||
OutletID: r.OutletID,
|
||||
OutletName: r.OutletName,
|
||||
OrderType: r.OrderType,
|
||||
Status: r.Status,
|
||||
PaymentStatus: r.PaymentStatus,
|
||||
TotalAmount: r.TotalAmount,
|
||||
ItemCount: r.ItemCount,
|
||||
IsVoid: r.IsVoid,
|
||||
IsRefund: r.IsRefund,
|
||||
PointsEarned: earned.Points,
|
||||
CoinsEarned: earned.Coins,
|
||||
CreatedAt: r.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type customerOrderRepoFake struct {
|
||||
owner map[uuid.UUID]uuid.UUID // order -> customer
|
||||
orders []repository.CustomerOrderRow
|
||||
items map[uuid.UUID][]repository.CustomerOrderItemRow
|
||||
payments map[uuid.UUID][]repository.CustomerOrderPaymentRow
|
||||
offset int
|
||||
limit int
|
||||
}
|
||||
|
||||
func (f *customerOrderRepoFake) ListOrders(_ context.Context, customerID uuid.UUID, offset, limit int) ([]repository.CustomerOrderRow, int64, error) {
|
||||
f.offset, f.limit = offset, limit
|
||||
var mine []repository.CustomerOrderRow
|
||||
for _, o := range f.orders {
|
||||
if f.owner[o.ID] == customerID {
|
||||
mine = append(mine, o)
|
||||
}
|
||||
}
|
||||
return mine, int64(len(mine)), nil
|
||||
}
|
||||
|
||||
func (f *customerOrderRepoFake) GetOrder(_ context.Context, customerID, orderID uuid.UUID) (*repository.CustomerOrderRow, error) {
|
||||
for _, o := range f.orders {
|
||||
if o.ID == orderID && f.owner[o.ID] == customerID {
|
||||
c := o
|
||||
return &c, nil
|
||||
}
|
||||
}
|
||||
return nil, repository.ErrCustomerOrderNotFound
|
||||
}
|
||||
|
||||
func (f *customerOrderRepoFake) ListItems(_ context.Context, orderID uuid.UUID) ([]repository.CustomerOrderItemRow, error) {
|
||||
return f.items[orderID], nil
|
||||
}
|
||||
|
||||
func (f *customerOrderRepoFake) ListPayments(_ context.Context, orderID uuid.UUID) ([]repository.CustomerOrderPaymentRow, error) {
|
||||
return f.payments[orderID], nil
|
||||
}
|
||||
|
||||
type earnedFake map[uuid.UUID]OrderEarned
|
||||
|
||||
func (f earnedFake) EarnedByOrders(_ context.Context, ids []uuid.UUID) (map[uuid.UUID]OrderEarned, error) {
|
||||
out := map[uuid.UUID]OrderEarned{}
|
||||
for _, id := range ids {
|
||||
if e, ok := f[id]; ok {
|
||||
out[id] = e
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newCustomerOrderTest() (*customerOrderRepoFake, uuid.UUID, uuid.UUID, uuid.UUID) {
|
||||
customer, other := uuid.New(), uuid.New()
|
||||
mine, theirs := uuid.New(), uuid.New()
|
||||
repo := &customerOrderRepoFake{
|
||||
owner: map[uuid.UUID]uuid.UUID{mine: customer, theirs: other},
|
||||
orders: []repository.CustomerOrderRow{
|
||||
{ID: mine, OrderNumber: "ORD-1", OutletName: "Gokuna 1", Status: "completed", PaymentStatus: "completed", Subtotal: 90000, TotalAmount: 99000, ItemCount: 2, CreatedAt: time.Now()},
|
||||
{ID: theirs, OrderNumber: "ORD-2"},
|
||||
},
|
||||
}
|
||||
return repo, customer, mine, theirs
|
||||
}
|
||||
|
||||
func TestCustomerOrders_ListShowsOnlyTheCustomersOrdersWithEarning(t *testing.T) {
|
||||
repo, customer, mine, _ := newCustomerOrderTest()
|
||||
p := NewCustomerOrderProcessor(repo, earnedFake{mine: {Points: 900, Coins: 3}})
|
||||
|
||||
got, err := p.List(context.Background(), customer, models.ListCustomerOrdersQuery{Page: 2, Limit: 10})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.Data, 1)
|
||||
assert.Equal(t, "ORD-1", got.Data[0].OrderNumber)
|
||||
assert.Equal(t, int64(900), got.Data[0].PointsEarned)
|
||||
assert.Equal(t, int64(3), got.Data[0].CoinsEarned)
|
||||
assert.Equal(t, 10, repo.offset, "page 2 of 10 skips the first 10")
|
||||
assert.Equal(t, 10, repo.limit)
|
||||
assert.Equal(t, 2, got.Pagination.Page)
|
||||
}
|
||||
|
||||
func TestCustomerOrders_ListDefaultsAndLimits(t *testing.T) {
|
||||
repo, customer, _, _ := newCustomerOrderTest()
|
||||
p := NewCustomerOrderProcessor(repo, earnedFake{})
|
||||
|
||||
got, err := p.List(context.Background(), customer, models.ListCustomerOrdersQuery{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, got.Pagination.Page)
|
||||
assert.Equal(t, 20, got.Pagination.Limit)
|
||||
|
||||
for _, q := range []models.ListCustomerOrdersQuery{{Page: -1}, {Limit: 101}, {Limit: -5}} {
|
||||
_, err := p.List(context.Background(), customer, q)
|
||||
assert.ErrorIs(t, err, ErrInvalidWalletQuery, "%+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerOrders_DetailHasItemsPaymentsAndEarning(t *testing.T) {
|
||||
repo, customer, mine, _ := newCustomerOrderTest()
|
||||
variant, unit := "Large", "ons"
|
||||
weight := 4.2
|
||||
repo.items = map[uuid.UUID][]repository.CustomerOrderItemRow{
|
||||
mine: {
|
||||
{ProductName: "Kopi Susu", VariantName: &variant, Quantity: 2, UnitPrice: 25000, TotalPrice: 50000, Status: "completed"},
|
||||
{ProductName: "Ikan Tude", Quantity: 1, Weight: &weight, UnitName: &unit, UnitPrice: 4500, TotalPrice: 18900, Status: "completed"},
|
||||
},
|
||||
}
|
||||
repo.payments = map[uuid.UUID][]repository.CustomerOrderPaymentRow{
|
||||
mine: {
|
||||
{MethodName: "Card", MethodType: "card", Amount: 12500, Status: "completed"},
|
||||
{MethodName: "Cash", MethodType: "cash", Amount: 86500, Status: "completed"},
|
||||
},
|
||||
}
|
||||
p := NewCustomerOrderProcessor(repo, earnedFake{mine: {Points: 865}})
|
||||
|
||||
got, err := p.Detail(context.Background(), customer, mine)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Gokuna 1", got.OutletName)
|
||||
assert.Equal(t, float64(90000), got.Subtotal)
|
||||
assert.Equal(t, int64(865), got.PointsEarned)
|
||||
require.Len(t, got.Items, 2)
|
||||
assert.Equal(t, "Large", *got.Items[0].VariantName)
|
||||
assert.Equal(t, []map[string]interface{}{}, got.Items[0].Modifiers, "no modifiers is an empty list, not null")
|
||||
assert.Equal(t, 4.2, *got.Items[1].Weight)
|
||||
require.Len(t, got.Payments, 2)
|
||||
assert.Equal(t, "Card", got.Payments[0].MethodName)
|
||||
assert.Equal(t, float64(12500), got.Payments[0].Amount)
|
||||
}
|
||||
|
||||
func TestCustomerOrders_AnotherCustomersOrderIsNotFound(t *testing.T) {
|
||||
repo, customer, _, theirs := newCustomerOrderTest()
|
||||
_, err := NewCustomerOrderProcessor(repo, earnedFake{}).Detail(context.Background(), customer, theirs)
|
||||
assert.ErrorIs(t, err, repository.ErrCustomerOrderNotFound)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
// CustomerOutletProcessor lists the outlets the customer app shows: the active
|
||||
// outlets of the customer's organization, where their wallet can be used (K4).
|
||||
type CustomerOutletProcessor struct {
|
||||
repo repository.CustomerOutletRepository
|
||||
settings outletSettingsReader
|
||||
}
|
||||
|
||||
func NewCustomerOutletProcessor(repo repository.CustomerOutletRepository, settings outletSettingsReader) *CustomerOutletProcessor {
|
||||
return &CustomerOutletProcessor{repo: repo, settings: settings}
|
||||
}
|
||||
|
||||
// List is GET /customer/outlets.
|
||||
func (p *CustomerOutletProcessor) List(ctx context.Context, customerID uuid.UUID) ([]models.CustomerOutlet, error) {
|
||||
organizationID, err := p.repo.CustomerOrganizationID(ctx, customerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outlets, err := p.repo.ListActiveOutlets(ctx, organizationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list := make([]models.CustomerOutlet, 0, len(outlets))
|
||||
for _, o := range outlets {
|
||||
settings, err := p.settings.Outlet(ctx, o.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
list = append(list, models.CustomerOutlet{
|
||||
ID: o.ID,
|
||||
Name: o.Name,
|
||||
Address: o.Address,
|
||||
EarnsPoints: settings.Point.Enabled,
|
||||
EarnsCoins: settings.Coin.Enabled,
|
||||
})
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/repository"
|
||||
)
|
||||
|
||||
type customerOutletRepoFake struct {
|
||||
orgs map[uuid.UUID]uuid.UUID
|
||||
outlets map[uuid.UUID][]repository.CustomerOutlet
|
||||
}
|
||||
|
||||
func (f customerOutletRepoFake) CustomerOrganizationID(_ context.Context, id uuid.UUID) (uuid.UUID, error) {
|
||||
org, ok := f.orgs[id]
|
||||
if !ok {
|
||||
return uuid.Nil, repository.ErrWalletNotFound
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (f customerOutletRepoFake) ListActiveOutlets(_ context.Context, org uuid.UUID) ([]repository.CustomerOutlet, error) {
|
||||
return f.outlets[org], nil
|
||||
}
|
||||
|
||||
type outletSettingsFake map[uuid.UUID]models.OutletLoyaltySettings
|
||||
|
||||
func (f outletSettingsFake) Outlet(_ context.Context, id uuid.UUID) (*models.OutletLoyaltySettings, error) {
|
||||
s := f[id]
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func TestCustomerOutlets_ListsTheCustomersOrganizationWithLoyaltyFlags(t *testing.T) {
|
||||
customer, org, other := uuid.New(), uuid.New(), uuid.New()
|
||||
kemang, blokm := uuid.New(), uuid.New()
|
||||
addr := "Jl. Kemang Raya 10"
|
||||
repo := customerOutletRepoFake{
|
||||
orgs: map[uuid.UUID]uuid.UUID{customer: org},
|
||||
outlets: map[uuid.UUID][]repository.CustomerOutlet{
|
||||
org: {{ID: blokm, Name: "Blok M"}, {ID: kemang, Name: "Kemang", Address: &addr}},
|
||||
other: {{ID: uuid.New(), Name: "Not mine"}},
|
||||
},
|
||||
}
|
||||
settings := outletSettingsFake{
|
||||
kemang: {Point: models.LoyaltyEarnSettings{Enabled: true}},
|
||||
}
|
||||
|
||||
got, err := NewCustomerOutletProcessor(repo, settings).List(context.Background(), customer)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []models.CustomerOutlet{
|
||||
{ID: blokm, Name: "Blok M"},
|
||||
{ID: kemang, Name: "Kemang", Address: &addr, EarnsPoints: true},
|
||||
}, got)
|
||||
}
|
||||
|
||||
func TestCustomerOutlets_UnknownCustomer(t *testing.T) {
|
||||
_, err := NewCustomerOutletProcessor(customerOutletRepoFake{}, outletSettingsFake{}).List(context.Background(), uuid.New())
|
||||
assert.ErrorIs(t, err, repository.ErrWalletNotFound)
|
||||
}
|
||||
|
||||
func TestCustomerOutlets_NoOutletsIsAnEmptyList(t *testing.T) {
|
||||
customer := uuid.New()
|
||||
repo := customerOutletRepoFake{orgs: map[uuid.UUID]uuid.UUID{customer: uuid.New()}}
|
||||
got, err := NewCustomerOutletProcessor(repo, outletSettingsFake{}).List(context.Background(), customer)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, got)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
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"
|
||||
)
|
||||
|
||||
type pinVerifier interface {
|
||||
VerifyPin(ctx context.Context, customerID uuid.UUID, pin string, action PinAction, info models.CustomerPinRequestInfo) error
|
||||
}
|
||||
|
||||
// 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,100 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"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, 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.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
m["earn_mode"] = line.Settings.EarnMode
|
||||
m["earn_percent"] = line.Settings.EarnPercent
|
||||
}
|
||||
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
|
||||
// amount = 0 if basis < min_order_amount
|
||||
// amount = floor(basis / earn_per_amount) × earn_value in PER_AMOUNT mode
|
||||
// amount = floor(basis × earn_percent / 100) in PERCENTAGE mode
|
||||
// amount = min(amount, max_per_order) if max_per_order is set
|
||||
//
|
||||
// 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, settings models.OutletLoyaltySettings) EarningResult {
|
||||
basisCents := toCents(order.Subtotal) - toCents(order.DiscountAmount)
|
||||
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 || basisCents < s.MinOrderAmount*100 {
|
||||
return line
|
||||
}
|
||||
if s.EarnMode == constants.LoyaltyEarnModePercentage {
|
||||
// The percent has at most two decimals, so in hundredths it is whole and the
|
||||
// product stays in integers: cents × hundredths / (100 × 100 × 100).
|
||||
hundredths := int64(math.Round(s.EarnPercent * 100))
|
||||
if hundredths <= 0 {
|
||||
return line
|
||||
}
|
||||
line.Amount = basisCents * hundredths / 1_000_000
|
||||
} else {
|
||||
if s.EarnPerAmount <= 0 || s.EarnValue <= 0 {
|
||||
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,152 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"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, prdEarningSettings())
|
||||
assert.Equal(t, 87500.0, got.Basis)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), 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, prdEarningSettings()), CalculateEarning(withTax, prdEarningSettings()))
|
||||
assert.Equal(t, int64(500), CalculateEarning(withTax, 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}, s).Point.Amount)
|
||||
assert.Equal(t, int64(500), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount, "the minimum itself earns")
|
||||
// The minimum applies to the basis, after discount.
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 60000, DiscountAmount: 15000}, s).Point.Amount)
|
||||
// Coin has its own minimum.
|
||||
assert.Equal(t, int64(1), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Coin.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MaxPerOrder(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
max := int64(300)
|
||||
s.Point.MaxPerOrder = &max
|
||||
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 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}, 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}, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_DisabledEarnsNothing(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.Enabled = false
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 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}, 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}, 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}, 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: 905.48}, per250).Point.Amount)
|
||||
// A discount larger than the subtotal leaves nothing, never a negative amount.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 10000, DiscountAmount: 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}, 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}, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_Percentage(t *testing.T) {
|
||||
s := prdEarningSettings()
|
||||
s.Point.EarnMode = constants.LoyaltyEarnModePercentage
|
||||
s.Point.EarnPercent = 1
|
||||
|
||||
// 1% of the basis; the per-amount settings are ignored, and coin keeps its own mode.
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 97500, DiscountAmount: 10000}, s)
|
||||
assert.Equal(t, int64(875), got.Point.Amount)
|
||||
assert.Equal(t, int64(3), got.Coin.Amount)
|
||||
|
||||
// Decimals, and floor, not round: 2.5% of 87.500 is 2187.5.
|
||||
s.Point.EarnPercent = 2.5
|
||||
assert.Equal(t, int64(2187), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
s.Point.EarnPercent = 0.01
|
||||
assert.Equal(t, int64(8), CalculateEarning(&entities.Order{Subtotal: 87599.99}, s).Point.Amount)
|
||||
|
||||
// The minimum and the cap apply as in the other mode.
|
||||
s.Point.EarnPercent = 10
|
||||
s.Point.MinOrderAmount = 50000
|
||||
max := int64(6000)
|
||||
s.Point.MaxPerOrder = &max
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 49999}, s).Point.Amount)
|
||||
assert.Equal(t, int64(5000), CalculateEarning(&entities.Order{Subtotal: 50000}, s).Point.Amount)
|
||||
got = CalculateEarning(&entities.Order{Subtotal: 87500}, s)
|
||||
assert.Equal(t, int64(6000), got.Point.Amount)
|
||||
assert.True(t, got.Point.Capped)
|
||||
|
||||
// The mode and percent are frozen on the ledger row.
|
||||
assert.Equal(t, constants.LoyaltyEarnModePercentage, got.Metadata(got.Point)["earn_mode"])
|
||||
assert.Equal(t, 10.0, got.Metadata(got.Point)["earn_percent"])
|
||||
|
||||
// A zero percent earns nothing even when enabled.
|
||||
s.Point.EarnPercent = 0
|
||||
assert.Equal(t, int64(0), CalculateEarning(&entities.Order{Subtotal: 87500}, s).Point.Amount)
|
||||
}
|
||||
|
||||
func TestCalculateEarning_MetadataSnapshot(t *testing.T) {
|
||||
got := CalculateEarning(&entities.Order{Subtotal: 87500}, 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))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user