Compare commits
131
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6506108fc | ||
|
|
0f60aadbfd | ||
|
|
10de2ed3de | ||
|
|
3af16a1fbc | ||
|
|
a787dd96fd | ||
|
|
c43baa53e1 | ||
|
|
a01e651709 | ||
|
|
19afa50b9c | ||
|
|
4b7eddbd3e | ||
|
|
4c5d8f33d5 | ||
|
|
52e8fe11c6 | ||
|
|
c515c7cc0a | ||
|
|
b5d2cd491a | ||
|
|
1bb071aec6 | ||
|
|
325ad8fc5a | ||
|
|
a320e66705 | ||
|
|
d6114a9fa9 | ||
|
|
18e87398bb | ||
|
|
296708244e | ||
|
|
798a36bd6c | ||
|
|
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 | ||
|
|
ebf666c004 | ||
|
|
d3987c7114 | ||
|
|
992bb04816 | ||
|
|
1e5573af75 | ||
|
|
2c6864147b | ||
|
|
b42d141927 | ||
|
|
e6078e3c0b | ||
|
|
dbc143954c | ||
|
|
0726fcecf0 | ||
|
|
9ae5be2c33 | ||
|
|
a230199ce0 | ||
|
|
4f7e774043 | ||
|
|
793ef10ce8 | ||
|
|
a7c2d6cbb3 | ||
|
|
1d412959d7 | ||
|
|
b9ac97178f | ||
|
|
7b46da7007 | ||
|
|
2b80c92caa | ||
|
|
f7dd0bd5e8 | ||
|
|
1533914e4d | ||
|
|
bfce4b865b | ||
|
|
581e4a5453 | ||
|
|
9b0fc9a63b | ||
|
|
793919cf10 | ||
|
|
25024c210a | ||
|
|
3977370079 | ||
|
|
37bcb90ab0 | ||
|
|
e345aeee97 | ||
|
|
486d94335b | ||
|
|
7d5acb33e8 | ||
|
|
2138b44c53 | ||
|
|
503fb5734f | ||
|
|
ac06a4bbe9 | ||
|
|
87540fa1b7 | ||
|
|
66d4c9f0af | ||
|
|
55119b3e91 | ||
|
|
67a5c076e7 | ||
|
|
c1d859ebdd | ||
|
|
7a2060efdc | ||
|
|
2ad9e2f85f |
@@ -9,3 +9,7 @@ vendor
|
||||
|
||||
# Firebase service account credentials
|
||||
infra/firebase-service-account.json
|
||||
|
||||
# Config files containing secrets (manage manually on each server)
|
||||
# infra/production.yaml
|
||||
# infra/staging.yaml
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{}
|
||||
+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"]
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
#PROJECT_NAME = "enaklo-pos-backend"
|
||||
DB_USERNAME :=apskel
|
||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||
DB_HOST :=62.72.45.250
|
||||
DB_PORT :=5433
|
||||
DB_NAME :=apskel_pos
|
||||
|
||||
# ─── Environment (default: staging) ──────────────────────────────────────────
|
||||
ENV ?= staging
|
||||
|
||||
ifeq ($(ENV),production)
|
||||
DB_USERNAME :=apskel
|
||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||
DB_HOST :=62.72.45.250
|
||||
DB_PORT :=5433
|
||||
DB_NAME :=apskel_pos
|
||||
else
|
||||
DB_USERNAME :=apskel
|
||||
DB_PASSWORD :=7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk
|
||||
DB_HOST :=62.72.45.250
|
||||
DB_PORT :=5433
|
||||
DB_NAME :=apskel_pos_staging
|
||||
endif
|
||||
|
||||
DB_URL = postgres://$(DB_USERNAME):$(DB_PASSWORD)@$(DB_HOST):$(DB_PORT)/$(DB_NAME)?sslmode=disable
|
||||
|
||||
@@ -16,16 +28,21 @@ endif
|
||||
.SILENT: help
|
||||
help:
|
||||
@echo
|
||||
@echo "Usage: make [command]"
|
||||
@echo "Usage: make [command] [ENV=staging|production]"
|
||||
@echo
|
||||
@echo "Commands:"
|
||||
@echo " run Run server (default: staging)"
|
||||
@echo " run ENV=production Run server with production config"
|
||||
@echo
|
||||
@echo " rename-project name={name} Rename project"
|
||||
@echo
|
||||
@echo " build-http Build http server"
|
||||
@echo
|
||||
@echo " migration-create name={name} Create migration"
|
||||
@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"
|
||||
@@ -89,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)
|
||||
@@ -114,7 +138,11 @@ fmt:
|
||||
@go fmt ./...
|
||||
|
||||
start:
|
||||
go run main.go --env-path .env
|
||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||
|
||||
.SILENT: run
|
||||
run:
|
||||
ENV_MODE=$(ENV) go run cmd/server/main.go
|
||||
|
||||
# Default
|
||||
|
||||
|
||||
@@ -15,15 +15,19 @@ Makefile requires installed dependecies:
|
||||
```shell
|
||||
$ make
|
||||
|
||||
Usage: make [command]
|
||||
Usage: make [command] [ENV=staging|production]
|
||||
|
||||
Commands:
|
||||
run Run server (default: staging)
|
||||
run ENV=production Run server with production config
|
||||
|
||||
rename-project name={name} Rename project
|
||||
|
||||
|
||||
build-http Build http server
|
||||
|
||||
migration-create name={name} Create migration
|
||||
migration-up Up migrations
|
||||
migration-up ENV=production Up migrations (production DB)
|
||||
migration-down Down last migration
|
||||
|
||||
docker-up Up docker services
|
||||
@@ -36,24 +40,16 @@ Commands:
|
||||
|
||||
## HTTP Server
|
||||
|
||||
```shell
|
||||
$ ./bin/http-server --help
|
||||
|
||||
Usage: http-server
|
||||
|
||||
Flags:
|
||||
-h, --help Show mycontext-sensitive help.
|
||||
--env-path=STRING Path to env config file
|
||||
```
|
||||
|
||||
**Configuration** is based on the environment variables. See [.env.template](.env).
|
||||
The server takes no CLI flags. It reads `ENV_MODE` and loads the matching YAML file from
|
||||
[infra/](infra/) — see [Running the Application](#running-the-application) for details.
|
||||
|
||||
```shell
|
||||
# Expose env vars before and start server
|
||||
$ ./bin/http-server
|
||||
# Build, then start with the staging config (default)
|
||||
$ go build -o ./bin/http-server ./cmd/server/main.go
|
||||
$ ENV_MODE=staging ./bin/http-server
|
||||
|
||||
# Expose env vars from the file and start server
|
||||
$ ./bin/http-server --env-path ./config/env/.env
|
||||
# Start with the production config
|
||||
$ ENV_MODE=production ./bin/http-server
|
||||
```
|
||||
|
||||
## API Docs
|
||||
@@ -124,7 +120,7 @@ Handler → Service → Processor → Repository
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
- `GET /api/v1/health` - Health check endpoint
|
||||
- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`)
|
||||
|
||||
### Organizations
|
||||
- `POST /api/v1/organizations` - Create organization
|
||||
@@ -157,73 +153,139 @@ Handler → Service → Processor → Repository
|
||||
- `PUT /api/v1/order-items/{id}` - Update order item
|
||||
- `DELETE /api/v1/order-items/{id}` - Remove order item
|
||||
|
||||
## Installation
|
||||
## Running the Application
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd apskel-pos-backend
|
||||
```
|
||||
### Prerequisites
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
go mod tidy
|
||||
```
|
||||
| Tool | Version | Needed for |
|
||||
|------|---------|------------|
|
||||
| [Go](https://go.dev/doc/install) | 1.24+ | building & running the server |
|
||||
| [golang-migrate](https://github.com/golang-migrate/migrate) | latest | `make migration-*` targets |
|
||||
| [make](https://www.gnu.org/software/make/) | any | shortcut commands (Windows: use Git Bash / WSL, see note below) |
|
||||
| [docker & docker-compose](https://docs.docker.com/compose/) | optional | running Postgres/Redis locally |
|
||||
| [air](https://github.com/air-verse/air) | optional | hot reload during development (`.air.toml` is already configured) |
|
||||
|
||||
3. **Set up database**
|
||||
```bash
|
||||
# Set your PostgreSQL database URL
|
||||
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
||||
```
|
||||
|
||||
4. **Run migrations**
|
||||
```bash
|
||||
make migration-up
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Development
|
||||
### 1. Clone & install dependencies
|
||||
|
||||
```bash
|
||||
# Start the server
|
||||
go run cmd/server/main.go -port 8080 -db-url "postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
||||
|
||||
# Or using environment variable
|
||||
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
|
||||
go run cmd/server/main.go -port 8080
|
||||
git clone <repository-url>
|
||||
cd apskel-pos-backend
|
||||
go mod download
|
||||
```
|
||||
|
||||
### Using Make Commands
|
||||
### 2. Configuration
|
||||
|
||||
Configuration is **not** read from `.env` files — it is loaded from YAML files in [infra/](infra/)
|
||||
by [config/configs.go](config/configs.go) using viper.
|
||||
|
||||
The file is selected by the `ENV_MODE` environment variable:
|
||||
|
||||
| `ENV_MODE` | Config file loaded |
|
||||
|------------|--------------------|
|
||||
| `local` | `infra/local.yaml` |
|
||||
| `development` | `infra/development.yaml` |
|
||||
| `staging` *(default)* | `infra/staging.yaml` |
|
||||
| `production` | `infra/production.yaml` |
|
||||
|
||||
Any other/unset value falls back to `staging`. Only `staging.yaml` and `production.yaml` are
|
||||
committed — for `local`/`development` copy one of them first:
|
||||
|
||||
```bash
|
||||
# Run the application
|
||||
make start
|
||||
|
||||
# Format code
|
||||
make fmt
|
||||
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Build for production
|
||||
make build-http
|
||||
|
||||
# Docker operations
|
||||
make docker-up
|
||||
make docker-down
|
||||
|
||||
# Database migrations
|
||||
make migration-create name=create_users_table
|
||||
make migration-up
|
||||
make migration-down
|
||||
cp infra/staging.yaml infra/local.yaml
|
||||
```
|
||||
|
||||
Two important notes:
|
||||
|
||||
* The config path is **relative to the working directory**, so always run the server from the
|
||||
repository root, otherwise viper panics with `failed to read config file`.
|
||||
* Push notifications need `infra/firebase-service-account.json` (git-ignored). Without it, obtain
|
||||
the file from the team before enabling FCM features.
|
||||
|
||||
### 3. Run migrations
|
||||
|
||||
The migration targets build the DB URL from the credentials at the top of the [Makefile](Makefile):
|
||||
|
||||
```bash
|
||||
make migration-up # staging DB (default)
|
||||
make migration-up ENV=production # production DB
|
||||
|
||||
make migration-create name=create_cash_advances_table
|
||||
make migration-down # roll back the last migration
|
||||
make migration-force version=87 # clear a dirty migration state
|
||||
```
|
||||
|
||||
### 4. Start the server
|
||||
|
||||
```bash
|
||||
make run # ENV_MODE=staging
|
||||
make run ENV=production # ENV_MODE=production
|
||||
```
|
||||
|
||||
`make run` is just a wrapper around:
|
||||
|
||||
```bash
|
||||
ENV_MODE=staging go run cmd/server/main.go
|
||||
```
|
||||
|
||||
The server listens on the `server.port` value from the loaded YAML (**4000** for both staging and
|
||||
production). Verify it is up:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4000/health
|
||||
```
|
||||
|
||||
All application routes live under `/api/v1` (see [internal/router/router.go](internal/router/router.go)).
|
||||
|
||||
#### Windows note
|
||||
|
||||
The `run`/`start` targets use POSIX inline env-var syntax, which `cmd.exe` and PowerShell do not
|
||||
understand. Either run `make` from Git Bash / WSL, or start the server directly:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
$env:ENV_MODE = "staging"; go run cmd/server/main.go
|
||||
```
|
||||
|
||||
```cmd
|
||||
:: cmd.exe
|
||||
set ENV_MODE=staging && go run cmd/server/main.go
|
||||
```
|
||||
|
||||
#### Hot reload
|
||||
|
||||
```bash
|
||||
ENV_MODE=local air # rebuilds ./tmp/main on every .go change
|
||||
```
|
||||
|
||||
### 5. Other commands
|
||||
|
||||
```bash
|
||||
make # show all available targets
|
||||
make fmt # go fmt ./...
|
||||
make test # go test ./... -v
|
||||
|
||||
# Build a binary (make build-http still points at the old ./cmd/http path)
|
||||
go build -o ./bin/http-server ./cmd/server/main.go
|
||||
```
|
||||
|
||||
### Running with Docker
|
||||
|
||||
`docker-compose.yaml` provides Postgres (`5432`), Redis (`6379`), and the API image. See
|
||||
[DOCKER.md](DOCKER.md) for the full workflow.
|
||||
|
||||
```bash
|
||||
make docker-up # docker-compose up -d
|
||||
make docker-down # docker-compose down
|
||||
```
|
||||
|
||||
If you use the containerised Postgres/Redis, point `infra/local.yaml` at `localhost:5432` /
|
||||
`localhost:6379` instead of the remote hosts baked into `staging.yaml`.
|
||||
|
||||
## Example API Usage
|
||||
|
||||
### Create Organization
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/organizations \
|
||||
curl -X POST http://localhost:4000/api/v1/organizations \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "My Restaurant",
|
||||
@@ -233,7 +295,7 @@ curl -X POST http://localhost:8080/api/v1/organizations \
|
||||
|
||||
### Create User
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/users \
|
||||
curl -X POST http://localhost:4000/api/v1/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"organization_id": "uuid-here",
|
||||
@@ -247,7 +309,7 @@ curl -X POST http://localhost:8080/api/v1/users \
|
||||
|
||||
### Create Order with Items
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/orders \
|
||||
curl -X POST http://localhost:4000/api/v1/orders \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"outlet_id": "uuid-here",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+2
-1
@@ -12,13 +12,14 @@ import (
|
||||
const (
|
||||
YAML_PATH = "infra/%s"
|
||||
ENV_MODE = "ENV_MODE"
|
||||
DEFAULT_ENV_MODE = "development"
|
||||
DEFAULT_ENV_MODE = "staging"
|
||||
)
|
||||
|
||||
var (
|
||||
validEnvMode = map[string]struct{}{
|
||||
"local": {},
|
||||
"development": {},
|
||||
"staging": {},
|
||||
"production": {},
|
||||
}
|
||||
)
|
||||
|
||||
+168
-15
@@ -1,24 +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"
|
||||
PORT="4000"
|
||||
LEGACY_CONTAINER="apskel-pos" # nama lama sebelum nama container memakai environment
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
usage() {
|
||||
echo "Usage: $0 <staging|production> [--rollback] [--yes]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "🐳 Building Docker image (production target)..."
|
||||
docker build --target production -t $APP_NAME:latest .
|
||||
fail() {
|
||||
echo "❌ $*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f $APP_NAME 2>/dev/null || true
|
||||
# ─── Argumen ──────────────────────────────────────────────────────────────────
|
||||
ENV_MODE="${1:-}"
|
||||
shift || true
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name $APP_NAME \
|
||||
-p $PORT:$PORT \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
$APP_NAME:latest
|
||||
ROLLBACK=false
|
||||
ASSUME_YES=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--rollback) ROLLBACK=true ;;
|
||||
--yes|-y) ASSUME_YES=true ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "✅ Deployment complete."
|
||||
# Environment wajib disebut eksplisit, tidak lagi ditebak dari branch aktif
|
||||
case "$ENV_MODE" in
|
||||
production)
|
||||
EXPECTED_BRANCH="main"
|
||||
PORT="4000"
|
||||
;;
|
||||
staging)
|
||||
EXPECTED_BRANCH="staging"
|
||||
PORT="4001"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
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 ""
|
||||
|
||||
# ─── 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
|
||||
|
||||
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" .
|
||||
|
||||
run_container "$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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,930 @@
|
||||
# Integrasi Backoffice: Loyalitas & EnakGame
|
||||
|
||||
**Untuk:** tim backoffice (dashboard owner/admin) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026
|
||||
|
||||
Kamu mengerjakan **backoffice** yang dipakai owner, admin, dan manager organisasi untuk
|
||||
mengelola program loyalitas: pengaturan EnakPoint & EnakCoin, wallet customer, voucher,
|
||||
dan EnakGame (game, hadiah, budget, event, analytics). Jangan mengarang endpoint,
|
||||
field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke
|
||||
tim backend.
|
||||
|
||||
Dokumen ini menggantikan `backoffice-enakpoint.md`, bagian dashboard di
|
||||
`integration-enakpoint.md` dan `api-enakpoint.md`, serta langkah admin di
|
||||
`enakgame-spin.md`. Alasan di balik aturannya ada di
|
||||
[`prd-point-coin.md`](./prd-point-coin.md), [`enakgame-prd.md`](./enakgame-prd.md), dan
|
||||
[`rfc-enakgame.md`](./rfc-enakgame.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konvensi
|
||||
|
||||
**Akses.** Semua endpoint butuh login user dan otomatis dibatasi ke organisasi user itu;
|
||||
data organisasi lain dijawab `404`.
|
||||
|
||||
| Aksi | Role |
|
||||
|---|---|
|
||||
| Membaca semua data di dokumen ini, serta membuat/mengubah game | superadmin, admin, manager, owner, purchasing |
|
||||
| Mengubah reward config, budget, event, voucher, dan menerima rekomendasi | superadmin, admin, manager, owner (**loyalty manager**) |
|
||||
|
||||
Sembunyikan tombol ubah untuk role yang tidak boleh; server tetap menolaknya (`403`).
|
||||
|
||||
**Format response.** Sukses `{ "success": true, "data": … }`; gagal
|
||||
`{ "success": false, "errors": [{ "code", "entity", "cause" }] }`. Contoh response di
|
||||
dokumen ini adalah isi `data`. Daftar berhalaman isinya
|
||||
`{ "data": [ … ], "pagination": { "page", "limit", "total_count", "total_pages" } }`,
|
||||
jadi pada response mentah array-nya ada di `data.data`; `limit` maks. 100 (default 20).
|
||||
|
||||
**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.
|
||||
|
||||
**Body ketat.** Endpoint EnakGame dan voucher (`/marketing/enakgame/*`, `/marketing/vouchers/*`) serta `PUT` setting menolak
|
||||
field yang tidak dikenal (`310`), supaya salah ketik tidak diam-diam diabaikan. Pada
|
||||
`PUT`, field yang tidak dikirim tetap memakai nilai sekarang.
|
||||
|
||||
---
|
||||
|
||||
## 2. Layar yang perlu dibuat
|
||||
|
||||
| Layar | Endpoint | Tempat di menu (usulan) |
|
||||
|---|---|---|
|
||||
| Setting loyalitas outlet | `GET` / `PUT /outlets/:outlet_id/loyalty-settings` | Outlet → detail → 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 → tab Wallet |
|
||||
| Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dari baris riwayat wallet |
|
||||
| PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail → tab Keamanan |
|
||||
| Game | `/marketing/enakgame/games` | EnakGame → Game |
|
||||
| Hadiah game (reward config) | `/marketing/enakgame/games/:id/reward-configs`, `/reward-configs/:id/activate` | EnakGame → Game → tab Hadiah |
|
||||
| Budget + metrik + rekomendasi | `/marketing/enakgame/budgets` | EnakGame → Budget |
|
||||
| Event | `/marketing/enakgame/events` | EnakGame → Event |
|
||||
| Voucher + kode | `/marketing/vouchers` | Marketing → Voucher |
|
||||
| Analytics | `/marketing/enakgame/analytics/games`, `/analytics/economy` | EnakGame → Analytics |
|
||||
|
||||
---
|
||||
|
||||
## 3. 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.
|
||||
|
||||
```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 | `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`.
|
||||
|
||||
**Mode earning.** Tampilkan hanya field mode yang dipilih. Field mode lain tetap
|
||||
tersimpan di server. Pada mode `PERCENTAGE` jumlahnya `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.
|
||||
|
||||
---
|
||||
|
||||
## 4. Setting loyalitas organisasi
|
||||
|
||||
Nilai rupiah EnakPoint, kurs exchange, batas transfer, kedaluwarsa, dan batas hadiah
|
||||
EnakGame berlaku sama untuk semua outlet. 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 §5" },
|
||||
"coin_expiry": { "…": "lihat §5" },
|
||||
"enakgame": { "user_daily_limit": 0, "global_daily_limit": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
| 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, per currency, reset tengah malam WIB |
|
||||
| `enakgame.user_daily_limit` | Maks. EnakCoin dari EnakGame per customer per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB |
|
||||
| `enakgame.global_daily_limit` | Maks. EnakCoin dari EnakGame seluruh organisasi per hari | 0 = tanpa batas | ≥ 0, reset tengah malam WIB |
|
||||
|
||||
Hadiah yang melewati batas harian **dipotong ke sisa batas**, tidak dibatalkan; bila
|
||||
sisanya 0, hadiahnya 0. Batas per game ada di `result_rules.daily_reward_limit` (§8.3).
|
||||
|
||||
### 4.1 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, §5).
|
||||
5. Konfirmasi memanggil `PUT` yang sama tanpa `dry_run`.
|
||||
|
||||
### 4.2 Dialog dampak
|
||||
|
||||
| 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: "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.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pengaturan kedaluwarsa
|
||||
|
||||
Kedaluwarsa diatur terpisah untuk EnakPoint (`point_expiry`) dan EnakCoin
|
||||
(`coin_expiry`). Defaultnya mati; 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, `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. Saldo yang
|
||||
didapat kurang dari `grace_months` sebelum tanggal itu ikut ke tanggal berikutnya: saldo
|
||||
1 Oktober dengan tanggung 3 bulan hangus 31 Desember tahun depan. Pakai pemilih
|
||||
tanggal+bulan tanpa tahun.
|
||||
|
||||
**Sejak didapat (`ROLLING`).** Tiap saldo berlaku `period` hari atau bulan sejak masuk.
|
||||
Dengan `end_of_month`, saldo yang didapat 14 Maret 2026 hangus 31 Maret 2027.
|
||||
|
||||
**Preview.** `GET`, `PUT`, dan dry run membawa `expiry_preview.point` dan `.coin`:
|
||||
kapan saldo yang didapat sekarang kedaluwarsa (`null` = tidak). Tampilkan "EnakPoint
|
||||
yang didapat hari ini kedaluwarsa pada 31 Des 2026." 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. Dry run mengembalikan `expiry_activations` (`currency`,
|
||||
`lots`, `amount`, `expires_at`); tampilkan di dialog konfirmasi dengan kalimat tegas,
|
||||
mis. "1.250.000 EnakPoint milik customer akan kedaluwarsa pada 31 Des 2027. Tindakan ini
|
||||
tidak bisa dibatalkan dengan mematikan kedaluwarsa."
|
||||
|
||||
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. Customer mendapat push `reminder_days` hari sebelumnya
|
||||
dan saat hangus.
|
||||
|
||||
---
|
||||
|
||||
## 6. Wallet customer
|
||||
|
||||
Tab Wallet di detail customer dipakai untuk menangani komplain: melihat saldo dan
|
||||
asal-usulnya, mengoreksi saldo, dan menelusuri satu mutasi sampai ke asalnya.
|
||||
|
||||
### 6.1 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; tanggal WIB, inklusif)
|
||||
|
||||
```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 lewat tanggal tapi belum
|
||||
diproses job kedaluwarsa (paling lama sekitar 15 menit).
|
||||
- **Lot:** paket saldo yang masih berisi, urut dari yang paling cepat kedaluwarsa. Tandai
|
||||
`expired: true`.
|
||||
- **Riwayat:** ditambah nama asli yang disamarkan untuk customer: `counterparty`,
|
||||
`created_by` (admin pelaku adjustment), `outlet`, `reason`, dan `metadata`.
|
||||
|
||||
| `type` | Mata uang | Label | `source` / `destination` |
|
||||
|---|---|---|---|
|
||||
| `EARN` / `EARN_REVERSAL` | keduanya | Dari belanja / Ditarik (void/refund) | `ORDER` |
|
||||
| `EXCHANGE_OUT` / `EXCHANGE_IN` | COIN / POINT | Tukar EnakCoin ke EnakPoint | `WALLET_TX` (baris pasangannya) |
|
||||
| `TRANSFER_OUT` / `TRANSFER_IN` | keduanya | Transfer antar customer | `WALLET_TX` (baris pasangannya) |
|
||||
| `GAME_SPEND` | COIN | Biaya main game | `GAME_SESSION` (data lama: `GAME_PLAY`) |
|
||||
| `GAME_SPEND_REFUND` | COIN | Biaya main dikembalikan | `GAME_SESSION` |
|
||||
| `GAME_REWARD` | COIN | Hadiah game (`metadata.budget_id`: budget yang membayar) | `GAME_SESSION` |
|
||||
| `REWARD_REDEEM` | POINT | Ditukar ke voucher | `REWARD_REDEMPTION` |
|
||||
| `REWARD_REDEEM_REFUND` | POINT | Penukaran voucher gagal, dikembalikan | `REWARD_REDEMPTION` |
|
||||
| `EXPIRE` | keduanya | Kedaluwarsa | `LOT` |
|
||||
| `ADJUSTMENT` | keduanya | Koreksi admin | `USER` |
|
||||
| `MIGRATION` | keduanya | Saldo dari sistem lama | `LEGACY_POINTS` / `LEGACY_TOKENS` |
|
||||
|
||||
### 6.2 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: 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 bahwa
|
||||
adjustment tidak disertai pembayaran uang, jadi alasan tidak boleh "pencairan".
|
||||
|
||||
### 6.3 Telusuri mutasi
|
||||
|
||||
Tombol Telusuri di setiap baris riwayat 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
|
||||
adalah asal pertama saldo, mis. `EARN`, `GAME_REWARD`, `ADJUSTMENT`, atau `MIGRATION`;
|
||||
bila `reference_type` = `ORDER`, jadikan tautan ke detail order. Mutasi keluar
|
||||
menampilkan lot yang dipakai; mutasi masuk menampilkan lot yang dibuatnya.
|
||||
|
||||
---
|
||||
|
||||
## 7. PIN dan riwayat setting
|
||||
|
||||
### 7.1 PIN & keamanan customer
|
||||
|
||||
Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aksi adalah
|
||||
menghapusnya (mis. customer ganti nomor HP), sehingga customer membuat PIN baru lewat OTP.
|
||||
|
||||
- `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) |
|
||||
|
||||
### 7.2 Riwayat perubahan setting
|
||||
|
||||
`GET /marketing/loyalty-settings/history?page=1&limit=20` untuk setting organisasi;
|
||||
tambah `&outlet_id=…` untuk 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 default. Tampilkan `key` dengan label yang
|
||||
sama seperti di form (mis. `loyalty.point.value` → "Nilai 1 EnakPoint",
|
||||
`enakgame.limit.user_daily` → "Maks. EnakCoin per customer per hari"), dan `changed_by`
|
||||
sebagai nama user.
|
||||
|
||||
---
|
||||
|
||||
## 8. EnakGame: game dan hadiah
|
||||
|
||||
Semua game (spin, raffle, minigame) adalah game EnakGame: customer membayar `entry_cost`
|
||||
EnakCoin per main, dan hadiahnya EnakCoin yang dihitung server dari **reward config**
|
||||
game itu. Game client (Phaser) dibuat tim EnakGame dan di-host di `game_url`.
|
||||
|
||||
### 8.1 Game
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|---|---|---|
|
||||
| `POST` | `/marketing/enakgame/games` | Objek game |
|
||||
| `GET` | `/marketing/enakgame/games` | `?status=&search=&page=&limit=` (game `ARCHIVED` hanya tampil bila diminta lewat `status`) |
|
||||
| `GET` | `/marketing/enakgame/games/:id` | – |
|
||||
| `PUT` | `/marketing/enakgame/games/:id` | Field yang diubah saja; status tidak lewat sini |
|
||||
| `PUT` | `/marketing/enakgame/games/:id/status` | `{ "status": "INACTIVE", "reason": "…" }` |
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Spin Harian",
|
||||
"type": "SPIN",
|
||||
"slug": "spin",
|
||||
"description": "Putar roda setiap hari",
|
||||
"thumbnail_url": "https://…/spin.png",
|
||||
"game_url": "https://…/spin/index.html",
|
||||
"version": "1.2.0",
|
||||
"status": "DRAFT",
|
||||
"entry_cost": 5,
|
||||
"session_ttl_seconds": 600,
|
||||
"result_rules": { "max_score": 5000, "min_duration_seconds": 10, "daily_reward_limit": 10000 }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Validasi |
|
||||
|---|---|---|
|
||||
| `name` | Nama game | wajib, maks. 255 |
|
||||
| `type` | Jenis | `SPIN`, `RAFFLE`, `MINIGAME` (default `MINIGAME`) |
|
||||
| `slug` | Kode unik | huruf kecil, angka, tanda `-` tunggal, maks. 100, unik per organisasi |
|
||||
| `thumbnail_url`, `game_url` | Gambar, URL game | maks. 500; `game_url` dari tim EnakGame |
|
||||
| `version` | Versi game | maks. 50 |
|
||||
| `status` | Status awal | hanya saat buat: `DRAFT` (default), `ACTIVE`, `INACTIVE` |
|
||||
| `entry_cost` | Biaya main (EnakCoin) | ≥ 1; tidak ada game gratis |
|
||||
| `session_ttl_seconds` | Batas waktu satu main | 1–86.400 detik, default 600 |
|
||||
| `result_rules` | Validasi hasil (§8.3) | opsional |
|
||||
|
||||
**Status game:**
|
||||
|
||||
| `status` | Arti |
|
||||
|---|---|
|
||||
| `DRAFT` | Disiapkan, belum tampil di aplikasi |
|
||||
| `ACTIVE` | Tampil dan bisa dimainkan (butuh reward config aktif dan budget global, §9) |
|
||||
| `INACTIVE` | Disembunyikan. Session yang sedang berjalan direfund otomatis |
|
||||
| `ARCHIVED` | Pensiun permanen; tidak bisa diubah lagi. Game lama sebelum EnakGame berstatus ini |
|
||||
|
||||
Mengubah `entry_cost` tidak memengaruhi session yang sudah berjalan.
|
||||
|
||||
### 8.2 Reward config (hadiah)
|
||||
|
||||
Hadiah diatur sebagai **versi**: versi tidak pernah diedit; perubahan = versi baru, lalu
|
||||
diaktifkan. Satu game hanya punya satu versi `ACTIVE`; session memakai versi yang aktif
|
||||
saat dimulai.
|
||||
|
||||
| Method | Path | Body |
|
||||
|---|---|---|
|
||||
| `GET` | `/marketing/enakgame/games/:id/reward-configs` | Semua versi, terbaru di atas |
|
||||
| `POST` | `/marketing/enakgame/games/:id/reward-configs` | `{ "reward_type", "rules", "max_reward", "effective_at", "reason" }` → versi baru `DRAFT` |
|
||||
| `POST` | `/marketing/enakgame/reward-configs/:id/activate` | `{ "reason": "…" }` → versi ini `ACTIVE`, versi aktif sebelumnya `RETIRED` |
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…", "game_id": "…", "version": 3, "reward_type": "FIXED", "rules": { "amount": 9 },
|
||||
"max_reward": 9, "status": "ACTIVE", "effective_at": "…", "created_by": "…", "reason": "…",
|
||||
"base_config_id": "…", "multiplier": 0.9, "budget_id": "…", "created_at": "…"
|
||||
}
|
||||
```
|
||||
|
||||
`base_config_id`, `multiplier`, dan `budget_id` hanya terisi pada versi yang dibuat
|
||||
Budget Controller (§9.3). Tampilkan badge "Disesuaikan Budget Controller × 0,90".
|
||||
Versi `RETIRED` tidak bisa diaktifkan lagi; untuk kembali, buat versi baru dengan aturan
|
||||
lama.
|
||||
|
||||
**Empat jenis `reward_type`:**
|
||||
|
||||
| `reward_type` | `rules` | Hasil yang dikirim game |
|
||||
|---|---|---|
|
||||
| `FIXED` | `{ "amount": 5 }` | Apa saja; selalu 5 |
|
||||
| `SCORE_BASED` | `{ "bands": [{ "min": 0, "max": 100, "amount": 1 }, { "min": 101, "amount": 20 }] }` | `score` |
|
||||
| `OUTCOME_BASED` | `{ "outcomes": { "PERFECT": 20, "GOOD": 10, "FAIL": 0 } }` | `outcome` |
|
||||
| `PROBABILITY` | `{ "table": [{ "weight": 1, "amount": 1000, "label": "Jackpot" }, { "weight": 999, "amount": 0, "label": "Zonk" }] }` | – (server mengundi) |
|
||||
|
||||
- `SCORE_BASED`: band urut mulai dari 0, tanpa celah dan tanpa tumpang tindih; hanya band
|
||||
terakhir boleh tanpa `max`. Skor di luar semua band → hadiah 0.
|
||||
- `OUTCOME_BASED`: `outcome` yang tidak terdaftar → hadiah 0.
|
||||
- `PROBABILITY`: `weight` bilangan bulat ≥ 1; peluang = `weight` ÷ total weight.
|
||||
`label` opsional (maks. 100) dan tampil di roda spin. Customer melihat segmen dan
|
||||
hadiahnya, tidak pernah weight-nya.
|
||||
- Semua `amount` ≥ 0, EnakCoin bulat.
|
||||
- `max_reward`: batas atas hadiah **total** per main, termasuk tambahan event (§10).
|
||||
0 = tanpa batas. Bila lewat, yang dipotong lebih dulu adalah tambahan event dengan
|
||||
prioritas terendah.
|
||||
|
||||
Tampilkan editor sesuai jenis (bukan textarea JSON) dan preview, mis. tabel peluang
|
||||
"Jackpot 0,1% · Zonk 99,9%" untuk `PROBABILITY`.
|
||||
|
||||
### 8.3 Validasi hasil (`result_rules`)
|
||||
|
||||
Hasil dari game diperiksa server. Hasil yang tidak lolos tetap dicatat, tapi hadiahnya
|
||||
0 dan session ditandai mencurigakan.
|
||||
|
||||
| Field | Arti |
|
||||
|---|---|
|
||||
| `max_score` | Skor maksimal yang masuk akal |
|
||||
| `min_duration_seconds` | Main lebih cepat dari ini dianggap curang |
|
||||
| `max_score_per_second` | Laju skor maksimal |
|
||||
| `outcomes` | Daftar `outcome` yang diterima; kosong = semua |
|
||||
| `daily_reward_limit` | Maks. EnakCoin yang boleh diberikan game ini per hari (seluruh customer) |
|
||||
|
||||
Semua opsional. Isi bersama tim EnakGame, karena mereka tahu skor dan durasi wajar
|
||||
game-nya.
|
||||
|
||||
### 8.4 Membuat spin
|
||||
|
||||
1. **Game:** `POST /marketing/enakgame/games` dengan
|
||||
`{ "name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "DRAFT", "thumbnail_url": "…", "game_url": "…" }`.
|
||||
2. **Hadiah:** `POST /marketing/enakgame/games/:id/reward-configs`:
|
||||
|
||||
```json
|
||||
{
|
||||
"reward_type": "PROBABILITY",
|
||||
"max_reward": 50,
|
||||
"rules": { "table": [
|
||||
{ "weight": 50, "amount": 0, "label": "Zonk" },
|
||||
{ "weight": 30, "amount": 3, "label": "3 Coin" },
|
||||
{ "weight": 15, "amount": 10, "label": "10 Coin" },
|
||||
{ "weight": 5, "amount": 50, "label": "Jackpot" }
|
||||
] },
|
||||
"reason": "Spin pertama"
|
||||
}
|
||||
```
|
||||
|
||||
Satu baris `table` = satu segmen roda, urut searah gambar roda. `max_reward` minimal
|
||||
sebesar `amount` terbesar; beri ruang lebih bila ingin event bisa menambah hadiah.
|
||||
3. **Aktifkan:** `POST /marketing/enakgame/reward-configs/:id/activate`.
|
||||
4. **Budget:** pastikan ada budget global bulan berjalan (§9.1).
|
||||
5. **Tayangkan:** `PUT /marketing/enakgame/games/:id/status` dengan `{ "status": "ACTIVE" }`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Budget
|
||||
|
||||
Budget adalah rupiah yang boleh dihabiskan organisasi untuk hadiah EnakGame. Biaya
|
||||
dihitung dari **voucher yang benar-benar ditukar**: saat customer menukar EnakPoint yang
|
||||
asalnya dari hadiah game (EnakCoin hadiah → ditukar ke EnakPoint → voucher), nilai
|
||||
voucher (`face_value`) dicatat sebagai biaya budget yang membayar hadiah itu. EnakPoint
|
||||
dari belanja tidak dihitung. Entry cost yang dibayar customer tidak menambah budget.
|
||||
|
||||
### 9.1 Budget global dan event
|
||||
|
||||
| `scope` | Membayar | Aturan |
|
||||
|---|---|---|
|
||||
| `GLOBAL` | Hadiah dasar semua game | Satu per periode; periode tidak boleh tumpang tindih. **Tanpa budget global yang mencakup hari ini, customer tidak bisa mulai main.** Setiap hari sistem membuat budget bulan berikutnya dari budget yang sedang berjalan (jumlah dan threshold sama) bila belum ada |
|
||||
| `EVENT` | Tambahan hadiah dari satu event | Dipasang di event (§10) |
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|---|---|---|
|
||||
| `GET` | `/marketing/enakgame/budgets` | `?scope=GLOBAL&page=&limit=` |
|
||||
| `GET` | `/marketing/enakgame/budgets/:id` | – |
|
||||
| `POST` | `/marketing/enakgame/budgets` | Objek budget |
|
||||
| `PUT` | `/marketing/enakgame/budgets/:id` | Field yang diubah; `scope` tidak bisa berubah |
|
||||
| `DELETE` | `/marketing/enakgame/budgets/:id` | Hanya budget yang belum dipakai hadiah, event, atau reward config |
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "GLOBAL",
|
||||
"name": "Oktober 2026",
|
||||
"period_start": "2026-10-01",
|
||||
"period_end": "2026-10-31",
|
||||
"amount": 100000000,
|
||||
"thresholds": {
|
||||
"warning": 70, "critical": 90,
|
||||
"max_step_percent": 10, "min_multiplier_percent": 50, "max_multiplier_percent": 150, "cooldown_days": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan | Validasi |
|
||||
|---|---|---|
|
||||
| `name` | Nama | wajib, maks. 255 |
|
||||
| `period_start`, `period_end` | Periode | `YYYY-MM-DD`, inklusif, akhir ≥ awal |
|
||||
| `amount` | Budget (Rp) | > 0 |
|
||||
| `thresholds.warning` / `.critical` | Ambang peringatan / kritis (%) | 0–100, warning ≤ critical; default 70 / 90 |
|
||||
| `thresholds.max_step_percent` | Maks. perubahan hadiah per rekomendasi (%) | 1–50; default 10 |
|
||||
| `thresholds.min_multiplier_percent` | Hadiah terendah (% dari yang ditulis admin) | 1–100; default 50 |
|
||||
| `thresholds.max_multiplier_percent` | Hadiah tertinggi (% dari yang ditulis admin) | 100–1000; default 150 |
|
||||
| `thresholds.cooldown_days` | Jeda antar rekomendasi diterima (hari) | 0–90; default 7 |
|
||||
|
||||
Nilai default threshold dan guardrail masih **sementara**, menunggu keputusan bisnis
|
||||
(RFC §19.2 #4). Tampilkan default sebagai placeholder, bukan nilai yang tersimpan.
|
||||
|
||||
### 9.2 Metrik — `GET /marketing/enakgame/budgets/:id/metrics`
|
||||
|
||||
```json
|
||||
{
|
||||
"budget_id": "…", "scope": "GLOBAL", "period_start": "2026-10-01", "period_end": "2026-10-31",
|
||||
"as_of": "2026-10-21", "amount": 100000000,
|
||||
"realized_cost": 60000000, "remaining": 40000000, "utilization_percent": 60,
|
||||
"daily_burn": 5500000, "window_days": 7, "remaining_days": 10,
|
||||
"forecast_cost": 115000000, "forecast_remaining": -15000000, "forecast_utilization_percent": 115,
|
||||
"coin_issued": 1250000,
|
||||
"exposure": { "coins": 400000, "points": 90000 },
|
||||
"thresholds": { "warning": 70, "critical": 90 },
|
||||
"status": "CRITICAL"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Arti | Tampilkan sebagai |
|
||||
|---|---|---|
|
||||
| `realized_cost` | Biaya voucher yang sudah ditukar (Rp). Global: dalam periode; event: tanpa batas waktu | Terpakai |
|
||||
| `remaining`, `utilization_percent` | Sisa dan persen terpakai | Progress bar |
|
||||
| `daily_burn`, `window_days` | Rata-rata biaya per hari dalam `window_days` hari terakhir | Burn rate |
|
||||
| `forecast_cost`, `forecast_remaining` | Perkiraan biaya di akhir periode = realized + burn × `remaining_days` | Perkiraan; merah bila `forecast_remaining` negatif |
|
||||
| `coin_issued` | EnakCoin hadiah yang dibayar budget ini | – |
|
||||
| `exposure` | EnakCoin dan EnakPoint dari budget ini yang masih beredar: batas atas biaya yang masih bisa datang | "Masih bisa menjadi biaya" |
|
||||
| `status` | `HEALTHY`, `WARNING`, `CRITICAL`, `EXHAUSTED` | Badge hijau / kuning / oranye / merah |
|
||||
|
||||
Status: `EXHAUSTED` bila realized ≥ budget; `CRITICAL` bila forecast melewati budget atau
|
||||
utilization/forecast ≥ critical; `WARNING` bila ≥ warning. Budget habis **belum
|
||||
menghentikan hadiah** (kebijakannya belum diputuskan), jadi tampilkan peringatan yang
|
||||
jelas.
|
||||
|
||||
### 9.3 Rekomendasi Budget Controller
|
||||
|
||||
Untuk budget `GLOBAL`, sistem menghitung pengali hadiah supaya perkiraan biaya pas dengan
|
||||
budget. Tidak ada yang berubah sampai admin menerimanya.
|
||||
|
||||
`GET /marketing/enakgame/budgets/:id/recommendation`
|
||||
|
||||
```json
|
||||
{
|
||||
"budget_id": "…",
|
||||
"state": "RECOMMENDED",
|
||||
"message": "forecast Rp115000000 against a budget of Rp100000000: multiply rewards by 0.90",
|
||||
"metrics": { "…": "sama seperti §9.2" },
|
||||
"guardrails": { "max_step_percent": 10, "min_multiplier_percent": 50, "max_multiplier_percent": 150, "cooldown_days": 7 },
|
||||
"target_multiplier": 0.7272,
|
||||
"multiplier": 0.9,
|
||||
"games": [
|
||||
{
|
||||
"game_id": "…", "game_name": "Tap Tap", "reward_config_id": "…", "version": 1, "base_config_id": "…",
|
||||
"reward_type": "FIXED", "current_multiplier": 1, "new_multiplier": 0.9,
|
||||
"current_rules": { "amount": 10 }, "new_rules": { "amount": 9 },
|
||||
"current_max_reward": 10, "new_max_reward": 9
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| `state` | Arti | Tampilan |
|
||||
|---|---|---|
|
||||
| `RECOMMENDED` | Ada rekomendasi yang bisa diterima | Tombol Terima aktif |
|
||||
| `NO_CHANGE` | Perkiraan sudah pas | "Hadiah tidak perlu diubah" |
|
||||
| `COOLDOWN` | Rekomendasi diterima kurang dari `cooldown_days` lalu | "Bisa diterima lagi pada {cooldown_until}"; tampilkan `games` sebagai gambaran |
|
||||
| `AT_LIMIT` | Semua game sudah di batas min/max | "Hadiah sudah di batas terendah/tertinggi" |
|
||||
| `INSUFFICIENT_DATA` | Belum ada biaya dalam `window_days` hari terakhir | "Belum cukup data" |
|
||||
| `OUT_OF_PERIOD` | Periode belum mulai atau tidak ada hari tersisa | "Periode tidak berjalan" |
|
||||
|
||||
- `target_multiplier`: pengali yang membuat perkiraan pas dengan budget; `multiplier`:
|
||||
yang direkomendasikan, dibatasi `max_step_percent` dari 1 dan dibulatkan ke bawah ke
|
||||
dua desimal. Bisa di bawah 1 (hadiah turun) atau di atas 1 (hadiah naik).
|
||||
- `games`: perubahan per game. Pengali tiap game dihitung dari versi yang ditulis admin
|
||||
(`base_config_id`), dan dibatasi `min_multiplier_percent`–`max_multiplier_percent`.
|
||||
Semua jumlah dibulatkan ke bawah, jadi hadiah kecil bisa menjadi 0 (1 × 0,9 = 0).
|
||||
Tampilkan `current_rules` → `new_rules` berdampingan supaya admin melihatnya.
|
||||
- Budget `EVENT` ditolak (`304`): tambahan event diatur di event.
|
||||
|
||||
**Terima:** `POST /marketing/enakgame/budgets/:id/recommendation/accept`
|
||||
|
||||
```json
|
||||
{ "multiplier": 0.9, "reason": "Burn rate terlalu tinggi" }
|
||||
```
|
||||
|
||||
- Kirim `multiplier` yang ditampilkan. Bila rekomendasi sudah berubah sejak layar dibuka,
|
||||
server menolak (`304`) dan tidak mengubah apa pun: muat ulang rekomendasi.
|
||||
- Berhasil: setiap game di `games` mendapat versi reward config baru yang langsung
|
||||
`ACTIVE`, versi lama `RETIRED`. Response
|
||||
`{ "budget_id", "multiplier", "reward_configs": [ … ] }`.
|
||||
- Tercatat di audit dengan sumber Budget Controller. Mulai saat itu cooldown berlaku untuk
|
||||
seluruh organisasi.
|
||||
- Admin yang menulis versi baru sendiri untuk sebuah game memulai pengalinya dari 1 lagi.
|
||||
|
||||
---
|
||||
|
||||
## 10. Event
|
||||
|
||||
Event (= campaign) membuat game tertentu memberi hadiah lebih selama periode tertentu.
|
||||
Tambahannya dibayar budget `EVENT` milik event itu.
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|---|---|---|
|
||||
| `GET` | `/marketing/enakgame/events` | `?status=&page=&limit=` |
|
||||
| `GET` | `/marketing/enakgame/events/:id` | – |
|
||||
| `POST` | `/marketing/enakgame/events` | Objek event |
|
||||
| `PUT` | `/marketing/enakgame/events/:id` | Field yang diubah |
|
||||
| `PUT` | `/marketing/enakgame/events/:id/status` | `{ "status": "ENDED", "reason": "…" }` |
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ramadan 2x",
|
||||
"slug": "ramadan-2x",
|
||||
"description": "Hadiah dobel selama Ramadan",
|
||||
"banner_url": "https://…/ramadan.png",
|
||||
"start_at": "2027-02-17T00:00:00+07:00",
|
||||
"end_at": "2027-03-18T23:59:59+07:00",
|
||||
"timezone": "Asia/Jakarta",
|
||||
"priority": 10,
|
||||
"multiplier": 2,
|
||||
"bonus": null,
|
||||
"budget_id": "<id budget EVENT>",
|
||||
"reward_limit": 5000000,
|
||||
"user_daily_limit": 200,
|
||||
"game_ids": ["…", "…"],
|
||||
"status": "DRAFT"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Arti | Validasi |
|
||||
|---|---|---|
|
||||
| `start_at`, `end_at` | Periode berlaku | wajib, akhir setelah awal |
|
||||
| `timezone` | Zona waktu untuk tampilan | default `Asia/Jakarta` |
|
||||
| `multiplier` | Pengali hadiah dasar: 2 = hadiah dasar ditambah sekali lagi | ≥ 1, maks. 2 desimal |
|
||||
| `bonus` | Tambahan EnakCoin per main | ≥ 1 |
|
||||
| | | Minimal salah satu: `multiplier` > 1 atau `bonus` |
|
||||
| `priority` | Urutan bila beberapa event berlaku | angka lebih besar didahulukan |
|
||||
| `budget_id` | Budget `EVENT` organisasi ini | wajib |
|
||||
| `reward_limit` | Maks. tambahan EnakCoin selama event | ≥ 1, kosong = tanpa batas |
|
||||
| `user_daily_limit` | Maks. tambahan per customer per hari | ≥ 1, kosong = tanpa batas |
|
||||
| `game_ids` | Game yang ikut | minimal satu game organisasi ini |
|
||||
| `status` | Status awal | hanya saat buat: `DRAFT` (default) atau `ACTIVE` |
|
||||
|
||||
**Status:** `DRAFT` → `ACTIVE` atau `CANCELLED`; `ACTIVE` → `ENDED` atau `CANCELLED`.
|
||||
Event `ACTIVE` hanya berlaku di antara `start_at` dan `end_at`.
|
||||
|
||||
**Beberapa event sekaligus.** Tambahan setiap event dihitung dari hadiah dasar (tidak
|
||||
saling mengalikan), lalu dijumlahkan. Bila total melewati `max_reward` game, tambahan
|
||||
event berprioritas terendah dipotong lebih dulu. Main yang hadiah dasarnya 0 tidak
|
||||
mendapat tambahan event. Contoh: hadiah dasar 10, event 2x → 10 dari budget global +
|
||||
10 dari budget event.
|
||||
|
||||
---
|
||||
|
||||
## 11. Voucher
|
||||
|
||||
Voucher adalah satu-satunya cara memakai EnakPoint: customer menukar EnakPoint
|
||||
(`point_cost`) dengan voucher di aplikasi, memakai PIN. Voucher berdiri sendiri, tidak
|
||||
di bawah EnakGame: EnakPoint dari belanja pun ditukar di sini. Hubungannya dengan EnakGame
|
||||
hanya di budget: bila EnakPoint yang ditukar berasal dari hadiah game, nilai vouchernya
|
||||
dicatat sebagai biaya budget (§9).
|
||||
|
||||
### 11.1 Voucher
|
||||
|
||||
| Method | Path | Body / query |
|
||||
|---|---|---|
|
||||
| `GET` | `/marketing/vouchers` | `?status=&search=&page=&limit=` |
|
||||
| `GET` | `/marketing/vouchers/:id` | – |
|
||||
| `POST` | `/marketing/vouchers` | Objek voucher |
|
||||
| `PUT` | `/marketing/vouchers/:id` | Field yang diubah; `stock_mode` tidak bisa berubah |
|
||||
| `PUT` | `/marketing/vouchers/:id/status` | `{ "status": "ACTIVE", "reason": "…" }` |
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Kopi Susu Gratis",
|
||||
"description": "Berlaku untuk ukuran regular",
|
||||
"image_url": "https://…/kopi.png",
|
||||
"voucher_type": "FREE_ITEM",
|
||||
"face_value": 20000,
|
||||
"point_cost": 15000,
|
||||
"business_cost": 8000,
|
||||
"stock_mode": "CODE_POOL",
|
||||
"max_per_customer": 2,
|
||||
"valid_from": "2026-10-01T00:00:00+07:00",
|
||||
"valid_until": "2026-12-31T23:59:59+07:00",
|
||||
"terms": { "outlets": "Semua outlet", "notes": "Tidak bisa digabung promo lain" },
|
||||
"status": "DRAFT"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Arti | Validasi |
|
||||
|---|---|---|
|
||||
| `voucher_type` | Jenis | `FIXED_VALUE`, `PERCENTAGE`, `FREE_ITEM`, `MERCHANT_BENEFIT` |
|
||||
| `face_value` | Nilai voucher (Rp); **dihitung sebagai biaya budget** | > 0 |
|
||||
| `point_cost` | EnakPoint yang dibayar customer | > 0 |
|
||||
| `business_cost` | Biaya sebenarnya untuk laporan Finance; tidak dipakai budget | ≥ 0, opsional |
|
||||
| `stock_mode` | Asal voucher (lihat bawah) | `STATIC`, `CODE_POOL`, `EXTERNAL` |
|
||||
| `stock` | Stok, hanya `STATIC` | wajib untuk `STATIC`, ≥ 0 |
|
||||
| `provider`, `provider_ref` | Hanya `EXTERNAL` | `provider` wajib untuk `EXTERNAL` |
|
||||
| `max_per_customer` | Batas tukar per customer | ≥ 1, kosong = tanpa batas |
|
||||
| `valid_from`, `valid_until` | Masa bisa ditukar | akhir setelah awal |
|
||||
| `terms` | Syarat & ketentuan | objek JSON; sepakati bentuknya dengan tim aplikasi |
|
||||
| `status` | Status awal | hanya saat buat: `DRAFT` (default), `ACTIVE`, `INACTIVE` |
|
||||
|
||||
| `stock_mode` | Cara kerja |
|
||||
|---|---|
|
||||
| `STATIC` | Stok berupa angka; customer mendapat voucher tanpa kode |
|
||||
| `CODE_POOL` | Setiap penukaran mengambil satu kode yang diimpor (§11.2); stok = kode `AVAILABLE` |
|
||||
| `EXTERNAL` | Kode dari penyedia luar. **Belum bisa dipakai**: belum ada penyedia yang tersambung, jadi voucher ini tidak tampil di katalog customer |
|
||||
|
||||
**Status:** `DRAFT`, `ACTIVE` (tampil di katalog selama dalam masa berlaku dan ada stok),
|
||||
`INACTIVE`, `ARCHIVED` (permanen, tidak bisa diubah lagi).
|
||||
|
||||
### 11.2 Kode voucher (`CODE_POOL`)
|
||||
|
||||
**Impor:** `POST /marketing/vouchers/:id/codes` dengan file CSV sebagai
|
||||
multipart field `file` (atau CSV sebagai body).
|
||||
|
||||
```csv
|
||||
code,expires_at
|
||||
KOPI-7F3C-2291,2026-12-31
|
||||
KOPI-8A1D-5530,
|
||||
```
|
||||
|
||||
- Kolom 1: kode (wajib, maks. 255). Kolom 2: kedaluwarsa, opsional, `YYYY-MM-DD`
|
||||
(berlaku sampai akhir hari WIB) atau RFC3339. Baris header `code` boleh ada.
|
||||
- Maks. 50.000 baris dan 16 MB per file. Kode yang sudah ada di pool atau berulang di file
|
||||
dilewati.
|
||||
|
||||
```json
|
||||
{ "imported": 1998, "duplicate_count": 1, "duplicates": ["KOPI-7F3C-2291"], "invalid": [{ "line": 17, "reason": "…" }] }
|
||||
```
|
||||
|
||||
Tampilkan ringkasan: berapa masuk, berapa duplikat, dan baris yang gagal dengan nomor
|
||||
barisnya.
|
||||
|
||||
**Daftar:** `GET /marketing/vouchers/:id/codes?status=AVAILABLE&page=1&limit=20`
|
||||
|
||||
```json
|
||||
{
|
||||
"counts": { "AVAILABLE": 1500, "REDEEMED": 480, "EXPIRED": 20 },
|
||||
"codes": { "data": [ { "id": "…", "code": "KOPI-…", "status": "AVAILABLE", "redemption_id": null, "expires_at": "…", "created_at": "…" } ], "pagination": { "…": "…" } }
|
||||
}
|
||||
```
|
||||
|
||||
Status kode: `AVAILABLE`, `RESERVED`, `REDEEMED`, `EXPIRED` (lewat `expires_at`,
|
||||
diproses tiap jam), `CANCELLED`. Tampilkan `counts` sebagai ringkasan stok di atas tabel,
|
||||
dan peringatan bila `AVAILABLE` hampir habis.
|
||||
|
||||
---
|
||||
|
||||
## 12. Analytics
|
||||
|
||||
Rentang tanggal WIB, kedua ujung termasuk, maks. 366 hari. `from` dan `to` wajib.
|
||||
|
||||
### 12.1 Game — `GET /marketing/enakgame/analytics/games?from=2026-10-01&to=2026-10-31&game_id=`
|
||||
|
||||
`game_id` opsional untuk satu game. Dihitung dari session yang **dimulai** dalam
|
||||
rentang.
|
||||
|
||||
```json
|
||||
{
|
||||
"from": "2026-10-01", "to": "2026-10-31",
|
||||
"totals": {
|
||||
"plays": 4200, "completed": 3900, "refunded": 12, "expired": 288, "flagged": 35, "players": 820,
|
||||
"average_score": 742.5, "average_reward": 6.2, "reward_per_play": 5.76,
|
||||
"coin_issued": 24180, "entry_cost_paid": 21000, "coin_refunded": 60
|
||||
},
|
||||
"games": [ { "game_id": "…", "game_name": "Spin Harian", "plays": 3000, "…": "field sama dengan totals" } ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Label usulan |
|
||||
|---|---|
|
||||
| `plays` | Total main |
|
||||
| `completed`, `refunded`, `expired` | Selesai / dikembalikan / tidak selesai |
|
||||
| `flagged` | Hasil mencurigakan (gagal validasi, hadiah 0) |
|
||||
| `players` | Customer unik |
|
||||
| `average_score` | Rata-rata skor (`null` bila game tidak memakai skor) |
|
||||
| `average_reward`, `reward_per_play` | Rata-rata hadiah per main selesai / per main |
|
||||
| `coin_issued` | EnakCoin hadiah |
|
||||
| `entry_cost_paid`, `coin_refunded` | EnakCoin dibayar untuk main / dikembalikan |
|
||||
|
||||
`games` urut dari yang paling banyak dimainkan.
|
||||
|
||||
### 12.2 Ekonomi — `GET /marketing/enakgame/analytics/economy?from=2026-10-01&to=2026-10-31`
|
||||
|
||||
Dihitung dari semua mutasi wallet organisasi dalam rentang.
|
||||
|
||||
```json
|
||||
{
|
||||
"from": "2026-10-01", "to": "2026-10-31",
|
||||
"coin": { "generated": 52000, "game_rewards": 24180, "spent_on_games": 20940, "exchanged": 9000, "spent": 29940, "expired": 300, "outstanding": 61000 },
|
||||
"point": { "earned": 1800000, "exchanged": 2700, "redeemed": 900000, "expired": 15000, "balance": 4200000 },
|
||||
"by_type": [ { "currency": "COIN", "type": "GAME_REWARD", "credit": 24180, "debit": 0, "transactions": 3900 } ]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Arti |
|
||||
|---|---|
|
||||
| `coin.generated` | EnakCoin baru: hadiah game, belanja (dikurangi pembatalan), migrasi, adjustment tambah |
|
||||
| `coin.spent_on_games` | Entry cost dikurangi yang dikembalikan |
|
||||
| `coin.exchanged` | Ditukar ke EnakPoint |
|
||||
| `coin.outstanding` / `point.balance` | Dipegang customer di akhir rentang |
|
||||
| `point.earned` | Dari belanja, dikurangi pembatalan |
|
||||
| `point.exchanged` | Hasil tukar EnakCoin |
|
||||
| `point.redeemed` | Ditukar ke voucher, dikurangi penukaran yang gagal |
|
||||
| `by_type` | Rincian per tipe mutasi, termasuk transfer (yang tidak dihitung di angka utama) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Belum tersedia
|
||||
|
||||
Fitur berikut belum ada di backend; jangan dibuat layarnya dulu:
|
||||
|
||||
- Daftar session main dan filter hasil mencurigakan (`flagged`) untuk admin.
|
||||
- Daftar penukaran voucher untuk admin.
|
||||
- Layar audit log (perubahan tetap tercatat di backend).
|
||||
- Kebijakan saat budget habis, dan mode otomatis Budget Controller.
|
||||
- Menandai voucher sudah dipakai di POS ([`integration-pos.md`](./integration-pos.md) §5).
|
||||
- Voucher `EXTERNAL` (belum ada penyedia).
|
||||
|
||||
---
|
||||
|
||||
## 14. Pesan error dan checklist
|
||||
|
||||
| `code` | HTTP | Kapan terjadi | Yang ditampilkan |
|
||||
| --- | --- | --- | --- |
|
||||
| `303`, `310` | 400 | Body tidak valid, field tak dikenal, UUID salah | "Data tidak valid" + `cause` untuk developer |
|
||||
| `304` | 400 | Nilai di luar batas, aturan bisnis (slug terpakai, periode budget tumpang tindih, versi sudah pensiun, rekomendasi berubah, dst.) | `cause` di dekat field atau di toast |
|
||||
| – | 403 | Role tidak boleh mengubah (§1) | "Kamu tidak punya akses" |
|
||||
| `404` | 404 | Data bukan milik organisasi ini atau tidak ada | "Data tidak ditemukan" |
|
||||
| `900` | 500 | Kesalahan server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
Pesan `cause` berbahasa Inggris, mis. `thresholds.warning cannot be above
|
||||
thresholds.critical`. Cek batas di sisi klien (tabel di tiap bagian) dan tampilkan
|
||||
`cause` hanya sebagai cadangan.
|
||||
|
||||
### Checklist rilis
|
||||
|
||||
**Loyalitas**
|
||||
- [ ] Form setting outlet menampilkan cashback efektif dan contoh earning.
|
||||
- [ ] Setting organisasi selalu lewat dry run dan dialog konfirmasi (`impact`, `expiry_activations`).
|
||||
- [ ] Preview kedaluwarsa tampil di bawah pengaturan kedaluwarsa.
|
||||
- [ ] Wallet customer: saldo yang bisa dipakai, lot, riwayat dengan nama asli, label semua tipe mutasi termasuk game dan voucher.
|
||||
- [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`; Telusuri di setiap baris.
|
||||
- [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log.
|
||||
- [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …".
|
||||
|
||||
**EnakGame**
|
||||
- [ ] Game: form lengkap, `entry_cost` ≥ 1, status, `result_rules`.
|
||||
- [ ] Reward config: editor per jenis, riwayat versi, aktivasi dengan alasan, badge versi Budget Controller.
|
||||
- [ ] Spin bisa dibuat end-to-end mengikuti §8.4.
|
||||
- [ ] Budget global per bulan, threshold dan guardrail, peringatan bila tidak ada budget global berjalan.
|
||||
- [ ] Metrik budget dengan status dan perkiraan; rekomendasi dengan perbandingan aturan lama/baru dan tombol Terima.
|
||||
- [ ] Event: form, status, budget `EVENT`, pilihan game.
|
||||
- [ ] Voucher: form per `stock_mode`, impor kode CSV dengan ringkasan, stok kode.
|
||||
- [ ] Analytics game dan ekonomi dengan pemilih rentang tanggal.
|
||||
- [ ] Tombol ubah disembunyikan untuk role yang bukan loyalty manager.
|
||||
|
||||
Transfer antar customer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar
|
||||
backoffice boleh disiapkan lebih dulu.
|
||||
@@ -0,0 +1,664 @@
|
||||
# Integrasi EnakGame: Game Client (Phaser)
|
||||
|
||||
**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 12 Okt 2026
|
||||
|
||||
Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di
|
||||
dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main
|
||||
dari awal sampai akhir: memulai session (EnakCoin dipotong), menjalankan permainan,
|
||||
mengirim hasil, dan menampilkan hadiah. Jangan mengarang endpoint, field, atau aturan
|
||||
yang tidak tertulis di sini; kalau ada yang kurang jelas, tanyakan ke tim backend.
|
||||
|
||||
Pembagian tugas dengan aplikasi customer:
|
||||
|
||||
| Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) |
|
||||
|---|---|
|
||||
| Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) |
|
||||
| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah, leaderboard mingguan (§10) |
|
||||
| Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup |
|
||||
|
||||
Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan
|
||||
[`enakgame-prd.md`](./enakgame-prd.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Aturan yang tidak boleh dilanggar
|
||||
|
||||
1. **Server yang menentukan hadiah.** Game hanya mengirim **hasil main**: `score`,
|
||||
`outcome`, dan `data`. Jangan pernah mengirim jumlah hadiah. Kalaupun terkirim,
|
||||
backend mengabaikannya. Untuk spin, server yang mengundi segmennya.
|
||||
2. **Tampilkan hadiah dari response, bukan dari hitungan sendiri.** Angka di layar akhir
|
||||
selalu `reward_total` dari backend.
|
||||
3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama.
|
||||
4. **Token customer adalah rahasia.** Hanya didapat dari bridge atau dari login di
|
||||
game, disimpan di memori, tidak pernah ditaruh di URL, `localStorage`,
|
||||
`sessionStorage`, cookie, log, atau analytics. Begitu juga password customer.
|
||||
(`session_id` boleh disimpan di `sessionStorage`, §6.4.)
|
||||
5. **Tanpa token, jangan panggil API customer.** Tampilkan layar login akun customer
|
||||
(§5.2).
|
||||
6. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin.
|
||||
7. **Main game tidak butuh PIN.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Daftar game
|
||||
|
||||
Game bukan daftar tetap di kode. Setiap game adalah data yang dibuat admin di
|
||||
backoffice per organisasi (nama, `slug`, `game_url`, biaya main, aturan hadiah) dan
|
||||
dibaca game lewat `GET /customer/enakgame/games` (§6.1). Satu `game_url` = satu game.
|
||||
|
||||
### 2.1 Game yang ada sekarang
|
||||
|
||||
| Game | `slug` | Jenis hadiah (`reward_type`) | Body complete | Status |
|
||||
|---|---|---|---|---|
|
||||
| Spin Harian | `spin` | `PROBABILITY`: server mengundi segmen roda | `{}` | Backend siap. Panduan: §9 |
|
||||
|
||||
Runner, Memory, dan Puzzle hanya disebut sebagai contoh di PRD; belum ada spesifikasi,
|
||||
`slug`, atau aturan hadiahnya. Game baru harus didaftarkan dulu (§2.3) sebelum bisa
|
||||
dimainkan.
|
||||
|
||||
### 2.2 Jenis hadiah menentukan apa yang dikirim game
|
||||
|
||||
API customer tidak memberi tahu `reward_type`. Jenisnya disepakati saat game
|
||||
didaftarkan (§2.3), dan game dibangun untuk jenis itu.
|
||||
|
||||
| `reward_type` | Hadiah dihitung dari | Body complete | Bila field-nya tidak dikirim |
|
||||
|---|---|---|---|
|
||||
| `FIXED` | Jumlah tetap per main | `{}` | – |
|
||||
| `SCORE_BASED` | Rentang skor yang diatur admin | `{ "score": 800 }` | Hadiah 0 |
|
||||
| `OUTCOME_BASED` | Hasil main, mis. `PERFECT`, `GOOD`, `FAIL` | `{ "outcome": "PERFECT" }` | Hadiah 0. `outcome` yang tidak terdaftar juga 0 |
|
||||
| `PROBABILITY` | Undian server; peluang tidak pernah dikirim ke game | `{}` | – |
|
||||
|
||||
Admin bisa mengubah besar hadiah kapan saja tanpa build game baru. Supaya game tetap
|
||||
benar bila admin juga mengganti jenisnya, **kirim semua hasil yang dimiliki game**:
|
||||
game berbasis skor selalu mengirim `score`, game berbasis hasil selalu mengirim
|
||||
`outcome`. Field yang tidak dipakai jenis hadiah aktif diabaikan.
|
||||
|
||||
### 2.3 Mendaftarkan game baru
|
||||
|
||||
Sepakati dengan tim backoffice, lalu admin membuatnya
|
||||
([`integration-backoffice.md`](./integration-backoffice.md) §8):
|
||||
|
||||
| Yang disepakati | Contoh | Catatan |
|
||||
|---|---|---|
|
||||
| `slug` | `runner` | Huruf kecil, angka, `-`; unik per organisasi. Game memakainya untuk memeriksa dirinya (§6.1) |
|
||||
| `game_url` | `https://…/runner/index.html` | URL build game; dibuka aplikasi di webview |
|
||||
| `version` | `1.0.0` | Versi build |
|
||||
| `reward_type` | `SCORE_BASED` | §2.2 |
|
||||
| Daftar `outcome` | `WIN`, `LOSE` | Hanya game `OUTCOME_BASED`; harus sama persis (huruf besar/kecil) |
|
||||
| `result_rules` | `max_score`, `min_duration_seconds`, `max_score_per_second` | Hasil di luar batas ini mendapat hadiah 0 tanpa pemberitahuan (§6.3). Isi dengan skor dan durasi wajar game-mu |
|
||||
| `session_ttl_seconds` | `600` | Batas waktu satu main. Harus lebih lama dari durasi main terpanjang, ditambah jeda jaringan |
|
||||
| `entry_cost` | `5` | Ditentukan bisnis, minimal 1 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Daftar API
|
||||
|
||||
Semua endpoint diawali `api_base_url` (dari `init`, §4, atau dari config build pada
|
||||
mode mandiri, §5.4), mis. `https://api.example.com/api/v1`. Selain login, semua wajib
|
||||
memakai header `Authorization: Bearer <token>`. Request ber-body memakai
|
||||
`Content-Type: application/json`.
|
||||
|
||||
| # | Endpoint | Header tambahan | Body | Dipakai saat | Detail |
|
||||
|---|---|---|---|---|---|
|
||||
| 0 | `POST /customer-auth/login` | Tanpa `Authorization` | `{ "phone_number", "password" }` | Tidak ada token, atau token ditolak | §5.3 |
|
||||
| 1 | `GET /customer/enakgame/games` | – | – | Setelah ada token: biaya main, event, segmen roda | §6.1 |
|
||||
| 2 | `POST /customer/enakgame/sessions` | `Idempotency-Key` (wajib) | `{ "game_id" }` | Customer menekan Main; EnakCoin dipotong | §6.2 |
|
||||
| 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 |
|
||||
| 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 |
|
||||
| 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 |
|
||||
| 6 | `GET /customer/enakgame/leaderboard?date=&game_id=&limit=` | – | – | Layar leaderboard; muat ulang setelah complete | §10 |
|
||||
|
||||
Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN
|
||||
adalah tugas aplikasi customer.
|
||||
|
||||
---
|
||||
|
||||
## 4. Bridge dengan aplikasi customer
|
||||
|
||||
> **Usulan.** Bentuk bridge di bawah belum diimplementasikan di sisi mana pun. Sepakati
|
||||
> dengan tim aplikasi customer sebelum mulai; aplikasi memakai kontrak yang sama
|
||||
> ([`integration-mobile-customer.md`](./integration-mobile-customer.md) §8.3).
|
||||
|
||||
Semua pesan berupa JSON string dengan field `type`.
|
||||
|
||||
- **Game → aplikasi:** `window.EnakGameHost.postMessage(JSON.stringify(pesan))`
|
||||
(JavaScript channel webview bernama `EnakGameHost`).
|
||||
- **Aplikasi → game:** aplikasi memanggil `window.enakGame.receive(jsonString)`. Game
|
||||
wajib mendefinisikan fungsi ini sebelum mengirim `ready`.
|
||||
|
||||
| Arah | `type` | Isi | Kapan |
|
||||
|---|---|---|---|
|
||||
| game → app | `ready` | – | Halaman game selesai dimuat |
|
||||
| app → game | `init` | `api_base_url`, `token`, `game_id` | Jawaban atas `ready`. `token` boleh kosong bila customer belum login |
|
||||
| game → app | `balance_changed` | `coin_balance` | Setelah start dan complete berhasil |
|
||||
| game → app | `close` | – | Customer keluar dari game |
|
||||
|
||||
Contoh `init`:
|
||||
|
||||
```json
|
||||
{ "type": "init", "api_base_url": "https://api.example.com/api/v1", "token": "eyJ…", "game_id": "8a1f…" }
|
||||
```
|
||||
|
||||
Di dalam aplikasi, jangan memanggil API apa pun sebelum `init` diterima. Token yang
|
||||
kosong atau ditolak tidak dikembalikan ke aplikasi: game sendiri yang meminta customer
|
||||
login (§5.2). Tanpa aplikasi (browser biasa), game berjalan dalam mode mandiri (§5.4).
|
||||
|
||||
---
|
||||
|
||||
## 5. Koneksi ke API dan token
|
||||
|
||||
### 5.1 Bentuk response dan error
|
||||
|
||||
- Sukses: `{ "success": true, "data": { … }, "errors": null }`.
|
||||
- Gagal: `{ "success": false, "data": null, "errors": [{ "code", "entity", "cause" }] }`.
|
||||
`cause` berbahasa Inggris; jangan tampilkan mentah ke customer.
|
||||
- Contoh response di dokumen ini adalah isi `data`, kecuali yang menampilkan amplop
|
||||
lengkap (§6.4).
|
||||
|
||||
| `errors[0].code` | HTTP | Arti | Yang dilakukan game |
|
||||
|---|---|---|---|
|
||||
| `304` dengan `entity` `auth_handler` | 400 | **Token tidak ada atau tidak berlaku** | Layar login (§5.2) |
|
||||
| `304` | 400 | Ditolak aturan bisnis | Lihat tabel per endpoint |
|
||||
| `303`, `310` | 400 | Request salah format | Bug di game; pesan umum |
|
||||
| `404` | 404 | Game/session tidak ada atau bukan milik customer | Pesan "tidak ditemukan", kembali ke aplikasi |
|
||||
| `900` | 500 | Error server | Retry (§8) |
|
||||
|
||||
Backend **tidak** memakai HTTP 401. Token yang ditolak dijawab HTTP 400 dengan code
|
||||
`304`, sama seperti penolakan bisnis; bedakan lewat `entity`: `auth_handler` untuk token,
|
||||
`enakgame_service` untuk aturan EnakGame.
|
||||
|
||||
### 5.2 Token tidak ada atau ditolak: customer login di game
|
||||
|
||||
Token didapat dari `init` (game dibuka dari aplikasi) atau dari login di game. Setiap
|
||||
kali tidak ada token yang berlaku, game menampilkan **layar login akun customer**
|
||||
(§5.3).
|
||||
|
||||
| Keadaan | Cara mengenali | Yang dilakukan game |
|
||||
|---|---|---|
|
||||
| `init` datang dengan token | `token` terisi | Pakai token itu, tanpa layar login |
|
||||
| `init` datang tanpa token | `token` kosong, `null`, atau tidak ada | Layar login |
|
||||
| Dibuka tanpa aplikasi (browser biasa) | `window.EnakGameHost` tidak ada | Mode mandiri (§5.4), dimulai dari layar login |
|
||||
| `init` tidak datang | Tidak ada `init` 5 detik setelah `ready` | Kirim `ready` sekali lagi. Masih tidak datang dalam 5 detik: mode mandiri (§5.4) |
|
||||
| Token ditolak backend | HTTP 400, `code` `304`, `entity` `auth_handler` | Layar login dengan pesan "Sesi kamu berakhir, silakan login lagi". Setelah login berhasil, ulangi request yang sama **satu kali** (body dan `Idempotency-Key` sama) |
|
||||
| Token hasil login langsung ditolak lagi | Penolakan `auth_handler` kedua untuk request yang sama | Berhenti: "Login bermasalah, coba buka ulang game" dengan tombol Keluar. Jangan menampilkan login berulang-ulang |
|
||||
| Customer login dengan akun lain | `GET /sessions/:id` untuk session tersimpan menjawab `404` | Hapus `session_id` dari `sessionStorage`, tampilkan layar awal (§6.4) |
|
||||
|
||||
Token ditolak sebelum apa pun diproses: start yang ditolak tidak memotong EnakCoin, dan
|
||||
complete yang ditolak tidak menyelesaikan session. Jadi aman mengulang request yang
|
||||
sama setelah login. Timer `expires_at` tetap berjalan selama customer login; complete
|
||||
setelah `expires_at` ditolak dan entry cost **tidak** dikembalikan (§7).
|
||||
|
||||
Token hasil login di game tidak dikirim ke aplikasi; aplikasi mengurus tokennya
|
||||
sendiri.
|
||||
|
||||
`cause` dari `auth_handler` hanya untuk debugging, jangan dicocokkan di kode:
|
||||
|
||||
| `cause` | Penyebab |
|
||||
|---|---|
|
||||
| `Authorization header is required` | Header tidak dikirim. Bug di game: memanggil API sebelum ada token |
|
||||
| `Invalid authorization header format` | Header bukan `Bearer <token>` |
|
||||
| `Invalid token: …` | Token kedaluwarsa, rusak, atau dari environment lain (staging vs. produksi) |
|
||||
| `Invalid token type` | Yang dipakai refresh token, bukan access token |
|
||||
| `Token is not valid`, `Customer ID not found in token`, `Phone number not found in token` | Token bukan token customer yang sah |
|
||||
|
||||
### 5.3 Layar login — `POST /customer-auth/login`
|
||||
|
||||
Form berisi nomor HP dan password akun customer, sama dengan akun di aplikasi. Endpoint
|
||||
ini tidak memakai header `Authorization`.
|
||||
|
||||
```json
|
||||
{ "phone_number": "6281234561234", "password": "rahasia123" }
|
||||
```
|
||||
|
||||
Response lengkap, termasuk amplopnya. Token ada di **`data.data.access_token`**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "SUCCESS",
|
||||
"message": "Login successful.",
|
||||
"data": {
|
||||
"access_token": "eyJ…",
|
||||
"refresh_token": "eyJ…",
|
||||
"user": { "id": "…", "name": "Budi", "phone_number": "6281234561234", "birth_date": "2000-01-31" }
|
||||
}
|
||||
},
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
- Pakai `access_token` saja. `refresh_token` ditolak endpoint customer dan belum ada
|
||||
endpoint refresh; abaikan dan jangan disimpan.
|
||||
- Token hanya di memori (§1). Halaman dimuat ulang berarti login lagi, kecuali di dalam
|
||||
aplikasi yang mengirim token lewat `init`.
|
||||
- Password hanya dipegang selama request login: jangan disimpan, di-log, atau dikirim
|
||||
ke analytics.
|
||||
- `user.name` boleh ditampilkan, mis. "Main sebagai Budi", dengan tombol "Ganti akun"
|
||||
yang menghapus token dari memori lalu menampilkan login lagi.
|
||||
- Nomor HP boleh ditulis `0812…`, `+62 812…`, `62812…`, atau `812…` (spasi dan `-`
|
||||
boleh). Backend mengubahnya menjadi format baku **`62812…`**, dan format itu juga yang
|
||||
dikembalikan di `user.phone_number`. Hanya nomor HP Indonesia (`628…`) yang diterima;
|
||||
selain itu ditolak `invalid phone number format`.
|
||||
- Registrasi tidak ada di game. Tampilkan "Belum punya akun atau pendaftaran belum
|
||||
selesai? Lanjutkan di aplikasi."
|
||||
|
||||
| `code` | `entity` | HTTP | Arti (`cause`) | Tampilan |
|
||||
|---|---|---|---|---|
|
||||
| `304` | `customer_auth_service` | 400 | Nomor HP tidak terdaftar atau password salah (`invalid phone number or password`), atau pendaftaran belum selesai (`customer not properly registered`) | **"Nomor HP atau password salah."** |
|
||||
| `304` | `request` | 400 | Format salah: `invalid phone number format`, `phone number is required`, `password is required` | "Periksa nomor HP dan password." Cek juga di game sebelum mengirim |
|
||||
| `303` | `request` | 400 | Body tidak lengkap | Bug di game |
|
||||
| `429` | `customer_auth_service` | 429 | Terlalu banyak percobaan login untuk nomor ini; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." Nonaktifkan tombol Masuk sampai `locked_until` |
|
||||
| `900` | – | 500 | Error server | "Gagal login, coba lagi." |
|
||||
|
||||
Login yang gagal jangan diulang otomatis. Setiap nomor HP hanya boleh mencoba login
|
||||
5 kali dalam 15 menit, berhasil maupun gagal; login yang berhasil memulai hitungan
|
||||
dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga bila
|
||||
password-nya benar. Batas ini berlaku untuk nomor HP, bukan perangkat, dan sama untuk
|
||||
aplikasi customer.
|
||||
|
||||
### 5.4 Mode mandiri (tanpa aplikasi)
|
||||
|
||||
Dipakai saat game dibuka di browser biasa, atau saat aplikasi tidak mengirim `init`
|
||||
(§5.2). Juga dipakai untuk development dengan config staging.
|
||||
|
||||
1. `api_base_url` diambil dari config build per environment (staging, produksi), bukan
|
||||
dari URL atau input customer.
|
||||
2. Tampilkan layar login (§5.3).
|
||||
3. Panggil `GET /customer/enakgame/games` dan ambil game dengan `slug` milik build ini;
|
||||
`id`-nya menjadi `game_id`. Tidak ada → "Game tidak tersedia untuk akun ini."
|
||||
4. Lanjutkan seperti biasa: pemulihan session (§6.4), lalu layar awal.
|
||||
|
||||
Tanpa aplikasi tidak ada bridge: `balance_changed` dan `close` tidak dikirim, dan tombol
|
||||
Keluar kembali ke layar awal game.
|
||||
|
||||
### 5.5 Contoh helper API
|
||||
|
||||
Helper ini menangani token dari `init`, layar login, dan token yang ditolak:
|
||||
|
||||
```js
|
||||
let apiBaseUrl = CONFIG.apiBaseUrl; // config build per environment; init menimpanya
|
||||
let gameId = null; // dari init; mode mandiri: dicari lewat slug (§5.4)
|
||||
let token = null; // hanya di memori
|
||||
let loginWaiters = []; // request yang menunggu customer login
|
||||
|
||||
const hasHost = () => typeof window.EnakGameHost !== 'undefined';
|
||||
const toHost = (msg) => hasHost() && window.EnakGameHost.postMessage(JSON.stringify(msg));
|
||||
|
||||
window.enakGame = {
|
||||
receive(raw) {
|
||||
const msg = JSON.parse(raw);
|
||||
if (msg.type !== 'init') return;
|
||||
apiBaseUrl = msg.api_base_url;
|
||||
gameId = msg.game_id;
|
||||
token = msg.token || null; // kosong → layar login saat request pertama
|
||||
onInit(); // pemulihan session (§6.4), lalu layar awal
|
||||
},
|
||||
};
|
||||
|
||||
// Menampilkan layar login; selesai setelah customer berhasil login.
|
||||
function waitForLogin(message) {
|
||||
return new Promise((resolve) => {
|
||||
if (loginWaiters.length === 0) showLoginScreen(message);
|
||||
loginWaiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
// Dipanggil tombol Masuk. Error dilempar ke layar login dan ditampilkan sesuai §5.3.
|
||||
async function login(phoneNumber, password) {
|
||||
const res = await fetch(apiBaseUrl + '/customer-auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone_number: phoneNumber, password }),
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!json?.success) throw json?.errors?.[0] ?? { code: String(res.status) };
|
||||
token = json.data.data.access_token;
|
||||
hideLoginScreen();
|
||||
loginWaiters.splice(0).forEach((resolve) => resolve());
|
||||
}
|
||||
|
||||
// Mengembalikan isi `data`, atau melempar errors[0]. Error jaringan ikut dilempar,
|
||||
// lalu ditangani pemanggil sesuai §8.
|
||||
async function api(method, path, { body, idempotencyKey } = {}) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
if (!token) await waitForLogin(attempt === 0 ? null : 'Sesi kamu berakhir, silakan login lagi.');
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
||||
const res = await fetch(apiBaseUrl + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => null);
|
||||
if (json?.success) return json.data;
|
||||
const err = json?.errors?.[0] ?? { code: String(res.status) };
|
||||
if (err.entity === 'auth_handler' && attempt === 0) {
|
||||
token = null; // login lagi, lalu ulangi request yang sama sekali
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Alur satu kali main
|
||||
|
||||
```
|
||||
init ─► token ada? tidak: login (§5.2) ─► cek session yang masih berjalan (§6.4)
|
||||
─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin)
|
||||
─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong)
|
||||
─► permainan berjalan (batas waktu: expires_at)
|
||||
─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah)
|
||||
─► tampilkan hadiah ─► main lagi atau close
|
||||
```
|
||||
|
||||
### 6.1 Data game — `GET /customer/enakgame/games`
|
||||
|
||||
Mengembalikan semua game aktif organisasi customer. Ambil yang `id`-nya sama dengan
|
||||
`game_id` dari `init`; pada mode mandiri, yang `slug`-nya milik build ini (§5.4).
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "8a1f…",
|
||||
"slug": "spin",
|
||||
"name": "Spin Harian",
|
||||
"description": null,
|
||||
"thumbnail_url": "https://…/spin.png",
|
||||
"game_url": "https://…/spin/index.html",
|
||||
"version": "1.2.0",
|
||||
"entry_cost": 5,
|
||||
"session_ttl_seconds": 600,
|
||||
"events": [
|
||||
{ "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" }
|
||||
],
|
||||
"prizes": [
|
||||
{ "entry": 1, "label": "Zonk", "amount": 0 },
|
||||
{ "entry": 2, "label": "3 Coin", "amount": 3 },
|
||||
{ "entry": 3, "label": "10 Coin", "amount": 10 },
|
||||
{ "entry": 4, "label": "Jackpot", "amount": 50 }
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `entry_cost`: EnakCoin per main. Tampilkan di tombol Main ("Main · 5 EnakCoin").
|
||||
- `events`: event yang sedang berlaku, prioritas tertinggi dulu. Tampilkan sebagai
|
||||
label, mis. "2x hadiah sampai 31 Okt". `multiplier` 2 berarti hadiah dasar ditambah
|
||||
sekali lagi; `bonus` menambah sejumlah EnakCoin.
|
||||
- `prizes`: hanya ada untuk game ber-reward `PROBABILITY` (spin). Urutan = urutan segmen
|
||||
roda. `label` bisa `null`. Bobot peluang tidak pernah dikirim.
|
||||
- Game tidak ada di daftar → game sudah dinonaktifkan; tampilkan pesan dan `close`.
|
||||
- `slug` bukan yang dibangun untuk build ini (mis. build spin menerima game `runner`)
|
||||
→ `game_url` salah dipasang admin. Tampilkan "Game sedang tidak tersedia", `close`,
|
||||
dan laporkan ke tim backoffice.
|
||||
|
||||
### 6.2 Mulai — `POST /customer/enakgame/sessions`
|
||||
|
||||
Header `Idempotency-Key` wajib (maks. 50 karakter, mis. UUID v4). Buat key baru saat
|
||||
customer menekan Main; pakai key yang sama bila request diulang karena jaringan atau
|
||||
karena customer login ulang (§5.2).
|
||||
|
||||
```json
|
||||
{ "game_id": "8a1f…" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "c0d3…",
|
||||
"game_id": "8a1f…",
|
||||
"entry_cost": 5,
|
||||
"expires_at": "2026-10-08T05:10:00Z",
|
||||
"coin_balance": 15,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
- EnakCoin sudah terpotong. Kirim `balance_changed` dengan `coin_balance`.
|
||||
- `replayed: true`: request ini mengulang start yang sudah berhasil; pakai session yang
|
||||
sama, EnakCoin tidak terpotong dua kali.
|
||||
- `expires_at`: batas waktu mengirim hasil (default 10 menit sejak start, diatur per
|
||||
game). Tampilkan timer bila permainan bisa lama.
|
||||
|
||||
| Penolakan `304` (`cause`) | Tampilan |
|
||||
|---|---|
|
||||
| `not enough EnakCoin` | "EnakCoin kamu kurang." Tombol kembali ke aplikasi |
|
||||
| `the game is not available` | "Game sedang tidak tersedia." |
|
||||
| `the game has no active reward configuration` | "Game sedang tidak tersedia." |
|
||||
| `no EnakGame budget is set for this period` | "Game sedang tidak tersedia." |
|
||||
| `the customer is not active` | "Akun tidak aktif." |
|
||||
| `this Idempotency-Key was already used to start another game` | Bug di game: key dipakai ulang untuk game lain |
|
||||
| `the Idempotency-Key header is required` / `… at most 50 characters` | Bug di game |
|
||||
|
||||
### 6.3 Kirim hasil — `POST /customer/enakgame/sessions/:id/complete`
|
||||
|
||||
Kirim sekali saat permainan selesai, sebelum `expires_at`. Body berisi hasil saja,
|
||||
sesuai jenis hadiah game (§2.2):
|
||||
|
||||
| Field | Tipe | Untuk |
|
||||
|---|---|---|
|
||||
| `score` | integer ≥ 0, opsional | Game berbasis skor |
|
||||
| `outcome` | string, opsional | Game berbasis hasil, mis. `"WIN"`, `"PERFECT"` |
|
||||
| `data` | objek JSON, opsional, maks. 16 KB | Data tambahan untuk audit (durasi per level, dsb.) |
|
||||
|
||||
Spin cukup mengirim `{}`. Game skor: `{ "score": 800 }`. Game hasil:
|
||||
`{ "outcome": "WIN" }`. Nilai `outcome` yang diterima ditentukan admin per game
|
||||
(§2.3).
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "c0d3…",
|
||||
"status": "COMPLETED",
|
||||
"reward_total": 10,
|
||||
"reward": { "base": 5, "event": 5 },
|
||||
"coin_balance": 25,
|
||||
"limited_by": ["USER_DAILY"],
|
||||
"prize": { "entry": 2, "label": "3 Coin", "amount": 3 }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Arti | Tampilan |
|
||||
|---|---|---|
|
||||
| `reward_total` | EnakCoin yang **benar-benar masuk** | Angka utama di layar hadiah |
|
||||
| `reward.base` / `reward.event` | Hadiah dasar dan tambahan event | "5 + 5 bonus event" |
|
||||
| `coin_balance` | Saldo EnakCoin setelah hadiah | Kirim `balance_changed` |
|
||||
| `limited_by` | Batas harian yang memotong hadiah: `USER_DAILY`, `GAME_DAILY`, `GLOBAL_DAILY` | "Hadiah hari ini sudah mencapai batas" |
|
||||
| `prize` | Untuk spin: segmen hasil undian. `amount` = hadiah dasar segmen, sebelum event dan batas | Hentikan roda di `prize.entry` |
|
||||
| `status` | `COMPLETED`, atau `REFUNDED` bila game dinonaktifkan selama dimainkan | Lihat di bawah |
|
||||
|
||||
- **`status: "REFUNDED"`** (`refund_reason: "GAME_DEACTIVATED"`): entry cost
|
||||
dikembalikan dan tidak ada hadiah. Tampilkan "Game sedang dihentikan, EnakCoin kamu
|
||||
dikembalikan."
|
||||
- **`reward_total` 0** bisa terjadi: hadiahnya memang 0 (mis. segmen Zonk), batas harian
|
||||
sudah habis, field yang dibutuhkan jenis hadiahnya tidak dikirim (§2.2), atau hasilnya
|
||||
tidak lolos validasi server (skor di atas batas, terlalu cepat selesai, `outcome`
|
||||
tidak dikenal). Server tidak memberi tahu alasan validasi; tampilkan hasil apa adanya.
|
||||
- **Mengirim ulang aman.** Complete untuk session yang sudah selesai mengembalikan
|
||||
jawaban yang sama, tanpa hadiah dua kali. Tidak perlu `Idempotency-Key`.
|
||||
|
||||
| Penolakan | Arti | Tampilan |
|
||||
|---|---|---|
|
||||
| `304` `the session has expired` | Lewat `expires_at` | "Waktu bermain habis." (lihat §7) |
|
||||
| `304` `data must be …` | `data` bukan JSON atau lebih dari 16 KB | Bug di game |
|
||||
| `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game |
|
||||
| `404` | Session tidak ada / milik customer lain | Pesan umum |
|
||||
|
||||
### 6.4 Pemulihan setelah reload
|
||||
|
||||
Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash)
|
||||
saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi
|
||||
session-nya. Dua endpoint dipakai:
|
||||
|
||||
**Satu session** — `GET /customer/enakgame/sessions/:id`
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0,
|
||||
"started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null
|
||||
}
|
||||
```
|
||||
|
||||
`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Response ini tidak memuat
|
||||
`prize` atau rincian hadiah; untuk itu kirim ulang complete (lihat di bawah).
|
||||
|
||||
**Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1`
|
||||
|
||||
Response lengkap, termasuk amplopnya. Daftar session ada di **`data.data`**, terbaru di
|
||||
atas:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"data": [
|
||||
{
|
||||
"id": "c0d3…", "game_id": "8a1f…", "status": "STARTED", "entry_cost": 5, "reward_total": 0,
|
||||
"started_at": "…", "expires_at": "…", "ended_at": null, "refund_reason": null
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 1, "total_count": 1, "total_pages": 1 }
|
||||
},
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
Tidak ada session yang cocok: `data.data` berupa array kosong `[]` (tidak pernah
|
||||
`null`). Bentuk lain berarti error, bukan "tidak ada session". Semua query opsional:
|
||||
`game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`, `EXPIRED`), `page`, `limit`.
|
||||
`status` atau `game_id` yang tidak valid ditolak `304`.
|
||||
|
||||
Bila pengecekan ini gagal (jaringan, `5xx`), **jangan** menganggap tidak ada session:
|
||||
customer bisa terpotong EnakCoin dua kali. Tampilkan "Coba lagi" sampai berhasil.
|
||||
|
||||
**Alurnya, setiap kali menerima `init`:**
|
||||
|
||||
1. Simpan `session_id` di **`sessionStorage`** setiap kali start berhasil, dan hapus
|
||||
setelah hasilnya ditampilkan. `session_id` bukan rahasia; token tetap hanya di
|
||||
memori (§1).
|
||||
2. Bila ada `session_id` tersimpan, panggil `GET /sessions/:id`. Bila tidak ada (mis.
|
||||
webview dibuka ulang dari awal), panggil
|
||||
`GET /sessions?game_id=<game_id>&status=STARTED&limit=1`.
|
||||
3. Tindak lanjuti sesuai status:
|
||||
|
||||
| Keadaan | Yang dilakukan game |
|
||||
|---|---|
|
||||
| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete. Complete setelah `expires_at` ditolak, jadi **akhiri ronde otomatis dan kirim skor saat itu** beberapa detik sebelum `expires_at` (mis. 5 detik, untuk jeda jaringan dan selisih jam perangkat) |
|
||||
| `STARTED`, `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru |
|
||||
| `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}`): server mengembalikan jawaban yang sama persis, termasuk `prize`, tanpa hadiah dobel. Tampilkan hasilnya |
|
||||
| `REFUNDED` | "EnakCoin kamu dikembalikan." |
|
||||
| `EXPIRED` | "Waktu bermain habis." |
|
||||
| `404` untuk `session_id` tersimpan | Session milik akun lain (customer berganti akun). Hapus `session_id`, lanjut seperti tidak ada session |
|
||||
| Tidak ada session | Tampilkan layar awal seperti biasa |
|
||||
|
||||
Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game.
|
||||
|
||||
---
|
||||
|
||||
## 7. Batas waktu dan refund
|
||||
|
||||
| Keadaan | Yang terjadi pada EnakCoin |
|
||||
|---|---|
|
||||
| Hasil dikirim sebelum `expires_at` | Entry cost terpakai, hadiah masuk |
|
||||
| Customer menutup game / game crash, hasil tidak pernah dikirim | Session menjadi `EXPIRED` setelah `expires_at`. **Entry cost tidak dikembalikan** |
|
||||
| Complete tertahan karena customer harus login ulang sampai `expires_at` lewat | Sama dengan di atas: `EXPIRED`, **entry cost tidak dikembalikan** |
|
||||
| Complete gagal karena error server (`5xx`) dan tidak berhasil sampai `expires_at` | Session direfund otomatis (`refund_reason: "SYSTEM_ERROR"`) dalam ±1 menit setelah `expires_at` |
|
||||
| Game dinonaktifkan admin saat dimainkan | Session direfund (`GAME_DEACTIVATED`) |
|
||||
|
||||
Karena itu kirim hasil **segera** setelah permainan selesai, sebelum animasi panjang.
|
||||
Saat customer menekan keluar di tengah permainan, tampilkan konfirmasi "EnakCoin yang
|
||||
sudah dipakai tidak kembali".
|
||||
|
||||
---
|
||||
|
||||
## 8. Retry dan jaringan
|
||||
|
||||
| Request | Gagal karena jaringan / `5xx` | Aturan |
|
||||
|---|---|---|
|
||||
| Start | Ulangi dengan **`Idempotency-Key` yang sama** | Key baru = potong EnakCoin lagi |
|
||||
| Complete | Ulangi dengan body yang sama sampai berhasil atau `expires_at` lewat | Aman diulang |
|
||||
| Token ditolak (`entity` `auth_handler`) | Bukan error jaringan; layar login (§5.2) | Setelah login, ulangi sekali |
|
||||
| Login gagal | Tampilkan pesan (§5.3) | Jangan diulang otomatis |
|
||||
|
||||
Gunakan backoff (mis. 1 s, 2 s, 4 s) dan tampilkan indikator "Menyimpan hasil…" selama
|
||||
complete diulang.
|
||||
|
||||
---
|
||||
|
||||
## 9. Spin
|
||||
|
||||
1. Gambar roda dari `prizes` (§6.1): satu segmen per entri, urut, dengan `label`
|
||||
(atau `amount` bila `label` `null`).
|
||||
2. Tap Putar → start session (§6.2).
|
||||
3. Mulai animasi berputar, lalu langsung kirim complete dengan `{}`.
|
||||
4. Dari response, hentikan roda di segmen `prize.entry`, lalu tampilkan `reward_total`.
|
||||
|
||||
Jangan menentukan segmen sendiri lalu "mencocokkan" dengan server. Bila `prize` tidak
|
||||
ada di response, hasil tidak bisa ditampilkan sebagai roda; tampilkan `reward_total`
|
||||
saja.
|
||||
|
||||
---
|
||||
|
||||
## 10. Leaderboard mingguan — `GET /customer/enakgame/leaderboard`
|
||||
|
||||
Peringkat customer satu organisasi menurut EnakCoin yang dimenangkan dari game dalam
|
||||
satu minggu, Senin 00:00 sampai Minggu 23:59 WIB. Yang dihitung hanya session
|
||||
`COMPLETED`, menurut minggu saat session dimulai; entry cost tidak mengurangi. Total
|
||||
yang sama diurutkan menurut siapa yang mencapainya lebih dulu, jadi tidak ada peringkat
|
||||
kembar.
|
||||
|
||||
| Query | Default | Keterangan |
|
||||
|---|---|---|
|
||||
| `date` | Minggu ini | Hari mana pun dalam minggu yang diminta, `YYYY-MM-DD`. Untuk minggu lalu, kirim tanggal 7 hari sebelum `week_start` minggu ini |
|
||||
| `game_id` | Semua game | Leaderboard satu game, mis. `game_id` dari `init` untuk "peringkat game ini" |
|
||||
| `limit` | 10 | Jumlah peringkat teratas, maks 100 |
|
||||
|
||||
```json
|
||||
{
|
||||
"week_start": "2026-10-12",
|
||||
"week_end": "2026-10-18",
|
||||
"game_id": null,
|
||||
"entries": [
|
||||
{ "rank": 1, "name": "Budi S******", "coins": 120, "is_me": false },
|
||||
{ "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true }
|
||||
],
|
||||
"me": { "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true }
|
||||
}
|
||||
```
|
||||
|
||||
- Nama customer lain sudah disamarkan server: kata pertama utuh, kata berikutnya hanya
|
||||
huruf pertama. Tampilkan apa adanya; untuk baris `is_me: true` tampilkan "Kamu".
|
||||
- `me` adalah posisi customer yang login, juga bila di luar `limit`: tampilkan sebagai
|
||||
baris tersendiri di bawah daftar. `null` bila belum menang EnakCoin minggu itu
|
||||
("Main untuk masuk leaderboard").
|
||||
- `entries` kosong (`[]`, tidak pernah `null`) bila belum ada yang menang minggu itu.
|
||||
- Tampilkan rentang minggu dari `week_start`–`week_end`; jangan menghitung minggu
|
||||
sendiri dari jam perangkat.
|
||||
- Leaderboard tidak berubah sendiri. Muat ulang setelah complete berhasil, atau saat
|
||||
layar leaderboard dibuka.
|
||||
- `date` yang bukan `YYYY-MM-DD` atau `game_id` yang bukan UUID ditolak `304` (bug di
|
||||
game; pesan umum).
|
||||
|
||||
---
|
||||
|
||||
## 11. Checklist
|
||||
|
||||
- [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1).
|
||||
- [ ] Body complete sesuai jenis hadiah game (§2.2).
|
||||
- [ ] Bridge sesuai kontrak §4 yang sudah disepakati dengan tim aplikasi.
|
||||
- [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics.
|
||||
- [ ] Tanpa token tidak ada request; token kosong atau ditolak → layar login → ulangi request sekali (§5.2).
|
||||
- [ ] Login memakai `access_token`; password tidak disimpan; nomor HP/password salah tampil sebagai pesan yang jelas (§5.3).
|
||||
- [ ] Mode mandiri (§5.4): dibuka di browser atau `init` tidak datang → login, `game_id` dicari lewat `slug`.
|
||||
- [ ] Pemulihan setelah reload (§6.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang.
|
||||
- [ ] Biaya main dan label event tampil sebelum main.
|
||||
- [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry.
|
||||
- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah.
|
||||
- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani.
|
||||
- [ ] Spin berhenti di `prize.entry`.
|
||||
- [ ] Leaderboard (§10): rentang minggu dari `week_start`/`week_end`, baris `is_me` sebagai "Kamu", `me` di luar `limit` tampil di bawah daftar, dimuat ulang setelah complete.
|
||||
- [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani.
|
||||
- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar.
|
||||
@@ -0,0 +1,827 @@
|
||||
# Integrasi Mobile App Customer: EnakPoint, EnakCoin, EnakGame & Voucher
|
||||
|
||||
**Untuk:** tim aplikasi mobile customer · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026
|
||||
|
||||
Kamu mengerjakan aplikasi mobile untuk **customer** (bukan kasir, bukan backoffice).
|
||||
Tugasmu: membangun fitur loyalitas di aplikasi, yaitu saldo EnakPoint & EnakCoin,
|
||||
PIN, tukar, transfer, voucher, dan pintu masuk ke game EnakGame. Semuanya 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.
|
||||
|
||||
Dokumen ini menggantikan `mobile-customer-enakpoint.md`, `integration-enakpoint.md`,
|
||||
`api-enakpoint.md`, dan `enakgame-spin.md` untuk sisi aplikasi customer. Game-nya
|
||||
sendiri (Phaser) dikerjakan tim EnakGame dengan
|
||||
[`integration-enakgame.md`](./integration-enakgame.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konteks bisnis
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Belanja (order lunas), tukar EnakCoin, koreksi admin | Belanja, **hadiah game**, 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.
|
||||
|
||||
### 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 tarik tunai, dan EnakPoint
|
||||
tidak bisa dipakai membayar. Jangan membangun layar bayar atau kode bayar.
|
||||
3. **PIN 6 digit wajib** untuk: tukar EnakCoin, transfer, dan **tukar EnakPoint ke
|
||||
voucher**. Main game, 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.
|
||||
7. **Hadiah game ditentukan server.** Aplikasi tidak menghitung atau mengirim hadiah.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
- Belum ada endpoint refresh token: bila token ditolak (§2.2, `entity` `auth_handler`),
|
||||
customer login ulang.
|
||||
- **Nomor HP customer selalu disimpan dan dikembalikan dalam format `62…`** (mis.
|
||||
`6281234561234`). Di request (registrasi, login, kirim ulang OTP, cek nomor, penerima
|
||||
transfer) nomor boleh ditulis `0812…`, `+62 812…`, `62812…`, atau `812…`; backend
|
||||
mengubahnya ke `62…`. Hanya nomor HP Indonesia (`628…`) yang diterima; selain itu
|
||||
ditolak `304` `invalid phone number format`. Pengecualian: nomor penerima transfer
|
||||
yang disamarkan tetap ditampilkan dalam format lokal, `08**-****-1234` (§7.2).
|
||||
|
||||
### 2.1 Registrasi customer
|
||||
|
||||
`POST /api/v1/customer-auth/register/start` menerima `organization_id` (opsional):
|
||||
|
||||
```json
|
||||
{ "phone_number": "6281234561234", "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.
|
||||
|
||||
### 2.2 Format response
|
||||
|
||||
Sukses:
|
||||
|
||||
```json
|
||||
{ "success": true, "data": { … }, "errors": null }
|
||||
```
|
||||
|
||||
Semua contoh response di dokumen ini adalah isi `data`. Untuk daftar berhalaman, isi
|
||||
itu sendiri berbentuk `{ "data": [ … ], "pagination": { … } }`, jadi array-nya ada di
|
||||
`data.data` pada response mentah.
|
||||
|
||||
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, **atau token tidak berlaku** bila `entity` = `auth_handler` | Pesan yang ramah per fitur; `cause` berbahasa Inggris, jangan tampilkan mentah. Token: login ulang |
|
||||
| `404` | 404 | Tidak ditemukan, juga untuk data milik customer lain | Tampilkan "tidak ditemukan" |
|
||||
| `429` | 429 | Minta OTP terlalu cepat, atau terlalu banyak percobaan login (§2.4) | 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" |
|
||||
|
||||
### 2.3 Idempotency-Key
|
||||
|
||||
Endpoint **tukar**, **transfer**, dan **tukar voucher** wajib header `Idempotency-Key`
|
||||
(string unik, maks. 50 karakter, mis. UUID v4; `X-Idempotency-Key` juga diterima).
|
||||
|
||||
- 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`).
|
||||
|
||||
### 2.4 Login — `POST /api/v1/customer-auth/login`
|
||||
|
||||
`{ "phone_number": "…", "password": "…" }` → token di `data.data.access_token`.
|
||||
|
||||
| `errors[0].code` | HTTP | Arti | Tampilan |
|
||||
|---|---|---|---|
|
||||
| `304`, `entity` `customer_auth_service` | 400 | Nomor HP tidak terdaftar, password salah, atau pendaftaran belum selesai | "Nomor HP atau password salah." |
|
||||
| `304`, `entity` `request` | 400 | Format nomor HP salah, field kosong | "Periksa nomor HP dan password." |
|
||||
| `429` | 429 | Terlalu banyak percobaan; `data.locked_until` (RFC3339 UTC) | "Terlalu banyak percobaan. Coba lagi pukul {jam}." |
|
||||
| `900` | 500 | Error server | "Terjadi kesalahan, coba lagi" |
|
||||
|
||||
> **Berubah per 9 Okt 2026.** Sebelumnya nomor HP atau password salah dijawab `900`
|
||||
> (HTTP 500) dengan `cause` `invalid password` / `customer not found`. Bila app
|
||||
> menangani login gagal dari HTTP 500 atau teks `cause` itu, ganti ke tabel di atas.
|
||||
|
||||
Setiap nomor HP hanya boleh mencoba login 5 kali dalam 15 menit; login yang berhasil
|
||||
memulai hitungan dari nol. Percobaan ke-6 ditolak `429` sampai 15 menit itu habis, juga
|
||||
bila password-nya benar.
|
||||
|
||||
---
|
||||
|
||||
## 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/*` | – |
|
||||
| Daftar game + webview game | `GET /customer/enakgame/games` | – |
|
||||
| Riwayat main | `GET /customer/enakgame/sessions` | – |
|
||||
| Katalog voucher | `GET /customer/vouchers` | – |
|
||||
| Tukar voucher | `POST /customer/vouchers/:id/redeem` | Ya |
|
||||
| Voucher saya | `GET /customer/vouchers/redemptions` | – |
|
||||
| (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), Voucher (§9).
|
||||
|
||||
Muat ulang beranda setelah setiap transaksi, saat webview game ditutup, 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 `−`.
|
||||
- Penambahan membawa `source`, pengurangan membawa `destination`, keduanya `{ type, id }`.
|
||||
- `description` sudah siap tampil (nama lawan transfer sudah disamarkan, nama game dan
|
||||
voucher sudah tertulis). 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` | Mata uang | Label | Arah |
|
||||
|---|---|---|---|
|
||||
| `EARN` | keduanya | Dari belanja | + |
|
||||
| `EARN_REVERSAL` | keduanya | Dibatalkan (order di-void/refund) | − |
|
||||
| `EXCHANGE_OUT` | EnakCoin | Ditukar ke EnakPoint | − |
|
||||
| `EXCHANGE_IN` | EnakPoint | Hasil tukar EnakCoin | + |
|
||||
| `TRANSFER_OUT` | keduanya | Transfer keluar | − |
|
||||
| `TRANSFER_IN` | keduanya | Transfer masuk | + |
|
||||
| `GAME_SPEND` | EnakCoin | Main game | − |
|
||||
| `GAME_SPEND_REFUND` | EnakCoin | Biaya main dikembalikan | + |
|
||||
| `GAME_REWARD` | EnakCoin | Hadiah game | + |
|
||||
| `REWARD_REDEEM` | EnakPoint | Ditukar ke voucher | − |
|
||||
| `REWARD_REDEEM_REFUND` | EnakPoint | Penukaran voucher dibatalkan | + |
|
||||
| `EXPIRE` | keduanya | Kedaluwarsa | − |
|
||||
| `ADJUSTMENT` | keduanya | Koreksi | + / − |
|
||||
| `MIGRATION` | keduanya | Saldo awal | + |
|
||||
|
||||
Tipe yang tidak dikenal (bila backend menambah tipe baru): tampilkan `description` dan
|
||||
arah dari tanda `amount`, tanpa label.
|
||||
|
||||
### 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`.
|
||||
- Satu token FCM hanya milik satu customer: bila customer lain login di HP yang sama,
|
||||
customer sebelumnya tidak lagi menerima notifikasi di HP itu.
|
||||
- Saat **logout**, panggil `DELETE /api/v1/customer/devices/{device_id}` sebelum
|
||||
menghapus token login.
|
||||
|
||||
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` | `reminder_days` 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 (tukar, transfer, tukar voucher). 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 EnakCoin dan tukar voucher 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}". EnakPoint hasil tukar tidak bisa hidup lebih lama dari EnakCoin
|
||||
asalnya.
|
||||
|
||||
Jumlah yang salah ditolak sebelum PIN dicek, jadi tidak memakan jatah percobaan PIN.
|
||||
|
||||
### 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=6281234561234`
|
||||
|
||||
```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": "6281234561234", "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 (EnakGame)
|
||||
|
||||
Game dimainkan di **webview** yang memuat `game_url` tiap game. Pembagian tugasnya:
|
||||
aplikasi menampilkan daftar game, membuka webview, dan memberi token lewat bridge; game
|
||||
EnakGame sendiri yang memulai session, memotong EnakCoin, mengirim hasil, dan
|
||||
menampilkan hadiah ([`integration-enakgame.md`](./integration-enakgame.md)). Aplikasi
|
||||
**tidak** memanggil `POST /customer/enakgame/sessions` atau `…/complete`.
|
||||
|
||||
### 8.1 Daftar game — `GET /customer/enakgame/games`
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "8a1f…",
|
||||
"slug": "spin",
|
||||
"name": "Spin Harian",
|
||||
"description": null,
|
||||
"thumbnail_url": "https://…/spin.png",
|
||||
"game_url": "https://…/spin/index.html",
|
||||
"version": "1.2.0",
|
||||
"entry_cost": 5,
|
||||
"session_ttl_seconds": 600,
|
||||
"events": [
|
||||
{ "id": "…", "name": "Ramadan 2x", "banner_url": "https://…", "multiplier": 2, "bonus": null, "end_at": "2026-10-31T16:59:59Z" }
|
||||
],
|
||||
"prizes": [ { "entry": 1, "label": "Zonk", "amount": 0 } ]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Tampilkan:
|
||||
- Kartu per game: `thumbnail_url`, `name`, biaya "{entry_cost} EnakCoin".
|
||||
- Badge event bila `events` tidak kosong: `name` atau `banner_url`, dan "berakhir
|
||||
{end_at}" (tampilkan dalam WIB).
|
||||
- Tombol Main nonaktif dengan teks "EnakCoin kurang" bila `coin_balance` (§4.1) lebih
|
||||
kecil dari `entry_cost`.
|
||||
- `prizes` hanya dipakai game spin di dalam webview; aplikasi boleh mengabaikannya.
|
||||
|
||||
Game yang dinonaktifkan admin hilang dari daftar ini. Muat ulang daftar setiap kali
|
||||
layar dibuka.
|
||||
|
||||
### 8.2 Membuka game
|
||||
|
||||
1. Customer menekan Main → buka webview layar penuh dengan `game_url`.
|
||||
2. Pasang bridge (§8.3) **sebelum** halaman dimuat.
|
||||
3. Saat game mengirim `ready`, jawab dengan `init`.
|
||||
4. Saat game mengirim `close`, tutup webview, lalu muat ulang beranda wallet (§4.1).
|
||||
|
||||
Jangan menaruh token di URL `game_url` (query string atau fragment): URL bisa tercatat di
|
||||
log server game dan riwayat webview.
|
||||
|
||||
### 8.3 Bridge (sisi aplikasi)
|
||||
|
||||
> **Usulan.** Kontrak ini sama dengan [`integration-enakgame.md`](./integration-enakgame.md)
|
||||
> §4 dan belum diimplementasikan. Sepakati dengan tim EnakGame sebelum mulai.
|
||||
|
||||
- Game → aplikasi: JavaScript channel webview bernama **`EnakGameHost`**; setiap pesan
|
||||
berupa JSON string.
|
||||
- Aplikasi → game: jalankan `window.enakGame.receive('<json>')` di webview.
|
||||
|
||||
| Pesan masuk dari game | Yang dilakukan aplikasi |
|
||||
|---|---|
|
||||
| `{ "type": "ready" }` | Kirim `{ "type": "init", "api_base_url": "<base URL>/api/v1", "token": "<access token customer>", "game_id": "<id game yang dibuka>" }`. Jawab setiap `ready`, termasuk setelah halaman game dimuat ulang. Access token, bukan refresh token (ditolak backend) |
|
||||
| `{ "type": "balance_changed", "coin_balance": 15 }` | Perbarui saldo EnakCoin yang ditampilkan aplikasi |
|
||||
| `{ "type": "close" }` | Tutup webview, muat ulang beranda |
|
||||
|
||||
Bila token kosong atau ditolak backend, game menampilkan layar login akun customer
|
||||
sendiri; aplikasi tidak perlu menangani apa pun, dan token hasil login di game tidak
|
||||
dikirim balik ke aplikasi.
|
||||
|
||||
Abaikan pesan dengan `type` lain. Tombol back Android jangan langsung menutup webview:
|
||||
tampilkan konfirmasi "Keluar dari game? EnakCoin yang sudah dipakai untuk main tidak
|
||||
kembali", lalu tutup. Tidak perlu mengirim pesan ke game.
|
||||
|
||||
### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20`
|
||||
|
||||
Query opsional `game_id` (riwayat satu game) dan `status` (`STARTED`, `COMPLETED`,
|
||||
`REFUNDED`, `EXPIRED`) untuk filter atau tab.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "…", "game_id": "8a1f…", "status": "COMPLETED", "entry_cost": 5, "reward_total": 10,
|
||||
"started_at": "…", "expires_at": "…", "ended_at": "…", "refund_reason": null
|
||||
}
|
||||
],
|
||||
"pagination": { "page": 1, "limit": 20, "total_count": 3, "total_pages": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
| `status` | Label | Keterangan |
|
||||
|---|---|---|
|
||||
| `STARTED` | Sedang dimainkan | |
|
||||
| `COMPLETED` | Selesai | "Dapat {reward_total} EnakCoin" |
|
||||
| `REFUNDED` | Dikembalikan | Entry cost kembali; `refund_reason` `SYSTEM_ERROR` atau `GAME_DEACTIVATED` |
|
||||
| `EXPIRED` | Tidak selesai | Hasil tidak dikirim sebelum batas waktu; entry cost tidak kembali |
|
||||
|
||||
Nama game diambil dari daftar game (§8.1) lewat `game_id`. Detail satu session:
|
||||
`GET /customer/enakgame/sessions/:id`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Voucher (tukar EnakPoint)
|
||||
|
||||
### 9.1 Katalog — `GET /customer/vouchers`
|
||||
|
||||
Voucher yang bisa ditukar sekarang: aktif, dalam masa berlaku, dan masih ada stoknya.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "…",
|
||||
"name": "Kopi Susu Gratis",
|
||||
"description": "Berlaku untuk ukuran regular",
|
||||
"image_url": "https://…/kopi.png",
|
||||
"voucher_type": "FREE_ITEM",
|
||||
"face_value": 20000,
|
||||
"point_cost": 15000,
|
||||
"max_per_customer": 2,
|
||||
"valid_until": "2026-12-31T16:59:59Z",
|
||||
"terms": { "…": "syarat & ketentuan, objek JSON bebas" },
|
||||
"available": 120
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `point_cost`: EnakPoint yang dipotong. Tombol Tukar nonaktif bila `point_balance`
|
||||
kurang.
|
||||
- `face_value`: nilai voucher dalam rupiah, tampilkan sebagai "senilai Rp 20.000".
|
||||
- `available`: sisa stok; `null` berarti stok tidak dihitung. Bila 0, tampilkan "Habis".
|
||||
- `max_per_customer`: batas tukar per customer; `null` = tanpa batas.
|
||||
- `terms`: objek JSON yang isinya diatur admin. Sepakati bentuknya dengan tim
|
||||
backoffice; sebelum itu tampilkan `description` saja.
|
||||
|
||||
| `voucher_type` | Label usulan |
|
||||
|---|---|
|
||||
| `FIXED_VALUE` | Potongan Rp {face_value} |
|
||||
| `PERCENTAGE` | Potongan persen |
|
||||
| `FREE_ITEM` | Gratis item |
|
||||
| `MERCHANT_BENEFIT` | Benefit merchant |
|
||||
|
||||
### 9.2 Tukar — `POST /customer/vouchers/:id/redeem`
|
||||
|
||||
Konfirmasi ("Tukar {point_cost} EnakPoint dengan {name}? Tidak bisa dibatalkan.") →
|
||||
minta PIN → kirim dengan header `Idempotency-Key`:
|
||||
|
||||
```json
|
||||
{ "pin": "482913" }
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "…",
|
||||
"voucher_id": "…",
|
||||
"voucher_name": "Kopi Susu Gratis",
|
||||
"voucher_image_url": "https://…/kopi.png",
|
||||
"voucher_type": "FREE_ITEM",
|
||||
"status": "COMPLETED",
|
||||
"face_value": 20000,
|
||||
"point_cost": 15000,
|
||||
"code": "KOPI-7F3C-2291",
|
||||
"code_expires_at": "2026-12-31T16:59:59Z",
|
||||
"completed_at": "…",
|
||||
"created_at": "…",
|
||||
"point_balance": 2500,
|
||||
"replayed": false
|
||||
}
|
||||
```
|
||||
|
||||
- Layar sukses: voucher, `code` bila ada (bisa disalin), masa berlaku, dan saldo
|
||||
EnakPoint baru (`point_balance`).
|
||||
- `code` bisa `null`: voucher ini tidak memakai kode; tunjukkan layar voucher ke kasir.
|
||||
- `status: "PENDING"`: voucher sedang diproses penyedia luar (belum ada voucher seperti
|
||||
ini di katalog, tapi tangani dari sekarang). EnakPoint sudah terpotong;
|
||||
tampilkan "Voucher sedang diproses" dan cek lagi di Voucher saya (§9.3). Bila akhirnya
|
||||
`FAILED`, EnakPoint dikembalikan otomatis (mutasi `REWARD_REDEEM_REFUND`).
|
||||
- Error PIN ditangani sesuai §6.5.
|
||||
|
||||
| Penolakan `304` (`cause`) | Tampilan |
|
||||
|---|---|
|
||||
| `not enough EnakPoint` | "EnakPoint kamu kurang." |
|
||||
| `the voucher is out of stock` | "Voucher sudah habis." Muat ulang katalog |
|
||||
| `this voucher can be redeemed at most … times per customer` | "Kamu sudah mencapai batas penukaran voucher ini." |
|
||||
| `the voucher is not available`, `… cannot be redeemed yet`, `… has ended`, `… not available yet` | "Voucher tidak tersedia." Muat ulang katalog |
|
||||
| `the customer is not active` | "Akun tidak aktif." |
|
||||
| `this Idempotency-Key was already used to redeem another voucher` | Bug di app: key dipakai ulang |
|
||||
|
||||
### 9.3 Voucher saya — `GET /customer/vouchers/redemptions?page=1&limit=20`
|
||||
|
||||
Daftar penukaran customer, terbaru di atas, dengan bentuk item sama seperti response
|
||||
§9.2 (tanpa `point_balance` dan `replayed`). Seperti semua daftar berhalaman di dokumen
|
||||
ini, array-nya ada di `data.data` dan pagination di `data.pagination` di dalam amplop
|
||||
response (§2.2); daftar kosong berupa `[]`.
|
||||
|
||||
| `status` | Tampilan |
|
||||
|---|---|
|
||||
| `COMPLETED` | Voucher siap dipakai: nama, `code` (bila ada), berlaku sampai `code_expires_at` |
|
||||
| `PENDING` | "Sedang diproses" |
|
||||
| `FAILED` | "Gagal, EnakPoint sudah dikembalikan" |
|
||||
|
||||
**Memakai voucher di outlet:** customer menunjukkan layar voucher ke kasir. POS belum
|
||||
bisa menandai voucher terpakai, jadi aplikasi belum bisa menampilkan status "sudah
|
||||
dipakai" ([`integration-pos.md`](./integration-pos.md) §5).
|
||||
|
||||
---
|
||||
|
||||
## 10. Yang sudah dihapus / deprecated
|
||||
|
||||
Sudah **dihapus** dari API (jangan dipanggil, akan error / tidak ada):
|
||||
|
||||
| Lama | Pengganti |
|
||||
|---|---|
|
||||
| `POST /customer/spin` | Game EnakGame di webview (§8) |
|
||||
| `GET /customer/games`, `GET /customer/ferris-wheel` | `GET /customer/enakgame/games` |
|
||||
| `coins_used`, `coins_remaining`, `prize_won`, `game_play` di response spin | Tidak ada; hasil game ditampilkan di dalam game |
|
||||
| `metadata.coin_cost` pada data game | `entry_cost` |
|
||||
| `GET /customer/tokens` | `GET /customer/wallet` → `coin_balance` |
|
||||
| `total_tokens`, `tokens_history`, `token_used`, `tokens_remaining` | `coin_balance`, `GET /customer/wallet/transactions?currency=COIN` |
|
||||
| `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 |
|
||||
| `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` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 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, dan label semua tipe di §4.2, termasuk tipe game dan voucher.
|
||||
- [ ] 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, transfer, voucher).
|
||||
- [ ] Tukar dengan preview, kelipatan kurs, konfirmasi, `Idempotency-Key`, retry dengan key sama.
|
||||
- [ ] Transfer dengan cek penerima tersamar, konfirmasi, `Idempotency-Key`, retry dengan key sama.
|
||||
- [ ] Daftar game dengan biaya, badge event, dan tombol nonaktif bila EnakCoin kurang.
|
||||
- [ ] Webview game dengan bridge §8.3; token tidak pernah di URL; beranda dimuat ulang saat game ditutup.
|
||||
- [ ] Riwayat main dengan label status.
|
||||
- [ ] Katalog voucher, tukar dengan PIN dan `Idempotency-Key`, status `PENDING` ditangani.
|
||||
- [ ] Voucher saya dengan kode yang bisa disalin.
|
||||
- [ ] Riwayat order dengan pagination dan layar detail (item, pembayaran, EnakPoint/EnakCoin yang didapat).
|
||||
- [ ] Tidak ada pemakaian endpoint atau field di §10.
|
||||
- [ ] PIN dan token tidak pernah disimpan sembarangan, di-log, atau dikirim ke analytics.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Integrasi POS: EnakPoint, EnakCoin & Voucher
|
||||
|
||||
**Untuk:** tim aplikasi POS (kasir) · **Base URL:** `/api/v1` · **Per:** 8 Okt 2026
|
||||
|
||||
Kamu mengerjakan aplikasi **POS** yang dipakai kasir di outlet. Dokumen ini menjelaskan
|
||||
bagian program loyalitas yang menyentuh POS: mengaitkan customer ke order, menampilkan
|
||||
EnakPoint dan EnakCoin yang didapat, void/refund, dan voucher. Jangan mengarang
|
||||
endpoint, field, atau aturan yang tidak tertulis di sini; kalau ada yang kurang jelas,
|
||||
tanyakan ke tim backend.
|
||||
|
||||
Dokumen ini menggantikan bagian POS di `integration-enakpoint.md` dan `api-enakpoint.md`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Yang perlu diketahui kasir
|
||||
|
||||
| | EnakPoint (`POINT`) | EnakCoin (`COIN`) |
|
||||
|---|---|---|
|
||||
| Didapat dari | Belanja (order lunas), hasil tukar EnakCoin, koreksi admin | Belanja, hadiah game, koreksi admin |
|
||||
| Dipakai untuk | **Ditukar ke voucher** di aplikasi customer | Main game, ditukar ke EnakPoint |
|
||||
| Bisa membayar order | **Tidak** | **Tidak** |
|
||||
|
||||
- **EnakPoint bukan alat bayar.** Tidak ada payment method EnakPoint di POS, dan saldo
|
||||
tidak bisa dicairkan. Customer menukar EnakPoint ke voucher di aplikasinya sendiri.
|
||||
- Saldo berlaku di **semua outlet** organisasi. Berapa yang didapat per order diatur
|
||||
**per outlet** oleh owner di backoffice.
|
||||
- Semua jumlah bilangan bulat.
|
||||
|
||||
---
|
||||
|
||||
## 2. Mengaitkan customer ke order
|
||||
|
||||
Earning hanya terjadi bila order dikaitkan ke customer terdaftar. Order tanpa customer,
|
||||
dengan **customer default (walk-in)**, atau dengan customer nonaktif tidak mendapat
|
||||
apa-apa.
|
||||
|
||||
1. **Cari customer:** `GET /api/v1/customers?search=0812…&page=1&limit=20`
|
||||
(cocok dengan nama, email, atau nomor HP). Abaikan customer dengan `is_default: true`.
|
||||
2. **Kaitkan** dengan salah satu cara:
|
||||
- saat membuat order: `POST /api/v1/orders` dengan `"customer_id": "…"`, atau
|
||||
- setelah order dibuat: `PUT /api/v1/orders/:id/customer` dengan
|
||||
`{ "customer_id": "…" }`.
|
||||
|
||||
**Kaitkan sebelum order lunas.** Earning dihitung saat order menjadi lunas penuh.
|
||||
Customer yang dikaitkan setelah lunas tetap mendapat earning lewat job susulan yang
|
||||
berjalan tiap 30 menit untuk order lunas 72 jam terakhir, tapi tidak langsung, sehingga
|
||||
struk akan menulis 0.
|
||||
|
||||
---
|
||||
|
||||
## 3. Earning: yang didapat dari order
|
||||
|
||||
Earning berjalan otomatis di backend saat order lunas lewat jalur pembayaran mana pun
|
||||
(`POST /payments`, update order, split bill). POS tidak memanggil apa-apa.
|
||||
|
||||
- **Basis** = `subtotal − discount_amount`, **sebelum pajak** dan biaya lain.
|
||||
- Rumus per outlet (diatur owner): mode `PER_AMOUNT`
|
||||
`floor(basis ÷ earn_per_amount) × earn_value`, atau mode `PERCENTAGE`
|
||||
`floor(basis × earn_percent ÷ 100)`, dengan minimal belanja dan batas per order.
|
||||
- Contoh: basis Rp 87.500, outlet memberi 1 EnakPoint per Rp 100 dan 1 EnakCoin per
|
||||
Rp 25.000 → **875 EnakPoint** dan **3 EnakCoin**.
|
||||
|
||||
Response order (`GET /api/v1/orders/:id` dan response order lainnya) membawa:
|
||||
|
||||
```json
|
||||
{ "points_earned": 875, "coins_earned": 3 }
|
||||
```
|
||||
|
||||
Keduanya 0 bila order tidak mendapat apa-apa. **Cetak di struk**, mis. "Kamu mendapat
|
||||
875 EnakPoint & 3 EnakCoin". Ambil nilainya setelah pembayaran terakhir berhasil; bila
|
||||
masih 0 padahal customer sudah dikaitkan, earning akan menyusul (§2).
|
||||
|
||||
---
|
||||
|
||||
## 4. Void dan refund
|
||||
|
||||
Tidak ada langkah tambahan di POS. Saat order di-void atau direfund, backend menarik
|
||||
kembali yang didapat dari order itu (mutasi `EARN_REVERSAL` di riwayat customer):
|
||||
|
||||
| Kejadian | Yang ditarik |
|
||||
|---|---|
|
||||
| Void | Semua EnakPoint dan EnakCoin dari order itu |
|
||||
| Refund (sebagian atau penuh) | `floor(earned × total_refund ÷ basis)`, tidak pernah lebih dari yang didapat; refund berikutnya hanya menarik sisanya |
|
||||
|
||||
Bila saldo customer sudah terpakai, yang ditarik sebanyak yang ada. **Refund tidak
|
||||
pernah diblokir** karena ini.
|
||||
|
||||
---
|
||||
|
||||
## 5. Voucher dari EnakPoint
|
||||
|
||||
Customer menukar EnakPoint ke voucher di aplikasi customer. Voucher yang didapat tampil
|
||||
di menu "Voucher saya" di aplikasi itu, dengan nama, nilai (`face_value`), jenis, dan
|
||||
bila ada, **kode** serta tanggal berlakunya.
|
||||
|
||||
> **Belum tersedia:** POS belum punya endpoint untuk **mengecek** atau **menandai
|
||||
> voucher sudah dipakai**. Ini pekerjaan lanjutan di backend.
|
||||
|
||||
Sampai endpoint itu ada:
|
||||
|
||||
1. Kasir melihat voucher di layar aplikasi customer (nama, nilai, kode, masa berlaku).
|
||||
2. Kasir memasukkan potongannya sebagai **diskon biasa** di order, sesuai jenisnya:
|
||||
|
||||
| `voucher_type` | Cara memasukkan |
|
||||
|---|---|
|
||||
| `FIXED_VALUE` | Diskon nominal sebesar `face_value` |
|
||||
| `PERCENTAGE` | Diskon persen sesuai syarat voucher |
|
||||
| `FREE_ITEM` | Item gratis sesuai syarat voucher |
|
||||
| `MERCHANT_BENEFIT` | Sesuai syarat voucher |
|
||||
|
||||
3. Karena backend belum mencatat voucher terpakai, outlet perlu mencatat kode yang
|
||||
sudah dipakai secara manual supaya voucher yang sama tidak dipakai dua kali.
|
||||
|
||||
Diskon dari voucher mengurangi basis earning seperti diskon lain (§3).
|
||||
|
||||
---
|
||||
|
||||
## 6. Yang sudah dihapus
|
||||
|
||||
Bayar dengan EnakPoint dihapus pada 7 Okt 2026. Jangan dipanggil atau ditampilkan lagi;
|
||||
tidak ada penggantinya.
|
||||
|
||||
| Dihapus | Catatan |
|
||||
|---|---|
|
||||
| Payment method tipe `point` ("EnakPoint") | Tidak ada di daftar payment method |
|
||||
| Field `points` dan `payment_code` di `POST /payments` | `amount` wajib seperti pembayaran lain |
|
||||
| `GET /orders/:id/point-payment/preview` | – |
|
||||
| Kode bayar dari aplikasi customer | – |
|
||||
| `points_used`, `point_value` di response pembayaran | – |
|
||||
| `point_amount`, `points_used`, `total_with_points`, `counts_as_cash_in` di laporan payment method | `summary.total_amount` adalah total semua method |
|
||||
|
||||
---
|
||||
|
||||
## 7. Checklist
|
||||
|
||||
- [ ] Kasir bisa mencari dan mengaitkan customer ke order sebelum pembayaran.
|
||||
- [ ] Customer default (walk-in) tidak ditawarkan sebagai pemilik earning.
|
||||
- [ ] Struk mencetak `points_earned` dan `coins_earned`.
|
||||
- [ ] Tidak ada payment method EnakPoint dan tidak ada field pembayaran EnakPoint di
|
||||
request.
|
||||
- [ ] Void/refund tidak menampilkan langkah tambahan untuk EnakPoint/EnakCoin.
|
||||
- [ ] SOP outlet untuk voucher manual (§5) sudah disepakati sampai endpoint POS tersedia.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Integrasi Produk Timbangan — POS Mobile & Backoffice
|
||||
|
||||
**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
|
||||
|
||||
Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
|
||||
Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
**Satu penimbangan = satu baris order.**
|
||||
|
||||
Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
|
||||
baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
|
||||
|
||||
| Bukan begini | Melainkan begini |
|
||||
|---|---|
|
||||
| `quantity: 9.8` | `quantity: 1, weight: 4.2`<br>`quantity: 1, weight: 5.6` |
|
||||
| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
|
||||
|
||||
Empat aturan yang berlaku di seluruh dokumen ini:
|
||||
|
||||
1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
|
||||
menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
|
||||
2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
|
||||
apa pun yang dipilih saat setup).
|
||||
3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
|
||||
`unit_price` tetap berarti harga per satu satuan (per ons).
|
||||
4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
|
||||
produknya sama.
|
||||
|
||||
Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
|
||||
cacah seperti sekarang.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backoffice — setup produk
|
||||
|
||||
### 2.1 Pastikan satuannya ada
|
||||
|
||||
Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
|
||||
|
||||
`POST /api/v1/units`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ons",
|
||||
"abbreviation": "ons",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
|
||||
pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
|
||||
|
||||
### 2.2 Buat produk sebagai produk timbangan
|
||||
|
||||
`POST /api/v1/products`
|
||||
|
||||
```json
|
||||
{
|
||||
"category_id": "…",
|
||||
"name": "Ikan Tude",
|
||||
"price": 4500,
|
||||
"cost": 3000,
|
||||
"sell_by": "weight",
|
||||
"unit_id": "<id satuan Ons>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
|
||||
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
|
||||
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
|
||||
|
||||
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
|
||||
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
|
||||
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
|
||||
|
||||
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
|
||||
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
|
||||
`/products/:id`).
|
||||
|
||||
### 2.3 Catatan UI
|
||||
|
||||
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
|
||||
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
|
||||
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
|
||||
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
|
||||
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
|
||||
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
|
||||
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
|
||||
dikonsumsi apa pun di POS.
|
||||
|
||||
---
|
||||
|
||||
## 3. POS Mobile — transaksi
|
||||
|
||||
### 3.1 Bentuk input mengikuti `sell_by`
|
||||
|
||||
| `sell_by` | Input di POS | Yang dikirim |
|
||||
|---|---|---|
|
||||
| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
|
||||
| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
|
||||
|
||||
### 3.2 Mengirim order
|
||||
|
||||
`POST /api/v1/orders`
|
||||
|
||||
```json
|
||||
{
|
||||
"outlet_id": "…",
|
||||
"user_id": "…",
|
||||
"order_type": "dine_in",
|
||||
"order_items": [
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
|
||||
{ "product_id": "<nasi-goreng>", "quantity": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
|
||||
mandiri `POST /api/v1/self-order/orders`.
|
||||
|
||||
**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
|
||||
bawah `0.001` membulat ke nol dan ditolak.
|
||||
|
||||
**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
|
||||
dari master produk. Jangan mengirim harga hasil hitungan sendiri.
|
||||
|
||||
---
|
||||
|
||||
## 4. Menampilkan baris
|
||||
|
||||
Setiap `order_items[]` di response membawa empat field tambahan:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Ikan Tude",
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_id": "…",
|
||||
"unit_name": "Ons",
|
||||
"unit_abbreviation": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900
|
||||
}
|
||||
```
|
||||
|
||||
Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
|
||||
|
||||
| Kondisi | Tampilkan |
|
||||
|---|---|
|
||||
| `weight == null` | `2 × Rp 25.000` |
|
||||
| `weight != null` | `4,2 ons × Rp 4.500` |
|
||||
|
||||
**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
|
||||
terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
|
||||
desimal sesuai format Indonesia.
|
||||
|
||||
---
|
||||
|
||||
## 5. Void, refund, split bill
|
||||
|
||||
Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
|
||||
`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
|
||||
rupiahnya dari `weight`.
|
||||
|
||||
| Aksi | Endpoint | Field untuk baris berbobot |
|
||||
|---|---|---|
|
||||
| Void per item | `POST /orders/void` | `items[].quantity: 1` |
|
||||
| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
|
||||
| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"reason": "Salah timbang",
|
||||
"type": "ITEM",
|
||||
"items": [
|
||||
{ "order_item_id": "<baris 4,2 ons>", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
|
||||
ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
|
||||
tombol pilih baris.
|
||||
|
||||
**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
|
||||
tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
|
||||
sehingga jejak auditnya tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 6. Referensi error
|
||||
|
||||
Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{ "code": "900", "entity": "ORDER",
|
||||
"cause": "product Ikan Tude is sold by weight and requires a weight" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
|
||||
| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
|
||||
| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
|
||||
| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
|
||||
|
||||
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
|
||||
|
||||
### Setup produk (Backoffice)
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
|
||||
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
|
||||
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Kompatibilitas mundur
|
||||
|
||||
- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
|
||||
- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
|
||||
produk satuan.
|
||||
- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
|
||||
parser lama tidak terganggu.
|
||||
- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
|
||||
|
||||
Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
|
||||
permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 8. Batasan yang diketahui
|
||||
|
||||
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
|
||||
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
|
||||
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
|
||||
- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
|
||||
produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
|
||||
sisa stok yang mengandaikan itu berjalan.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist per tim
|
||||
|
||||
**Backoffice Website**
|
||||
|
||||
- [ ] CRUD satuan tersedia di menu master data
|
||||
- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
|
||||
- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
|
||||
- [ ] Label harga ikut satuan — "Harga per ons"
|
||||
- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
|
||||
- [ ] Daftar produk menandai mana yang dijual per timbangan
|
||||
- [ ] Laporan harian menampilkan kolom Berat
|
||||
|
||||
**POS Mobile**
|
||||
|
||||
- [ ] Menu membaca `sell_by` tiap produk
|
||||
- [ ] Papan angka desimal untuk produk timbangan
|
||||
- [ ] Kirim `quantity: 1` + `weight`
|
||||
- [ ] Dua penimbangan menjadi dua baris, tidak digabung
|
||||
- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
|
||||
- [ ] Void & refund baris berbobot bersifat utuh
|
||||
- [ ] Split bill: pilih baris, bukan stepper jumlah
|
||||
- [ ] Pesan error validasi ditampilkan ke kasir
|
||||
@@ -0,0 +1,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.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
# RFC: Produk Timbangan (Weight-Based Products)
|
||||
|
||||
**Status:** Diimplementasikan (migrasi `000089`)
|
||||
**Tanggal:** 2026-09-05, diperbarui 2026-09-06
|
||||
**Scope:** Product, Order, Void/Refund, Report
|
||||
**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8)
|
||||
|
||||
---
|
||||
|
||||
## 1. Masalah
|
||||
|
||||
Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity`
|
||||
bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung
|
||||
`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill.
|
||||
|
||||
Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons,
|
||||
lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang
|
||||
terpisah** — bukan satu baris berisi 9,8.
|
||||
|
||||
Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya.
|
||||
|
||||
> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk
|
||||
> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau
|
||||
> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang
|
||||
> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Keputusan Inti
|
||||
|
||||
**Satu penimbangan = satu baris `order_items`.**
|
||||
|
||||
| | Baris 1 | Baris 2 |
|
||||
|---|---|---|
|
||||
| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | |
|
||||
| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` |
|
||||
|
||||
`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke
|
||||
kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua
|
||||
ikan yang berbeda.
|
||||
|
||||
### Kenapa bukan `quantity = 4.2`
|
||||
|
||||
Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi
|
||||
`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan:
|
||||
|
||||
1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan
|
||||
informasi bahwa ada dua ikan. Tidak bisa direkonstruksi.
|
||||
2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan
|
||||
"total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang
|
||||
bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun
|
||||
yang berubah di dunia nyata.
|
||||
3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai
|
||||
di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan
|
||||
`4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang
|
||||
sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer.
|
||||
|
||||
Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan
|
||||
epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prinsip
|
||||
|
||||
**P1 — Baris transaksi adalah snapshot yang beku.**
|
||||
`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join
|
||||
ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh
|
||||
mengubah arti transaksi yang sudah terjadi.
|
||||
|
||||
**P2 — Perhitungan harga baris hanya ada di satu tempat.**
|
||||
Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh
|
||||
ada perkalian harga yang tersebar; semuanya memanggil satu fungsi.
|
||||
|
||||
**P3 — Harga tetap otoritas backend.**
|
||||
Klien tidak pernah mengirim harga. Backend membacanya dari `products` /
|
||||
`product_outlet_prices` seperti sekarang.
|
||||
|
||||
**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.**
|
||||
`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan
|
||||
satuan berbeda tidak bermakna.
|
||||
|
||||
---
|
||||
|
||||
## 4. Perubahan Skema
|
||||
|
||||
```sql
|
||||
-- Products: cara jual
|
||||
ALTER TABLE products
|
||||
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'
|
||||
CHECK (sell_by IN ('unit', 'weight'));
|
||||
|
||||
-- Order items: berat + snapshot satuan
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN weight DECIMAL(12,3),
|
||||
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_positive
|
||||
CHECK (weight IS NULL OR weight > 0),
|
||||
ADD CONSTRAINT chk_order_items_weight_single_line
|
||||
CHECK (weight IS NULL OR quantity = 1);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
|
||||
- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid
|
||||
tanpa backfill, dan perilakunya tidak berubah sama sekali.
|
||||
- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database:
|
||||
baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak
|
||||
ambigu dan membuat void otomatis bersifat utuh (§6).
|
||||
- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap
|
||||
berlaku.
|
||||
- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai
|
||||
presisi sama.
|
||||
- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan
|
||||
"ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Perhitungan Harga
|
||||
|
||||
Satu-satunya tempat yang boleh mengalikan harga (P2):
|
||||
|
||||
```go
|
||||
// BillableQuantity mengembalikan pengali harga untuk baris ini:
|
||||
// berat bila produk dijual per timbangan, jumlah bila dijual per satuan.
|
||||
// Baris berbobot dijamin quantity = 1 oleh constraint DB.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
```
|
||||
|
||||
`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor
|
||||
konversi yang menyelinap ke perhitungan uang.
|
||||
|
||||
### Titik yang harus diganti
|
||||
|
||||
Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan
|
||||
menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang,
|
||||
bukan salah tampilan**, dan tidak memicu error apa pun.
|
||||
|
||||
| Lokasi | Sekarang |
|
||||
|---|---|
|
||||
| `processor/order_processor.go:197-198` | buat order |
|
||||
| `processor/order_processor.go:330-331` | tambah item ke order |
|
||||
| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void |
|
||||
| `processor/order_processor.go:723` | jumlah refund |
|
||||
| `processor/split_bill_processor.go:143` | hitung jumlah split |
|
||||
| `processor/split_bill_processor.go:189` | catat pembayaran |
|
||||
| `processor/split_bill_processor.go:231` | metadata pembayaran |
|
||||
| `repository/order_item_repository.go:113` | jumlah void penuh |
|
||||
|
||||
Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur
|
||||
inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis:
|
||||
|
||||
| Lokasi | Status |
|
||||
|---|---|
|
||||
| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** |
|
||||
| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** |
|
||||
| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** |
|
||||
|
||||
Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan
|
||||
dihitung sebagai 1 satuan resep.
|
||||
|
||||
### Verifikasi
|
||||
|
||||
```bash
|
||||
grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \
|
||||
| grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity
|
||||
```
|
||||
|
||||
Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman:
|
||||
|
||||
- `mappers/inventory_movement_mapper.go:129` dan
|
||||
`processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan
|
||||
baris order.
|
||||
- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian,
|
||||
yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`.
|
||||
|
||||
Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Void, Refund, Split Bill
|
||||
|
||||
**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap
|
||||
integer.
|
||||
|
||||
**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada
|
||||
`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan
|
||||
`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang
|
||||
pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa
|
||||
berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5).
|
||||
|
||||
**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di
|
||||
`order_processor.go:723` yang perlu memakai `BillableQuantity()`.
|
||||
|
||||
**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot
|
||||
nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di
|
||||
`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi
|
||||
float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang
|
||||
berubah.
|
||||
|
||||
**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons
|
||||
dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan,
|
||||
koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya
|
||||
adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat.
|
||||
Ini menjaga jejak audit tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validasi & Tampilan
|
||||
|
||||
### 7.1 Aturan validasi
|
||||
|
||||
Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat:
|
||||
|
||||
| `products.sell_by` | Aturan |
|
||||
|---|---|
|
||||
| `unit` | `weight` harus kosong. Bila dikirim → tolak. |
|
||||
| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. |
|
||||
|
||||
`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) —
|
||||
bukan dibaca lewat join saat ditampilkan.
|
||||
|
||||
Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan
|
||||
nilai yang divalidasi.
|
||||
|
||||
### 7.2 Tampilan
|
||||
|
||||
`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris
|
||||
berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`.
|
||||
|
||||
Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat
|
||||
menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Report
|
||||
|
||||
**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti
|
||||
"berapa banyak barang":
|
||||
|
||||
- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas
|
||||
produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.
|
||||
- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan.
|
||||
- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per
|
||||
ekor", yang tetap merupakan angka bermakna.
|
||||
|
||||
**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk**
|
||||
(P4):
|
||||
|
||||
```sql
|
||||
COALESCE(SUM(oi.weight), 0) AS weight_sold
|
||||
```
|
||||
|
||||
Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang
|
||||
berbeda.
|
||||
|
||||
---
|
||||
|
||||
## 9. Di Luar Scope
|
||||
|
||||
**Pengurangan stok otomatis.** `adjustInventoryWithTransaction`
|
||||
(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction`
|
||||
(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** —
|
||||
sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat
|
||||
penjualan tidak tersambung.
|
||||
|
||||
Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi
|
||||
apa pun.
|
||||
|
||||
Catatan untuk nanti bila jalur stok disambungkan:
|
||||
|
||||
- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau
|
||||
tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1.
|
||||
- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol
|
||||
sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.
|
||||
- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan.
|
||||
Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa
|
||||
diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam.
|
||||
|
||||
Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan
|
||||
menggantung — komentar di dalamnya ditulis seolah-olah aktif.
|
||||
|
||||
---
|
||||
|
||||
## 10. Temuan Sampingan: `unit_price` pada request diabaikan
|
||||
|
||||
`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar
|
||||
*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak
|
||||
pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di
|
||||
`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari
|
||||
`products` / `product_outlet_prices`.
|
||||
|
||||
Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang
|
||||
menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract,
|
||||
atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database.
|
||||
|
||||
Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka
|
||||
berhasil, padahal diabaikan diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 11. Urutan Implementasi
|
||||
|
||||
1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama
|
||||
tidak tersentuh.
|
||||
2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5).
|
||||
3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi.
|
||||
4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada
|
||||
response.
|
||||
5. **Validasi `sell_by`** (§7.1).
|
||||
6. **Template & tampilan struk** (§7.2).
|
||||
7. *(Opsional)* `weight_sold` pada laporan per produk (§8).
|
||||
|
||||
Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5
|
||||
mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiko
|
||||
|
||||
| Risiko | Dampak | Mitigasi |
|
||||
|---|---|---|
|
||||
| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) |
|
||||
| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB |
|
||||
| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB |
|
||||
| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan |
|
||||
| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Pertanyaan Terbuka
|
||||
|
||||
1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney`
|
||||
membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah
|
||||
dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`.
|
||||
|
||||
Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan**
|
||||
pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau
|
||||
kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat,
|
||||
dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan
|
||||
baru akan mengikuti aturan berbeda.
|
||||
2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input
|
||||
harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi,
|
||||
tambahkan `products.min_weight_increment`.
|
||||
3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila
|
||||
tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.
|
||||
@@ -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.
|
||||
@@ -52,7 +52,6 @@ require (
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
@@ -110,6 +109,7 @@ require (
|
||||
github.com/aws/aws-sdk-go v1.55.7
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
github.com/jackc/pgx/v5 v5.3.0
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
|
||||
@@ -351,6 +351,8 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
@@ -380,7 +382,6 @@ go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J
|
||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
|
||||
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
server:
|
||||
base-url:
|
||||
local-url:
|
||||
self-order-url: http://localhost:5173
|
||||
self-order-url:
|
||||
port: 4000
|
||||
|
||||
jwt:
|
||||
@@ -9,7 +9,7 @@ jwt:
|
||||
expires-ttl: 144000
|
||||
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
|
||||
refresh_token:
|
||||
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
|
||||
expires-ttl: 7776000
|
||||
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
|
||||
customer:
|
||||
expires-ttl: 7776000
|
||||
@@ -21,7 +21,7 @@ postgresql:
|
||||
driver: postgres
|
||||
db: apskel_pos
|
||||
username: apskel
|
||||
password: '7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk'
|
||||
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
||||
ssl-mode: disable
|
||||
max-idle-connections-in-second: 600
|
||||
max-open-connections-in-second: 600
|
||||
@@ -45,11 +45,11 @@ s3:
|
||||
endpoint: sin1.contabostorage.com
|
||||
bucket_name: enaklo
|
||||
log_level: Error
|
||||
host_url: 'https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:'
|
||||
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
||||
|
||||
log:
|
||||
log_format: 'json'
|
||||
log_level: 'debug'
|
||||
log_format: "json"
|
||||
log_level: "info"
|
||||
|
||||
fonnte:
|
||||
api_url: "https://api.fonnte.com/send"
|
||||
@@ -58,4 +58,4 @@ fonnte:
|
||||
|
||||
fcm:
|
||||
credentials_file: "infra/firebase-service-account.json"
|
||||
project_id: "apskel-pos-v2"
|
||||
project_id: "apskel-pos-v2"
|
||||
@@ -0,0 +1,61 @@
|
||||
server:
|
||||
base-url:
|
||||
local-url:
|
||||
self-order-url:
|
||||
port: 4000
|
||||
|
||||
jwt:
|
||||
token:
|
||||
expires-ttl: 144000
|
||||
secret: "eZ7LAZJuSOGSHxb1ZYaZCkrBo5YBvc"
|
||||
refresh_token:
|
||||
expires-ttl: 7776000
|
||||
secret: "EMx2DKPtMp0jQNpLvzzCsZkoUHe0d9"
|
||||
customer:
|
||||
expires-ttl: 7776000
|
||||
secret: "layCV2rne0X57acWzSS3NxENmYJs7B"
|
||||
|
||||
postgresql:
|
||||
host: 62.72.45.250
|
||||
port: 5433
|
||||
driver: postgres
|
||||
db: apskel_pos_staging
|
||||
username: apskel
|
||||
password: "7a8UJbM2GgBWaseh0lnP3O5i1i5nINXk"
|
||||
ssl-mode: disable
|
||||
max-idle-connections-in-second: 600
|
||||
max-open-connections-in-second: 600
|
||||
connection-max-life-time-in-second: 600
|
||||
debug: false
|
||||
|
||||
redis:
|
||||
host: 62.72.45.250
|
||||
port: 6380
|
||||
password: "CmICdmnX1EZPhVBYzQPEGw==U"
|
||||
db: 1
|
||||
dial_timeout: 5s
|
||||
read_timeout: 3s
|
||||
write_timeout: 3s
|
||||
pool_size: 10
|
||||
min_idle_connections: 5
|
||||
|
||||
s3:
|
||||
access_key_id: cf9a475e18bc7626cbdbf09709d82a64
|
||||
access_key_secret: 91f3321294d3e23035427a0ecb893ada
|
||||
endpoint: sin1.contabostorage.com
|
||||
bucket_name: enaklo
|
||||
log_level: Error
|
||||
host_url: "https://sin1.contabostorage.com/fda98c2228f246f29a7e466b86b3b9e7:"
|
||||
|
||||
log:
|
||||
log_format: "json"
|
||||
log_level: "info"
|
||||
|
||||
fonnte:
|
||||
api_url: "https://api.fonnte.com/send"
|
||||
token: "bADQrf9NTXfLZQCK2wGg"
|
||||
timeout: 30
|
||||
|
||||
fcm:
|
||||
credentials_file: "infra/firebase-service-account.json"
|
||||
project_id: "apskel-pos-v2"
|
||||
+205
-51
@@ -31,6 +31,13 @@ type App struct {
|
||||
router *router.Router
|
||||
shutdown chan os.Signal
|
||||
omsetScheduler *service.OmsetMilestoneScheduler
|
||||
walletRecon *service.WalletReconciliationJob
|
||||
earningRetry *service.EarningBackfillJob
|
||||
walletExpiry *service.WalletExpiryJob
|
||||
gameSessions *service.GameSessionJob
|
||||
gameBudgets *service.GameBudgetPeriodJob
|
||||
voucherCodes *service.VoucherCodeExpiryJob
|
||||
voucherRecover *service.VoucherRedemptionRecoveryJob
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
|
||||
@@ -53,6 +60,26 @@ 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))
|
||||
// Refunds or expires EnakGame sessions left open (docs/rfc-enakgame.md §7.3)
|
||||
a.gameSessions = service.NewGameSessionJob(processors.gameSessionProcessor)
|
||||
// Opens next month's global EnakGame budget (docs/rfc-enakgame.md §12)
|
||||
a.gameBudgets = service.NewGameBudgetPeriodJob(processors.gameBudgetProcessor)
|
||||
// Expires voucher codes past their date (docs/rfc-enakgame.md §12)
|
||||
a.voucherCodes = service.NewVoucherCodeExpiryJob(processors.voucherAdminProcessor)
|
||||
// Settles EXTERNAL voucher redemptions left PENDING (docs/rfc-enakgame.md §7.5)
|
||||
a.voucherRecover = service.NewVoucherRedemptionRecoveryJob(processors.voucherRedemptionProcessor)
|
||||
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
validators := a.initValidators()
|
||||
middleware := a.initMiddleware(services, cfg)
|
||||
@@ -122,14 +149,11 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.orderIngredientTransactionValidator,
|
||||
services.gamificationService,
|
||||
validators.gamificationValidator,
|
||||
services.rewardService,
|
||||
validators.rewardValidator,
|
||||
services.campaignService,
|
||||
validators.campaignValidator,
|
||||
services.customerAuthService,
|
||||
validators.customerAuthValidator,
|
||||
services.customerPointsService,
|
||||
services.spinGameService,
|
||||
middleware.customerAuthMiddleware,
|
||||
services.userDeviceService,
|
||||
validators.userDeviceValidator,
|
||||
@@ -140,6 +164,18 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
selfOrderHandler,
|
||||
services.expenseService,
|
||||
validators.expenseValidator,
|
||||
services.cashAdvanceService,
|
||||
validators.cashAdvanceValidator,
|
||||
services.walletAdminService,
|
||||
validators.walletValidator,
|
||||
services.loyaltySettingsService,
|
||||
services.customerPinService,
|
||||
services.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
services.customerOutletService,
|
||||
services.customerOrderService,
|
||||
services.enakGameAdminService,
|
||||
services.enakGameCustomerService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -151,6 +187,27 @@ 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)
|
||||
}
|
||||
if a.gameSessions != nil {
|
||||
a.gameSessions.Start(time.Minute)
|
||||
}
|
||||
if a.gameBudgets != nil {
|
||||
a.gameBudgets.Start(24 * time.Hour)
|
||||
}
|
||||
if a.voucherCodes != nil {
|
||||
a.voucherCodes.Start(time.Hour)
|
||||
}
|
||||
if a.voucherRecover != nil {
|
||||
a.voucherRecover.Start(time.Minute)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@@ -190,6 +247,27 @@ 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()
|
||||
}
|
||||
if a.gameSessions != nil {
|
||||
a.gameSessions.Stop()
|
||||
}
|
||||
if a.gameBudgets != nil {
|
||||
a.gameBudgets.Stop()
|
||||
}
|
||||
if a.voucherCodes != nil {
|
||||
a.voucherCodes.Stop()
|
||||
}
|
||||
if a.voucherRecover != nil {
|
||||
a.voucherRecover.Stop()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -224,17 +302,12 @@ type repositories struct {
|
||||
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
|
||||
accountRepo *repository.AccountRepositoryImpl
|
||||
orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl
|
||||
customerTokensRepo *repository.CustomerTokensRepository
|
||||
tierRepo *repository.TierRepository
|
||||
gameRepo *repository.GameRepository
|
||||
gamePrizeRepo *repository.GamePrizeRepository
|
||||
gamePlayRepo repository.GamePlayRepository
|
||||
omsetTrackerRepo *repository.OmsetTrackerRepository
|
||||
rewardRepo repository.RewardRepository
|
||||
campaignRepo repository.CampaignRepository
|
||||
campaignRuleRepo repository.CampaignRuleRepository
|
||||
customerAuthRepo repository.CustomerAuthRepository
|
||||
customerPointsRepo repository.CustomerPointsRepository
|
||||
customerLoginAttemptRepo repository.CustomerLoginAttemptRepository
|
||||
otpRepo repository.OtpRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
txManager *repository.TxManager
|
||||
@@ -244,6 +317,10 @@ type repositories struct {
|
||||
notificationDeliveryRepo *repository.NotificationDeliveryRepositoryImpl
|
||||
productOutletPriceRepo *repository.ProductOutletPriceRepositoryImpl
|
||||
expenseRepo *repository.ExpenseRepositoryImpl
|
||||
cashAdvanceRepo *repository.CashAdvanceRepositoryImpl
|
||||
walletRepo repository.WalletRepository
|
||||
walletQueryRepo repository.WalletQueryRepository
|
||||
loyaltySettingsRepo repository.LoyaltySettingsRepository
|
||||
}
|
||||
|
||||
func (a *App) initRepositories() *repositories {
|
||||
@@ -278,17 +355,12 @@ 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),
|
||||
gamePlayRepo: repository.NewGamePlayRepository(a.db),
|
||||
omsetTrackerRepo: repository.NewOmsetTrackerRepository(a.db),
|
||||
rewardRepo: repository.NewRewardRepository(a.db),
|
||||
campaignRepo: repository.NewCampaignRepository(a.db),
|
||||
campaignRuleRepo: repository.NewCampaignRuleRepository(a.db),
|
||||
customerAuthRepo: repository.NewCustomerAuthRepository(a.db),
|
||||
customerPointsRepo: repository.NewCustomerPointsRepository(a.db),
|
||||
customerLoginAttemptRepo: repository.NewCustomerLoginAttemptRepository(a.redisClient),
|
||||
otpRepo: repository.NewOtpRepository(a.db),
|
||||
sessionRepo: repository.NewSessionRepository(a.redisClient),
|
||||
txManager: repository.NewTxManager(a.db),
|
||||
@@ -298,6 +370,10 @@ func (a *App) initRepositories() *repositories {
|
||||
notificationDeliveryRepo: repository.NewNotificationDeliveryRepository(a.db),
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,13 +403,8 @@ type processors struct {
|
||||
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
|
||||
accountProcessor *processor.AccountProcessorImpl
|
||||
orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl
|
||||
customerTokensProcessor *processor.CustomerTokensProcessor
|
||||
tierProcessor *processor.TierProcessor
|
||||
gameProcessor *processor.GameProcessor
|
||||
gamePrizeProcessor *processor.GamePrizeProcessor
|
||||
gamePlayProcessor *processor.GamePlayProcessor
|
||||
omsetTrackerProcessor *processor.OmsetTrackerProcessor
|
||||
rewardProcessor processor.RewardProcessor
|
||||
campaignProcessor processor.CampaignProcessor
|
||||
campaignRuleProcessor processor.CampaignRuleProcessor
|
||||
customerAuthProcessor processor.CustomerAuthProcessor
|
||||
@@ -345,14 +416,50 @@ type processors struct {
|
||||
notificationProcessor *processor.NotificationProcessorImpl
|
||||
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
|
||||
enakGameAdminProcessor *processor.EnakGameAdminProcessor
|
||||
gameBudgetProcessor *processor.GameBudgetProcessor
|
||||
gameSessionProcessor *processor.GameSessionProcessor
|
||||
voucherAdminProcessor *processor.VoucherAdminProcessor
|
||||
voucherRedemptionProcessor *processor.VoucherRedemptionProcessor
|
||||
}
|
||||
|
||||
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)
|
||||
auditLogger := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db))
|
||||
enakGameRepo := repository.NewEnakGameRepository(a.db)
|
||||
gameBudgetRepo := repository.NewGameBudgetRepository(a.db)
|
||||
voucherRepo := repository.NewVoucherRepository(a.db)
|
||||
// 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),
|
||||
@@ -362,7 +469,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),
|
||||
@@ -372,31 +479,49 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
|
||||
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
|
||||
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.cashAdvanceRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
|
||||
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
|
||||
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
|
||||
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
|
||||
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),
|
||||
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),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, repos.customerLoginAttemptRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager))),
|
||||
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),
|
||||
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),
|
||||
// EnakGame (docs/rfc-enakgame.md)
|
||||
enakGameAdminProcessor: processor.NewEnakGameAdminProcessor(enakGameRepo, auditLogger, repos.txManager),
|
||||
gameBudgetProcessor: processor.NewGameBudgetProcessor(gameBudgetRepo, auditLogger, repos.txManager),
|
||||
gameSessionProcessor: processor.NewGameSessionProcessor(repository.NewWalletMoveRepository(a.db), enakGameRepo,
|
||||
repository.NewGameSessionRepository(a.db), gameBudgetRepo, repository.NewGameEventRepository(a.db), repository.NewGameRewardCounterRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), auditLogger, repos.txManager),
|
||||
voucherAdminProcessor: processor.NewVoucherAdminProcessor(voucherRepo, auditLogger, repos.txManager),
|
||||
voucherRedemptionProcessor: processor.NewVoucherRedemptionProcessor(repository.NewWalletMoveRepository(a.db), voucherRepo,
|
||||
repository.NewVoucherRedemptionRepository(a.db), customerPinProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo),
|
||||
// No provider has an adapter yet (EG-801): EXTERNAL vouchers stay out of the
|
||||
// catalog and cannot be redeemed until one is added here.
|
||||
processor.VoucherProviders{}, repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,15 +554,23 @@ type services struct {
|
||||
accountService service.AccountService
|
||||
orderIngredientTransactionService *service.OrderIngredientTransactionService
|
||||
gamificationService service.GamificationService
|
||||
rewardService service.RewardService
|
||||
campaignService service.CampaignService
|
||||
customerAuthService service.CustomerAuthService
|
||||
customerPointsService service.CustomerPointsService
|
||||
spinGameService service.SpinGameService
|
||||
userDeviceService service.UserDeviceService
|
||||
notificationService service.NotificationService
|
||||
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
|
||||
enakGameAdminService *service.EnakGameAdminServiceImpl
|
||||
enakGameCustomerService *service.EnakGameCustomerServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -452,6 +585,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
inventoryService := service.NewInventoryService(processors.inventoryProcessor)
|
||||
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created
|
||||
paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor)
|
||||
enakGameAudit := processor.NewAuditLogger(repository.NewAuditLogRepository(a.db))
|
||||
gameBudgetMetrics := processor.NewGameBudgetMetricsProcessor(repository.NewGameBudgetRepository(a.db), repository.NewGameBudgetMetricsRepository(a.db))
|
||||
fileService := service.NewFileServiceImpl(processors.fileProcessor)
|
||||
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
|
||||
analyticsService := service.NewAnalyticsServiceImpl(processors.analyticsProcessor)
|
||||
@@ -468,12 +603,10 @@ 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)
|
||||
rewardService := service.NewRewardService(processors.rewardProcessor)
|
||||
gamificationService := service.NewGamificationService(processors.tierProcessor, processors.omsetTrackerProcessor)
|
||||
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
|
||||
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
||||
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
|
||||
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
||||
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
||||
|
||||
@@ -509,15 +642,30 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
accountService: accountService,
|
||||
orderIngredientTransactionService: orderIngredientTransactionService,
|
||||
gamificationService: gamificationService,
|
||||
rewardService: rewardService,
|
||||
campaignService: campaignService,
|
||||
customerAuthService: customerAuthService,
|
||||
customerPointsService: customerPointsService,
|
||||
spinGameService: spinGameService,
|
||||
userDeviceService: userDeviceService,
|
||||
notificationService: notificationService,
|
||||
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),
|
||||
enakGameAdminService: service.NewEnakGameAdminService(processors.enakGameAdminProcessor, processors.gameBudgetProcessor,
|
||||
gameBudgetMetrics,
|
||||
processor.NewGameBudgetControllerProcessor(repository.NewGameBudgetRepository(a.db), gameBudgetMetrics, repository.NewEnakGameRepository(a.db),
|
||||
enakGameAudit, repos.txManager),
|
||||
processor.NewGameEventProcessor(repository.NewGameEventRepository(a.db), repository.NewEnakGameRepository(a.db), repository.NewGameBudgetRepository(a.db),
|
||||
enakGameAudit, repos.txManager),
|
||||
processors.voucherAdminProcessor,
|
||||
processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(a.db))),
|
||||
enakGameCustomerService: service.NewEnakGameCustomerService(processors.gameSessionProcessor, processors.voucherRedemptionProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,13 +703,14 @@ type validators struct {
|
||||
accountValidator *validator.AccountValidatorImpl
|
||||
orderIngredientTransactionValidator *validator.OrderIngredientTransactionValidatorImpl
|
||||
gamificationValidator *validator.GamificationValidatorImpl
|
||||
rewardValidator validator.RewardValidator
|
||||
campaignValidator validator.CampaignValidator
|
||||
customerAuthValidator validator.CustomerAuthValidator
|
||||
userDeviceValidator *validator.UserDeviceValidatorImpl
|
||||
notificationValidator *validator.NotificationValidatorImpl
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||
walletValidator *validator.WalletValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -587,29 +736,34 @@ func (a *App) initValidators() *validators {
|
||||
accountValidator: validator.NewAccountValidator().(*validator.AccountValidatorImpl),
|
||||
orderIngredientTransactionValidator: validator.NewOrderIngredientTransactionValidator().(*validator.OrderIngredientTransactionValidatorImpl),
|
||||
gamificationValidator: validator.NewGamificationValidator(),
|
||||
rewardValidator: validator.NewRewardValidator(),
|
||||
campaignValidator: validator.NewCampaignValidator(),
|
||||
customerAuthValidator: validator.NewCustomerAuthValidator(),
|
||||
userDeviceValidator: validator.NewUserDeviceValidator(),
|
||||
notificationValidator: validator.NewNotificationValidator(),
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package constants
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,62 @@
|
||||
package constants
|
||||
|
||||
// A cash advance is money handed to a team so it can go shopping. Its status is
|
||||
// about the document only — whether the money may leave the drawer. How much of it
|
||||
// has been accounted for is a separate axis, derived from the spending charged to
|
||||
// the advance rather than stored, so the two never have to be kept in step.
|
||||
const (
|
||||
CashAdvanceStatusDraft = "draft"
|
||||
CashAdvanceStatusApproved = "approved"
|
||||
CashAdvanceStatusRejected = "rejected"
|
||||
CashAdvanceStatusCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// Settlement states come out of the amount, the spending charged to the advance, and
|
||||
// the cash handed back. An advance the team overspent still counts as settled: the
|
||||
// shortfall is owed back to the team and shows up as a negative remaining amount.
|
||||
const (
|
||||
CashAdvanceSettlementOpen = "open"
|
||||
CashAdvanceSettlementPartial = "partial"
|
||||
CashAdvanceSettlementSettled = "settled"
|
||||
)
|
||||
|
||||
func GetAllCashAdvanceStatuses() []string {
|
||||
return []string{
|
||||
CashAdvanceStatusDraft,
|
||||
CashAdvanceStatusApproved,
|
||||
CashAdvanceStatusRejected,
|
||||
CashAdvanceStatusCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidCashAdvanceStatus(status string) bool {
|
||||
for _, valid := range GetAllCashAdvanceStatuses() {
|
||||
if status == valid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetAllCashAdvanceSettlementStatuses() []string {
|
||||
return []string{
|
||||
CashAdvanceSettlementOpen,
|
||||
CashAdvanceSettlementPartial,
|
||||
CashAdvanceSettlementSettled,
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidCashAdvanceSettlementStatus(status string) bool {
|
||||
for _, valid := range GetAllCashAdvanceSettlementStatuses() {
|
||||
if status == valid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Settlement entries name where a piece of spending was recorded.
|
||||
const (
|
||||
CashAdvanceSettlementTypePurchaseOrder = "purchase_order"
|
||||
CashAdvanceSettlementTypeExpense = "expense"
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
package constants
|
||||
|
||||
// EnakGame values (docs/rfc-enakgame.md §5). Each set matches a CHECK in the
|
||||
// migrations.
|
||||
|
||||
// games.status.
|
||||
const (
|
||||
GameStatusDraft = "DRAFT"
|
||||
GameStatusActive = "ACTIVE"
|
||||
GameStatusInactive = "INACTIVE"
|
||||
// The old games, and games taken out of EnakGame for good. Never changed again.
|
||||
GameStatusArchived = "ARCHIVED"
|
||||
)
|
||||
|
||||
// game_reward_configs.reward_type (§8).
|
||||
const (
|
||||
GameRewardTypeFixed = "FIXED"
|
||||
GameRewardTypeScoreBased = "SCORE_BASED"
|
||||
GameRewardTypeOutcomeBased = "OUTCOME_BASED"
|
||||
GameRewardTypeProbability = "PROBABILITY"
|
||||
)
|
||||
|
||||
// game_reward_configs.status. Only status ever changes on a config (D7).
|
||||
const (
|
||||
GameRewardConfigStatusDraft = "DRAFT"
|
||||
GameRewardConfigStatusActive = "ACTIVE"
|
||||
GameRewardConfigStatusRetired = "RETIRED"
|
||||
)
|
||||
|
||||
// game_sessions.status: STARTED → COMPLETED | REFUNDED | EXPIRED (D4).
|
||||
const (
|
||||
GameSessionStatusStarted = "STARTED"
|
||||
GameSessionStatusCompleted = "COMPLETED"
|
||||
GameSessionStatusRefunded = "REFUNDED"
|
||||
GameSessionStatusExpired = "EXPIRED"
|
||||
)
|
||||
|
||||
// game_sessions.refund_reason (§7.3).
|
||||
const (
|
||||
GameSessionRefundSystemError = "SYSTEM_ERROR"
|
||||
GameSessionRefundGameDeactivated = "GAME_DEACTIVATED"
|
||||
)
|
||||
|
||||
// game_budgets.scope (§5.6).
|
||||
const (
|
||||
GameBudgetScopeGlobal = "GLOBAL"
|
||||
GameBudgetScopeEvent = "EVENT"
|
||||
)
|
||||
|
||||
// audit_logs.actor_type (§5.9).
|
||||
const (
|
||||
AuditActorUser = "USER"
|
||||
AuditActorSystem = "SYSTEM"
|
||||
)
|
||||
|
||||
// audit_logs.source: where a change came from.
|
||||
const (
|
||||
AuditSourceAdminAPI = "admin_api"
|
||||
AuditSourceBudgetController = "budget_controller"
|
||||
AuditSourceSessionJob = "session_job"
|
||||
AuditSourceBudgetPeriodJob = "budget_period_job"
|
||||
AuditSourceCustomerAPI = "customer_api"
|
||||
)
|
||||
|
||||
// audit_logs.entity_type: what an audit row is about (§13).
|
||||
const (
|
||||
AuditEntityGame = "GAME"
|
||||
AuditEntityGameRewardConfig = "GAME_REWARD_CONFIG"
|
||||
AuditEntityGameBudget = "GAME_BUDGET"
|
||||
AuditEntityGameEvent = "GAME_EVENT"
|
||||
AuditEntityGameSession = "GAME_SESSION"
|
||||
AuditEntityVoucher = "VOUCHER"
|
||||
)
|
||||
|
||||
// audit_logs.action on a GAME_BUDGET whose recommendation was accepted.
|
||||
const AuditActionRecommendationAccepted = "RECOMMENDATION_ACCEPTED"
|
||||
|
||||
// game_reward_counters.scope_type: what an Economy Guard counter counts for (§5.8).
|
||||
const (
|
||||
GameRewardScopeUser = "USER"
|
||||
GameRewardScopeGame = "GAME"
|
||||
GameRewardScopeEvent = "EVENT"
|
||||
GameRewardScopeGlobal = "GLOBAL"
|
||||
)
|
||||
|
||||
// vouchers.voucher_type (§5.7).
|
||||
const (
|
||||
VoucherTypeFixedValue = "FIXED_VALUE"
|
||||
VoucherTypePercentage = "PERCENTAGE"
|
||||
VoucherTypeFreeItem = "FREE_ITEM"
|
||||
VoucherTypeMerchantBenefit = "MERCHANT_BENEFIT"
|
||||
)
|
||||
|
||||
// vouchers.stock_mode: where a redemption's voucher comes from.
|
||||
const (
|
||||
// A counted stock with nothing to hand out but the redemption itself.
|
||||
VoucherStockStatic = "STATIC"
|
||||
// One imported code per redemption.
|
||||
VoucherStockCodePool = "CODE_POOL"
|
||||
// Issued by a provider (§7.5), not before EG-801.
|
||||
VoucherStockExternal = "EXTERNAL"
|
||||
)
|
||||
|
||||
// vouchers.status.
|
||||
const (
|
||||
VoucherStatusDraft = "DRAFT"
|
||||
VoucherStatusActive = "ACTIVE"
|
||||
VoucherStatusInactive = "INACTIVE"
|
||||
VoucherStatusArchived = "ARCHIVED"
|
||||
)
|
||||
|
||||
// voucher_codes.status.
|
||||
const (
|
||||
VoucherCodeAvailable = "AVAILABLE"
|
||||
VoucherCodeReserved = "RESERVED"
|
||||
VoucherCodeRedeemed = "REDEEMED"
|
||||
VoucherCodeExpired = "EXPIRED"
|
||||
VoucherCodeCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// voucher_redemptions.status.
|
||||
const (
|
||||
VoucherRedemptionPending = "PENDING"
|
||||
VoucherRedemptionCompleted = "COMPLETED"
|
||||
VoucherRedemptionFailed = "FAILED"
|
||||
)
|
||||
|
||||
// Budget statuses (PRD §32), from its utilization and forecast against its thresholds.
|
||||
const (
|
||||
GameBudgetHealthy = "HEALTHY"
|
||||
GameBudgetWarning = "WARNING"
|
||||
GameBudgetCritical = "CRITICAL"
|
||||
GameBudgetExhausted = "EXHAUSTED"
|
||||
)
|
||||
|
||||
// Thresholds of a budget that sets none, in percent (PRD §8). Pending RFC §19.2 #4.
|
||||
const (
|
||||
GameBudgetWarningDefault = int64(70)
|
||||
GameBudgetCriticalDefault = int64(90)
|
||||
)
|
||||
|
||||
// game_events.status. An ACTIVE event changes rewards only between its start_at and
|
||||
// end_at.
|
||||
const (
|
||||
GameEventStatusDraft = "DRAFT"
|
||||
GameEventStatusActive = "ACTIVE"
|
||||
GameEventStatusEnded = "ENDED"
|
||||
GameEventStatusCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
// Budget Controller guardrails of a budget that sets none (PRD §31). Pending RFC
|
||||
// §19.2 #4.
|
||||
const (
|
||||
// Percent a recommendation may move rewards by, either way.
|
||||
GameBudgetMaxStepDefault = int64(10)
|
||||
// Bounds of a game's multiplier, in percent of the configuration its admin wrote.
|
||||
GameBudgetMinMultiplierDefault = int64(50)
|
||||
GameBudgetMaxMultiplierDefault = int64(150)
|
||||
// Days after an accepted recommendation before the next one, in the organization.
|
||||
// The same as the forecast window, so the next one sees the effect of the last.
|
||||
GameBudgetCooldownDaysDefault = int64(7)
|
||||
)
|
||||
|
||||
// What a Budget Controller recommendation says (docs/rfc-enakgame.md §10). Only
|
||||
// RECOMMENDED can be accepted.
|
||||
const (
|
||||
GameBudgetRecommended = "RECOMMENDED"
|
||||
// The forecast meets the budget closely enough that rewards stay.
|
||||
GameBudgetRecommendationNoChange = "NO_CHANGE"
|
||||
// The last accepted recommendation of the organization is too recent.
|
||||
GameBudgetRecommendationCooldown = "COOLDOWN"
|
||||
// Every game is already at its min or max multiplier.
|
||||
GameBudgetRecommendationAtLimit = "AT_LIMIT"
|
||||
// No cost in the forecast window to extrapolate from.
|
||||
GameBudgetRecommendationNoData = "INSUFFICIENT_DATA"
|
||||
// The budget's period has not started or has no day left after today.
|
||||
GameBudgetRecommendationOutOfPeriod = "OUT_OF_PERIOD"
|
||||
)
|
||||
+23
-12
@@ -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,31 +52,36 @@ const (
|
||||
TableEntity = "table"
|
||||
// Gamification entities
|
||||
CustomerPointsEntity = "customer_points"
|
||||
CustomerTokensEntity = "customer_tokens"
|
||||
TierEntity = "tier"
|
||||
GameEntity = "game"
|
||||
GamePrizeEntity = "game_prize"
|
||||
GamePlayEntity = "game_play"
|
||||
OmsetTrackerEntity = "omset_tracker"
|
||||
RewardEntity = "reward"
|
||||
CampaignEntity = "campaign"
|
||||
CampaignRuleEntity = "campaign_rule"
|
||||
CustomerEntity = "customer"
|
||||
SpinGameHandlerEntity = "spin_game_handler"
|
||||
UserDeviceServiceEntity = "user_device_service"
|
||||
NotificationServiceEntity = "notification_service"
|
||||
NotificationHandlerEntity = "notification_handler"
|
||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
WalletServiceEntity = "wallet_service"
|
||||
EnakGameServiceEntity = "enakgame_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
CustomerPinServiceEntity = "customer_pin_service"
|
||||
CustomerAuthServiceEntity = "customer_auth_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,100 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Per organization: EnakGame reward limits (docs/rfc-enakgame.md §5.10). 0 means no
|
||||
// limit. A reward over a limit is cut to what is left of it (§9).
|
||||
const (
|
||||
EnakGameLimitUserDailyKey = "enakgame.limit.user_daily"
|
||||
EnakGameLimitGlobalDailyKey = "enakgame.limit.global_daily"
|
||||
)
|
||||
|
||||
// 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,17 @@
|
||||
package constants
|
||||
|
||||
// SellBy describes how a product's quantity is expressed on an order line.
|
||||
type SellBy = string
|
||||
|
||||
const (
|
||||
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
|
||||
SellByUnit SellBy = "unit"
|
||||
// SellByWeight means the line carries a weighed amount. One weighing is one line,
|
||||
// so quantity stays 1 and the measured value lives in order_items.weight.
|
||||
SellByWeight SellBy = "weight"
|
||||
)
|
||||
|
||||
// IsValidSellBy reports whether s is a supported sell_by value.
|
||||
func IsValidSellBy(s string) bool {
|
||||
return s == SellByUnit || s == SellByWeight
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package constants
|
||||
|
||||
// A purchase order is charged to a team. Teams come from the parent product
|
||||
// categories, plus Pusat for spending that belongs to no single team.
|
||||
const (
|
||||
PurchaseTeamScopeCategory = "category"
|
||||
PurchaseTeamScopeCentral = "central"
|
||||
|
||||
// PurchaseTeamCentralName is what Pusat is called in the picker. Pusat has no
|
||||
// row of its own, so the name lives here rather than in the database.
|
||||
PurchaseTeamCentralName = "Pusat"
|
||||
|
||||
// PurchaseTeamNone is the value the list filter takes to ask for purchases
|
||||
// that have not been charged to any team yet.
|
||||
PurchaseTeamNone = "none"
|
||||
|
||||
// PurchaseTeamNoneName labels those purchases in the reports.
|
||||
PurchaseTeamNoneName = "Tanpa Team"
|
||||
)
|
||||
@@ -3,11 +3,12 @@ package constants
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RolePurchasing UserRole = "purchasing"
|
||||
)
|
||||
|
||||
func GetAllUserRoles() []UserRole {
|
||||
@@ -17,6 +18,7 @@ func GetAllUserRoles() []UserRole {
|
||||
RoleCashier,
|
||||
RoleWaiter,
|
||||
RoleOwner,
|
||||
RolePurchasing,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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"
|
||||
|
||||
// EnakGame (docs/rfc-enakgame.md §6.1).
|
||||
WalletTxTypeGameSpendRefund = "GAME_SPEND_REFUND"
|
||||
WalletTxTypeGameReward = "GAME_REWARD"
|
||||
WalletTxTypeRewardRedeemRefund = "REWARD_REDEEM_REFUND"
|
||||
)
|
||||
|
||||
// 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"
|
||||
WalletRefTypeGameSession = "GAME_SESSION"
|
||||
)
|
||||
@@ -18,6 +18,7 @@ type PaymentMethodAnalyticsRequest struct {
|
||||
type PaymentMethodAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -54,6 +55,7 @@ type SalesAnalyticsRequest struct {
|
||||
type SalesAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -86,15 +88,19 @@ type SalesAnalyticsData struct {
|
||||
type PurchasingAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
||||
// Team narrows the report to one team: a parent category id, "central" for
|
||||
// Pusat, or "none" for purchases charged to no team. Empty covers all teams.
|
||||
Team string `form:"team,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
GroupBy string `form:"group_by,default=day" validate:"omitempty,oneof=day hour week month"`
|
||||
}
|
||||
|
||||
type PurchasingAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Team string `json:"team,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -102,6 +108,21 @@ type PurchasingAnalyticsResponse struct {
|
||||
Data []PurchasingAnalyticsData `json:"data"`
|
||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||
TeamData []PurchasingTeamData `json:"team_data"`
|
||||
}
|
||||
|
||||
// PurchasingTeamData is one team's share of the purchases. Scope and CategoryID
|
||||
// are exactly what the team filter takes, so a row doubles as a drill-down link.
|
||||
type PurchasingTeamData struct {
|
||||
Scope string `json:"scope"`
|
||||
CategoryID *uuid.UUID `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
type PurchasingSummary struct {
|
||||
@@ -115,6 +136,7 @@ type PurchasingSummary struct {
|
||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||
TotalIngredients int64 `json:"total_ingredients"`
|
||||
TotalVendors int64 `json:"total_vendors"`
|
||||
TotalTeams int64 `json:"total_teams"`
|
||||
}
|
||||
|
||||
type PurchasingAnalyticsData struct {
|
||||
@@ -140,12 +162,12 @@ type PurchasingIngredientData struct {
|
||||
}
|
||||
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsRequest represents the request for product analytics
|
||||
@@ -161,6 +183,7 @@ type ProductAnalyticsRequest struct {
|
||||
type ProductAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsData `json:"data"`
|
||||
@@ -175,6 +198,7 @@ type ProductAnalyticsData struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -198,6 +222,7 @@ type ProductAnalyticsPerCategoryRequest struct {
|
||||
type ProductAnalyticsPerCategoryResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsPerCategoryData `json:"data"`
|
||||
@@ -215,6 +240,151 @@ type ProductAnalyticsPerCategoryData struct {
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryRequest represents the request for product analytics per parent category
|
||||
type ProductAnalyticsPerParentCategoryRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
}
|
||||
|
||||
// ProductAnalyticsPerParentCategoryResponse represents the response for product analytics per parent category
|
||||
type ProductAnalyticsPerParentCategoryResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Data []ProductAnalyticsPerParentCategoryData `json:"data"`
|
||||
Budget BudgetCutOff `json:"budget"`
|
||||
}
|
||||
|
||||
type ProductAnalyticsPerParentCategoryData struct {
|
||||
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
|
||||
type ParentCategoryAnalyticsDetailRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
ParentCategoryID string
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
}
|
||||
|
||||
// ParentCategoryAnalyticsDetailResponse represents the drill-down of one parent category
|
||||
type ParentCategoryAnalyticsDetailResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
ParentCategoryID uuid.UUID `json:"parent_category_id"`
|
||||
ParentCategoryName string `json:"parent_category_name"`
|
||||
Summary ParentCategoryAnalyticsDetailSummary `json:"summary"`
|
||||
Categories []ParentCategoryAnalyticsDetailData `json:"categories"`
|
||||
Budget BudgetCutOff `json:"budget"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailSummary struct {
|
||||
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"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
TotalStandardHpp float64 `json:"total_standard_hpp"`
|
||||
TotalFifoHpp float64 `json:"total_fifo_hpp"`
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
Products []ParentCategoryAnalyticsProductData `json:"products"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsProductData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
StandardHppPerUnit float64 `json:"standard_hpp_per_unit"`
|
||||
StandardHppTotal float64 `json:"standard_hpp_total"`
|
||||
FifoHppPerUnit float64 `json:"fifo_hpp_per_unit"`
|
||||
FifoHppTotal float64 `json:"fifo_hpp_total"`
|
||||
MovingAverageHppPerUnit float64 `json:"moving_average_hpp_per_unit"`
|
||||
MovingAverageHppTotal float64 `json:"moving_average_hpp_total"`
|
||||
}
|
||||
|
||||
// BudgetCutOff is the Monday-to-Sunday spending limit breakdown attached to the
|
||||
// parent category reports.
|
||||
type BudgetCutOff struct {
|
||||
Percentages BudgetPercentages `json:"percentages"`
|
||||
CutOffFrom time.Time `json:"cut_off_from"`
|
||||
CutOffTo time.Time `json:"cut_off_to"`
|
||||
Total BudgetPeriod `json:"total"`
|
||||
Weekly []BudgetPeriod `json:"weekly"`
|
||||
Monthly []BudgetMonthPeriod `json:"monthly"`
|
||||
}
|
||||
|
||||
type BudgetPercentages struct {
|
||||
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"`
|
||||
SDL float64 `json:"sdl"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
type BudgetMonthPeriod struct {
|
||||
Month string `json:"month"`
|
||||
WeekCount int `json:"week_count"`
|
||||
BudgetPeriod
|
||||
}
|
||||
|
||||
// DashboardAnalyticsRequest represents the request for dashboard analytics
|
||||
type DashboardAnalyticsRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
@@ -227,6 +397,7 @@ type DashboardAnalyticsRequest struct {
|
||||
type DashboardAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Overview DashboardOverview `json:"overview"`
|
||||
@@ -237,12 +408,15 @@ type DashboardAnalyticsResponse struct {
|
||||
|
||||
// DashboardOverview represents the overview data for dashboard
|
||||
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"`
|
||||
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 ProfitLossAnalyticsRequest struct {
|
||||
@@ -256,6 +430,7 @@ type ProfitLossAnalyticsRequest struct {
|
||||
type ProfitLossAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
@@ -263,10 +438,28 @@ type ProfitLossAnalyticsResponse struct {
|
||||
Data []ProfitLossData `json:"data"`
|
||||
ProductData []ProductProfitData `json:"product_data"`
|
||||
MainSummary []ProfitLossSummaryRow `json:"main_summary"`
|
||||
Purchasing ProfitLossPurchasing `json:"purchasing"`
|
||||
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Items []ProfitLossPurchasingItem `json:"items"`
|
||||
}
|
||||
|
||||
type ProfitLossPurchasingItem struct {
|
||||
Date time.Time `json:"date"`
|
||||
Item string `json:"item"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
type ProfitLossSummary struct {
|
||||
TotalRevenue float64 `json:"total_revenue"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
@@ -339,9 +532,17 @@ type ExclusiveSummaryMonthlyRequest struct {
|
||||
Month string `form:"month" validate:"required"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMTDRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Period ExclusiveSummaryPeriodRange `json:"period"`
|
||||
Summary ExclusiveSummaryPeriodSummary `json:"summary"`
|
||||
Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
|
||||
@@ -401,6 +602,7 @@ type ExclusiveSummaryDailyTransaction struct {
|
||||
type ExclusiveSummaryMonthlyResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
Month string `json:"month"`
|
||||
Summary ExclusiveSummaryMonthlySummary `json:"summary"`
|
||||
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
type CreateCampaignRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -25,7 +25,7 @@ type UpdateCampaignRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"`
|
||||
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS MIXED"`
|
||||
StartDate time.Time `json:"start_date" binding:"required"`
|
||||
EndDate time.Time `json:"end_date" binding:"required"`
|
||||
IsActive bool `json:"is_active"`
|
||||
@@ -58,7 +58,7 @@ type CreateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -70,7 +70,7 @@ type UpdateCampaignRuleRequest struct {
|
||||
CampaignID uuid.UUID `json:"campaign_id" binding:"required"`
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
@@ -142,7 +142,7 @@ type ListCampaignRulesResponse struct {
|
||||
type CampaignRuleStruct struct {
|
||||
RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"`
|
||||
ConditionValue *string `json:"condition_value,omitempty"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=POINTS COINS REWARD"`
|
||||
RewardValue *int64 `json:"reward_value,omitempty"`
|
||||
RewardSubtype *string `json:"reward_subtype,omitempty"`
|
||||
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateCashAdvanceRequest struct {
|
||||
// OutletID falls back to the caller's outlet when omitted; an advance is cash out
|
||||
// of one drawer, so one of the two has to be known.
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty" validate:"omitempty"`
|
||||
CodeNumber string `json:"code_number" validate:"required,min=1,max=50"`
|
||||
TeamScope string `json:"team_scope" validate:"required,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required,gt=0"`
|
||||
IssuedDate string `json:"issued_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type UpdateCashAdvanceRequest struct {
|
||||
CodeNumber *string `json:"code_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
// An advance always belongs to a team, so team_scope can be moved but not cleared.
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gt=0"`
|
||||
ReturnedAmount *float64 `json:"returned_amount,omitempty" validate:"omitempty,gte=0"`
|
||||
IssuedDate *string `json:"issued_date,omitempty" validate:"omitempty"`
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type CashAdvanceResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
CodeNumber string `json:"code_number"`
|
||||
TeamScope string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
SettledAmount float64 `json:"settled_amount"`
|
||||
ReturnedAmount float64 `json:"returned_amount"`
|
||||
RemainingAmount float64 `json:"remaining_amount"`
|
||||
SettlementStatus string `json:"settlement_status"`
|
||||
IssuedDate time.Time `json:"issued_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
Status string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||
Settlements []CashAdvanceSettlementResponse `json:"settlements,omitempty"`
|
||||
}
|
||||
|
||||
// CashAdvanceSettlementResponse is one purchase order or expense paid out of the
|
||||
// advance.
|
||||
type CashAdvanceSettlementResponse struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ListCashAdvancesRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft approved rejected cancelled"`
|
||||
// SettlementStatus filters on how much of the cash has been accounted for,
|
||||
// which is derived from the spending charged to the advance rather than stored.
|
||||
SettlementStatus string `json:"settlement_status,omitempty" validate:"omitempty,oneof=open partial settled"`
|
||||
// Team is the single-value form of the two filters below, so the team picker can
|
||||
// send back what it was given: a parent category id or "central" for Pusat.
|
||||
Team string `json:"team,omitempty"`
|
||||
TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListCashAdvancesResponse struct {
|
||||
CashAdvances []CashAdvanceResponse `json:"cash_advances"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -1,32 +1,88 @@
|
||||
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"`
|
||||
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"`
|
||||
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 {
|
||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Type string `json:"type,omitempty" validate:"omitempty,oneof=parent child"`
|
||||
BusinessType string `json:"business_type,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"required,min=1"`
|
||||
@@ -35,16 +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"`
|
||||
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"`
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Request Contracts
|
||||
type GetCustomerGamesRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
// Response Contracts
|
||||
type GetCustomerGamesResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetCustomerGamesResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetCustomerGamesResponseData struct {
|
||||
Games []CustomerGameResponse `json:"games"`
|
||||
}
|
||||
|
||||
type CustomerGameResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CustomerGamePrizeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Ferris Wheel Game Contracts
|
||||
type GetFerrisWheelGameRequest struct {
|
||||
// No additional fields needed - customer ID comes from JWT token
|
||||
}
|
||||
|
||||
type GetFerrisWheelGameResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *GetFerrisWheelGameResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type GetFerrisWheelGameResponseData struct {
|
||||
Game CustomerGameResponse `json:"game"`
|
||||
Prizes []CustomerGamePrizeResponse `json:"prizes"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package contract
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
// StartGameSessionRequest is POST /customer/enakgame/sessions (docs/rfc-enakgame.md
|
||||
// §7.1). The Idempotency-Key header is required.
|
||||
type StartGameSessionRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" binding:"required"`
|
||||
}
|
||||
|
||||
// RedeemVoucherRequest is POST /customer/vouchers/:id/redeem (§7.4). The
|
||||
// Idempotency-Key header is required.
|
||||
type RedeemVoucherRequest struct {
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
@@ -7,15 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type CreateExpenseRequest struct {
|
||||
Receiver string `json:"receiver" validate:"required"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"`
|
||||
CodeNumber string `json:"code_number" validate:"required"`
|
||||
OutletID string `json:"outlet_id" validate:"required"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total" validate:"required"`
|
||||
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
|
||||
Receiver string `json:"receiver" validate:"required"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"`
|
||||
CodeNumber string `json:"code_number" validate:"required"`
|
||||
OutletID string `json:"outlet_id" validate:"required"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total" validate:"required"`
|
||||
// CashAdvanceID marks the expense as paid out of cash advanced to a team, which is
|
||||
// what accounts for that advance.
|
||||
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||
Items []CreateExpenseItemRequest `json:"items" validate:"required"`
|
||||
}
|
||||
|
||||
type CreateExpenseItemRequest struct {
|
||||
@@ -27,16 +30,18 @@ type CreateExpenseItemRequest struct {
|
||||
}
|
||||
|
||||
type UpdateExpenseRequest struct {
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
OutletID *string `json:"outlet_id,omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
Receiver *string `json:"receiver,omitempty"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty"`
|
||||
CodeNumber *string `json:"code_number,omitempty"`
|
||||
OutletID *string `json:"outlet_id,omitempty"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Tax *float64 `json:"tax,omitempty"`
|
||||
Total *float64 `json:"total,omitempty"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
// An empty string unlinks the cash advance; omitting the field leaves it untouched.
|
||||
CashAdvanceID *string `json:"cash_advance_id,omitempty"`
|
||||
Items []UpdateExpenseItemRequest `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateExpenseItemRequest struct {
|
||||
@@ -59,6 +64,7 @@ type ExpenseResponse struct {
|
||||
Tax float64 `json:"tax"`
|
||||
Total float64 `json:"total"`
|
||||
Reserved1 *string `json:"reserved1,omitempty"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Items []ExpenseItemResponse `json:"items,omitempty"`
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGameRequest struct {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateGameRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,required"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type GameResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListGamesRequest struct {
|
||||
Page int `json:"page" form:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" form:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search" form:"search"`
|
||||
Type string `json:"type" form:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive *bool `json:"is_active" form:"is_active"`
|
||||
SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedGamesResponse struct {
|
||||
Data []GameResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
type GamePlayResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
TokenUsed int `json:"token_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
Customer *CustomerResponse `json:"customer,omitempty"`
|
||||
Prize *GamePrizeResponse `json:"prize,omitempty"`
|
||||
}
|
||||
|
||||
type ListGamePlaysRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search"`
|
||||
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"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedGamePlaysResponse struct {
|
||||
Data []GamePlayResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreateGamePrizeRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Weight int `json:"weight" validate:"min=1"`
|
||||
Stock int `json:"stock" validate:"min=0"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type UpdateGamePrizeRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,required"`
|
||||
Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"`
|
||||
Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type GamePrizeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
Name string `json:"name"`
|
||||
Weight int `json:"weight"`
|
||||
Stock int `json:"stock"`
|
||||
MaxStock *int `json:"max_stock,omitempty"`
|
||||
Threshold *int64 `json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"`
|
||||
Image *string `json:"image,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListGamePrizesRequest struct {
|
||||
Page int `json:"page" form:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" form:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search" form:"search"`
|
||||
GameID *uuid.UUID `json:"game_id" form:"game_id"`
|
||||
SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"`
|
||||
SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
type PaginatedGamePrizesResponse struct {
|
||||
Data []GamePrizeResponse `json:"data"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -77,7 +77,7 @@ type ListIngredientUnitConvertersResponse struct {
|
||||
type IngredientUnitsResponse struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
IngredientName string `json:"ingredient_name"`
|
||||
BaseUnitID uuid.UUID `json:"base_unit_id"`
|
||||
BaseUnitID *uuid.UUID `json:"base_unit_id"`
|
||||
BaseUnitName string `json:"base_unit_name"`
|
||||
Units []*UnitResponse `json:"units"`
|
||||
}
|
||||
|
||||
@@ -27,15 +27,14 @@ type AddOmsetRequest struct {
|
||||
}
|
||||
|
||||
type OmsetTrackerResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PeriodType string `json:"period_type"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Total int64 `json:"total"`
|
||||
GameID *uuid.UUID `json:"game_id,omitempty"`
|
||||
Game *GameResponse `json:"game,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PeriodType string `json:"period_type"`
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Total int64 `json:"total"`
|
||||
GameID *uuid.UUID `json:"game_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListOmsetTrackerRequest struct {
|
||||
|
||||
@@ -40,13 +40,16 @@ type UpdateOrderRequest struct {
|
||||
}
|
||||
|
||||
type CreateOrderItemRequest struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Optional, will use database price if not provided
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
// Weight is required for products with sell_by = "weight" and must be absent otherwise.
|
||||
// One weighing is one line, so quantity stays 1 and the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitPrice *float64 `json:"unit_price,omitempty" validate:"omitempty,min=0"` // Ignored: price always comes from the database
|
||||
Modifiers []map[string]interface{} `json:"modifiers,omitempty"`
|
||||
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateOrderItemRequest struct {
|
||||
@@ -89,29 +92,38 @@ type OrderResponse struct {
|
||||
TotalPaid float64 `json:"total_paid"`
|
||||
PaymentCount int `json:"payment_count"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
// EnakPoint and EnakCoin the order earned, for the order screen and the receipt
|
||||
// (docs/prd-point-coin.md F3). Zero when it earned nothing.
|
||||
PointsEarned int64 `json:"points_earned"`
|
||||
CoinsEarned int64 `json:"coins_earned"`
|
||||
}
|
||||
|
||||
type OrderItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id"`
|
||||
ProductVariantName *string `json:"product_variant_name,omitempty"`
|
||||
CategoryID *uuid.UUID `json:"category_id,omitempty"`
|
||||
CategoryName *string `json:"category_name,omitempty"`
|
||||
Quantity int `json:"quantity"`
|
||||
// Weight is set only for products sold by weight; the line is priced weight * unit_price.
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
UnitName *string `json:"unit_name,omitempty"`
|
||||
UnitAbbreviation *string `json:"unit_abbreviation,omitempty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Modifiers []map[string]interface{} `json:"modifiers"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
PrinterTypes []string `json:"printer_types"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
type ListOrdersQuery struct {
|
||||
|
||||
@@ -16,8 +16,10 @@ 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"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||
@@ -36,8 +38,10 @@ 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"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
@@ -72,7 +76,9 @@ 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"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
|
||||
@@ -7,13 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type CreatePurchaseOrderRequest struct {
|
||||
VendorID uuid.UUID `json:"vendor_id" validate:"required"`
|
||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber string `json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate string `json:"transaction_date" validate:"required"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
// CashAdvanceID marks the purchase as paid out of cash advanced to the team. Sending
|
||||
// it without a team charges the purchase to the cash advance's team.
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"`
|
||||
Items []CreatePurchaseOrderItemRequest `json:"items" validate:"required,min=1,dive"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -28,13 +33,18 @@ type CreatePurchaseOrderItemRequest struct {
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderRequest struct {
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
|
||||
PONumber *string `json:"po_number,omitempty" validate:"omitempty,min=1,max=50"`
|
||||
TransactionDate *string `json:"transaction_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
DueDate *string `json:"due_date,omitempty" validate:"omitempty"` // Format: YYYY-MM-DD
|
||||
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
|
||||
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
Message *string `json:"message,omitempty" validate:"omitempty"`
|
||||
// An empty string clears the team; omitting the field leaves it untouched.
|
||||
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
|
||||
// An all-zero uuid unlinks the cash advance; omitting the field leaves it untouched.
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"`
|
||||
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
|
||||
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
@@ -52,7 +62,8 @@ type UpdatePurchaseOrderItemRequest struct {
|
||||
type PurchaseOrderResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
PONumber string `json:"po_number"`
|
||||
TransactionDate time.Time `json:"transaction_date"`
|
||||
DueDate *time.Time `json:"due_date"`
|
||||
@@ -60,13 +71,30 @@ type PurchaseOrderResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message *string `json:"message"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
TeamScope *string `json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id"`
|
||||
CashAdvanceID *uuid.UUID `json:"cash_advance_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Team *PurchaseTeamResponse `json:"team,omitempty"`
|
||||
Vendor *VendorResponse `json:"vendor,omitempty"`
|
||||
Items []PurchaseOrderItemResponse `json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachmentResponse `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// PurchaseTeamResponse is one entry of the team picker. Teams come from the parent
|
||||
// product categories; Pusat is the extra entry that has no category behind it, so
|
||||
// its CategoryID is null.
|
||||
type PurchaseTeamResponse struct {
|
||||
Scope string `json:"scope"`
|
||||
CategoryID *uuid.UUID `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ListPurchaseTeamsResponse struct {
|
||||
Teams []PurchaseTeamResponse `json:"teams"`
|
||||
}
|
||||
|
||||
type PurchaseOrderItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
@@ -92,13 +120,20 @@ type PurchaseOrderAttachmentResponse struct {
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersRequest struct {
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
|
||||
VendorID *uuid.UUID `json:"vendor_id,omitempty"`
|
||||
// Team is the single-value form of the two filters below, so the team picker
|
||||
// can send back what it was given: a parent category id, "central" for Pusat,
|
||||
// or "none" for purchases with no team yet. It replaces them rather than
|
||||
// narrowing alongside them.
|
||||
Team string `json:"team,omitempty"`
|
||||
TeamScope string `json:"team_scope,omitempty" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty"`
|
||||
StartDate *time.Time `json:"start_date,omitempty"`
|
||||
EndDate *time.Time `json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
type ListPurchaseOrdersResponse struct {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Request Contracts
|
||||
type CreateRewardRequest struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL"`
|
||||
CostPoints int64 `json:"cost_points" binding:"required,min=1"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer" binding:"min=1"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateRewardRequest struct {
|
||||
ID uuid.UUID `json:"id" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=1,max=150"`
|
||||
RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"`
|
||||
CostPoints int64 `json:"cost_points" binding:"required,min=1"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer" binding:"min=1"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
type ListRewardsRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
Limit int `form:"limit" binding:"min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
RewardType string `form:"reward_type"`
|
||||
MinPoints *int64 `form:"min_points"`
|
||||
MaxPoints *int64 `form:"max_points"`
|
||||
HasStock *bool `form:"has_stock"`
|
||||
}
|
||||
|
||||
type GetRewardRequest struct {
|
||||
ID uuid.UUID `uri:"id" binding:"required"`
|
||||
}
|
||||
|
||||
type DeleteRewardRequest struct {
|
||||
ID uuid.UUID `uri:"id" binding:"required"`
|
||||
}
|
||||
|
||||
// Response Contracts
|
||||
type RewardResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
RewardType string `json:"reward_type"`
|
||||
CostPoints int64 `json:"cost_points"`
|
||||
Stock *int `json:"stock,omitempty"`
|
||||
MaxPerCustomer int `json:"max_per_customer"`
|
||||
Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `json:"metadata,omitempty"`
|
||||
Images *[]string `json:"images,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListRewardsResponse struct {
|
||||
Rewards []RewardResponse `json:"rewards"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// Helper structs
|
||||
type TermsAndConditionsStruct struct {
|
||||
Sections []TncSectionStruct `json:"sections"`
|
||||
ExpiryDays int `json:"expiry_days"`
|
||||
}
|
||||
|
||||
type TncSectionStruct struct {
|
||||
Title string `json:"title"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package contract
|
||||
|
||||
// SpinGameRequest represents the request to play a spin game
|
||||
type SpinGameRequest struct {
|
||||
SpinID string `json:"spin_id" validate:"required,uuid"`
|
||||
}
|
||||
|
||||
// SpinGameResponse represents the response from playing a spin game
|
||||
type SpinGameResponse struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Data *SpinGameResponseData `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -12,14 +12,14 @@ type CreateUserRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||
Permissions map[string]interface{} `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
|
||||
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Permissions *map[string]interface{} `json:"permissions,omitempty"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
+113
-22
@@ -27,6 +27,14 @@ type SalesAnalytics struct {
|
||||
NetSales float64 `json:"net_sales"`
|
||||
}
|
||||
|
||||
// PurchaseTeamFilter narrows purchasing figures to a single team: a parent
|
||||
// category, Pusat, or the purchases that carry no team at all. A nil filter
|
||||
// leaves the figures spanning every team.
|
||||
type PurchaseTeamFilter struct {
|
||||
Scope string
|
||||
CategoryID *uuid.UUID
|
||||
}
|
||||
|
||||
// PurchasingAnalytics represents purchasing analytics data
|
||||
type PurchasingAnalytics struct {
|
||||
OutletName *string `json:"outlet_name,omitempty"`
|
||||
@@ -34,6 +42,22 @@ type PurchasingAnalytics struct {
|
||||
Data []PurchasingAnalyticsData `json:"data"`
|
||||
IngredientData []PurchasingIngredientData `json:"ingredient_data"`
|
||||
VendorData []PurchasingVendorData `json:"vendor_data"`
|
||||
TeamData []PurchasingTeamData `json:"team_data"`
|
||||
}
|
||||
|
||||
// PurchasingTeamData is one team's share of the purchases: a parent category,
|
||||
// Pusat, or the purchases charged to no team at all. Scope and CategoryID are
|
||||
// what the team filter takes back, so a row can be clicked straight through.
|
||||
type PurchasingTeamData struct {
|
||||
Scope string `json:"scope"`
|
||||
CategoryID *uuid.UUID `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
type PurchasingSummary struct {
|
||||
@@ -47,6 +71,7 @@ type PurchasingSummary struct {
|
||||
AveragePurchaseOrderValue float64 `json:"average_purchase_order_value"`
|
||||
TotalIngredients int64 `json:"total_ingredients"`
|
||||
TotalVendors int64 `json:"total_vendors"`
|
||||
TotalTeams int64 `json:"total_teams"`
|
||||
}
|
||||
|
||||
type PurchasingAnalyticsData struct {
|
||||
@@ -72,12 +97,12 @@ type PurchasingIngredientData struct {
|
||||
}
|
||||
|
||||
type PurchasingVendorData struct {
|
||||
VendorID uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
VendorID *uuid.UUID `json:"vendor_id"`
|
||||
VendorName string `json:"vendor_name"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
PurchaseOrderCount int64 `json:"purchase_order_count"`
|
||||
IngredientCount int64 `json:"ingredient_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
type ProductAnalytics struct {
|
||||
@@ -89,6 +114,7 @@ type ProductAnalytics struct {
|
||||
CategoryName string `json:"category_name"`
|
||||
CategoryOrder int `json:"category_order"`
|
||||
QuantitySold int64 `json:"quantity_sold"`
|
||||
WeightSold float64 `json:"weight_sold"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
AveragePrice float64 `json:"average_price"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
@@ -112,27 +138,92 @@ type ProductAnalyticsPerCategory struct {
|
||||
TotalMovingAverageHpp float64 `json:"total_moving_average_hpp"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
// 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:
|
||||
// its own totals, the sub-categories underneath it, and the products in each.
|
||||
type ParentCategoryAnalyticsDetail struct {
|
||||
ParentCategoryID uuid.UUID
|
||||
ParentCategoryName string
|
||||
Summary *ProductAnalyticsPerParentCategory
|
||||
Categories []*ProductAnalyticsPerCategory
|
||||
Products []*ProductAnalytics
|
||||
}
|
||||
|
||||
// BudgetCutOffWeek is one Monday-to-Sunday bucket of revenue, used to derive the
|
||||
// weekly spending limits.
|
||||
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"`
|
||||
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 {
|
||||
Summary ProfitLossSummary
|
||||
Data []ProfitLossData
|
||||
ProductData []ProductProfitData
|
||||
TodayRevenue float64
|
||||
TodayCost float64
|
||||
MtdRevenue float64
|
||||
MtdCost float64
|
||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||
OperationalExpenseItems []OperationalExpenseItem
|
||||
Summary ProfitLossSummary
|
||||
Data []ProfitLossData
|
||||
ProductData []ProductProfitData
|
||||
TodayRevenue float64
|
||||
TodayCost float64
|
||||
MtdRevenue float64
|
||||
MtdCost float64
|
||||
TodayPurchasing float64
|
||||
MtdPurchasing float64
|
||||
TodayPurchasingRawMaterial float64
|
||||
MtdPurchasingRawMaterial float64
|
||||
TodayPurchasingExpense float64
|
||||
MtdPurchasingExpense float64
|
||||
PurchasingItems []PurchasingItemDetail
|
||||
TodayExpenseByCategory []ExpenseCategoryTotal
|
||||
MtdExpenseByCategory []ExpenseCategoryTotal
|
||||
OperationalExpenseItems []OperationalExpenseItem
|
||||
}
|
||||
|
||||
type PurchasingItemDetail struct {
|
||||
Date time.Time
|
||||
Item string
|
||||
Quantity float64
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type ProfitLossSummary struct {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AuditLog is one change to EnakGame configuration or state: who made it, to what,
|
||||
// from what, to what (docs/rfc-enakgame.md §5.9, §13). Append-only.
|
||||
type AuditLog struct {
|
||||
ID uuid.UUID
|
||||
OrganizationID uuid.UUID
|
||||
// USER or SYSTEM.
|
||||
ActorType string
|
||||
// The admin for USER; nil for SYSTEM.
|
||||
ActorID *uuid.UUID
|
||||
EntityType string
|
||||
EntityID uuid.UUID
|
||||
Action string
|
||||
// JSON snapshots of the entity; nil when there was nothing before (a create) or
|
||||
// after (a delete).
|
||||
Before json.RawMessage
|
||||
After json.RawMessage
|
||||
Reason *string
|
||||
// admin_api, budget_controller, session_job, ...
|
||||
Source string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CashAdvance is money handed to a team up front so it can go shopping — kasbon in
|
||||
// the Indonesian UI. While the cash is out it is still the outlet's, not a cost, so
|
||||
// nothing about what the team bought lives here: that is read back from the purchase
|
||||
// orders and expenses charged to the advance.
|
||||
type CashAdvance 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"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
|
||||
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
|
||||
// An advance is always handed to a team, so unlike a purchase order it has no
|
||||
// "not chosen yet" state. TeamCategoryID is set only when the scope is category.
|
||||
TeamScope string `gorm:"not null;size:20;index" json:"team_scope"`
|
||||
TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
|
||||
ReturnedAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"returned_amount"`
|
||||
IssuedDate time.Time `gorm:"type:date;not null" json:"issued_date"`
|
||||
DueDate *time.Time `gorm:"type:date" json:"due_date"`
|
||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// SettledAmount is filled in by the read queries from the spending charged to
|
||||
// this advance. It has no column of its own, so it can never drift out of step
|
||||
// with the purchases behind it; the arrow tag keeps writes from touching it.
|
||||
SettledAmount float64 `gorm:"->;-:migration" json:"settled_amount"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
||||
}
|
||||
|
||||
func (k *CashAdvance) BeforeCreate(tx *gorm.DB) error {
|
||||
if k.ID == uuid.Nil {
|
||||
k.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (CashAdvance) TableName() string {
|
||||
return "cash_advances"
|
||||
}
|
||||
|
||||
// CashAdvanceSettlement is one piece of spending charged to an advance. It is read
|
||||
// out of purchase_orders and expenses, so it has no table of its own.
|
||||
type CashAdvanceSettlement struct {
|
||||
Type string `json:"type"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Number string `json:"number"`
|
||||
Date time.Time `json:"date"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
@@ -34,13 +34,21 @@ type Category 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"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Parent *Category `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
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"`
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// JSONDocument is a JSONB column kept as raw JSON, for documents whose shape depends
|
||||
// on another column or is only read back as a whole. Empty is SQL NULL.
|
||||
type JSONDocument json.RawMessage
|
||||
|
||||
func (d JSONDocument) Value() (driver.Value, error) {
|
||||
if len(d) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return string(d), nil
|
||||
}
|
||||
|
||||
func (d *JSONDocument) Scan(value interface{}) error {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
*d = nil
|
||||
case []byte:
|
||||
*d = append(JSONDocument(nil), v...)
|
||||
case string:
|
||||
*d = JSONDocument(v)
|
||||
default:
|
||||
return errors.New("JSONDocument: unsupported type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d JSONDocument) MarshalJSON() ([]byte, error) {
|
||||
if len(d) == 0 {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *JSONDocument) UnmarshalJSON(data []byte) error {
|
||||
*d = append(JSONDocument(nil), data...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GameResultRules are a game's limits on a result (docs/rfc-enakgame.md §5.1, §7.2)
|
||||
// and its daily reward limit (§5.10, §9). A limit left out does not apply.
|
||||
type GameResultRules struct {
|
||||
MaxScore *int64 `json:"max_score,omitempty"`
|
||||
MinDurationSeconds *int64 `json:"min_duration_seconds,omitempty"`
|
||||
MaxScorePerSecond *float64 `json:"max_score_per_second,omitempty"`
|
||||
// The outcomes a result may report. Empty accepts any.
|
||||
Outcomes []string `json:"outcomes,omitempty"`
|
||||
// EnakCoin the game may give out per day, in Asia/Jakarta.
|
||||
DailyRewardLimit *int64 `json:"daily_reward_limit,omitempty"`
|
||||
}
|
||||
|
||||
func (r GameResultRules) Value() (driver.Value, error) {
|
||||
b, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func (r *GameResultRules) Scan(value interface{}) error {
|
||||
*r = GameResultRules{}
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []byte:
|
||||
return json.Unmarshal(v, r)
|
||||
case string:
|
||||
return json.Unmarshal([]byte(v), r)
|
||||
default:
|
||||
return errors.New("GameResultRules: unsupported type")
|
||||
}
|
||||
}
|
||||
|
||||
// GameRewardConfig is one version of how a game computes its base reward (§5.2, §8).
|
||||
// Never updated apart from Status (D7): a change is a new version.
|
||||
type GameRewardConfig 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"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"`
|
||||
Version int `gorm:"not null" json:"version"`
|
||||
// FIXED, SCORE_BASED, OUTCOME_BASED or PROBABILITY.
|
||||
RewardType string `gorm:"size:30;not null" json:"reward_type"`
|
||||
// Shape per RewardType in §8.
|
||||
Rules JSONDocument `gorm:"type:jsonb;not null" json:"rules"`
|
||||
MaxReward int64 `gorm:"not null" json:"max_reward"`
|
||||
// DRAFT, ACTIVE or RETIRED.
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
EffectiveAt *time.Time `json:"effective_at"`
|
||||
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
// Set together on a version made by accepting a Budget Controller recommendation
|
||||
// (§10): the admin's version it scales, by how much, and for which budget.
|
||||
BaseConfigID *uuid.UUID `gorm:"type:uuid" json:"base_config_id"`
|
||||
Multiplier *float64 `gorm:"type:numeric(6,4)" json:"multiplier"`
|
||||
BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (GameRewardConfig) TableName() string { return "game_reward_configs" }
|
||||
|
||||
// GameSession is one play of a game by a customer (§5.3): STARTED, then COMPLETED,
|
||||
// REFUNDED or EXPIRED, each move a conditional update (D4).
|
||||
type GameSession 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"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null" json:"game_id"`
|
||||
// Snapshots taken at start (D7, P3).
|
||||
RewardConfigID uuid.UUID `gorm:"type:uuid;not null" json:"reward_config_id"`
|
||||
EntryCost int64 `gorm:"not null" json:"entry_cost"`
|
||||
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
StartedAt time.Time `gorm:"not null" json:"started_at"`
|
||||
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
||||
EndedAt *time.Time `json:"ended_at"`
|
||||
|
||||
// What the client sent: data only, never a reward amount (P1).
|
||||
Result JSONDocument `gorm:"type:jsonb" json:"result"`
|
||||
RewardBreakdown JSONDocument `gorm:"type:jsonb" json:"reward_breakdown"`
|
||||
RewardTotal int64 `gorm:"not null;default:0" json:"reward_total"`
|
||||
Flagged bool `gorm:"not null;default:false" json:"flagged"`
|
||||
|
||||
SpendTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"spend_transaction_id"`
|
||||
RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"`
|
||||
// SYSTEM_ERROR or GAME_DEACTIVATED.
|
||||
RefundReason *string `gorm:"size:30" json:"refund_reason"`
|
||||
CompletionFailedAt *time.Time `json:"completion_failed_at"`
|
||||
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (GameSession) TableName() string { return "game_sessions" }
|
||||
|
||||
// GameSessionReward is the part of a session's reward one budget paid for, with its
|
||||
// own ledger row (D6).
|
||||
type GameSessionReward struct {
|
||||
SessionID uuid.UUID `gorm:"type:uuid;primary_key" json:"session_id"`
|
||||
BudgetID uuid.UUID `gorm:"type:uuid;primary_key" json:"budget_id"`
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
WalletTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"wallet_transaction_id"`
|
||||
}
|
||||
|
||||
func (GameSessionReward) TableName() string { return "game_session_rewards" }
|
||||
|
||||
// GameBudget is what an organization may spend on EnakGame rewards over a period
|
||||
// (§5.6): GLOBAL for base rewards, EVENT for what an event adds.
|
||||
type GameBudget 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"`
|
||||
// GLOBAL or EVENT.
|
||||
Scope string `gorm:"size:20;not null" json:"scope"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
// Dates, inclusive at both ends.
|
||||
PeriodStart time.Time `gorm:"type:date;not null" json:"period_start"`
|
||||
PeriodEnd time.Time `gorm:"type:date;not null" json:"period_end"`
|
||||
// Rupiah.
|
||||
Amount int64 `gorm:"not null" json:"amount"`
|
||||
// {"warning": 70, "critical": 90}.
|
||||
Thresholds JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"thresholds"`
|
||||
ExhaustionPolicy *string `gorm:"size:30" json:"exhaustion_policy"`
|
||||
CreatedBy uuid.UUID `gorm:"type:uuid;not null" json:"created_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameBudget) TableName() string { return "game_budgets" }
|
||||
|
||||
// GameEvent is a period in which some games pay more, the extra paid by the event's
|
||||
// own budget (docs/rfc-enakgame.md §5.5, D6). Event and campaign are the same thing.
|
||||
type GameEvent 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"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Slug string `gorm:"size:100;not null" json:"slug"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
BannerURL *string `gorm:"column:banner_url;size:500" json:"banner_url"`
|
||||
StartAt time.Time `gorm:"not null" json:"start_at"`
|
||||
EndAt time.Time `gorm:"not null" json:"end_at"`
|
||||
Timezone string `gorm:"size:50;not null" json:"timezone"`
|
||||
// DRAFT, ACTIVE, ENDED or CANCELLED.
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
Priority int `gorm:"not null" json:"priority"`
|
||||
// NUMERIC(5,2): at most two decimals.
|
||||
Multiplier *float64 `gorm:"type:numeric(5,2)" json:"multiplier"`
|
||||
Bonus *int64 `json:"bonus"`
|
||||
BudgetID uuid.UUID `gorm:"type:uuid;not null" json:"budget_id"`
|
||||
RewardLimit *int64 `json:"reward_limit"`
|
||||
UserDailyLimit *int64 `json:"user_daily_limit"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GameEvent) TableName() string { return "game_events" }
|
||||
@@ -28,10 +28,7 @@ func GetAllEntities() []interface{} {
|
||||
&CustomerTokens{},
|
||||
&Tier{},
|
||||
&Game{},
|
||||
&GamePrize{},
|
||||
&GamePlay{},
|
||||
&OmsetTracker{},
|
||||
&Reward{},
|
||||
&Campaign{},
|
||||
&CampaignRule{},
|
||||
&OtpSession{},
|
||||
@@ -43,6 +40,13 @@ func GetAllEntities() []interface{} {
|
||||
&NotificationDelivery{},
|
||||
&ProductOutletPrice{},
|
||||
&Expense{},
|
||||
&CashAdvance{},
|
||||
// Wallet entities
|
||||
&CustomerWallet{},
|
||||
&WalletTransaction{},
|
||||
&WalletLot{},
|
||||
&WalletLotAllocation{},
|
||||
&LoyaltySettingChange{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,11 +20,14 @@ type Expense struct {
|
||||
Tax float64 `gorm:"type:decimal(15,2);not null;default:0" json:"tax"`
|
||||
Total float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total"`
|
||||
Reserved1 *string `gorm:"type:text" json:"reserved1"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// CashAdvanceID is set when the expense was paid out of cash advanced to a team.
|
||||
CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
|
||||
Items []ExpenseItem `gorm:"foreignKey:ExpenseID" json:"items,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,30 @@ const (
|
||||
)
|
||||
|
||||
type Game struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"`
|
||||
// The old games' settings. Nothing reads it any more; their coin_cost became
|
||||
// entry_cost (migration 000103).
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
|
||||
// EnakGame (docs/rfc-enakgame.md §5.1). Only an ARCHIVED game, which every old game
|
||||
// is, may lack an organization and a slug.
|
||||
OrganizationID *uuid.UUID `gorm:"type:uuid" json:"organization_id"`
|
||||
Slug *string `gorm:"size:100" json:"slug"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
ThumbnailURL *string `gorm:"column:thumbnail_url;size:500" json:"thumbnail_url"`
|
||||
GameURL *string `gorm:"column:game_url;size:500" json:"game_url"`
|
||||
Version *string `gorm:"size:50" json:"version"`
|
||||
// DRAFT, ACTIVE, INACTIVE or ARCHIVED.
|
||||
Status string `gorm:"size:20;not null;default:ACTIVE" json:"status"`
|
||||
// EnakCoin a session costs; at least 1.
|
||||
EntryCost int64 `gorm:"not null" json:"entry_cost"`
|
||||
SessionTTLSeconds int `gorm:"column:session_ttl_seconds;not null;default:600" json:"session_ttl_seconds"`
|
||||
ResultRules GameResultRules `gorm:"type:jsonb;not null;default:'{}'" json:"result_rules"`
|
||||
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Prizes []GamePrize `gorm:"foreignKey:GameID" json:"prizes,omitempty"`
|
||||
Plays []GamePlay `gorm:"foreignKey:GameID" json:"plays,omitempty"`
|
||||
}
|
||||
|
||||
func (g *Game) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GamePlay struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
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"`
|
||||
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"`
|
||||
Customer Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`
|
||||
Prize *GamePrize `gorm:"foreignKey:PrizeID" json:"prize,omitempty"`
|
||||
}
|
||||
|
||||
func (gp *GamePlay) BeforeCreate(tx *gorm.DB) error {
|
||||
if gp.ID == uuid.Nil {
|
||||
gp.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (GamePlay) TableName() string {
|
||||
return "game_plays"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GamePrize struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
|
||||
Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"`
|
||||
Weight int `gorm:"not null" json:"weight" validate:"min=1"`
|
||||
Stock int `gorm:"default:0" json:"stock" validate:"min=0"`
|
||||
MaxStock *int `gorm:"" json:"max_stock,omitempty"`
|
||||
Threshold *int64 `gorm:"" json:"threshold,omitempty"`
|
||||
FallbackPrizeID *uuid.UUID `gorm:"type:uuid" json:"fallback_prize_id,omitempty"`
|
||||
Image *string `gorm:"type:varchar(500)" json:"image,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"`
|
||||
FallbackPrize *GamePrize `gorm:"foreignKey:FallbackPrizeID" json:"fallback_prize,omitempty"`
|
||||
Plays []GamePlay `gorm:"foreignKey:PrizeID" json:"plays,omitempty"`
|
||||
}
|
||||
|
||||
func (gp *GamePrize) BeforeCreate(tx *gorm.DB) error {
|
||||
if gp.ID == uuid.Nil {
|
||||
gp.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (GamePrize) TableName() string {
|
||||
return "game_prizes"
|
||||
}
|
||||
@@ -11,7 +11,7 @@ type Ingredient struct {
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
UnitID uuid.UUID `gorm:"type:uuid;not null;index" json:"unit_id"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
Cost float64 `gorm:"type:decimal(10,2);default:0.00" json:"cost"`
|
||||
Stock float64 `gorm:"type:decimal(10,2);default:0.00" json:"stock"`
|
||||
IsSemiFinished bool `gorm:"default:false" json:"is_semi_finished"`
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LoyaltySettingChange records one change to a loyalty setting: which key, from what,
|
||||
// to what, by whom (docs/prd-point-coin.md F2). Append-only.
|
||||
type LoyaltySettingChange struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
|
||||
// Nil for an organization setting.
|
||||
OutletID *uuid.UUID `gorm:"type:uuid" json:"outlet_id"`
|
||||
Key string `gorm:"not null;size:100" json:"key"`
|
||||
// Nil when the key had no stored value, or has none any more.
|
||||
OldValue *string `gorm:"type:text" json:"old_value"`
|
||||
NewValue *string `gorm:"type:text" json:"new_value"`
|
||||
ChangedBy uuid.UUID `gorm:"type:uuid;not null" json:"changed_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (c *LoyaltySettingChange) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (LoyaltySettingChange) TableName() string {
|
||||
return "loyalty_setting_changes"
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -47,6 +48,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
|
||||
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
|
||||
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
|
||||
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
|
||||
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
|
||||
@@ -68,6 +71,7 @@ type OrderItem struct {
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
|
||||
return "order_items"
|
||||
}
|
||||
|
||||
// IsWeighed reports whether this line was sold by weight rather than by count.
|
||||
func (oi *OrderItem) IsWeighed() bool {
|
||||
return oi.Weight != nil
|
||||
}
|
||||
|
||||
// BillableQuantity is the multiplier for unit price and unit cost on this line:
|
||||
// the weighed amount for weight-based products, the item count otherwise.
|
||||
//
|
||||
// This is the ONLY place that decides between the two. Every price or cost
|
||||
// calculation must go through it — multiplying by Quantity directly would bill a
|
||||
// 4.2 ons fish as a single ons.
|
||||
//
|
||||
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
|
||||
// so there is no ambiguity between counting and weighing.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
|
||||
// voiding or refunding `units` of it.
|
||||
//
|
||||
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
|
||||
// covers the whole weighing — hence the full weight is returned regardless of units.
|
||||
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(units)
|
||||
}
|
||||
|
||||
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
|
||||
// value written back always matches what the database keeps.
|
||||
func RoundMoney(amount float64) float64 {
|
||||
return math.Round(amount*100) / 100
|
||||
}
|
||||
|
||||
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
|
||||
// is identical to the stored one.
|
||||
func RoundWeight(weight float64) float64 {
|
||||
return math.Round(weight*1000) / 1000
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CanBeModified() bool {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package entities
|
||||
|
||||
import "testing"
|
||||
|
||||
func weightPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestBillableQuantity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item OrderItem
|
||||
want float64
|
||||
}{
|
||||
{"unit line counts items", OrderItem{Quantity: 3}, 3},
|
||||
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
|
||||
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.item.BillableQuantity(); got != tt.want {
|
||||
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A weighed line is voided or refunded whole, so the units asked for must not shrink
|
||||
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
|
||||
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
|
||||
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
|
||||
if got := weighed.BillableQuantityFor(1); got != 5.6 {
|
||||
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
|
||||
}
|
||||
|
||||
counted := OrderItem{Quantity: 3}
|
||||
if got := counted.BillableQuantityFor(2); got != 2 {
|
||||
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
|
||||
// must not be billed as a single ons.
|
||||
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 18900 {
|
||||
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 12600 {
|
||||
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 75000 {
|
||||
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 30000 {
|
||||
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rupiah residue must not survive into a decimal(10,2) column.
|
||||
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
|
||||
item.CalculateTotalPrice()
|
||||
|
||||
if item.TotalPrice != 19066.5 {
|
||||
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeighed(t *testing.T) {
|
||||
if (&OrderItem{Quantity: 1}).IsWeighed() {
|
||||
t.Error("unit line reported as weighed")
|
||||
}
|
||||
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
|
||||
t.Error("weighed line reported as unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundWeight(t *testing.T) {
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{4.2, 4.2},
|
||||
{4.2374, 4.237},
|
||||
{4.2375, 4.238},
|
||||
{0.0004, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := RoundWeight(tt.in); got != tt.want {
|
||||
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -8,23 +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"`
|
||||
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"`
|
||||
@@ -47,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"`
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
type PurchaseOrder 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" validate:"required"`
|
||||
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
|
||||
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
|
||||
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
|
||||
PONumber string `gorm:"not null;size:50" json:"po_number" validate:"required,min=1,max=50"`
|
||||
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date" validate:"required"`
|
||||
DueDate *time.Time `gorm:"type:date" json:"due_date" validate:"omitempty"`
|
||||
@@ -19,11 +20,21 @@ type PurchaseOrder struct {
|
||||
Status string `gorm:"not null;size:20;default:'draft'" json:"status" validate:"required,oneof=draft sent approved received cancelled"`
|
||||
Message *string `gorm:"type:text" json:"message" validate:"omitempty"`
|
||||
TotalAmount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"total_amount"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// TeamScope is 'category' when the purchase is charged to a parent category, or
|
||||
// 'central' for Pusat. Nil means no team was chosen, which is not the same as Pusat.
|
||||
TeamScope *string `gorm:"size:20;index" json:"team_scope" validate:"omitempty,oneof=category central"`
|
||||
TeamCategoryID *uuid.UUID `gorm:"type:uuid;index" json:"team_category_id" validate:"omitempty"`
|
||||
// CashAdvanceID is set when the purchase was paid out of cash advanced to the team.
|
||||
// It is what accounts for that advance, so the cash advance holds no copy of the items.
|
||||
CashAdvanceID *uuid.UUID `gorm:"type:uuid;index" json:"cash_advance_id" validate:"omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Organization *Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Vendor *Vendor `gorm:"foreignKey:VendorID" json:"vendor,omitempty"`
|
||||
TeamCategory *Category `gorm:"foreignKey:TeamCategoryID" json:"team_category,omitempty"`
|
||||
CashAdvance *CashAdvance `gorm:"foreignKey:CashAdvanceID" json:"cash_advance,omitempty"`
|
||||
Items []PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderID" json:"items,omitempty"`
|
||||
Attachments []PurchaseOrderAttachment `gorm:"foreignKey:PurchaseOrderID" json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RewardType string
|
||||
|
||||
const (
|
||||
RewardTypeVoucher RewardType = "VOUCHER"
|
||||
RewardTypePhysical RewardType = "PHYSICAL"
|
||||
RewardTypeDigital RewardType = "DIGITAL"
|
||||
RewardTypeBalance RewardType = "BALANCE"
|
||||
)
|
||||
|
||||
// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer
|
||||
type StringSlice []string
|
||||
|
||||
// Scan implements the sql.Scanner interface for StringSlice
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into StringSlice", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for StringSlice
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
type TermsAndConditions struct {
|
||||
Sections []TncSection `json:"sections"`
|
||||
ExpiryDays int `json:"expiry_days"`
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface for TermsAndConditions
|
||||
func (t *TermsAndConditions) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*t = TermsAndConditions{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into TermsAndConditions", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, t)
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for TermsAndConditions
|
||||
func (t TermsAndConditions) Value() (driver.Value, error) {
|
||||
return json.Marshal(t)
|
||||
}
|
||||
|
||||
type TncSection struct {
|
||||
Title string `json:"title"`
|
||||
Rules []string `json:"rules"`
|
||||
}
|
||||
|
||||
type Reward struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"type:varchar(150);not null" json:"name"`
|
||||
RewardType RewardType `gorm:"type:varchar(50);not null" json:"reward_type"`
|
||||
CostPoints int64 `gorm:"type:bigint;not null" json:"cost_points"`
|
||||
Stock *int `gorm:"type:int" json:"stock,omitempty"`
|
||||
MaxPerCustomer int `gorm:"type:int;default:1" json:"max_per_customer"`
|
||||
Tnc *TermsAndConditions `gorm:"type:jsonb" json:"tnc,omitempty"`
|
||||
Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||
Images *StringSlice `gorm:"type:jsonb" json:"images,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Reward) TableName() string {
|
||||
return "rewards"
|
||||
}
|
||||
|
||||
func (r *Reward) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reward) BeforeUpdate(tx *gorm.DB) error {
|
||||
r.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
type ListRewardsRequest struct {
|
||||
Page int `form:"page" binding:"min=1"`
|
||||
Limit int `form:"limit" binding:"min=1,max=100"`
|
||||
Search string `form:"search"`
|
||||
RewardType string `form:"reward_type"`
|
||||
MinPoints *int64 `form:"min_points"`
|
||||
MaxPoints *int64 `form:"max_points"`
|
||||
HasStock *bool `form:"has_stock"`
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer
|
||||
type StringSlice []string
|
||||
|
||||
// Scan implements the sql.Scanner interface for StringSlice
|
||||
func (s *StringSlice) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StringSlice{}
|
||||
return nil
|
||||
}
|
||||
|
||||
var bytes []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
bytes = v
|
||||
case string:
|
||||
bytes = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("cannot scan %T into StringSlice", value)
|
||||
}
|
||||
|
||||
return json.Unmarshal(bytes, s)
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface for StringSlice
|
||||
func (s StringSlice) Value() (driver.Value, error) {
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(s)
|
||||
}
|
||||
@@ -13,10 +13,12 @@ import (
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleAdmin UserRole = "admin"
|
||||
RoleManager UserRole = "manager"
|
||||
RoleCashier UserRole = "cashier"
|
||||
RoleWaiter UserRole = "waiter"
|
||||
RoleOwner UserRole = "owner"
|
||||
RolePurchasing UserRole = "purchasing"
|
||||
)
|
||||
|
||||
type Permissions map[string]interface{}
|
||||
@@ -46,7 +48,7 @@ type User struct {
|
||||
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
|
||||
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
|
||||
PasswordHash string `gorm:"not null;size:255" json:"-"`
|
||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
|
||||
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"`
|
||||
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Voucher is what EnakPoint is redeemed for (docs/rfc-enakgame.md §5.7).
|
||||
type Voucher 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"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
ImageURL *string `gorm:"column:image_url;size:500" json:"image_url"`
|
||||
VoucherType string `gorm:"size:30;not null" json:"voucher_type"`
|
||||
// Rupiah: the basis of realized cost.
|
||||
FaceValue int64 `gorm:"not null" json:"face_value"`
|
||||
PointCost int64 `gorm:"not null" json:"point_cost"`
|
||||
// Reporting only.
|
||||
BusinessCost *int64 `json:"business_cost"`
|
||||
StockMode string `gorm:"size:20;not null" json:"stock_mode"`
|
||||
// STATIC only.
|
||||
Stock *int64 `json:"stock"`
|
||||
// EXTERNAL only.
|
||||
Provider *string `gorm:"size:50" json:"provider"`
|
||||
ProviderRef *string `gorm:"size:255" json:"provider_ref"`
|
||||
MaxPerCustomer *int `json:"max_per_customer"`
|
||||
ValidFrom *time.Time `json:"valid_from"`
|
||||
ValidUntil *time.Time `json:"valid_until"`
|
||||
Terms JSONDocument `gorm:"type:jsonb;not null;default:'{}'" json:"terms"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Voucher) TableName() string { return "vouchers" }
|
||||
|
||||
// VoucherCode is one code of a CODE_POOL voucher.
|
||||
type VoucherCode struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"`
|
||||
Code string `gorm:"size:255;not null" json:"code"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
RedemptionID *uuid.UUID `gorm:"type:uuid" json:"redemption_id"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VoucherCode) TableName() string { return "voucher_codes" }
|
||||
|
||||
// VoucherRedemption is one redemption, with the voucher's numbers frozen (P3).
|
||||
type VoucherRedemption 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"`
|
||||
VoucherID uuid.UUID `gorm:"type:uuid;not null" json:"voucher_id"`
|
||||
IdempotencyKey string `gorm:"size:100;not null" json:"idempotency_key"`
|
||||
Status string `gorm:"size:20;not null" json:"status"`
|
||||
FaceValue int64 `gorm:"not null" json:"face_value"`
|
||||
PointCost int64 `gorm:"not null" json:"point_cost"`
|
||||
VoucherCodeID *uuid.UUID `gorm:"type:uuid" json:"voucher_code_id"`
|
||||
ExternalCode *string `gorm:"size:255" json:"external_code"`
|
||||
ExternalRef *string `gorm:"size:255" json:"external_ref"`
|
||||
DebitTransactionID uuid.UUID `gorm:"type:uuid;not null" json:"debit_transaction_id"`
|
||||
RefundTransactionID *uuid.UUID `gorm:"type:uuid" json:"refund_transaction_id"`
|
||||
FailureReason *string `gorm:"size:255" json:"failure_reason"`
|
||||
Attempts int `gorm:"not null;default:0" json:"attempts"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (VoucherRedemption) TableName() string { return "voucher_redemptions" }
|
||||
|
||||
// VoucherRedemptionCost is the part of a redemption's face value one source of its
|
||||
// EnakPoint accounts for (D5). BudgetID is nil for EnakPoint not from EnakGame.
|
||||
type VoucherRedemptionCost struct {
|
||||
RedemptionID uuid.UUID `gorm:"type:uuid;not null" json:"redemption_id"`
|
||||
BudgetID *uuid.UUID `gorm:"type:uuid" json:"budget_id"`
|
||||
SourceType string `gorm:"size:30;not null" json:"source_type"`
|
||||
Points int64 `gorm:"not null" json:"points"`
|
||||
Cost int64 `gorm:"not null" json:"cost"`
|
||||
RecognizedAt time.Time `gorm:"not null" json:"recognized_at"`
|
||||
}
|
||||
|
||||
func (VoucherRedemptionCost) TableName() string { return "voucher_redemption_costs" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -157,6 +157,55 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerCategory")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetProductAnalyticsPerParentCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ProductAnalyticsPerParentCategoryRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq := transformer.ProductAnalyticsPerParentCategoryContractToModel(&req)
|
||||
|
||||
response, err := h.analyticsService.GetProductAnalyticsPerParentCategory(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetProductAnalyticsPerParentCategory", err.Error())}), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ProductAnalyticsPerParentCategoryModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProductAnalyticsPerParentCategory")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetParentCategoryAnalyticsDetail(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ParentCategoryAnalyticsDetailRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.ParentCategoryID = c.Param("parent_category_id")
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq := transformer.ParentCategoryAnalyticsDetailContractToModel(&req)
|
||||
|
||||
response, err := h.analyticsService.GetParentCategoryAnalyticsDetail(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetParentCategoryAnalyticsDetail", err.Error())}), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ParentCategoryAnalyticsDetailModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetParentCategoryAnalyticsDetail")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
@@ -266,3 +315,31 @@ func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
|
||||
contractResp := transformer.ExclusiveSummaryMonthlyModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetExclusiveSummaryMTD(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExclusiveSummaryMTDRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq, err := transformer.ExclusiveSummaryMTDContractToModel(&req)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.analyticsService.GetExclusiveSummaryMTD(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMTD", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryMTD")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CashAdvanceHandler struct {
|
||||
cashAdvanceService service.CashAdvanceService
|
||||
cashAdvanceValidator validator.CashAdvanceValidator
|
||||
}
|
||||
|
||||
func NewCashAdvanceHandler(cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator) *CashAdvanceHandler {
|
||||
return &CashAdvanceHandler{
|
||||
cashAdvanceService: cashAdvanceService,
|
||||
cashAdvanceValidator: cashAdvanceValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) CreateCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.CreateCashAdvanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::CreateCashAdvance -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateCreateCashAdvanceRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::CreateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.CreateCashAdvance(ctx, contextInfo, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::CreateCashAdvance -> Failed to create cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::CreateCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) UpdateCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdateCashAdvanceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvance -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateUpdateCashAdvanceRequest(&req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.UpdateCashAdvance(ctx, contextInfo, cashAdvanceID, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvance -> Failed to update cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) DeleteCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::DeleteCashAdvance -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::DeleteCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.DeleteCashAdvance(ctx, contextInfo, cashAdvanceID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::DeleteCashAdvance -> Failed to delete cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::DeleteCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) GetCashAdvance(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::GetCashAdvance -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::GetCashAdvance")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.GetCashAdvanceByID(ctx, contextInfo, cashAdvanceID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::GetCashAdvance -> Failed to get cash advance from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::GetCashAdvance")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) ListCashAdvances(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
req := &contract.ListCashAdvancesRequest{
|
||||
Page: 1,
|
||||
Limit: 10,
|
||||
}
|
||||
|
||||
if pageStr := c.Query("page"); pageStr != "" {
|
||||
if page, err := strconv.Atoi(pageStr); err == nil {
|
||||
req.Page = page
|
||||
}
|
||||
}
|
||||
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
if limit, err := strconv.Atoi(limitStr); err == nil {
|
||||
req.Limit = limit
|
||||
}
|
||||
}
|
||||
|
||||
req.Search = c.Query("search")
|
||||
req.Status = c.Query("status")
|
||||
req.SettlementStatus = c.Query("settlement_status")
|
||||
req.Team = c.Query("team")
|
||||
req.TeamScope = c.Query("team_scope")
|
||||
|
||||
if teamCategoryIDStr := c.Query("team_category_id"); teamCategoryIDStr != "" {
|
||||
if teamCategoryID, err := uuid.Parse(teamCategoryIDStr); err == nil {
|
||||
req.TeamCategoryID = &teamCategoryID
|
||||
}
|
||||
}
|
||||
|
||||
if startDateStr := c.Query("start_date"); startDateStr != "" {
|
||||
if startDate, err := time.Parse("2006-01-02", startDateStr); err == nil {
|
||||
req.StartDate = &startDate
|
||||
}
|
||||
}
|
||||
|
||||
if endDateStr := c.Query("end_date"); endDateStr != "" {
|
||||
if endDate, err := time.Parse("2006-01-02", endDateStr); err == nil {
|
||||
req.EndDate = &endDate
|
||||
}
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.cashAdvanceValidator.ValidateListCashAdvancesRequest(req)
|
||||
if validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::ListCashAdvances")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.ListCashAdvances(ctx, contextInfo, req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvances -> Failed to list cash advances from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvances")
|
||||
}
|
||||
|
||||
func (h *CashAdvanceHandler) UpdateCashAdvanceStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
cashAdvanceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Invalid cash advance ID")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid cash advance ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus")
|
||||
return
|
||||
}
|
||||
|
||||
status := c.Param("status")
|
||||
if status == "" {
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Status parameter is required")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CashAdvanceHandler::UpdateCashAdvanceStatus")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.cashAdvanceService.UpdateCashAdvanceStatus(ctx, contextInfo, cashAdvanceID, status)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::UpdateCashAdvanceStatus -> Failed to update cash advance status from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::UpdateCashAdvanceStatus")
|
||||
}
|
||||
|
||||
// ListCashAdvanceTeams serves the team picker for the advance form: the parent
|
||||
// categories of the caller's outlet, plus Pusat. Same list the purchase form uses.
|
||||
func (h *CashAdvanceHandler) ListCashAdvanceTeams(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
response := h.cashAdvanceService.ListCashAdvanceTeams(ctx, contextInfo)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("CashAdvanceHandler::ListCashAdvanceTeams -> Failed to list cash advance teams from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, response, "CashAdvanceHandler::ListCashAdvanceTeams")
|
||||
}
|
||||
@@ -191,6 +191,18 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
}
|
||||
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||
if parentID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
req.ParentID = &parentID
|
||||
}
|
||||
}
|
||||
|
||||
// type=parent -> top level categories only
|
||||
// type=child -> leaf categories (sub categories + top level ones without children)
|
||||
if categoryType := c.Query("type"); categoryType != "" {
|
||||
req.Type = categoryType
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
@@ -161,13 +164,36 @@ func (h *CustomerAuthHandler) Login(c *gin.Context) {
|
||||
response, err := h.customerAuthService.Login(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("CustomerAuthHandler::Login -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error())}), "CustomerAuthHandler::Login")
|
||||
util.HandleResponse(c.Writer, c.Request, loginErrorResponse(err), "CustomerAuthHandler::Login")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerAuthHandler::Login")
|
||||
}
|
||||
|
||||
// loginErrorResponse answers a phone number with too many attempts with 429 and when it
|
||||
// may try again, a refused login with 304 and its reason, and anything else with 900.
|
||||
func loginErrorResponse(err error) *contract.Response {
|
||||
var locked *processor.CustomerLoginLockedError
|
||||
if errors.As(err, &locked) {
|
||||
return &contract.Response{
|
||||
Success: false,
|
||||
Data: map[string]interface{}{"locked_until": locked.Until},
|
||||
Errors: []*contract.ResponseError{contract.NewResponseError(constants.TooManyRequestsErrorCode, constants.CustomerAuthServiceEntity, locked.Error())},
|
||||
}
|
||||
}
|
||||
for _, refused := range []error{processor.ErrCustomerLoginInvalid, processor.ErrCustomerNotRegistered} {
|
||||
if errors.Is(err, refused) {
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.CustomerAuthServiceEntity, refused.Error()),
|
||||
})
|
||||
}
|
||||
}
|
||||
return contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CustomerAuthHandler) ResendOtp(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"apskel-pos-be/internal/entities"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// customerAuthRepoFake holds customers by phone number. Only the login lookup is used.
|
||||
type customerAuthRepoFake struct {
|
||||
customers map[string]*entities.Customer
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *customerAuthRepoFake) GetCustomerByPhoneNumber(_ context.Context, phone string) (*entities.Customer, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.customers[phone], nil
|
||||
}
|
||||
|
||||
func (f *customerAuthRepoFake) GetCustomerByID(context.Context, string) (*entities.Customer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) CreateCustomer(context.Context, *entities.Customer) error { return nil }
|
||||
func (f *customerAuthRepoFake) UpdateCustomer(context.Context, *entities.Customer) error { return nil }
|
||||
func (f *customerAuthRepoFake) CheckPhoneNumberExists(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) SetCustomerPassword(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) OrganizationExists(context.Context, uuid.UUID) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (f *customerAuthRepoFake) OrganizationIDs(context.Context, int) ([]uuid.UUID, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// loginAttemptsFake counts attempts per phone number in one window that never ends.
|
||||
type loginAttemptsFake struct {
|
||||
counts map[string]int64
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *loginAttemptsFake) Hit(_ context.Context, phone string, window time.Duration) (int64, time.Duration, error) {
|
||||
if f.err != nil {
|
||||
return 0, 0, f.err
|
||||
}
|
||||
f.counts[phone]++
|
||||
return f.counts[phone], window, nil
|
||||
}
|
||||
|
||||
func (f *loginAttemptsFake) Reset(_ context.Context, phone string) error {
|
||||
delete(f.counts, phone)
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
loginTestPassword = "rahasia123"
|
||||
loginTestRegistered = "6281234561234"
|
||||
loginTestUnfinished = "6281234569999"
|
||||
)
|
||||
|
||||
type loginTest struct {
|
||||
repo *customerAuthRepoFake
|
||||
attempts *loginAttemptsFake
|
||||
router *gin.Engine
|
||||
}
|
||||
|
||||
func newLoginTest(t *testing.T) *loginTest {
|
||||
t.Helper()
|
||||
applogger.Setup("fatal", "json")
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(loginTestPassword), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
hashStr := string(hash)
|
||||
registered, unfinished := loginTestRegistered, loginTestUnfinished
|
||||
birth := time.Date(2000, 1, 31, 0, 0, 0, 0, time.UTC)
|
||||
lt := &loginTest{
|
||||
repo: &customerAuthRepoFake{customers: map[string]*entities.Customer{
|
||||
registered: {ID: uuid.New(), Name: "Budi", PhoneNumber: ®istered, BirthDate: &birth, PasswordHash: &hashStr},
|
||||
unfinished: {ID: uuid.New(), Name: "Sari", PhoneNumber: &unfinished},
|
||||
}},
|
||||
attempts: &loginAttemptsFake{counts: map[string]int64{}},
|
||||
}
|
||||
h := NewCustomerAuthHandler(
|
||||
service.NewCustomerAuthService(processor.NewCustomerAuthProcessor(lt.repo, lt.attempts, nil, nil, "test", 60)),
|
||||
validator.NewCustomerAuthValidator(),
|
||||
)
|
||||
gin.SetMode(gin.TestMode)
|
||||
lt.router = gin.New()
|
||||
lt.router.POST("/customer-auth/login", h.Login)
|
||||
return lt
|
||||
}
|
||||
|
||||
func (lt *loginTest) login(t *testing.T, phone, password string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"phone_number": phone, "password": password})
|
||||
rec := httptest.NewRecorder()
|
||||
lt.router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/customer-auth/login", bytes.NewReader(body)))
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
|
||||
return rec.Code, out
|
||||
}
|
||||
|
||||
func firstLoginError(t *testing.T, out map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
errs, ok := out["errors"].([]any)
|
||||
require.True(t, ok, "errors: %v", out)
|
||||
require.NotEmpty(t, errs)
|
||||
return errs[0].(map[string]any)
|
||||
}
|
||||
|
||||
func TestCustomerLoginRefusalsAreValidationErrors(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
|
||||
for name, tc := range map[string]struct{ phone, password, cause string }{
|
||||
"wrong password": {loginTestRegistered, "salah", "invalid phone number or password"},
|
||||
"unknown phone": {"6280000000000", loginTestPassword, "invalid phone number or password"},
|
||||
"registration unfinished": {loginTestUnfinished, loginTestPassword, "customer not properly registered"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
status, out := lt.login(t, tc.phone, tc.password)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
e := firstLoginError(t, out)
|
||||
assert.Equal(t, "304", e["code"])
|
||||
assert.Equal(t, "customer_auth_service", e["entity"])
|
||||
assert.Equal(t, tc.cause, e["cause"])
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("right password", func(t *testing.T) {
|
||||
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
require.Equal(t, http.StatusOK, status, "%v", out)
|
||||
inner := out["data"].(map[string]any)["data"].(map[string]any)
|
||||
assert.NotEmpty(t, inner["access_token"])
|
||||
})
|
||||
|
||||
t.Run("the number written another way finds the same customer", func(t *testing.T) {
|
||||
for _, phone := range []string{"081234561234", "+62 812-3456-1234", "81234561234"} {
|
||||
status, out := lt.login(t, phone, loginTestPassword)
|
||||
require.Equal(t, http.StatusOK, status, "%s: %v", phone, out)
|
||||
user := out["data"].(map[string]any)["data"].(map[string]any)["user"].(map[string]any)
|
||||
assert.Equal(t, loginTestRegistered, user["phone_number"], phone)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a number that is not an Indonesian mobile number", func(t *testing.T) {
|
||||
status, out := lt.login(t, "021-1234567", loginTestPassword)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
e := firstLoginError(t, out)
|
||||
assert.Equal(t, "304", e["code"])
|
||||
assert.Equal(t, "invalid phone number format", e["cause"])
|
||||
})
|
||||
|
||||
t.Run("a failing lookup is still a server error", func(t *testing.T) {
|
||||
lt.repo.err = errors.New("connection refused")
|
||||
defer func() { lt.repo.err = nil }()
|
||||
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
assert.Equal(t, http.StatusInternalServerError, status)
|
||||
assert.Equal(t, "900", firstLoginError(t, out)["code"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestCustomerLoginLocksAfterTooManyAttempts(t *testing.T) {
|
||||
t.Run("the sixth attempt is refused, even with the right password", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, _ := lt.login(t, loginTestRegistered, "salah")
|
||||
require.Equal(t, http.StatusBadRequest, status, "attempt %d", i+1)
|
||||
}
|
||||
status, out := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
assert.Equal(t, http.StatusTooManyRequests, status)
|
||||
e := firstLoginError(t, out)
|
||||
assert.Equal(t, "429", e["code"])
|
||||
assert.Equal(t, "customer_auth_service", e["entity"])
|
||||
data, ok := out["data"].(map[string]any)
|
||||
require.True(t, ok, "data: %v", out)
|
||||
until, err := time.Parse(time.RFC3339, data["locked_until"].(string))
|
||||
require.NoError(t, err)
|
||||
assert.WithinDuration(t, time.Now().Add(15*time.Minute), until, time.Minute)
|
||||
|
||||
// Another number is not affected.
|
||||
status, _ = lt.login(t, loginTestUnfinished, loginTestPassword)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
})
|
||||
|
||||
t.Run("writing the number another way counts toward the same limit", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for _, phone := range []string{"081234561234", "+6281234561234", "81234561234", "0812-3456-1234", loginTestRegistered} {
|
||||
lt.login(t, phone, "salah")
|
||||
}
|
||||
status, _ := lt.login(t, "081234561234", loginTestPassword)
|
||||
assert.Equal(t, http.StatusTooManyRequests, status)
|
||||
})
|
||||
|
||||
t.Run("unknown numbers lock the same way", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for i := 0; i < 5; i++ {
|
||||
lt.login(t, "6280000000000", "salah")
|
||||
}
|
||||
status, _ := lt.login(t, "6280000000000", "salah")
|
||||
assert.Equal(t, http.StatusTooManyRequests, status)
|
||||
})
|
||||
|
||||
t.Run("a successful login starts the count again", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
for i := 0; i < 4; i++ {
|
||||
lt.login(t, loginTestRegistered, "salah")
|
||||
}
|
||||
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
for i := 0; i < 5; i++ {
|
||||
status, _ := lt.login(t, loginTestRegistered, "salah")
|
||||
require.Equal(t, http.StatusBadRequest, status, "attempt %d after the login", i+1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logins go on when the counter is down", func(t *testing.T) {
|
||||
lt := newLoginTest(t)
|
||||
lt.attempts.err = errors.New("redis: connection refused")
|
||||
for i := 0; i < 6; i++ {
|
||||
lt.login(t, loginTestRegistered, "salah")
|
||||
}
|
||||
status, _ := lt.login(t, loginTestRegistered, loginTestPassword)
|
||||
assert.Equal(t, http.StatusOK, status)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -122,32 +93,73 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet")
|
||||
}
|
||||
|
||||
func (h *CustomerPointsHandler) GetCustomerGames(c *gin.Context) {
|
||||
// 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()
|
||||
|
||||
response, err := h.customerPointsService.GetCustomerGames(ctx)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerGames -> service call failed")
|
||||
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.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerGames")
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerGames")
|
||||
}
|
||||
|
||||
func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
response, err := h.customerPointsService.GetFerrisWheelGame(ctx)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetFerrisWheelGame -> service call failed")
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
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,213 @@
|
||||
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))),
|
||||
)))
|
||||
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=REWARD_REDEEM,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")
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// EnakGameAdminHandler serves /marketing/enakgame (docs/rfc-enakgame.md §11). Every
|
||||
// request works on the admin's own organization.
|
||||
type EnakGameAdminHandler struct {
|
||||
service service.EnakGameAdminService
|
||||
}
|
||||
|
||||
func NewEnakGameAdminHandler(s service.EnakGameAdminService) *EnakGameAdminHandler {
|
||||
return &EnakGameAdminHandler{service: s}
|
||||
}
|
||||
|
||||
// voucherCodeUploadLimit caps a CSV of codes: 50 000 lines of up to 255 characters.
|
||||
const voucherCodeUploadLimit = 16 << 20
|
||||
|
||||
// pathID reads a UUID path parameter, answering 400 when it is not one.
|
||||
func pathID(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
|
||||
}
|
||||
|
||||
// rawBody reads the request body, answering 400 when it cannot.
|
||||
func rawBody(c *gin.Context, method string) ([]byte, bool) {
|
||||
body, err := c.GetRawData()
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "cannot read the request body"),
|
||||
}), method)
|
||||
return nil, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func bindQuery(c *gin.Context, q any, method string) bool {
|
||||
if err := c.ShouldBindQuery(q); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), method)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateGame is POST /marketing/enakgame/games.
|
||||
func (h *EnakGameAdminHandler) CreateGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateGame"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateGame(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListGames is GET /marketing/enakgame/games?status=&search=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListGames(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListGames"
|
||||
var q models.EnakGameListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListGames(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetGame is GET /marketing/enakgame/games/:id.
|
||||
func (h *EnakGameAdminHandler) GetGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetGame"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetGame(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateGame is PUT /marketing/enakgame/games/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateGame(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateGame"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateGame(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetGameStatus is PUT /marketing/enakgame/games/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetGameStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetGameStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetGameStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// CreateRewardConfig is POST /marketing/enakgame/games/:id/reward-configs.
|
||||
func (h *EnakGameAdminHandler) CreateRewardConfig(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateRewardConfig"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// ListRewardConfigs is GET /marketing/enakgame/games/:id/reward-configs.
|
||||
func (h *EnakGameAdminHandler) ListRewardConfigs(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListRewardConfigs"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListRewardConfigs(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// ActivateRewardConfig is POST /marketing/enakgame/reward-configs/:id/activate.
|
||||
func (h *EnakGameAdminHandler) ActivateRewardConfig(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ActivateRewardConfig"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ActivateRewardConfig(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// CreateBudget is POST /marketing/enakgame/budgets.
|
||||
func (h *EnakGameAdminHandler) CreateBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateBudget"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateBudget(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListBudgets is GET /marketing/enakgame/budgets?scope=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListBudgets(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListBudgets"
|
||||
var q models.GameBudgetListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListBudgets(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetBudget is GET /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) GetBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetBudget(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateBudget is PUT /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateBudget(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// DeleteBudget is DELETE /marketing/enakgame/budgets/:id.
|
||||
func (h *EnakGameAdminHandler) DeleteBudget(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::DeleteBudget"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.DeleteBudget(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// BudgetMetrics is GET /marketing/enakgame/budgets/:id/metrics.
|
||||
func (h *EnakGameAdminHandler) BudgetMetrics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::BudgetMetrics"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.BudgetMetrics(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// BudgetRecommendation is GET /marketing/enakgame/budgets/:id/recommendation.
|
||||
func (h *EnakGameAdminHandler) BudgetRecommendation(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::BudgetRecommendation"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.BudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// AcceptBudgetRecommendation is POST /marketing/enakgame/budgets/:id/recommendation/accept.
|
||||
func (h *EnakGameAdminHandler) AcceptBudgetRecommendation(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::AcceptBudgetRecommendation"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.AcceptBudgetRecommendation(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// GameAnalytics is GET /marketing/enakgame/analytics/games?from=&to=&game_id=.
|
||||
func (h *EnakGameAdminHandler) GameAnalytics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GameAnalytics"
|
||||
var q models.EnakGameAnalyticsQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GameAnalytics(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// EconomyAnalytics is GET /marketing/enakgame/analytics/economy?from=&to=.
|
||||
func (h *EnakGameAdminHandler) EconomyAnalytics(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::EconomyAnalytics"
|
||||
var q models.EnakGameAnalyticsQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.EconomyAnalytics(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// CreateVoucher is POST /marketing/vouchers.
|
||||
func (h *EnakGameAdminHandler) CreateVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateVoucher"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateVoucher(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListVouchers is GET /marketing/vouchers?status=&search=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListVouchers(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListVouchers"
|
||||
var q models.VoucherListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetVoucher is GET /marketing/vouchers/:id.
|
||||
func (h *EnakGameAdminHandler) GetVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetVoucher"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetVoucher(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateVoucher is PUT /marketing/vouchers/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateVoucher(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateVoucher"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateVoucher(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetVoucherStatus is PUT /marketing/vouchers/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetVoucherStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetVoucherStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetVoucherStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// ImportVoucherCodes is POST /marketing/vouchers/:id/codes, with the CSV as
|
||||
// a multipart file named "file" or as the request body.
|
||||
func (h *EnakGameAdminHandler) ImportVoucherCodes(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ImportVoucherCodes"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var data []byte
|
||||
if strings.HasPrefix(c.ContentType(), "multipart/") {
|
||||
file, err := c.FormFile("file")
|
||||
if err == nil {
|
||||
var f multipart.File
|
||||
if f, err = file.Open(); err == nil {
|
||||
data, err = io.ReadAll(io.LimitReader(f, voucherCodeUploadLimit))
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "send the CSV as a file named \"file\""),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
} else if data, ok = rawBody(c, method); !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ImportVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, data), method)
|
||||
}
|
||||
|
||||
// ListVoucherCodes is GET /marketing/vouchers/:id/codes?status=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListVoucherCodes(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListVoucherCodes"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q models.VoucherCodeListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVoucherCodes(ctx, appcontext.FromGinContext(ctx), id, q), method)
|
||||
}
|
||||
|
||||
// CreateEvent is POST /marketing/enakgame/events.
|
||||
func (h *EnakGameAdminHandler) CreateEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::CreateEvent"
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CreateEvent(ctx, appcontext.FromGinContext(ctx), body), method)
|
||||
}
|
||||
|
||||
// ListEvents is GET /marketing/enakgame/events?status=&page=&limit=.
|
||||
func (h *EnakGameAdminHandler) ListEvents(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::ListEvents"
|
||||
var q models.GameEventListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListEvents(ctx, appcontext.FromGinContext(ctx), q), method)
|
||||
}
|
||||
|
||||
// GetEvent is GET /marketing/enakgame/events/:id.
|
||||
func (h *EnakGameAdminHandler) GetEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::GetEvent"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetEvent(ctx, appcontext.FromGinContext(ctx), id), method)
|
||||
}
|
||||
|
||||
// UpdateEvent is PUT /marketing/enakgame/events/:id.
|
||||
func (h *EnakGameAdminHandler) UpdateEvent(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::UpdateEvent"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.UpdateEvent(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
|
||||
// SetEventStatus is PUT /marketing/enakgame/events/:id/status.
|
||||
func (h *EnakGameAdminHandler) SetEventStatus(c *gin.Context) {
|
||||
const method = "EnakGameAdminHandler::SetEventStatus"
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.SetEventStatus(ctx, appcontext.FromGinContext(ctx), id, body), method)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// EnakGameCustomerHandler serves /customer/enakgame (docs/rfc-enakgame.md §11).
|
||||
type EnakGameCustomerHandler struct {
|
||||
service service.EnakGameCustomerService
|
||||
}
|
||||
|
||||
func NewEnakGameCustomerHandler(s service.EnakGameCustomerService) *EnakGameCustomerHandler {
|
||||
return &EnakGameCustomerHandler{service: s}
|
||||
}
|
||||
|
||||
// ListGames is GET /customer/enakgame/games.
|
||||
func (h *EnakGameCustomerHandler) ListGames(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListGames"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListGames(c.Request.Context(), customerID), method)
|
||||
}
|
||||
|
||||
// StartSession is POST /customer/enakgame/sessions. It requires Idempotency-Key.
|
||||
func (h *EnakGameCustomerHandler) StartSession(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::StartSession"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.StartGameSessionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "game_id is required"),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.StartSession(c.Request.Context(), customerID, &req, idempotencyKey(c)), method)
|
||||
}
|
||||
|
||||
// ListSessions is GET /customer/enakgame/sessions?game_id=&status=&page=&limit=.
|
||||
func (h *EnakGameCustomerHandler) ListSessions(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListSessions"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q models.GameSessionListQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, q), method)
|
||||
}
|
||||
|
||||
// GetSession is GET /customer/enakgame/sessions/:id.
|
||||
func (h *EnakGameCustomerHandler) GetSession(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::GetSession"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.GetSession(c.Request.Context(), customerID, id), method)
|
||||
}
|
||||
|
||||
// Leaderboard is GET /customer/enakgame/leaderboard?date=&game_id=&limit=.
|
||||
func (h *EnakGameCustomerHandler) Leaderboard(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::Leaderboard"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var q models.EnakGameLeaderboardQuery
|
||||
if !bindQuery(c, &q, method) {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.Leaderboard(c.Request.Context(), customerID, q), method)
|
||||
}
|
||||
|
||||
// CompleteSession is POST /customer/enakgame/sessions/:id/complete. The body is read
|
||||
// leniently on purpose: a reward amount, or anything else the backend does not take
|
||||
// from the client, is ignored rather than refused (P1). An empty body is a result
|
||||
// with nothing in it.
|
||||
func (h *EnakGameCustomerHandler) CompleteSession(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::CompleteSession"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, ok := rawBody(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in models.GameSessionCompleteInput
|
||||
if len(bytes.TrimSpace(body)) > 0 {
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "score must be a whole number and outcome a text"),
|
||||
}), method)
|
||||
return
|
||||
}
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.CompleteSession(c.Request.Context(), customerID, id, in), method)
|
||||
}
|
||||
|
||||
// ListVouchers is GET /customer/vouchers.
|
||||
func (h *EnakGameCustomerHandler) ListVouchers(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListVouchers"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListVouchers(c.Request.Context(), customerID), method)
|
||||
}
|
||||
|
||||
// RedeemVoucher is POST /customer/vouchers/:id/redeem. It requires the PIN
|
||||
// and Idempotency-Key; the body holds the PIN, so it is never logged.
|
||||
func (h *EnakGameCustomerHandler) RedeemVoucher(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::RedeemVoucher"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := pathID(c, "id", method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RedeemVoucherRequest
|
||||
if !bindPinRequest(c, &req, method) {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.RedeemVoucher(c.Request.Context(), customerID, id, &req, idempotencyKey(c), pinRequestInfo(c)), method)
|
||||
}
|
||||
|
||||
// ListRedemptions is GET /customer/vouchers/redemptions?page=&limit=.
|
||||
func (h *EnakGameCustomerHandler) ListRedemptions(c *gin.Context) {
|
||||
const method = "EnakGameCustomerHandler::ListRedemptions"
|
||||
customerID, ok := customerIDFromGin(c, method)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.Query("page"))
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
util.HandleResponse(c.Writer, c.Request, h.service.ListRedemptions(c.Request.Context(), customerID, page, limit), method)
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"mime/multipart"
|
||||
"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"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// enakGameHandlers builds both EnakGame handlers on db, the way app.go does.
|
||||
func enakGameHandlers(db *gorm.DB) (*EnakGameAdminHandler, *EnakGameCustomerHandler) {
|
||||
txm := repository.NewTxManager(db)
|
||||
audit := processor.NewAuditLogger(repository.NewAuditLogRepository(db))
|
||||
games := repository.NewEnakGameRepository(db)
|
||||
budgets := repository.NewGameBudgetRepository(db)
|
||||
vouchers := repository.NewVoucherRepository(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
customers := repository.NewWalletMoveRepository(db)
|
||||
spendable := repository.NewWalletQueryRepository(db)
|
||||
metrics := processor.NewGameBudgetMetricsProcessor(budgets, repository.NewGameBudgetMetricsRepository(db))
|
||||
admin := NewEnakGameAdminHandler(service.NewEnakGameAdminService(
|
||||
processor.NewEnakGameAdminProcessor(games, audit, txm), processor.NewGameBudgetProcessor(budgets, audit, txm), metrics,
|
||||
processor.NewGameBudgetControllerProcessor(budgets, metrics, games, audit, txm),
|
||||
processor.NewGameEventProcessor(repository.NewGameEventRepository(db), games, budgets, audit, txm),
|
||||
processor.NewVoucherAdminProcessor(vouchers, audit, txm),
|
||||
processor.NewEnakGameAnalyticsProcessor(repository.NewEnakGameAnalyticsRepository(db))))
|
||||
customer := NewEnakGameCustomerHandler(service.NewEnakGameCustomerService(
|
||||
processor.NewGameSessionProcessor(customers, games, repository.NewGameSessionRepository(db), budgets,
|
||||
repository.NewGameEventRepository(db), repository.NewGameRewardCounterRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm),
|
||||
spendable, wallet, audit, txm),
|
||||
processor.NewVoucherRedemptionProcessor(customers, vouchers, repository.NewVoucherRedemptionRepository(db),
|
||||
processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(db), nil, nil), spendable, wallet, processor.VoucherProviders{}, txm)))
|
||||
return admin, customer
|
||||
}
|
||||
|
||||
// Drives /marketing/enakgame and /customer/enakgame over HTTP down to Postgres
|
||||
// (docs/rfc-enakgame.md §11). Needs TEST_DATABASE_URL; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestEnakGameEndpoints_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()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame http', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_counters WHERE organization_id = ?`,
|
||||
`DELETE FROM game_sessions WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lots WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_wallets WHERE organization_id = ?`,
|
||||
`DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`,
|
||||
`DELETE FROM game_events WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_configs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id = ?`,
|
||||
`DELETE FROM games WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
adminHandler, customerHandler := enakGameHandlers(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+"/enakgame", 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.POST("/games", adminHandler.CreateGame)
|
||||
g.GET("/games/:id", adminHandler.GetGame)
|
||||
g.PUT("/games/:id", adminHandler.UpdateGame)
|
||||
g.PUT("/games/:id/status", adminHandler.SetGameStatus)
|
||||
g.POST("/games/:id/reward-configs", auth.RequireLoyaltyManager(), adminHandler.CreateRewardConfig)
|
||||
g.POST("/reward-configs/:id/activate", auth.RequireLoyaltyManager(), adminHandler.ActivateRewardConfig)
|
||||
g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget)
|
||||
g.GET("/budgets/:id/metrics", adminHandler.BudgetMetrics)
|
||||
}
|
||||
c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
|
||||
c.GET("/games", customerHandler.ListGames)
|
||||
c.POST("/sessions", customerHandler.StartSession)
|
||||
c.GET("/sessions/:id", customerHandler.GetSession)
|
||||
|
||||
call := func(method, path, body string, headers ...string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
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.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 2, "status": "ACTIVE",
|
||||
"result_rules": {"max_score": 5000, "outcomes": ["WIN", "LOSE"]}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
gameID := data(body)["id"].(string)
|
||||
assert.Equal(t, map[string]any{"max_score": float64(5000), "outcomes": []any{"WIN", "LOSE"}}, data(body)["result_rules"])
|
||||
|
||||
status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "x", "entry_cost": 1, "entry_cots": 2}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused")
|
||||
status, _ = call(http.MethodPost, "/manager/enakgame/games", `{"name": "X", "slug": "runner", "entry_cost": 1}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "slug in use")
|
||||
status, _ = call(http.MethodGet, "/manager/enakgame/games/"+uuid.NewString(), "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodGet, "/manager/enakgame/games/not-a-uuid", "")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// A change keeps the fields it does not send.
|
||||
status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID, `{"entry_cost": 3}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 3, data(body)["entry_cost"])
|
||||
assert.Equal(t, "runner", data(body)["slug"])
|
||||
assert.EqualValues(t, 5000, data(body)["result_rules"].(map[string]any)["max_score"])
|
||||
|
||||
// Reward configurations and budgets need a loyalty manager.
|
||||
config := `{"reward_type": "OUTCOME_BASED", "rules": {"outcomes": {"WIN": 10, "LOSE": 1}}, "max_reward": 10}`
|
||||
status, _ = call(http.MethodPost, "/purchasing/enakgame/games/"+gameID+"/reward-configs", config)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/games/"+gameID+"/reward-configs", config)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/reward-configs/"+data(body)["id"].(string)+"/activate", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, "ACTIVE", data(body)["status"])
|
||||
|
||||
// The customer cannot start before the period has a budget.
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 10,
|
||||
ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
start := `{"game_id": "` + gameID + `"}`
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1")
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
now := time.Now().In(time.FixedZone("WIB", 7*3600))
|
||||
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
budget := `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "` + first.Format("2006-01-02") +
|
||||
`", "period_end": "` + first.AddDate(0, 1, -1).Format("2006-01-02") + `", "amount": 1000000}`
|
||||
status, _ = call(http.MethodPost, "/purchasing/enakgame/budgets", budget)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/budgets", budget)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, body = call(http.MethodGet, "/purchasing/enakgame/budgets/"+data(body)["id"].(string)+"/metrics", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, "HEALTHY", data(body)["status"])
|
||||
assert.EqualValues(t, 1000000, data(body)["remaining"])
|
||||
status, _ = call(http.MethodGet, "/manager/enakgame/budgets/"+uuid.NewString()+"/metrics", "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/games", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
listed := body["data"].([]any)
|
||||
require.Len(t, listed, 1)
|
||||
assert.NotContains(t, listed[0], "result_rules", "the validation limits stay internal")
|
||||
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required")
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
sessionID := data(body)["session_id"].(string)
|
||||
assert.EqualValues(t, 7, data(body)["coin_balance"])
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", start, "X-Idempotency-Key", "k1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, sessionID, data(body)["session_id"])
|
||||
assert.Equal(t, true, data(body)["replayed"])
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions", `{}`, "Idempotency-Key", "k2")
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/sessions/"+sessionID, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, "STARTED", data(body)["status"])
|
||||
status, _ = call(http.MethodGet, "/customer/enakgame/sessions/"+uuid.NewString(), "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
// The backend alone decides the reward (P1): a reward in the body is ignored.
|
||||
c.POST("/sessions/:id/complete", customerHandler.CompleteSession)
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "LOSE", "reward": 999, "reward_total": 999}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["reward_total"])
|
||||
assert.EqualValues(t, 8, data(body)["coin_balance"])
|
||||
status, body = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"outcome": "WIN"}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["reward_total"], "the first completion stands")
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+sessionID+"/complete", `{"score": "high"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/sessions/"+uuid.NewString()+"/complete", ``)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
// Deactivating the game is audited; the session waits for the job.
|
||||
status, body = call(http.MethodPut, "/manager/enakgame/games/"+gameID+"/status", `{"status": "INACTIVE", "reason": "bug"}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, _ = call(http.MethodPost, "/customer/enakgame/sessions", start, "Idempotency-Key", "k3")
|
||||
assert.Equal(t, http.StatusBadRequest, status, "an inactive game cannot start")
|
||||
}
|
||||
|
||||
// Drives the voucher endpoints over HTTP down to Postgres (docs/rfc-enakgame.md §7.4,
|
||||
// §11).
|
||||
func TestEnakGameVoucherEndpoints_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()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("482913"), bcrypt.MinCost)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'voucher http', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name, pin_hash, pin_set_at) VALUES (?, ?, 'Budi', ?, NOW())`, customer, org, string(hash)).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM voucher_redemption_costs WHERE redemption_id IN (SELECT id FROM voucher_redemptions WHERE organization_id = ?)`,
|
||||
`DELETE FROM voucher_redemptions WHERE organization_id = ?`,
|
||||
`DELETE FROM voucher_codes WHERE voucher_id IN (SELECT id FROM vouchers WHERE organization_id = ?)`,
|
||||
`DELETE FROM vouchers WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_security_events WHERE customer_id IN (SELECT id FROM customers WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lots WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_wallets WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: "POINT", Type: "MIGRATION", Amount: 50_000,
|
||||
ReferenceType: "LEGACY_POINTS", ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
adminHandler, customerHandler := enakGameHandlers(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.POST("/vouchers", auth.RequireLoyaltyManager(), adminHandler.CreateVoucher)
|
||||
g.PUT("/vouchers/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateVoucher)
|
||||
g.POST("/vouchers/:id/codes", auth.RequireLoyaltyManager(), adminHandler.ImportVoucherCodes)
|
||||
g.GET("/vouchers/:id/codes", adminHandler.ListVoucherCodes)
|
||||
}
|
||||
c := router.Group("/customer", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
|
||||
c.GET("/vouchers", customerHandler.ListVouchers)
|
||||
c.POST("/vouchers/:id/redeem", customerHandler.RedeemVoucher)
|
||||
c.GET("/vouchers/redemptions", customerHandler.ListRedemptions)
|
||||
|
||||
send := func(req *http.Request) (int, map[string]any) {
|
||||
t.Helper()
|
||||
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
|
||||
}
|
||||
call := func(method, path, body string, headers ...string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
return send(req)
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
voucher := `{"name": "Kopi", "voucher_type": "FREE_ITEM", "face_value": 20000, "point_cost": 15000, "stock_mode": "CODE_POOL", "status": "ACTIVE"}`
|
||||
status, _ := call(http.MethodPost, "/purchasing/vouchers", voucher)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body := call(http.MethodPost, "/manager/vouchers", voucher)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
id := data(body)["id"].(string)
|
||||
status, _ = call(http.MethodPut, "/manager/vouchers/"+id, `{"stock_mode": "STATIC", "stock": 5}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "the stock mode stays")
|
||||
|
||||
// Codes as a multipart file, then the same as a plain body.
|
||||
var form bytes.Buffer
|
||||
w := multipart.NewWriter(&form)
|
||||
part, err := w.CreateFormFile("file", "codes.csv")
|
||||
require.NoError(t, err)
|
||||
_, _ = part.Write([]byte("code\nKOPI-1\nKOPI-2\n"))
|
||||
require.NoError(t, w.Close())
|
||||
req := httptest.NewRequest(http.MethodPost, "/manager/vouchers/"+id+"/codes", &form)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
status, body = send(req)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, data(body)["imported"])
|
||||
status, body = call(http.MethodPost, "/manager/vouchers/"+id+"/codes", "KOPI-2\nKOPI-3\n", "Content-Type", "text/csv")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["imported"])
|
||||
assert.Equal(t, []any{"KOPI-2"}, data(body)["duplicates"])
|
||||
status, body = call(http.MethodGet, "/manager/vouchers/"+id+"/codes", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, map[string]any{"AVAILABLE": float64(3)}, data(body)["counts"])
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/vouchers", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
require.Len(t, body["data"], 1)
|
||||
assert.EqualValues(t, 3, body["data"].([]any)[0].(map[string]any)["available"])
|
||||
|
||||
redeem := "/customer/vouchers/" + id + "/redeem"
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "Idempotency-Key is required")
|
||||
status, body = call(http.MethodPost, redeem, `{}`, "Idempotency-Key", "r1")
|
||||
assert.Equal(t, http.StatusBadRequest, status, "the PIN is required")
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "000000"}`, "Idempotency-Key", "r1")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, "PIN_INVALID", body["errors"].([]any)[0].(map[string]any)["code"])
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
code := data(body)["code"]
|
||||
assert.NotEmpty(t, code)
|
||||
assert.EqualValues(t, 35_000, data(body)["point_balance"])
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r1")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, code, data(body)["code"])
|
||||
assert.Equal(t, true, data(body)["replayed"])
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r2")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
status, body = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r3")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 5_000, data(body)["point_balance"])
|
||||
status, _ = call(http.MethodPost, redeem, `{"pin": "482913"}`, "Idempotency-Key", "r5")
|
||||
assert.Equal(t, http.StatusBadRequest, status, "out of codes and of EnakPoint")
|
||||
status, _ = call(http.MethodPost, "/customer/vouchers/"+uuid.NewString()+"/redeem", `{"pin": "482913"}`, "Idempotency-Key", "r4")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/vouchers/redemptions", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 3, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
}
|
||||
|
||||
// Drives /marketing/enakgame/events over HTTP, and the events on the customer's game
|
||||
// list (docs/rfc-enakgame.md §11).
|
||||
func TestEnakGameEventEndpoints_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()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'event http', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_event_games WHERE event_id IN (SELECT id FROM game_events WHERE organization_id = ?)`,
|
||||
`DELETE FROM game_events WHERE organization_id = ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id = ?`,
|
||||
`DELETE FROM games WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
adminHandler, customerHandler := enakGameHandlers(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+"/enakgame", 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.POST("/games", adminHandler.CreateGame)
|
||||
g.POST("/budgets", auth.RequireLoyaltyManager(), adminHandler.CreateBudget)
|
||||
g.POST("/events", auth.RequireLoyaltyManager(), adminHandler.CreateEvent)
|
||||
g.PUT("/events/:id", auth.RequireLoyaltyManager(), adminHandler.UpdateEvent)
|
||||
g.GET("/events", adminHandler.ListEvents)
|
||||
}
|
||||
router.GET("/customer/enakgame/games", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerHandler.ListGames)
|
||||
call := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(method, path, bytes.NewBufferString(body)))
|
||||
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.MethodPost, "/manager/enakgame/games", `{"name": "Runner", "slug": "runner", "entry_cost": 1, "status": "ACTIVE"}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
gameID := data(body)["id"].(string)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/budgets", `{"scope": "EVENT", "name": "Ramadan", "period_start": "2026-01-01", "period_end": "2030-12-31", "amount": 5000000}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
budgetID := data(body)["id"].(string)
|
||||
|
||||
start := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
end := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
|
||||
event := `{"name": "Ramadan", "slug": "ramadan", "start_at": "` + start + `", "end_at": "` + end + `", "multiplier": 2,
|
||||
"budget_id": "` + budgetID + `", "game_ids": ["` + gameID + `"], "status": "ACTIVE"}`
|
||||
status, _ = call(http.MethodPost, "/purchasing/enakgame/events", event)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodPost, "/manager/enakgame/events", event)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
eventID := data(body)["id"].(string)
|
||||
status, _ = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"multipler": 3}`)
|
||||
assert.Equal(t, http.StatusBadRequest, status, "a misspelt field is refused")
|
||||
status, body = call(http.MethodPut, "/manager/enakgame/events/"+eventID, `{"bonus": 5}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, data(body)["multiplier"], "kept")
|
||||
assert.EqualValues(t, 5, data(body)["bonus"])
|
||||
status, body = call(http.MethodGet, "/purchasing/enakgame/events?status=active", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
status, body = call(http.MethodGet, "/customer/enakgame/games", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
games := body["data"].([]any)
|
||||
require.Len(t, games, 1)
|
||||
events := games[0].(map[string]any)["events"].([]any)
|
||||
require.Len(t, events, 1)
|
||||
assert.Equal(t, "Ramadan", events[0].(map[string]any)["name"])
|
||||
assert.EqualValues(t, 5, events[0].(map[string]any)["bonus"])
|
||||
assert.NotContains(t, events[0], "budget_id", "the budget stays internal")
|
||||
}
|
||||
|
||||
// EG-1001: the admin steps of docs/integration-backoffice.md §8.4 make a spin wheel, and a customer
|
||||
// plays it through /customer/enakgame/sessions, over HTTP down to Postgres.
|
||||
func TestEnakGameSpin_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()
|
||||
require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame spin', 'basic')`, org).Error)
|
||||
require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error)
|
||||
t.Cleanup(func() {
|
||||
for _, q := range []string{
|
||||
`DELETE FROM audit_logs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_counters WHERE organization_id = ?`,
|
||||
`DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id = ?)`,
|
||||
`DELETE FROM game_sessions WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`,
|
||||
`DELETE FROM wallet_lots WHERE organization_id = ?`,
|
||||
`DELETE FROM wallet_transactions WHERE organization_id = ?`,
|
||||
`DELETE FROM customer_wallets WHERE organization_id = ?`,
|
||||
`DELETE FROM game_reward_configs WHERE organization_id = ?`,
|
||||
`DELETE FROM game_budgets WHERE organization_id = ?`,
|
||||
`DELETE FROM games WHERE organization_id = ?`,
|
||||
`DELETE FROM customers WHERE organization_id = ?`,
|
||||
`DELETE FROM organizations WHERE id = ?`,
|
||||
} {
|
||||
db.Exec(q, org)
|
||||
}
|
||||
})
|
||||
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 20,
|
||||
ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
adminHandler, customerHandler := enakGameHandlers(db)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
m := router.Group("/marketing/enakgame", 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, "manager")
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
m.POST("/games", adminHandler.CreateGame)
|
||||
m.POST("/games/:id/reward-configs", adminHandler.CreateRewardConfig)
|
||||
m.POST("/reward-configs/:id/activate", adminHandler.ActivateRewardConfig)
|
||||
m.POST("/budgets", adminHandler.CreateBudget)
|
||||
c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) })
|
||||
c.GET("/games", customerHandler.ListGames)
|
||||
c.POST("/sessions", customerHandler.StartSession)
|
||||
c.POST("/sessions/:id/complete", customerHandler.CompleteSession)
|
||||
|
||||
call := func(method, path, body string, headers ...string) map[string]any {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
for i := 0; i+1 < len(headers); i += 2 {
|
||||
req.Header.Set(headers[i], headers[i+1])
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
require.Equal(t, http.StatusOK, rec.Code, out)
|
||||
return out["data"].(map[string]any)
|
||||
}
|
||||
|
||||
// The admin steps.
|
||||
game := call(http.MethodPost, "/marketing/enakgame/games",
|
||||
`{"name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "ACTIVE"}`)
|
||||
gameID := game["id"].(string)
|
||||
config := call(http.MethodPost, "/marketing/enakgame/games/"+gameID+"/reward-configs", `{"reward_type": "PROBABILITY", "max_reward": 50,
|
||||
"rules": {"table": [
|
||||
{"weight": 50, "amount": 0, "label": "Zonk"},
|
||||
{"weight": 30, "amount": 3, "label": "3 Coin"},
|
||||
{"weight": 15, "amount": 10, "label": "10 Coin"},
|
||||
{"weight": 5, "amount": 50, "label": "Jackpot"}]}}`)
|
||||
call(http.MethodPost, "/marketing/enakgame/reward-configs/"+config["id"].(string)+"/activate", `{}`)
|
||||
now := time.Now().In(time.FixedZone("WIB", 7*3600))
|
||||
first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||
call(http.MethodPost, "/marketing/enakgame/budgets", `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "`+
|
||||
first.Format("2006-01-02")+`", "period_end": "`+first.AddDate(0, 1, -1).Format("2006-01-02")+`", "amount": 1000000}`)
|
||||
|
||||
// The wheel shows the segments, never their odds.
|
||||
req := httptest.NewRequest(http.MethodGet, "/customer/enakgame/games", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var listed struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Prizes []struct {
|
||||
Entry int `json:"entry"`
|
||||
Label *string `json:"label"`
|
||||
Amount int64 `json:"amount"`
|
||||
} `json:"prizes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed))
|
||||
require.Len(t, listed.Data, 1)
|
||||
require.Len(t, listed.Data[0].Prizes, 4)
|
||||
assert.Equal(t, "Jackpot", *listed.Data[0].Prizes[3].Label)
|
||||
assert.NotContains(t, rec.Body.String(), "weight")
|
||||
|
||||
// Two spins: each lands on a listed segment and pays what it shows.
|
||||
balance := int64(20)
|
||||
for _, key := range []string{"spin-1", "spin-2"} {
|
||||
started := call(http.MethodPost, "/customer/enakgame/sessions", `{"game_id": "`+gameID+`"}`, "Idempotency-Key", key)
|
||||
balance -= 5
|
||||
assert.EqualValues(t, balance, started["coin_balance"])
|
||||
done := call(http.MethodPost, "/customer/enakgame/sessions/"+started["session_id"].(string)+"/complete", `{}`)
|
||||
prize := done["prize"].(map[string]any)
|
||||
entry := int(prize["entry"].(float64))
|
||||
require.True(t, entry >= 1 && entry <= 4, prize)
|
||||
segment := listed.Data[0].Prizes[entry-1]
|
||||
assert.Equal(t, *segment.Label, prize["label"])
|
||||
assert.EqualValues(t, segment.Amount, prize["amount"])
|
||||
assert.EqualValues(t, segment.Amount, done["reward_total"])
|
||||
balance += segment.Amount
|
||||
assert.EqualValues(t, balance, done["coin_balance"])
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"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"
|
||||
"apskel-pos-be/internal/validator"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type GamificationHandler struct {
|
||||
@@ -26,502 +19,3 @@ func NewGamificationHandler(
|
||||
gamificationValidator: gamificationValidator,
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
var req contract.PlayGameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame")
|
||||
return
|
||||
}
|
||||
|
||||
validationError, validationErrorCode := h.gamificationValidator.ValidatePlayGameRequest(&req)
|
||||
if validationError != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::PlayGame -> request validation failed")
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.gamificationService.PlayGame(ctx, &req)
|
||||
if err != nil {
|
||||
logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::PlayGame")
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user