Compare commits
100
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a8d62bc5e8 | ||
|
|
8816e4addc | ||
|
|
2921631ac3 | ||
|
|
0db838e2c4 | ||
|
|
4b6cbb69c1 | ||
|
|
9e0ba0ce56 | ||
|
|
6c19876a47 | ||
|
|
b2db56f855 | ||
|
|
8c4d9c69d0 | ||
|
|
657a201fc0 | ||
|
|
7a7ac25dcf | ||
|
|
d0a548f44e | ||
|
|
f4172fcea7 | ||
|
|
d5216e7994 | ||
|
|
1718c5adab | ||
|
|
d0c090a657 | ||
|
|
c3db919531 | ||
|
|
e09feff36d | ||
|
|
e7dd9660da | ||
|
|
c57620beeb | ||
|
|
29aeb58fc0 | ||
|
|
69d8c8ce5e | ||
|
|
6e3fc43d86 | ||
|
|
094e8b2a47 | ||
|
|
b90a3cde4a | ||
|
|
7c8c7fb7db |
@@ -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,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": {},
|
||||
}
|
||||
)
|
||||
|
||||
+46
-8
@@ -2,23 +2,61 @@
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="apskel-pos"
|
||||
PORT="4000"
|
||||
|
||||
# ─── Deteksi environment dari branch aktif ───────────────────────────────────
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
case "$CURRENT_BRANCH" in
|
||||
main)
|
||||
ENV_MODE="production"
|
||||
PORT="4000"
|
||||
;;
|
||||
staging)
|
||||
ENV_MODE="staging"
|
||||
PORT="4001"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Branch '$CURRENT_BRANCH' tidak dikenali untuk deployment."
|
||||
echo " Gunakan branch 'main' (production) atau 'staging' (staging)."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
CONTAINER_NAME="$APP_NAME"
|
||||
IMAGE_NAME="$APP_NAME:$ENV_MODE"
|
||||
|
||||
echo "📦 Environment : $ENV_MODE"
|
||||
echo "🌿 Branch : $CURRENT_BRANCH"
|
||||
echo "🐳 Container : $CONTAINER_NAME"
|
||||
echo "🔌 Port : $PORT"
|
||||
echo ""
|
||||
|
||||
# ─── Pastikan config file ada ─────────────────────────────────────────────────
|
||||
CONFIG_FILE="infra/$ENV_MODE.yaml"
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
echo "❌ Config file '$CONFIG_FILE' tidak ditemukan."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🔄 Pulling latest code..."
|
||||
git pull
|
||||
|
||||
echo "🐳 Building Docker image (production target)..."
|
||||
docker build --target production -t $APP_NAME:latest .
|
||||
echo "🐳 Building Docker image ($ENV_MODE)..."
|
||||
docker build --target production -t "$IMAGE_NAME" .
|
||||
|
||||
echo "🛑 Stopping and removing old container..."
|
||||
docker rm -f $APP_NAME 2>/dev/null || true
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
echo "🚀 Running new container..."
|
||||
docker run -d --name $APP_NAME \
|
||||
-p $PORT:$PORT \
|
||||
docker run -d --name "$CONTAINER_NAME" \
|
||||
-p "$PORT:4000" \
|
||||
-e TZ=Asia/Jakarta \
|
||||
-e ENV_MODE="$ENV_MODE" \
|
||||
-v "$(pwd)/infra":/infra:ro \
|
||||
-v "$(pwd)/templates":/templates:ro \
|
||||
$APP_NAME:latest
|
||||
"$IMAGE_NAME"
|
||||
|
||||
echo "✅ Deployment complete."
|
||||
echo ""
|
||||
echo "✅ Deployment $ENV_MODE complete."
|
||||
echo " Container : $CONTAINER_NAME"
|
||||
echo " Port : $PORT"
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
# Integrasi Produk Timbangan — POS Mobile & Backoffice
|
||||
|
||||
**Migrasi:** `000089` · **Base URL:** `/api/v1` · **Kompatibilitas:** mundur penuh
|
||||
|
||||
Panduan untuk menjual produk per timbangan (ikan, daging, buah) dari sisi klien.
|
||||
Alasan di balik setiap keputusan desain ada di [`rfc-weight-based-products.md`](./rfc-weight-based-products.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Konsep inti
|
||||
|
||||
**Satu penimbangan = satu baris order.**
|
||||
|
||||
Pelanggan memesan Ikan Tude 4,2 ons, lalu memesan Ikan Tude lagi 5,6 ons. Itu **dua
|
||||
baris terpisah**, karena keduanya dua ikan berbeda yang ditimbang sendiri-sendiri.
|
||||
|
||||
| Bukan begini | Melainkan begini |
|
||||
|---|---|
|
||||
| `quantity: 9.8` | `quantity: 1, weight: 4.2`<br>`quantity: 1, weight: 5.6` |
|
||||
| Dua ikan hilang jejaknya, dan `quantity` bertipe integer sehingga pecahan ditolak | Tiap penimbangan berdiri sendiri, bisa di-void atau dibayar terpisah |
|
||||
|
||||
Empat aturan yang berlaku di seluruh dokumen ini:
|
||||
|
||||
1. `quantity` untuk produk timbangan **selalu 1**. Backend memaksanya, dan database
|
||||
menolak nilai lain lewat constraint `chk_order_items_weight_single_line`.
|
||||
2. `weight` menyimpan angka timbangan, dalam satuan produk itu sendiri (ons, kg, gram —
|
||||
apa pun yang dipilih saat setup).
|
||||
3. Harga baris dihitung `weight × unit_price`, bukan `quantity × unit_price`.
|
||||
`unit_price` tetap berarti harga per satu satuan (per ons).
|
||||
4. **Jangan pernah menggabungkan dua baris** produk timbangan menjadi satu, meski
|
||||
produknya sama.
|
||||
|
||||
Untuk produk biasa tidak ada yang berubah: `weight` tidak dikirim, `quantity` tetap
|
||||
cacah seperti sekarang.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backoffice — setup produk
|
||||
|
||||
### 2.1 Pastikan satuannya ada
|
||||
|
||||
Satuan disimpan per organisasi. Buat sekali, pakai ulang untuk semua produk timbangan.
|
||||
|
||||
`POST /api/v1/units`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ons",
|
||||
"abbreviation": "ons",
|
||||
"is_active": true
|
||||
}
|
||||
```
|
||||
|
||||
`abbreviation` yang dipakai POS untuk mencetak `4,2 ons` di struk — isi dengan bentuk
|
||||
pendek yang benar-benar ingin ditampilkan. Daftar satuan dibaca lewat `GET /api/v1/units`.
|
||||
|
||||
### 2.2 Buat produk sebagai produk timbangan
|
||||
|
||||
`POST /api/v1/products`
|
||||
|
||||
```json
|
||||
{
|
||||
"category_id": "…",
|
||||
"name": "Ikan Tude",
|
||||
"price": 4500,
|
||||
"cost": 3000,
|
||||
"sell_by": "weight",
|
||||
"unit_id": "<id satuan Ons>"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Tipe | Keterangan |
|
||||
|---|---|---|
|
||||
| `sell_by` | `"unit"` \| `"weight"` | Opsional, default `"unit"`. Nilai lain **ditolak** dengan pesan jelas. |
|
||||
| `unit_id` | UUID | **Wajib** saat `sell_by: "weight"`, ditolak backend bila kosong. Opsional untuk produk satuan. |
|
||||
| `price` | number | Harga per satu satuan. Rp 4.500 per ons, bukan harga per ikan. |
|
||||
|
||||
Pada `PUT /api/v1/products/:id`, `unit_id` **tidak perlu dikirim ulang** bila produknya
|
||||
sudah punya satuan — mengubah `sell_by` menjadi `"weight"` saja sudah cukup. Yang ditolak
|
||||
adalah kondisi akhirnya: produk yang dijual per timbangan tanpa satuan.
|
||||
|
||||
Keduanya juga bisa diubah lewat `PUT /api/v1/products/:id` dengan bentuk yang sama, dan
|
||||
ikut terbaca di setiap response produk (`GET /api/v1/products`, `/products/all`,
|
||||
`/products/:id`).
|
||||
|
||||
### 2.3 Catatan UI
|
||||
|
||||
- Kunci `sell_by` **setelah produk punya transaksi**. Mengubah produk lama dari `unit`
|
||||
ke `weight` tidak mengubah baris order yang sudah ada — baris lama tetap dihitung per
|
||||
cacah — tapi akan membingungkan pengguna yang melihat riwayatnya.
|
||||
- Saat `weight` dipilih, jadikan pemilih satuan sebagai field **wajib** di form. Backend
|
||||
juga menolaknya, tapi ditangkap di form lebih baik daripada baru gagal saat simpan.
|
||||
- Ubah label harga mengikuti satuan yang dipilih: *"Harga per ons"*.
|
||||
- Untuk produk satuan, pemilih satuan boleh disembunyikan — `unit_id` opsional dan belum
|
||||
dikonsumsi apa pun di POS.
|
||||
|
||||
---
|
||||
|
||||
## 3. POS Mobile — transaksi
|
||||
|
||||
### 3.1 Bentuk input mengikuti `sell_by`
|
||||
|
||||
| `sell_by` | Input di POS | Yang dikirim |
|
||||
|---|---|---|
|
||||
| `"unit"` | Stepper − / + seperti sekarang | `quantity: n`, tanpa `weight` |
|
||||
| `"weight"` | Papan angka desimal, satuan dari `unit` produk | `quantity: 1` + `weight: 4.2` |
|
||||
|
||||
### 3.2 Mengirim order
|
||||
|
||||
`POST /api/v1/orders`
|
||||
|
||||
```json
|
||||
{
|
||||
"outlet_id": "…",
|
||||
"user_id": "…",
|
||||
"order_type": "dine_in",
|
||||
"order_items": [
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 4.2 },
|
||||
{ "product_id": "<ikan-tude>", "quantity": 1, "weight": 5.6 },
|
||||
{ "product_id": "<nasi-goreng>", "quantity": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Bentuk yang sama berlaku untuk `POST /api/v1/orders/:id/add-items` dan untuk pemesanan
|
||||
mandiri `POST /api/v1/self-order/orders`.
|
||||
|
||||
**Presisi.** Berat dibulatkan backend ke 3 desimal. Kirim `4.2` atau `4.237`; angka di
|
||||
bawah `0.001` membulat ke nol dan ditolak.
|
||||
|
||||
**Harga.** Field `unit_price` pada request **diabaikan** — harga selalu diambil backend
|
||||
dari master produk. Jangan mengirim harga hasil hitungan sendiri.
|
||||
|
||||
---
|
||||
|
||||
## 4. Menampilkan baris
|
||||
|
||||
Setiap `order_items[]` di response membawa empat field tambahan:
|
||||
|
||||
```json
|
||||
{
|
||||
"product_name": "Ikan Tude",
|
||||
"quantity": 1,
|
||||
"weight": 4.2,
|
||||
"unit_id": "…",
|
||||
"unit_name": "Ons",
|
||||
"unit_abbreviation": "ons",
|
||||
"unit_price": 4500,
|
||||
"total_price": 18900
|
||||
}
|
||||
```
|
||||
|
||||
Semuanya `null` atau absen untuk produk biasa, jadi cabangkan tampilan pada `weight`:
|
||||
|
||||
| Kondisi | Tampilkan |
|
||||
|---|---|
|
||||
| `weight == null` | `2 × Rp 25.000` |
|
||||
| `weight != null` | `4,2 ons × Rp 4.500` |
|
||||
|
||||
**Jangan menampilkan `quantity` untuk baris berbobot** — nilainya selalu 1 dan akan
|
||||
terbaca seperti "satu ons". Gunakan `weight` dengan `unit_abbreviation`, dan pakai koma
|
||||
desimal sesuai format Indonesia.
|
||||
|
||||
---
|
||||
|
||||
## 5. Void, refund, split bill
|
||||
|
||||
Baris berbobot bersifat **utuh**: dibatalkan seluruhnya atau tidak sama sekali. Karena
|
||||
`quantity`-nya 1, semua endpoint cukup dikirimi `1`, dan backend menghitung nilai
|
||||
rupiahnya dari `weight`.
|
||||
|
||||
| Aksi | Endpoint | Field untuk baris berbobot |
|
||||
|---|---|---|
|
||||
| Void per item | `POST /orders/void` | `items[].quantity: 1` |
|
||||
| Refund per item | `POST /orders/:id/refund` | `order_items[].refund_quantity: 1` (atau kosongkan) |
|
||||
| Split bill per item | `POST /orders/split-bill` | `items[].quantity: 1` = bayar baris itu penuh |
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "…",
|
||||
"reason": "Salah timbang",
|
||||
"type": "ITEM",
|
||||
"items": [
|
||||
{ "order_item_id": "<baris 4,2 ons>", "quantity": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Untuk split bill, baris berbobot hanya bisa berstatus belum dibayar atau lunas — tidak
|
||||
ada nilai di antaranya. Sembunyikan stepper jumlah pada baris berbobot, ganti dengan
|
||||
tombol pilih baris.
|
||||
|
||||
**Batasan yang disengaja.** Mengembalikan *sebagian berat* — 1 ons dari baris 4,2 ons —
|
||||
tidak didukung. Koreksi salah timbang ditangani dengan void baris itu lalu input ulang,
|
||||
sehingga jejak auditnya tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 6. Referensi error
|
||||
|
||||
Semua error mengikuti amplop standar. Pesan validasi baru muncul dengan kode `900`:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"data": null,
|
||||
"errors": [
|
||||
{ "code": "900", "entity": "ORDER",
|
||||
"cause": "product Ikan Tude is sold by weight and requires a weight" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `… is sold by weight and requires a weight` | Produk `sell_by: "weight"` dikirim tanpa `weight` | Wajibkan input timbangan sebelum item masuk keranjang |
|
||||
| `… is not sold by weight and must not carry a weight` | `weight` dikirim untuk produk satuan | Kirim `weight` hanya bila `sell_by == "weight"` |
|
||||
| `weight for … must be greater than 0` | Berat nol, negatif, atau membulat ke nol | Validasi minimal `0.001` di keypad |
|
||||
| `quantity for … must be at least 1` | Produk satuan dengan `quantity` ≤ 0 | Perilaku lama, tidak berubah |
|
||||
|
||||
Pesan menyebut **nama produk**, sehingga bisa ditampilkan apa adanya ke kasir.
|
||||
|
||||
### Setup produk (Backoffice)
|
||||
|
||||
| Pesan (`cause`) | Penyebab | Perbaikan di klien |
|
||||
|---|---|---|
|
||||
| `unit_id is required when sell_by is 'weight'` | Produk timbangan dibuat tanpa satuan | Wajibkan pemilih satuan saat Timbangan dipilih |
|
||||
| `sell_by must be either 'unit' or 'weight'` | Nilai `sell_by` di luar dua itu | Kirim persis `"unit"` atau `"weight"` |
|
||||
| `product '…' is sold by weight and requires a unit_id` | Update membuat produk jadi timbangan tanpa satuan | Kirim `unit_id` bersama perubahan `sell_by` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Kompatibilitas mundur
|
||||
|
||||
- Semua produk lama otomatis `sell_by: "unit"`. Perilakunya identik dengan sebelumnya.
|
||||
- `weight` opsional di request. Klien yang tidak mengenalnya tetap berfungsi penuh untuk
|
||||
produk satuan.
|
||||
- Field baru di response semuanya `omitempty` — tidak muncul untuk baris biasa, jadi
|
||||
parser lama tidak terganggu.
|
||||
- `quantity` tetap **integer** di seluruh API. Tidak ada field yang berubah tipe.
|
||||
|
||||
Yang tidak berfungsi di klien lama hanyalah menjual produk timbangan itu sendiri —
|
||||
permintaannya ditolak dengan pesan jelas, bukan gagal diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 8. Batasan yang diketahui
|
||||
|
||||
- **Pembulatan uang ke 2 desimal.** `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`,
|
||||
bukan dibulatkan ke rupiah utuh. Bila kasir harus menerima rupiah penuh, ini perlu
|
||||
diputuskan dan diubah di backend lebih dulu (`RoundMoney`, satu tempat).
|
||||
- **Stok belum otomatis berkurang** saat penjualan — untuk produk timbangan maupun
|
||||
produk biasa. Pengurangan stok belum tersambung di backend, jadi jangan menampilkan
|
||||
sisa stok yang mengandaikan itu berjalan.
|
||||
|
||||
---
|
||||
|
||||
## 9. Checklist per tim
|
||||
|
||||
**Backoffice Website**
|
||||
|
||||
- [ ] CRUD satuan tersedia di menu master data
|
||||
- [ ] Form produk punya pilihan cara jual: Satuan / Timbangan
|
||||
- [ ] Pemilih satuan menjadi wajib saat Timbangan dipilih
|
||||
- [ ] Label harga ikut satuan — "Harga per ons"
|
||||
- [ ] `sell_by` dikunci untuk produk yang sudah bertransaksi
|
||||
- [ ] Daftar produk menandai mana yang dijual per timbangan
|
||||
- [ ] Laporan harian menampilkan kolom Berat
|
||||
|
||||
**POS Mobile**
|
||||
|
||||
- [ ] Menu membaca `sell_by` tiap produk
|
||||
- [ ] Papan angka desimal untuk produk timbangan
|
||||
- [ ] Kirim `quantity: 1` + `weight`
|
||||
- [ ] Dua penimbangan menjadi dua baris, tidak digabung
|
||||
- [ ] Keranjang & struk menampilkan `4,2 ons × Rp 4.500`
|
||||
- [ ] Void & refund baris berbobot bersifat utuh
|
||||
- [ ] Split bill: pilih baris, bukan stepper jumlah
|
||||
- [ ] Pesan error validasi ditampilkan ke kasir
|
||||
@@ -0,0 +1,362 @@
|
||||
# RFC: Produk Timbangan (Weight-Based Products)
|
||||
|
||||
**Status:** Diimplementasikan (migrasi `000089`)
|
||||
**Tanggal:** 2026-09-05, diperbarui 2026-09-06
|
||||
**Scope:** Product, Order, Void/Refund, Report
|
||||
**Out of scope:** Inventory / pengurangan stok otomatis (lihat §8)
|
||||
|
||||
---
|
||||
|
||||
## 1. Masalah
|
||||
|
||||
Sistem mengasumsikan setiap produk dijual dalam satuan diskrit. `order_items.quantity`
|
||||
bertipe `INTEGER` dengan `CHECK (quantity > 0)`, dan harga dihitung
|
||||
`quantity × unit_price` di seluruh jalur order, void, refund, dan split bill.
|
||||
|
||||
Produk seperti Ikan Tude dijual per timbangan. Pelanggan memesan Ikan Tude 4,2 ons,
|
||||
lalu memesan Ikan Tude lagi 5,6 ons. Keduanya adalah **dua ikan berbeda yang ditimbang
|
||||
terpisah** — bukan satu baris berisi 9,8.
|
||||
|
||||
Angka 4,2 itu **berat**, bukan cacah. Sistem belum punya tempat untuk menyimpannya.
|
||||
|
||||
> **Catatan satuan.** RFC ini tidak mengasumsikan satuan tertentu. Satuan produk
|
||||
> ditentukan `products.unit_id` yang merujuk tabel `units` — bisa ons, kg, gram, atau
|
||||
> apa pun yang didefinisikan organisasi. Contoh memakai **ons** karena itu kasus yang
|
||||
> sedang dikerjakan; tidak ada bagian desain ini yang bergantung padanya.
|
||||
|
||||
---
|
||||
|
||||
## 2. Keputusan Inti
|
||||
|
||||
**Satu penimbangan = satu baris `order_items`.**
|
||||
|
||||
| | Baris 1 | Baris 2 |
|
||||
|---|---|---|
|
||||
| Ikan Tude 4,2 ons | `quantity = 1`, `weight = 4.2` | |
|
||||
| Ikan Tude 5,6 ons | | `quantity = 1`, `weight = 5.6` |
|
||||
|
||||
`quantity` tetap `INTEGER` dan tetap berarti "berapa banyak barang". Berat masuk ke
|
||||
kolom baru. Dua baris tidak pernah digabung menjadi `9.8`, karena keduanya memang dua
|
||||
ikan yang berbeda.
|
||||
|
||||
### Kenapa bukan `quantity = 4.2`
|
||||
|
||||
Alternatif yang sempat dipertimbangkan adalah mengubah `quantity` menjadi
|
||||
`DECIMAL(12,3)`. Model itu ditolak karena tiga alasan:
|
||||
|
||||
1. **Menghapus jejak barang.** `4.2` dan `5.6` yang digabung jadi `9.8` kehilangan
|
||||
informasi bahwa ada dua ikan. Tidak bisa direkonstruksi.
|
||||
2. **Merusak agregasi lintas produk.** `SUM(quantity)` untuk laporan
|
||||
"total item terjual" akan menjumlahkan ons dengan porsi — angka tanpa arti, yang
|
||||
bahkan berubah nilainya bila satuan produk diganti dari ons ke kg tanpa ada apa pun
|
||||
yang berubah di dunia nyata.
|
||||
3. **Membawa masalah presisi float ke seluruh sistem.** Perbandingan quantity dipakai
|
||||
di void, refund, dan split bill. Dengan float, `1,4 + 1,4 + 1,4` tidak sama dengan
|
||||
`4,2` — split bill "bagi rata bertiga" akan gagal menandai item lunas meski uang
|
||||
sudah diterima penuh. Semua itu tidak terjadi bila `quantity` tetap integer.
|
||||
|
||||
Konsekuensi langsung dari keputusan ini: **tidak diperlukan helper perbandingan
|
||||
epsilon.** Berat tidak pernah dibandingkan, hanya dikalikan.
|
||||
|
||||
---
|
||||
|
||||
## 3. Prinsip
|
||||
|
||||
**P1 — Baris transaksi adalah snapshot yang beku.**
|
||||
`order_items` sudah menyimpan `unit_price` dan `unit_cost` sebagai salinan, bukan join
|
||||
ke `products`. Satuan mendapat perlakuan sama: mengubah master data tidak boleh
|
||||
mengubah arti transaksi yang sudah terjadi.
|
||||
|
||||
**P2 — Perhitungan harga baris hanya ada di satu tempat.**
|
||||
Setelah RFC ini ada dua rumus (`quantity × harga` dan `weight × harga`). Tidak boleh
|
||||
ada perkalian harga yang tersebar; semuanya memanggil satu fungsi.
|
||||
|
||||
**P3 — Harga tetap otoritas backend.**
|
||||
Klien tidak pernah mengirim harga. Backend membacanya dari `products` /
|
||||
`product_outlet_prices` seperti sekarang.
|
||||
|
||||
**P4 — Berat boleh dijumlahkan dalam satu produk, tidak boleh antar produk.**
|
||||
`SUM(weight)` untuk satu produk bermakna ("terjual 47,3 ons"). Lintas produk dengan
|
||||
satuan berbeda tidak bermakna.
|
||||
|
||||
---
|
||||
|
||||
## 4. Perubahan Skema
|
||||
|
||||
```sql
|
||||
-- Products: cara jual
|
||||
ALTER TABLE products
|
||||
ADD COLUMN sell_by VARCHAR(20) NOT NULL DEFAULT 'unit'
|
||||
CHECK (sell_by IN ('unit', 'weight'));
|
||||
|
||||
-- Order items: berat + snapshot satuan
|
||||
ALTER TABLE order_items
|
||||
ADD COLUMN weight DECIMAL(12,3),
|
||||
ADD COLUMN unit_id UUID REFERENCES units(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE order_items
|
||||
ADD CONSTRAINT chk_order_items_weight_positive
|
||||
CHECK (weight IS NULL OR weight > 0),
|
||||
ADD CONSTRAINT chk_order_items_weight_single_line
|
||||
CHECK (weight IS NULL OR quantity = 1);
|
||||
```
|
||||
|
||||
**Catatan:**
|
||||
|
||||
- `weight` **nullable**. `NULL` berarti produk satuan biasa — seluruh data lama valid
|
||||
tanpa backfill, dan perilakunya tidak berubah sama sekali.
|
||||
- `chk_order_items_weight_single_line` menegakkan keputusan §2 di level database:
|
||||
baris berbobot selalu `quantity = 1`. Ini yang membuat `BillableQuantity()` tidak
|
||||
ambigu dan membuat void otomatis bersifat utuh (§6).
|
||||
- `quantity` **tidak berubah tipe**. `CHECK (quantity > 0)` yang sudah ada tetap
|
||||
berlaku.
|
||||
- `DECIMAL(12,3)` konsisten dengan `inventory_movements.quantity` yang sudah memakai
|
||||
presisi sama.
|
||||
- Tidak ada `weighed_unit`. Karena satu baris memang satu barang, "ikan curah" dan
|
||||
"ikan per ekor" berperilaku identik — pembedaan itu tidak punya konsekuensi.
|
||||
|
||||
---
|
||||
|
||||
## 5. Perhitungan Harga
|
||||
|
||||
Satu-satunya tempat yang boleh mengalikan harga (P2):
|
||||
|
||||
```go
|
||||
// BillableQuantity mengembalikan pengali harga untuk baris ini:
|
||||
// berat bila produk dijual per timbangan, jumlah bila dijual per satuan.
|
||||
// Baris berbobot dijamin quantity = 1 oleh constraint DB.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
```
|
||||
|
||||
`unit_price` tetap berarti **harga per satu satuan produk** (per ons). Tidak ada faktor
|
||||
konversi yang menyelinap ke perhitungan uang.
|
||||
|
||||
### Titik yang harus diganti
|
||||
|
||||
Ini bagian paling berisiko dari RFC. Setiap perkalian harga yang terlewat akan
|
||||
menghitung `1 × harga_per_ons` — ikan 4,2 ons ditagih seharga 1 ons. **Salah uang,
|
||||
bukan salah tampilan**, dan tidak memicu error apa pun.
|
||||
|
||||
| Lokasi | Sekarang |
|
||||
|---|---|
|
||||
| `processor/order_processor.go:197-198` | buat order |
|
||||
| `processor/order_processor.go:330-331` | tambah item ke order |
|
||||
| `processor/order_processor.go:605-606` | jumlah & HPP yang di-void |
|
||||
| `processor/order_processor.go:723` | jumlah refund |
|
||||
| `processor/split_bill_processor.go:143` | hitung jumlah split |
|
||||
| `processor/split_bill_processor.go:189` | catat pembayaran |
|
||||
| `processor/split_bill_processor.go:231` | metadata pembayaran |
|
||||
| `repository/order_item_repository.go:113` | jumlah void penuh |
|
||||
|
||||
Implementasi menemukan **lima titik tambahan** di luar daftar di atas, semuanya di jalur
|
||||
inventory movement dan resep bahan yang tidak terlihat saat RFC ini ditulis:
|
||||
|
||||
| Lokasi | Status |
|
||||
|---|---|
|
||||
| `order_processor.go:1056` `createInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1354` `prepareProductInventoryMovement` | **hidup** |
|
||||
| `order_processor.go:1420` `prepareIngredientRecipeItem` | **hidup** |
|
||||
| `order_processor.go:1518` `prepareRefundProductInventoryMovement` | mati (0 pemanggil), tetap diperbaiki |
|
||||
| `order_processor.go:1584` `prepareRefundedIngredientRecipeItem` | **hidup** |
|
||||
|
||||
Tiga yang hidup penting: tanpa perbaikan, konsumsi bahan untuk ikan 4,2 ons akan
|
||||
dihitung sebagai 1 satuan resep.
|
||||
|
||||
### Verifikasi
|
||||
|
||||
```bash
|
||||
grep -rn "Quantity) \* \|Quantity \* " --include=*.go internal/ \
|
||||
| grep -iE "price|cost" | grep -v BillableQuantity | grep -v totalIngredientQuantity
|
||||
```
|
||||
|
||||
Hasilnya **tidak kosong** — tersisa tujuh baris, semuanya sudah diperiksa dan aman:
|
||||
|
||||
- `mappers/inventory_movement_mapper.go:129` dan
|
||||
`processor/inventory_movement_processor.go:69` — penyesuaian stok manual, bukan
|
||||
baris order.
|
||||
- `repository/order_item_repository.go:144,146,147,165,166` — cabang void sebagian,
|
||||
yang baris berbobot tidak pernah jangkau karena dijaga `orderItem.IsWeighed()`.
|
||||
|
||||
Bila daftar ini bertambah di kemudian hari, baris barunya harus diperiksa satu per satu.
|
||||
|
||||
---
|
||||
|
||||
## 6. Void, Refund, Split Bill
|
||||
|
||||
**Tidak ada perubahan logika.** Ini konsekuensi menyenangkan dari `quantity` yang tetap
|
||||
integer.
|
||||
|
||||
**Void.** `VoidOrderItem` (`repository/order_item_repository.go:104`) bercabang pada
|
||||
`voidQuantity >= orderItem.Quantity`. Untuk baris berbobot, `quantity` dijamin `1` dan
|
||||
`voidQuantity` minimal `1`, sehingga **selalu** masuk cabang void penuh. Cabang
|
||||
pemecahan baris tidak pernah tersentuh, sehingga tidak mungkin lahir baris sisa
|
||||
berbobot nol. Yang berubah hanya perhitungan `voidedAmount` di baris 113 (§5).
|
||||
|
||||
**Refund.** Sama — refund baris berbobot bersifat utuh. Hanya `refundAmount` di
|
||||
`order_processor.go:723` yang perlu memakai `BillableQuantity()`.
|
||||
|
||||
**Split bill.** `payment_order_items.quantity` tetap `INTEGER`. Untuk baris berbobot
|
||||
nilainya `0` atau `1` — bayar penuh atau tidak sama sekali. Seluruh perbandingan di
|
||||
`split_bill_processor.go` tetap aritmatika bilangan bulat, sehingga masalah presisi
|
||||
float tidak pernah muncul. Hanya perhitungan `itemAmount` (baris 143 dan 189) yang
|
||||
berubah.
|
||||
|
||||
**Batasan yang diterima:** refund atau void **sebagian berat** (mengembalikan 1 ons
|
||||
dari baris 4,2 ons) tidak didukung. Untuk barang yang sudah ditimbang dan diserahkan,
|
||||
koreksi sebagian pada praktiknya berarti salah timbang — yang penanganan benarnya
|
||||
adalah void baris itu lalu input ulang, bukan mengubah berat baris yang sudah tercatat.
|
||||
Ini menjaga jejak audit tetap jujur.
|
||||
|
||||
---
|
||||
|
||||
## 7. Validasi & Tampilan
|
||||
|
||||
### 7.1 Aturan validasi
|
||||
|
||||
Divalidasi di processor saat membuat / menambah item, di mana produk sudah dimuat:
|
||||
|
||||
| `products.sell_by` | Aturan |
|
||||
|---|---|
|
||||
| `unit` | `weight` harus kosong. Bila dikirim → tolak. |
|
||||
| `weight` | `weight` wajib ada dan `> 0`. `quantity` dipaksa `1`. |
|
||||
|
||||
`unit_id` di `order_items` diisi dari `products.unit_id` saat baris dibuat (P1) —
|
||||
bukan dibaca lewat join saat ditampilkan.
|
||||
|
||||
Berat dibulatkan ke 3 desimal saat masuk, agar nilai tersimpan selalu sama dengan
|
||||
nilai yang divalidasi.
|
||||
|
||||
### 7.2 Tampilan
|
||||
|
||||
`templates/daily_transaction.html:539` mencetak `{{$item.Quantity}}`. Untuk baris
|
||||
berbobot ini akan menampilkan `1`, bukan `4,2 ons`. Perlu bercabang pada `weight`.
|
||||
|
||||
Response API menambah `weight` dan `unit` pada item, agar frontend dan struk dapat
|
||||
menampilkan `4,2 ons × Rp 4.500` alih-alih `1 × Rp 4.500`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Report
|
||||
|
||||
**Tidak ada perubahan yang wajib.** Karena `quantity` tetap integer dan tetap berarti
|
||||
"berapa banyak barang":
|
||||
|
||||
- `SUM(oi.quantity)` sebagai `total_items` tetap bermakna dan tetap konsisten lintas
|
||||
produk — 2 ikan tetap dihitung 2, bukan 9,8 ons.
|
||||
- `QuantitySold` tetap `int64`. Tidak ada pemotongan pecahan.
|
||||
- `average_price = SUM(total_price) / SUM(quantity)` menjadi "rata-rata harga per
|
||||
ekor", yang tetap merupakan angka bermakna.
|
||||
|
||||
**Tambahan opsional** — melaporkan berat terjual, hanya pada laporan **per produk**
|
||||
(P4):
|
||||
|
||||
```sql
|
||||
COALESCE(SUM(oi.weight), 0) AS weight_sold
|
||||
```
|
||||
|
||||
Tidak boleh dipakai pada agregat lintas produk, karena akan menjumlahkan satuan yang
|
||||
berbeda.
|
||||
|
||||
---
|
||||
|
||||
## 9. Di Luar Scope
|
||||
|
||||
**Pengurangan stok otomatis.** `adjustInventoryWithTransaction`
|
||||
(`order_processor.go:1177`) dan `adjustIngredientInventoryWithTransaction`
|
||||
(`order_processor.go:920`) terdefinisi tetapi **tidak pernah dipanggil dari mana pun** —
|
||||
sudah diverifikasi se-repo. Endpoint CRUD inventory berfungsi; pengurangan stok saat
|
||||
penjualan tidak tersambung.
|
||||
|
||||
Konsekuensi untuk RFC ini: `inventory.quantity` yang masih `int` tidak menghalangi
|
||||
apa pun.
|
||||
|
||||
Catatan untuk nanti bila jalur stok disambungkan:
|
||||
|
||||
- Stok produk timbangan harus berkurang sebesar `weight`, bukan `quantity` — kalau
|
||||
tidak, menjual ikan 4,2 ons hanya mengurangi stok sebanyak 1.
|
||||
- `inventory.quantity` perlu menjadi `DECIMAL(12,3)` lebih dulu. Biayanya hampir nol
|
||||
sekarang (3 call site, tanpa data historis); jauh lebih mahal setelah berjalan.
|
||||
- `order_processor.go:946` berisi `deltaInt := int(delta)` yang memotong pecahan.
|
||||
Kode ini mati, jadi bukan kebocoran aktif — tetapi bila disambungkan tanpa
|
||||
diperbaiki, konsumsi bahan di bawah 1 unit akan hilang diam-diam.
|
||||
|
||||
Kedua fungsi mati itu sebaiknya **dihapus atau disambungkan**, jangan dibiarkan
|
||||
menggantung — komentar di dalamnya ditulis seolah-olah aktif.
|
||||
|
||||
---
|
||||
|
||||
## 10. Temuan Sampingan: `unit_price` pada request diabaikan
|
||||
|
||||
`CreateOrderItemRequest.UnitPrice` (`contract/order_contract.go:46`) berkomentar
|
||||
*"Optional, will use database price if not provided"*. Kenyataannya field ini **tidak
|
||||
pernah dipakai** — satu-satunya yang menyentuhnya adalah validasi `< 0` di
|
||||
`service/order_service.go:431` dan `:474`. Processor selalu membaca harga dari
|
||||
`products` / `product_outlet_prices`.
|
||||
|
||||
Perilaku sekarang sudah benar dan sesuai P3. Yang salah hanya komentarnya, yang
|
||||
menyiratkan klien bisa mengirim harga. Sebaiknya field itu **dihapus** dari contract,
|
||||
atau komentarnya dikoreksi menjadi keterangan bahwa harga selalu diambil dari database.
|
||||
|
||||
Dibiarkan seperti sekarang, ini mengundang frontend mengirim harga dan menyangka
|
||||
berhasil, padahal diabaikan diam-diam.
|
||||
|
||||
---
|
||||
|
||||
## 11. Urutan Implementasi
|
||||
|
||||
1. **Migrasi skema** (§4). Aman: semua kolom nullable atau ber-default, data lama
|
||||
tidak tersentuh.
|
||||
2. **`BillableQuantity()` + `CalculateTotalPrice()` / `CalculateTotalCost()`** (§5).
|
||||
3. **Ganti 8 titik perkalian harga** (§5) lalu jalankan dua `grep` verifikasi.
|
||||
4. **Field kontrak**: `weight` pada request order & self-order, `weight` + `unit` pada
|
||||
response.
|
||||
5. **Validasi `sell_by`** (§7.1).
|
||||
6. **Template & tampilan struk** (§7.2).
|
||||
7. *(Opsional)* `weight_sold` pada laporan per produk (§8).
|
||||
|
||||
Langkah 1-4 membuat produk timbangan dapat dijual dengan harga yang benar. Langkah 5
|
||||
mencegah data tidak konsisten masuk. Langkah 6 membuat struk terbaca benar.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiko
|
||||
|
||||
| Risiko | Dampak | Mitigasi |
|
||||
|---|---|---|
|
||||
| Satu titik perkalian harga terlewat | Ikan 4,2 ons ditagih seharga 1 ons — salah uang, tanpa error | Dua `grep` verifikasi di §5; uji satu order timbangan lewat setiap jalur (create, tambah item, void, refund, split bill) |
|
||||
| `weight` dikirim untuk produk `unit` | Harga baris salah total | Validasi §7.1 + constraint DB |
|
||||
| `quantity > 1` pada baris berbobot | `BillableQuantity()` ambigu | Dicegah `chk_order_items_weight_single_line` di level DB |
|
||||
| Klien lama tidak mengirim `weight` | Produk timbangan ditagih 1 satuan | Validasi §7.1 menolak, bukan mendiamkan |
|
||||
| Struk menampilkan `1` alih-alih `4,2 ons` | Pelanggan bingung, kasir kehilangan kepercayaan | §7.2 |
|
||||
|
||||
---
|
||||
|
||||
## 13. Pertanyaan Terbuka
|
||||
|
||||
1. **Pembulatan uang — diputuskan sementara, perlu konfirmasi.** `RoundMoney`
|
||||
membulatkan ke **2 desimal**, mengikuti presisi kolom `decimal(10,2)` yang sudah
|
||||
dipakai semua nilai uang. Jadi `4,237 ons × Rp 4.500` tersimpan `Rp 19.066,50`.
|
||||
|
||||
Ini pilihan paling tidak mengejutkan dan konsisten dengan data lama, tetapi **bukan**
|
||||
pembulatan ke rupiah utuh. Bila kasir harus menerima uang dalam rupiah penuh (atau
|
||||
kelipatan Rp 100/500), ubah `RoundMoney` di `entities/order_item.go` — satu tempat,
|
||||
dan lakukan **sebelum** ada transaksi timbangan, karena setelahnya data lama dan
|
||||
baru akan mengikuti aturan berbeda.
|
||||
2. **Presisi input berat.** Apakah `4,237 ons` (resolusi 0,1 gram) valid, atau input
|
||||
harus dibatasi ke kelipatan tertentu sesuai resolusi timbangan? Bila perlu dibatasi,
|
||||
tambahkan `products.min_weight_increment`.
|
||||
3. **Sumber angka timbangan** — kasir mengetik manual atau timbangan tersambung? Bila
|
||||
tersambung, ada urusan tara dan pembacaan stabil yang berada di luar RFC ini.
|
||||
@@ -76,6 +76,7 @@ require (
|
||||
github.com/subosito/gotenv v1.4.2 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/zeebo/errs v1.4.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect
|
||||
@@ -107,6 +108,7 @@ require (
|
||||
|
||||
require (
|
||||
firebase.google.com/go/v4 v4.19.0
|
||||
github.com/alicebob/miniredis/v2 v2.39.0
|
||||
github.com/aws/aws-sdk-go v1.55.7
|
||||
github.com/boombuler/barcode v1.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3
|
||||
|
||||
@@ -74,6 +74,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0/go.mod h1:otE2jQekW/PqXk1Awf5lmfokJx4uwuqcj1ab5SpGeW0=
|
||||
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
|
||||
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0 h1:M7WbmV5BmV56L8KTG0rw6vEQ+woTOghpDgin2xv4A0g=
|
||||
github.com/alicebob/miniredis/v2 v2.39.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE=
|
||||
github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
@@ -349,8 +351,12 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM=
|
||||
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
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 +386,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"
|
||||
+138
-19
@@ -31,6 +31,8 @@ type App struct {
|
||||
router *router.Router
|
||||
shutdown chan os.Signal
|
||||
omsetScheduler *service.OmsetMilestoneScheduler
|
||||
walletRecon *service.WalletReconciliationJob
|
||||
earningRetry *service.EarningBackfillJob
|
||||
}
|
||||
|
||||
func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
|
||||
@@ -53,6 +55,15 @@ 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)
|
||||
|
||||
services := a.initServices(processors, repos, cfg)
|
||||
validators := a.initValidators()
|
||||
middleware := a.initMiddleware(services, cfg)
|
||||
@@ -108,6 +119,8 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
validators.vendorValidator,
|
||||
services.purchaseOrderService,
|
||||
validators.purchaseOrderValidator,
|
||||
services.purchaseCategoryService,
|
||||
validators.purchaseCategoryValidator,
|
||||
services.unitConverterService,
|
||||
validators.unitConverterValidator,
|
||||
services.chartOfAccountTypeService,
|
||||
@@ -138,6 +151,16 @@ 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.pointPaymentService,
|
||||
services.customerOrderPaymentService,
|
||||
services.customerWalletService,
|
||||
services.customerDeviceService,
|
||||
a.redisClient,
|
||||
)
|
||||
|
||||
@@ -149,6 +172,12 @@ 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)
|
||||
}
|
||||
|
||||
engine := a.router.Init()
|
||||
|
||||
@@ -188,6 +217,12 @@ 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()
|
||||
}
|
||||
close(a.shutdown)
|
||||
}
|
||||
|
||||
@@ -216,6 +251,7 @@ type repositories struct {
|
||||
productRecipeRepo *repository.ProductRecipeRepository
|
||||
vendorRepo *repository.VendorRepositoryImpl
|
||||
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
|
||||
purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl
|
||||
unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl
|
||||
chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl
|
||||
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
|
||||
@@ -241,6 +277,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 {
|
||||
@@ -269,6 +309,7 @@ func (a *App) initRepositories() *repositories {
|
||||
productRecipeRepo: repository.NewProductRecipeRepository(a.db),
|
||||
vendorRepo: repository.NewVendorRepositoryImpl(a.db),
|
||||
purchaseOrderRepo: repository.NewPurchaseOrderRepositoryImpl(a.db),
|
||||
purchaseCategoryRepo: repository.NewPurchaseCategoryRepositoryImpl(a.db),
|
||||
unitConverterRepo: repository.NewIngredientUnitConverterRepositoryImpl(a.db).(*repository.IngredientUnitConverterRepositoryImpl),
|
||||
chartOfAccountTypeRepo: repository.NewChartOfAccountTypeRepositoryImpl(a.db),
|
||||
chartOfAccountRepo: repository.NewChartOfAccountRepositoryImpl(a.db),
|
||||
@@ -294,6 +335,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +362,7 @@ type processors struct {
|
||||
productRecipeProcessor *processor.ProductRecipeProcessorImpl
|
||||
vendorProcessor *processor.VendorProcessorImpl
|
||||
purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl
|
||||
purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl
|
||||
unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl
|
||||
chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl
|
||||
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
|
||||
@@ -340,14 +386,45 @@ 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
|
||||
paymentCodeProcessor *processor.PaymentCodeProcessor
|
||||
pointPaymentProcessor *processor.PointPaymentProcessor
|
||||
walletExchangeProcessor *processor.WalletExchangeProcessor
|
||||
walletTransferProcessor *processor.WalletTransferProcessor
|
||||
walletTraceProcessor *processor.WalletTraceProcessor
|
||||
customerDeviceProcessor *processor.CustomerDeviceProcessor
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
fileClient := client.NewFileClient(cfg.S3Config)
|
||||
fcmClient := buildFCMClient(cfg)
|
||||
// Devices of the customer app, for pushes through FCM
|
||||
customerDeviceProcessor := processor.NewCustomerDeviceProcessor(repository.NewCustomerDeviceRepository(a.db), fcmClient)
|
||||
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
|
||||
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
|
||||
// Customer PIN (docs/prd-point-coin.md F11)
|
||||
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, customerDeviceProcessor)
|
||||
paymentCodeProcessor := processor.NewPaymentCodeProcessor(repository.NewPaymentCodeRepository(a.redisClient), customerPinProcessor)
|
||||
inventoryMovementService := service.NewInventoryMovementService(repos.inventoryMovementRepo, repos.ingredientRepo)
|
||||
|
||||
orderProcessor := processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo)
|
||||
loyaltySettingsProcessor := processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)
|
||||
// Earn EnakPoint and EnakCoin when an order becomes fully paid (docs/prd-point-coin.md F3)
|
||||
earningProcessor := processor.NewEarningProcessor(repository.NewEarningRepository(a.db), loyaltySettingsProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetLoyalty(earningProcessor)
|
||||
// Pay orders with EnakPoint, approved by the customer's one-time code (docs/prd-point-coin.md F9)
|
||||
pointPaymentProcessor := processor.NewPointPaymentProcessor(repository.NewPointPaymentRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
orderProcessor.SetPointPayments(pointPaymentProcessor, paymentCodeProcessor, customerPinProcessor)
|
||||
// Exchange EnakCoin into EnakPoint, approved by the customer's PIN (docs/prd-point-coin.md F4)
|
||||
walletExchangeProcessor := processor.NewWalletExchangeProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager)
|
||||
// Send EnakPoint or EnakCoin to another customer; the recipient gets a push through FCM (docs/prd-point-coin.md F5)
|
||||
walletTransferProcessor := processor.NewWalletTransferProcessor(repository.NewWalletMoveRepository(a.db), loyaltySettingsProcessor, repos.walletQueryRepo, customerPinProcessor, processor.NewWalletProcessor(repos.walletRepo), repos.txManager, customerDeviceProcessor)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
|
||||
organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo),
|
||||
@@ -357,8 +434,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
|
||||
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
|
||||
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
|
||||
orderProcessor: processor.NewOrderProcessorImpl(repos.orderRepo, repos.orderItemRepo, repos.paymentRepo, repos.paymentOrderItemRepo, repos.productRepo, repos.paymentMethodRepo, repos.inventoryRepo, repos.inventoryMovementRepo, repos.productVariantRepo, repos.outletRepo, repos.customerRepo, repos.txManager, repos.productRecipeRepo, repos.ingredientRepo, inventoryMovementService, repos.productOutletPriceRepo),
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
|
||||
orderProcessor: orderProcessor,
|
||||
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
|
||||
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
|
||||
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
|
||||
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
|
||||
@@ -367,7 +444,8 @@ 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.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),
|
||||
@@ -377,20 +455,32 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
tierProcessor: processor.NewTierProcessor(repos.tierRepo),
|
||||
gameProcessor: processor.NewGameProcessor(repos.gameRepo),
|
||||
gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo),
|
||||
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo),
|
||||
gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager),
|
||||
omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo),
|
||||
rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo),
|
||||
campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo),
|
||||
campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo),
|
||||
customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
|
||||
customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo),
|
||||
otpProcessor: otpProcessor,
|
||||
fileClient: fileClient,
|
||||
inventoryMovementService: inventoryMovementService,
|
||||
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
|
||||
notificationProcessor: buildNotificationProcessor(cfg, repos),
|
||||
notificationProcessor: buildNotificationProcessor(repos, fcmClient),
|
||||
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo),
|
||||
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
|
||||
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
|
||||
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
|
||||
loyaltySettingsProcessor: loyaltySettingsProcessor,
|
||||
earningProcessor: earningProcessor,
|
||||
customerPinProcessor: customerPinProcessor,
|
||||
paymentCodeProcessor: paymentCodeProcessor,
|
||||
pointPaymentProcessor: pointPaymentProcessor,
|
||||
walletExchangeProcessor: walletExchangeProcessor,
|
||||
walletTransferProcessor: walletTransferProcessor,
|
||||
walletTraceProcessor: processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(a.db)),
|
||||
customerDeviceProcessor: customerDeviceProcessor,
|
||||
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +506,7 @@ type services struct {
|
||||
productRecipeService *service.ProductRecipeServiceImpl
|
||||
vendorService *service.VendorServiceImpl
|
||||
purchaseOrderService *service.PurchaseOrderServiceImpl
|
||||
purchaseCategoryService service.PurchaseCategoryService
|
||||
unitConverterService *service.IngredientUnitConverterServiceImpl
|
||||
chartOfAccountTypeService service.ChartOfAccountTypeService
|
||||
chartOfAccountService service.ChartOfAccountService
|
||||
@@ -431,6 +522,14 @@ type services struct {
|
||||
notificationService service.NotificationService
|
||||
productOutletPriceService service.ProductOutletPriceService
|
||||
expenseService *service.ExpenseServiceImpl
|
||||
cashAdvanceService *service.CashAdvanceServiceImpl
|
||||
walletAdminService *service.WalletAdminServiceImpl
|
||||
loyaltySettingsService *service.LoyaltySettingsServiceImpl
|
||||
customerPinService *service.CustomerPinServiceImpl
|
||||
pointPaymentService *service.PointPaymentServiceImpl
|
||||
customerOrderPaymentService *service.CustomerOrderPaymentServiceImpl
|
||||
customerWalletService *service.CustomerWalletServiceImpl
|
||||
customerDeviceService *service.CustomerDeviceServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@@ -455,6 +554,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productRecipeService := service.NewProductRecipeService(processors.productRecipeProcessor)
|
||||
vendorService := service.NewVendorService(processors.vendorProcessor)
|
||||
purchaseOrderService := service.NewPurchaseOrderService(processors.purchaseOrderProcessor)
|
||||
purchaseCategoryService := service.NewPurchaseCategoryService(processors.purchaseCategoryProcessor)
|
||||
unitConverterService := service.NewIngredientUnitConverterService(processors.unitConverterProcessor)
|
||||
chartOfAccountTypeService := service.NewChartOfAccountTypeService(processors.chartOfAccountTypeProcessor)
|
||||
chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor)
|
||||
@@ -465,7 +565,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor)
|
||||
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
|
||||
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
|
||||
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor)
|
||||
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
|
||||
notificationService := service.NewNotificationService(processors.notificationProcessor)
|
||||
|
||||
@@ -494,6 +594,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
productRecipeService: productRecipeService,
|
||||
vendorService: vendorService,
|
||||
purchaseOrderService: purchaseOrderService,
|
||||
purchaseCategoryService: purchaseCategoryService,
|
||||
unitConverterService: unitConverterService,
|
||||
chartOfAccountTypeService: chartOfAccountTypeService,
|
||||
chartOfAccountService: chartOfAccountService,
|
||||
@@ -509,6 +610,14 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
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, processors.paymentCodeProcessor),
|
||||
pointPaymentService: service.NewPointPaymentService(processors.pointPaymentProcessor),
|
||||
customerOrderPaymentService: service.NewCustomerOrderPaymentService(processors.orderProcessor),
|
||||
customerWalletService: service.NewCustomerWalletService(processors.walletExchangeProcessor, processors.walletTransferProcessor),
|
||||
customerDeviceService: service.NewCustomerDeviceService(processors.customerDeviceProcessor),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -539,6 +648,7 @@ type validators struct {
|
||||
tableValidator *validator.TableValidator
|
||||
vendorValidator *validator.VendorValidatorImpl
|
||||
purchaseOrderValidator *validator.PurchaseOrderValidatorImpl
|
||||
purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl
|
||||
unitConverterValidator *validator.IngredientUnitConverterValidatorImpl
|
||||
chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl
|
||||
chartOfAccountValidator *validator.ChartOfAccountValidatorImpl
|
||||
@@ -552,6 +662,8 @@ type validators struct {
|
||||
notificationValidator *validator.NotificationValidatorImpl
|
||||
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
|
||||
expenseValidator *validator.ExpenseValidatorImpl
|
||||
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
|
||||
walletValidator *validator.WalletValidatorImpl
|
||||
}
|
||||
|
||||
func (a *App) initValidators() *validators {
|
||||
@@ -570,6 +682,7 @@ func (a *App) initValidators() *validators {
|
||||
tableValidator: validator.NewTableValidator(),
|
||||
vendorValidator: validator.NewVendorValidator(),
|
||||
purchaseOrderValidator: validator.NewPurchaseOrderValidator(),
|
||||
purchaseCategoryValidator: validator.NewPurchaseCategoryValidator(),
|
||||
unitConverterValidator: validator.NewIngredientUnitConverterValidator().(*validator.IngredientUnitConverterValidatorImpl),
|
||||
chartOfAccountTypeValidator: validator.NewChartOfAccountTypeValidator().(*validator.ChartOfAccountTypeValidatorImpl),
|
||||
chartOfAccountValidator: validator.NewChartOfAccountValidator().(*validator.ChartOfAccountValidatorImpl),
|
||||
@@ -583,22 +696,28 @@ func (a *App) initValidators() *validators {
|
||||
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,9 @@
|
||||
package constants
|
||||
|
||||
// Budget allocation of revenue used by the parent category cut-off report.
|
||||
// The three shares are expected to add up to 100.
|
||||
const (
|
||||
BudgetLimitPurchasePercent = 60.0
|
||||
BudgetLimitOwnerPercent = 20.0
|
||||
BudgetLimitTeamPercent = 20.0
|
||||
)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 (
|
||||
@@ -40,6 +46,7 @@ const (
|
||||
OutletServiceEntity = "outlet_service"
|
||||
VendorServiceEntity = "vendor_service"
|
||||
PurchaseOrderServiceEntity = "purchase_order_service"
|
||||
PurchaseCategoryServiceEntity = "purchase_category_service"
|
||||
IngredientUnitConverterServiceEntity = "ingredient_unit_converter_service"
|
||||
IngredientCompositionServiceEntity = "ingredient_composition_service"
|
||||
TableEntity = "table"
|
||||
@@ -61,15 +68,24 @@ const (
|
||||
NotificationHandlerEntity = "notification_handler"
|
||||
ProductOutletPriceServiceEntity = "product_outlet_price_service"
|
||||
ExpenseServiceEntity = "expense_service"
|
||||
CashAdvanceServiceEntity = "cash_advance_service"
|
||||
WalletServiceEntity = "wallet_service"
|
||||
LoyaltySettingsServiceEntity = "loyalty_settings_service"
|
||||
CustomerPinServiceEntity = "customer_pin_service"
|
||||
)
|
||||
|
||||
var HttpErrorMap = map[string]int{
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
InternalServerErrorCode: http.StatusInternalServerError,
|
||||
MissingFieldErrorCode: http.StatusBadRequest,
|
||||
MalformedFieldErrorCode: http.StatusBadRequest,
|
||||
ValidationErrorCode: http.StatusBadRequest,
|
||||
InvalidFieldErrorCode: http.StatusBadRequest,
|
||||
NotFoundErrorCode: http.StatusNotFound,
|
||||
PinNotSetErrorCode: http.StatusForbidden,
|
||||
PinInvalidErrorCode: http.StatusBadRequest,
|
||||
PinLockedErrorCode: http.StatusLocked,
|
||||
TransferBlockedErrorCode: http.StatusForbidden,
|
||||
TooManyRequestsErrorCode: http.StatusTooManyRequests,
|
||||
}
|
||||
|
||||
// Error messages
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package constants
|
||||
|
||||
// Loyalty setting keys (docs/prd-point-coin.md F1, F2, F12). Outlet keys live in
|
||||
// outlet_settings and organization keys in organization_settings. A key that was
|
||||
// never set takes the default in the PRD.
|
||||
|
||||
// Per outlet (F1): what an order earns, and whether EnakPoint can pay.
|
||||
const (
|
||||
LoyaltyPointEnabledKey = "loyalty.point.enabled"
|
||||
LoyaltyPointEarnPerAmountKey = "loyalty.point.earn_per_amount"
|
||||
LoyaltyPointEarnValueKey = "loyalty.point.earn_value"
|
||||
LoyaltyPointMinOrderAmountKey = "loyalty.point.min_order_amount"
|
||||
LoyaltyPointMaxPerOrderKey = "loyalty.point.max_per_order"
|
||||
|
||||
LoyaltyCoinEnabledKey = "loyalty.coin.enabled"
|
||||
LoyaltyCoinEarnPerAmountKey = "loyalty.coin.earn_per_amount"
|
||||
LoyaltyCoinEarnValueKey = "loyalty.coin.earn_value"
|
||||
LoyaltyCoinMinOrderAmountKey = "loyalty.coin.min_order_amount"
|
||||
LoyaltyCoinMaxPerOrderKey = "loyalty.coin.max_per_order"
|
||||
|
||||
LoyaltyPointAcceptPaymentKey = "loyalty.point.accept_payment"
|
||||
LoyaltyPointMinPaymentPointsKey = "loyalty.point.min_payment_points"
|
||||
LoyaltyPointMaxPaymentPercentKey = "loyalty.point.max_payment_percent"
|
||||
)
|
||||
|
||||
// Per organization (F2, F12): the value of EnakPoint, the exchange rate, transfers and
|
||||
// expiry, which must be the same in every outlet.
|
||||
const (
|
||||
LoyaltyPointValueKey = "loyalty.point.value"
|
||||
|
||||
LoyaltyExchangeCoinAmountKey = "loyalty.exchange.coin_amount"
|
||||
LoyaltyExchangePointAmountKey = "loyalty.exchange.point_amount"
|
||||
|
||||
LoyaltyTransferEnabledKey = "loyalty.transfer.enabled"
|
||||
LoyaltyTransferMinAmountKey = "loyalty.transfer.min_amount"
|
||||
LoyaltyTransferMaxPerTransactionKey = "loyalty.transfer.max_per_transaction"
|
||||
LoyaltyTransferDailyLimitKey = "loyalty.transfer.daily_limit"
|
||||
|
||||
// Suffixes after "loyalty.point." or "loyalty.coin.".
|
||||
LoyaltyExpiryEnabledSuffix = "expiry_enabled"
|
||||
LoyaltyExpiryPeriodSuffix = "expiry_period"
|
||||
LoyaltyExpiryUnitSuffix = "expiry_unit"
|
||||
LoyaltyExpiryEndOfMonthSuffix = "expiry_end_of_month"
|
||||
LoyaltyExpiryReminderDaysSuffix = "expiry_reminder_days"
|
||||
)
|
||||
|
||||
// Units of loyalty.{point,coin}.expiry_unit.
|
||||
const (
|
||||
LoyaltyExpiryUnitDay = "DAY"
|
||||
LoyaltyExpiryUnitMonth = "MONTH"
|
||||
)
|
||||
|
||||
// Defaults from the PRD.
|
||||
const (
|
||||
LoyaltyPointEarnPerAmountDefault = int64(100)
|
||||
LoyaltyCoinEarnPerAmountDefault = int64(25000)
|
||||
LoyaltyEarnValueDefault = int64(1)
|
||||
|
||||
LoyaltyMinPaymentPointsDefault = int64(1)
|
||||
LoyaltyMaxPaymentPercentDefault = int64(100)
|
||||
|
||||
LoyaltyPointValueDefault = int64(1)
|
||||
LoyaltyExchangeAmountDefault = int64(1)
|
||||
|
||||
LoyaltyTransferEnabledDefault = true
|
||||
LoyaltyTransferMinAmountDefault = int64(1)
|
||||
|
||||
LoyaltyExpiryPeriodDefault = int64(12)
|
||||
LoyaltyExpiryUnitDefault = LoyaltyExpiryUnitMonth
|
||||
LoyaltyExpiryReminderDaysDefault = int64(7)
|
||||
)
|
||||
@@ -8,6 +8,9 @@ const (
|
||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
||||
PaymentMethodTypeQR PaymentMethodType = "qr"
|
||||
PaymentMethodTypeEDC PaymentMethodType = "edc"
|
||||
// Paying with EnakPoint (docs/prd-point-coin.md F9). Not accepted as a payment method
|
||||
// type until that phase ships.
|
||||
PaymentMethodTypePoint PaymentMethodType = "point"
|
||||
)
|
||||
|
||||
type PaymentStatus string
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package constants
|
||||
|
||||
// SellBy describes how a product's quantity is expressed on an order line.
|
||||
type SellBy = string
|
||||
|
||||
const (
|
||||
// SellByUnit is the default: the line counts discrete items (2 portions, 3 bottles).
|
||||
SellByUnit SellBy = "unit"
|
||||
// SellByWeight means the line carries a weighed amount. One weighing is one line,
|
||||
// so quantity stays 1 and the measured value lives in order_items.weight.
|
||||
SellByWeight SellBy = "weight"
|
||||
)
|
||||
|
||||
// IsValidSellBy reports whether s is a supported sell_by value.
|
||||
func IsValidSellBy(s string) bool {
|
||||
return s == SellByUnit || s == SellByWeight
|
||||
}
|
||||
@@ -0,0 +1,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,44 @@
|
||||
package constants
|
||||
|
||||
// The two balances a customer wallet holds (docs/prd-point-coin.md). EnakPoint pays
|
||||
// for orders; EnakCoin is spent on games and can be exchanged into EnakPoint.
|
||||
const (
|
||||
WalletCurrencyPoint = "POINT"
|
||||
WalletCurrencyCoin = "COIN"
|
||||
)
|
||||
|
||||
func IsValidWalletCurrency(currency string) bool {
|
||||
return currency == WalletCurrencyPoint || currency == WalletCurrencyCoin
|
||||
}
|
||||
|
||||
// Ledger row types. §8.1 of the PRD lists, per type, which currency it may use, which
|
||||
// way it moves the balance, and which reference it must carry.
|
||||
const (
|
||||
WalletTxTypeEarn = "EARN"
|
||||
WalletTxTypeEarnReversal = "EARN_REVERSAL"
|
||||
WalletTxTypePayment = "PAYMENT"
|
||||
WalletTxTypePaymentRefund = "PAYMENT_REFUND"
|
||||
WalletTxTypeExchangeOut = "EXCHANGE_OUT"
|
||||
WalletTxTypeExchangeIn = "EXCHANGE_IN"
|
||||
WalletTxTypeTransferOut = "TRANSFER_OUT"
|
||||
WalletTxTypeTransferIn = "TRANSFER_IN"
|
||||
WalletTxTypeGameSpend = "GAME_SPEND"
|
||||
WalletTxTypeExpire = "EXPIRE"
|
||||
WalletTxTypeAdjustment = "ADJUSTMENT"
|
||||
WalletTxTypeMigration = "MIGRATION"
|
||||
WalletTxTypeRewardRedeem = "REWARD_REDEEM"
|
||||
)
|
||||
|
||||
// What a ledger row's reference_id points at: where the value came from for a
|
||||
// credit, or where it went for a debit.
|
||||
const (
|
||||
WalletRefTypeOrder = "ORDER"
|
||||
WalletRefTypePayment = "PAYMENT"
|
||||
WalletRefTypeWalletTx = "WALLET_TX"
|
||||
WalletRefTypeGamePlay = "GAME_PLAY"
|
||||
WalletRefTypeLot = "LOT"
|
||||
WalletRefTypeUser = "USER"
|
||||
WalletRefTypeLegacyPoints = "LEGACY_POINTS"
|
||||
WalletRefTypeLegacyTokens = "LEGACY_TOKENS"
|
||||
WalletRefTypeRewardRedemption = "REWARD_REDEMPTION"
|
||||
)
|
||||
@@ -5,12 +5,12 @@ import (
|
||||
)
|
||||
|
||||
type CreateAccountRequest struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Number string `json:"number" validate:"required,min=1,max=50"`
|
||||
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Description *string `json:"description"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id" validate:"required"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Number string `json:"number" validate:"required,min=1,max=50"`
|
||||
AccountType string `json:"account_type" validate:"required,oneof=cash wallet bank credit debit asset liability equity revenue expense"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type UpdateAccountRequest struct {
|
||||
@@ -24,21 +24,21 @@ type UpdateAccountRequest struct {
|
||||
}
|
||||
|
||||
type AccountResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
Name string `json:"name"`
|
||||
Number string `json:"number"`
|
||||
AccountType string `json:"account_type"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
CurrentBalance float64 `json:"current_balance"`
|
||||
Description *string `json:"description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
Name string `json:"name"`
|
||||
Number string `json:"number"`
|
||||
AccountType string `json:"account_type"`
|
||||
OpeningBalance float64 `json:"opening_balance"`
|
||||
CurrentBalance float64 `json:"current_balance"`
|
||||
Description *string `json:"description"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
ChartOfAccount *ChartOfAccountResponse `json:"chart_of_account,omitempty"`
|
||||
}
|
||||
|
||||
type ListAccountsRequest struct {
|
||||
|
||||
@@ -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"`
|
||||
@@ -27,7 +28,11 @@ type PaymentMethodAnalyticsResponse struct {
|
||||
|
||||
// PaymentMethodSummary represents the summary of payment method analytics
|
||||
type PaymentMethodSummary struct {
|
||||
// Money actually received; EnakPoint is reported apart (docs/prd-point-coin.md F9).
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
PointAmount float64 `json:"point_amount"`
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
TotalWithPoints float64 `json:"total_with_points"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
TotalPayments int64 `json:"total_payments"`
|
||||
AverageOrderValue float64 `json:"average_order_value"`
|
||||
@@ -41,6 +46,8 @@ type PaymentMethodAnalyticsData struct {
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PaymentCount int64 `json:"payment_count"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
CountsAsCashIn bool `json:"counts_as_cash_in"`
|
||||
}
|
||||
|
||||
type SalesAnalyticsRequest struct {
|
||||
@@ -54,6 +61,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 +94,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,24 +114,48 @@ 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 {
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
TotalQuantity float64 `json:"total_quantity"`
|
||||
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 {
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
}
|
||||
|
||||
type PurchasingIngredientData struct {
|
||||
@@ -132,12 +168,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
|
||||
@@ -153,6 +189,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"`
|
||||
@@ -162,10 +199,12 @@ type ProductAnalyticsData struct {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
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"`
|
||||
@@ -189,6 +228,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"`
|
||||
@@ -206,6 +246,136 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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"`
|
||||
}
|
||||
|
||||
type ParentCategoryAnalyticsDetailData struct {
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
CategoryName string `json:"category_name"`
|
||||
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 {
|
||||
Purchase float64 `json:"purchase"`
|
||||
Owner float64 `json:"owner"`
|
||||
Team float64 `json:"team"`
|
||||
}
|
||||
|
||||
type BudgetPeriod struct {
|
||||
PeriodStart time.Time `json:"period_start"`
|
||||
PeriodEnd time.Time `json:"period_end"`
|
||||
Revenue float64 `json:"revenue"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
LimitPurchase float64 `json:"limit_purchase"`
|
||||
LimitOwner float64 `json:"limit_owner"`
|
||||
LimitTeam float64 `json:"limit_team"`
|
||||
}
|
||||
|
||||
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
|
||||
@@ -218,6 +388,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"`
|
||||
@@ -228,12 +399,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 {
|
||||
@@ -247,6 +421,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"`
|
||||
@@ -254,10 +429,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"`
|
||||
@@ -315,3 +508,123 @@ type OperationalExpenseItem struct {
|
||||
Item string `json:"item"`
|
||||
Nominal float64 `json:"nominal"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
DateFrom string `form:"date_from" validate:"required"`
|
||||
DateTo string `form:"date_to" validate:"required"`
|
||||
ExcludeGajiStaffFromReimburse bool `form:"exclude_gaji_staff_from_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyRequest struct {
|
||||
OrganizationID uuid.UUID
|
||||
OutletID *string `form:"outlet_id,omitempty"`
|
||||
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"`
|
||||
HPPBreakdown []ExclusiveSummaryCategoryBreakdown `json:"hpp_breakdown"`
|
||||
OperationalExpenseBreakdown []ExclusiveSummaryCategoryBreakdown `json:"operational_expense_breakdown"`
|
||||
DailySummary []ExclusiveSummaryDailySummary `json:"daily_summary"`
|
||||
DailyTransactions []ExclusiveSummaryDailyTransaction `json:"daily_transactions"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodRange struct {
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryPeriodSummary struct {
|
||||
Sales float64 `json:"sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
SalaryTotal float64 `json:"salary_total"`
|
||||
SalaryDW float64 `json:"salary_dw"`
|
||||
SalaryStaff float64 `json:"salary_staff"`
|
||||
SalaryOther float64 `json:"salary_other"`
|
||||
OtherOperationalExpenses float64 `json:"other_operational_expenses"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryReimburse struct {
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
ExcludedSalaryStaff float64 `json:"excluded_salary_staff"`
|
||||
TotalReimburse float64 `json:"total_reimburse"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryCategoryBreakdown struct {
|
||||
CategoryCode string `json:"category_code"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Amount float64 `json:"amount"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailySummary struct {
|
||||
Date time.Time `json:"date"`
|
||||
TransactionCount int64 `json:"transaction_count"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailyTransaction struct {
|
||||
Date time.Time `json:"date"`
|
||||
CategoryCode string `json:"category_code"`
|
||||
CategoryName string `json:"category_name"`
|
||||
Description string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
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"`
|
||||
BankBalance []ExclusiveSummaryBankBalance `json:"bank_balance"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlySummary struct {
|
||||
TotalSales float64 `json:"total_sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
OperationalExpensesTotal float64 `json:"operational_expenses_total"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
NetProfit float64 `json:"net_profit"`
|
||||
NetProfitMargin float64 `json:"net_profit_margin"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryMonthlyPeriod struct {
|
||||
Label string `json:"label"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
Sales float64 `json:"sales"`
|
||||
HPP float64 `json:"hpp"`
|
||||
GrossProfit float64 `json:"gross_profit"`
|
||||
GrossMargin float64 `json:"gross_margin"`
|
||||
}
|
||||
|
||||
type ExclusiveSummaryBankBalance struct {
|
||||
Bank string `json:"bank"`
|
||||
OpeningBalance *float64 `json:"opening_balance"`
|
||||
IncomingMutation *float64 `json:"incoming_mutation"`
|
||||
OutgoingMutation *float64 `json:"outgoing_mutation"`
|
||||
ClosingBalance *float64 `json:"closing_balance"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
@@ -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 TOKENS 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 TOKENS 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 TOKENS 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 TOKENS 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 TOKENS 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"`
|
||||
}
|
||||
@@ -11,6 +11,7 @@ type CreateCategoryRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -20,6 +21,7 @@ type UpdateCategoryRequest struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
BusinessType *string `json:"business_type,omitempty"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Order *int `json:"order,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
@@ -27,6 +29,8 @@ type UpdateCategoryRequest struct {
|
||||
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"`
|
||||
@@ -38,6 +42,8 @@ type CategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
ParentName *string `json:"parent_name,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
BusinessType string `json:"business_type"`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package contract
|
||||
|
||||
// RegisterCustomerDeviceRequest is PUT /customer/devices: the FCM token the customer
|
||||
// app got for this device. Send it after login and whenever FCM refreshes the token.
|
||||
type RegisterCustomerDeviceRequest struct {
|
||||
DeviceID string `json:"device_id" binding:"required"`
|
||||
FCMToken string `json:"fcm_token" binding:"required"`
|
||||
// android, ios or web.
|
||||
Platform *string `json:"platform"`
|
||||
AppVersion *string `json:"app_version"`
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package contract
|
||||
|
||||
// Requests of /customer/pin and /marketing/customers/:id/pin (docs/prd-point-coin.md
|
||||
// F11). PINs are strings so a leading zero is kept.
|
||||
|
||||
type RequestPinOtpRequest struct {
|
||||
// pin_setup or pin_reset.
|
||||
Purpose string `json:"purpose" binding:"required"`
|
||||
}
|
||||
|
||||
type CreateCustomerPinRequest struct {
|
||||
OtpToken string `json:"otp_token" binding:"required"`
|
||||
OtpCode string `json:"otp_code" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||
}
|
||||
|
||||
type ChangeCustomerPinRequest struct {
|
||||
OldPin string `json:"old_pin" binding:"required"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
ConfirmPin string `json:"confirm_pin" binding:"required"`
|
||||
}
|
||||
|
||||
type ResetCustomerPinRequest = CreateCustomerPinRequest
|
||||
|
||||
type RemoveCustomerPinRequest struct {
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// IssuePaymentCodeRequest is POST /customer/wallet/payment-code.
|
||||
type IssuePaymentCodeRequest struct {
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
|
||||
// PayWithPointsRequest is POST /customer/orders/:id/pay-with-points.
|
||||
type PayWithPointsRequest struct {
|
||||
Points int64 `json:"points" binding:"required,min=1"`
|
||||
Pin string `json:"pin" binding:"required"`
|
||||
}
|
||||
@@ -7,42 +7,49 @@ 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 {
|
||||
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
|
||||
Item string `json:"item" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required"`
|
||||
ChartOfAccountID string `json:"chart_of_account_id" validate:"required"`
|
||||
PurchaseCategoryID string `json:"purchase_category_id" validate:"required"`
|
||||
Item string `json:"item" validate:"required"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
|
||||
Item *string `json:"item,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
ChartOfAccountID *string `json:"chart_of_account_id,omitempty"`
|
||||
PurchaseCategoryID *string `json:"purchase_category_id,omitempty"`
|
||||
Item *string `json:"item,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty"`
|
||||
}
|
||||
|
||||
type ExpenseResponse struct {
|
||||
@@ -57,21 +64,26 @@ 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"`
|
||||
}
|
||||
|
||||
type ExpenseItemResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExpenseID uuid.UUID `json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
|
||||
Item string `json:"item"`
|
||||
Description *string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ExpenseID uuid.UUID `json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name,omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
PurchaseCategoryName string `json:"purchase_category_name,omitempty"`
|
||||
PurchaseCategoryType string `json:"purchase_category_type,omitempty"`
|
||||
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
|
||||
Item string `json:"item"`
|
||||
Description *string `json:"description"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListExpenseRequest struct {
|
||||
@@ -91,3 +103,65 @@ type ListExpenseResponse struct {
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsRequest struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsResponse struct {
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
DateFrom time.Time `json:"date_from"`
|
||||
DateTo time.Time `json:"date_to"`
|
||||
GroupBy string `json:"group_by"`
|
||||
Summary ExpenseAnalyticsSummary `json:"summary"`
|
||||
Data []ExpenseAnalyticsData `json:"data"`
|
||||
CategoryData []ExpenseAnalyticsCategoryData `json:"category_data"`
|
||||
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData `json:"chart_of_account_data"`
|
||||
ItemData []ExpenseAnalyticsItemData `json:"item_data"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64 `json:"total_expenses"`
|
||||
TotalExpenseCount int64 `json:"total_expense_count"`
|
||||
TotalTax float64 `json:"total_tax"`
|
||||
AverageExpenseValue float64 `json:"average_expense_value"`
|
||||
TotalCategories int64 `json:"total_categories"`
|
||||
TotalItems int64 `json:"total_items"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time `json:"date"`
|
||||
Expenses float64 `json:"expenses"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Tax float64 `json:"tax"`
|
||||
Items int64 `json:"items"`
|
||||
Categories int64 `json:"categories"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
PurchaseCategoryName string `json:"purchase_category_name"`
|
||||
PurchaseCategoryType string `json:"purchase_category_type"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsChartOfAccountData struct {
|
||||
ChartOfAccountID uuid.UUID `json:"chart_of_account_id"`
|
||||
ChartOfAccountName string `json:"chart_of_account_name"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string `json:"item"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@ import (
|
||||
type CreateGamePlayRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
CoinsUsed int `json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
CustomerID uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id,omitempty"`
|
||||
CoinsUsed int `json:"coins_used"`
|
||||
// Deprecated: the same as CoinsUsed, kept until the apps read coins_used.
|
||||
TokenUsed int `json:"token_used"`
|
||||
RandomSeed *string `json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -33,7 +35,7 @@ type ListGamePlaysRequest struct {
|
||||
GameID *uuid.UUID `json:"game_id"`
|
||||
CustomerID *uuid.UUID `json:"customer_id"`
|
||||
PrizeID *uuid.UUID `json:"prize_id"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at token_used"`
|
||||
SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used token_used"`
|
||||
SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"`
|
||||
}
|
||||
|
||||
@@ -45,14 +47,17 @@ type PaginatedGamePlaysResponse struct {
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
// PlayGameRequest plays a game for a customer. What it costs is the game's
|
||||
// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice.
|
||||
type PlayGameRequest struct {
|
||||
GameID uuid.UUID `json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `json:"customer_id" validate:"required"`
|
||||
TokenUsed int `json:"token_used" validate:"min=0"`
|
||||
}
|
||||
|
||||
type PlayGameResponse struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
}
|
||||
|
||||
@@ -77,8 +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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ type AdjustInventoryRequest struct {
|
||||
}
|
||||
|
||||
type RestockInventoryRequest struct {
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
|
||||
Items []RestockItem `json:"items" validate:"required,min=1,dive"`
|
||||
Reason string `json:"reason" validate:"required,min=1,max=255"`
|
||||
Reason string `json:"reason" validate:"required,min=1,max=255"`
|
||||
}
|
||||
|
||||
type RestockItem struct {
|
||||
@@ -82,10 +82,10 @@ type InventoryAdjustmentResponse struct {
|
||||
}
|
||||
|
||||
type RestockInventoryResponse struct {
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Items []RestockItemResult `json:"items"`
|
||||
Reason string `json:"reason"`
|
||||
RestockedAt time.Time `json:"restocked_at"`
|
||||
OutletID uuid.UUID `json:"outlet_id"`
|
||||
Items []RestockItemResult `json:"items"`
|
||||
Reason string `json:"reason"`
|
||||
RestockedAt time.Time `json:"restocked_at"`
|
||||
}
|
||||
|
||||
type RestockItemResult 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"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
PaidQuantity int `json:"paid_quantity"`
|
||||
}
|
||||
|
||||
type ListOrdersQuery struct {
|
||||
@@ -174,9 +186,13 @@ type SetOrderCustomerResponse struct {
|
||||
}
|
||||
|
||||
type CreatePaymentRequest struct {
|
||||
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,min=0"`
|
||||
OrderID uuid.UUID `json:"order_id" validate:"required"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id" validate:"required"`
|
||||
// For the EnakPoint method: how many to use and the code the customer shows. The
|
||||
// amount is then computed from them.
|
||||
Points *int64 `json:"points,omitempty"`
|
||||
PaymentCode *string `json:"payment_code,omitempty"`
|
||||
Amount float64 `json:"amount" validate:"min=0"`
|
||||
TransactionID *string `json:"transaction_id,omitempty" validate:"omitempty"`
|
||||
SplitNumber int `json:"split_number,omitempty" validate:"omitempty,min=1"`
|
||||
SplitTotal int `json:"split_total,omitempty" validate:"omitempty,min=1"`
|
||||
@@ -192,19 +208,23 @@ type CreatePaymentOrderItemRequest struct {
|
||||
}
|
||||
|
||||
type PaymentResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id"`
|
||||
PaymentMethodName string `json:"payment_method_name"`
|
||||
PaymentMethodType string `json:"payment_method_type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
TransactionID *string `json:"transaction_id,omitempty"`
|
||||
SplitNumber int `json:"split_number"`
|
||||
SplitTotal int `json:"split_total"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
SplitDescription *string `json:"split_description,omitempty"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrderID uuid.UUID `json:"order_id"`
|
||||
PaymentMethodID uuid.UUID `json:"payment_method_id"`
|
||||
PaymentMethodName string `json:"payment_method_name"`
|
||||
PaymentMethodType string `json:"payment_method_type"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
TransactionID *string `json:"transaction_id,omitempty"`
|
||||
SplitNumber int `json:"split_number"`
|
||||
SplitTotal int `json:"split_total"`
|
||||
SplitType *string `json:"split_type,omitempty"`
|
||||
SplitDescription *string `json:"split_description,omitempty"`
|
||||
RefundAmount float64 `json:"refund_amount"`
|
||||
// Set for a payment with EnakPoint, for "EnakPoint: 50.000 (Rp 50.000)" on the
|
||||
// receipt (docs/prd-point-coin.md F9).
|
||||
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||
PointValue *float64 `json:"point_value,omitempty"`
|
||||
RefundReason *string `json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `json:"refunded_by,omitempty"`
|
||||
|
||||
@@ -18,7 +18,7 @@ type CreatePaymentMethodRequest struct {
|
||||
|
||||
type UpdatePaymentMethodRequest struct {
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=100"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
|
||||
Configuration map[string]interface{} `json:"configuration,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
@@ -38,11 +38,13 @@ type PaymentMethodResponse struct {
|
||||
|
||||
type ListPaymentMethodsRequest struct {
|
||||
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
// When set, EnakPoint is left out unless the outlet accepts it (F9).
|
||||
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc point"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Search string `json:"search,omitempty"`
|
||||
Page int `json:"page" validate:"min=1"`
|
||||
Limit int `json:"limit" validate:"min=1,max=100"`
|
||||
}
|
||||
|
||||
type ListPaymentMethodsResponse struct {
|
||||
|
||||
@@ -18,6 +18,8 @@ type CreateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Variants []CreateProductVariantRequest `json:"variants,omitempty"`
|
||||
@@ -38,6 +40,8 @@ type UpdateProductRequest struct {
|
||||
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
|
||||
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
|
||||
PrintToChecker *bool `json:"print_to_checker,omitempty"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy *string `json:"sell_by,omitempty" validate:"omitempty,oneof=unit weight"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
|
||||
@@ -73,6 +77,8 @@ type ProductResponse struct {
|
||||
BusinessType string `json:"business_type"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
PrinterType string `json:"printer_type"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty"`
|
||||
SellBy string `json:"sell_by"`
|
||||
PrintToChecker bool `json:"print_to_checker"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
IsActive bool `json:"is_active"`
|
||||
|
||||
@@ -34,34 +34,34 @@ type BulkCreateProductRecipeRequest struct {
|
||||
|
||||
// Response structures
|
||||
type ProductRecipeResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
VariantID *uuid.UUID `json:"variant_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Product *ProductResponse `json:"product,omitempty"`
|
||||
ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
VariantID *uuid.UUID `json:"variant_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Product *ProductResponse `json:"product,omitempty"`
|
||||
ProductVariant *ProductVariantResponse `json:"product_variant,omitempty"`
|
||||
Ingredient *ProductRecipeIngredientResponse `json:"ingredient,omitempty"`
|
||||
}
|
||||
|
||||
type ProductRecipeIngredientResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id"`
|
||||
Name string `json:"name"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
IsActive bool `json:"is_active"`
|
||||
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"`
|
||||
Name string `json:"name"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Cost float64 `json:"cost"`
|
||||
Stock float64 `json:"stock"`
|
||||
IsSemiFinished bool `json:"is_semi_finished"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Unit *ProductRecipeUnitResponse `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
@@ -71,4 +71,4 @@ type ProductRecipeUnitResponse struct {
|
||||
Symbol string `json:"symbol"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type CreatePurchaseCategoryRequest struct {
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Name string `json:"name" validate:"required,min=1,max=255"`
|
||||
Type string `json:"type" validate:"required,oneof=raw_material expense"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseCategoryRequest struct {
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Code *string `json:"code,omitempty"`
|
||||
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
|
||||
Type *string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
}
|
||||
|
||||
type ListPurchaseCategoriesRequest struct {
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
Type string `json:"type,omitempty" validate:"omitempty,oneof=raw_material expense"`
|
||||
Search string `json:"search,omitempty"`
|
||||
IsActive *bool `json:"is_active,omitempty"`
|
||||
Page int `json:"page" validate:"required,min=1"`
|
||||
Limit int `json:"limit" validate:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type PurchaseCategoryResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id"`
|
||||
PresetID *uuid.UUID `json:"preset_id"`
|
||||
ParentID *uuid.UUID `json:"parent_id"`
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListPurchaseCategoriesResponse struct {
|
||||
PurchaseCategories []PurchaseCategoryResponse `json:"purchase_categories"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -7,50 +7,63 @@ 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"`
|
||||
}
|
||||
|
||||
type CreatePurchaseOrderItemRequest struct {
|
||||
IngredientID uuid.UUID `json:"ingredient_id" validate:"required"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity float64 `json:"quantity" validate:"required,gt=0"`
|
||||
UnitID uuid.UUID `json:"unit_id" validate:"required"`
|
||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Amount float64 `json:"amount" validate:"required,gte=0"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type UpdatePurchaseOrderItemRequest struct {
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
||||
ID *uuid.UUID `json:"id,omitempty"` // For existing items
|
||||
IngredientID *uuid.UUID `json:"ingredient_id,omitempty" validate:"omitempty"`
|
||||
PurchaseCategoryID *uuid.UUID `json:"purchase_category_id,omitempty" validate:"omitempty"`
|
||||
Description *string `json:"description,omitempty" validate:"omitempty"`
|
||||
Quantity *float64 `json:"quantity,omitempty" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `json:"unit_id,omitempty" validate:"omitempty"`
|
||||
Amount *float64 `json:"amount,omitempty" validate:"omitempty,gte=0"`
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -58,25 +71,44 @@ 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"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
|
||||
Unit *UnitResponse `json:"unit,omitempty"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
PurchaseOrderID uuid.UUID `json:"purchase_order_id"`
|
||||
IngredientID *uuid.UUID `json:"ingredient_id"`
|
||||
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
|
||||
Description *string `json:"description"`
|
||||
Quantity *float64 `json:"quantity"`
|
||||
UnitID *uuid.UUID `json:"unit_id"`
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Ingredient *IngredientResponse `json:"ingredient,omitempty"`
|
||||
PurchaseCategory *PurchaseCategoryResponse `json:"purchase_category,omitempty"`
|
||||
Unit *UnitResponse `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
type PurchaseOrderAttachmentResponse struct {
|
||||
@@ -88,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 {
|
||||
|
||||
@@ -58,6 +58,7 @@ type SelfOrderCreateOrderItem struct {
|
||||
ProductID uuid.UUID `json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
|
||||
Quantity int `json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `json:"weight,omitempty" validate:"omitempty,gt=0"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ type SpinGameResponse struct {
|
||||
|
||||
// SpinGameResponseData contains the game play result
|
||||
type SpinGameResponseData struct {
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
TokensRemaining int64 `json:"tokens_remaining"`
|
||||
GamePlay GamePlayResponse `json:"game_play"`
|
||||
PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"`
|
||||
CoinsRemaining int64 `json:"coins_remaining"`
|
||||
// Deprecated: the same as CoinsRemaining, kept until the apps read coins_remaining.
|
||||
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"`
|
||||
}
|
||||
+154
-28
@@ -14,6 +14,8 @@ type PaymentMethodAnalytics struct {
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
PaymentCount int64 `json:"payment_count"`
|
||||
// EnakPoint used, for the EnakPoint method.
|
||||
PointsUsed int64 `json:"points_used"`
|
||||
}
|
||||
|
||||
// SalesAnalytics represents sales analytics data
|
||||
@@ -27,6 +29,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,24 +44,49 @@ 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 {
|
||||
TotalPurchases float64 `json:"total_purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
TotalPurchaseOrders int64 `json:"total_purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
TotalQuantity float64 `json:"total_quantity"`
|
||||
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 {
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
Date time.Time `json:"date"`
|
||||
Purchases float64 `json:"purchases"`
|
||||
RawMaterialPurchases float64 `json:"raw_material_purchases"`
|
||||
ExpensePurchases float64 `json:"expense_purchases"`
|
||||
PurchaseOrders int64 `json:"purchase_orders"`
|
||||
RawMaterialPurchaseOrders int64 `json:"raw_material_purchase_orders"`
|
||||
ExpenseCount int64 `json:"expense_count"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Ingredients int64 `json:"ingredients"`
|
||||
Vendors int64 `json:"vendors"`
|
||||
}
|
||||
|
||||
type PurchasingIngredientData struct {
|
||||
@@ -64,22 +99,24 @@ 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 {
|
||||
ProductID uuid.UUID `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
ProductSku string `json:"product_sku"`
|
||||
ProductPrice float64 `json:"product_price"`
|
||||
CategoryID uuid.UUID `json:"category_id"`
|
||||
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"`
|
||||
@@ -103,27 +140,77 @@ 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -177,3 +264,42 @@ type OperationalExpenseItem struct {
|
||||
Item string
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type ExclusiveSummaryAnalytics struct {
|
||||
SalesTotal float64
|
||||
SalesCount int64
|
||||
HPPBreakdown []ExclusiveSummaryCategoryTotal
|
||||
OperationalExpenseBreakdown []ExclusiveSummaryCategoryTotal
|
||||
DailySummary []ExclusiveSummaryDailySummary
|
||||
DailyTransactions []ExclusiveSummaryDailyTransaction
|
||||
}
|
||||
|
||||
type ExclusiveSummaryCategoryTotal struct {
|
||||
CategoryCode string
|
||||
CategoryName string
|
||||
Amount float64
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailySummary struct {
|
||||
Date time.Time
|
||||
TransactionCount int64
|
||||
TotalCost float64
|
||||
}
|
||||
|
||||
type ExclusiveSummaryDailyTransaction struct {
|
||||
Date time.Time
|
||||
CategoryCode string
|
||||
CategoryName string
|
||||
Description string
|
||||
Amount float64
|
||||
Source string
|
||||
}
|
||||
|
||||
type ExclusiveSummaryBankBalance struct {
|
||||
Bank string
|
||||
OpeningBalance *float64
|
||||
IncomingMutation *float64
|
||||
OutgoingMutation *float64
|
||||
ClosingBalance *float64
|
||||
Notes *string
|
||||
}
|
||||
|
||||
@@ -12,10 +12,31 @@ type CampaignType string
|
||||
const (
|
||||
CampaignTypeReward CampaignType = "REWARD"
|
||||
CampaignTypePoints CampaignType = "POINTS"
|
||||
CampaignTypeTokens CampaignType = "TOKENS"
|
||||
CampaignTypeCoins CampaignType = "COINS"
|
||||
CampaignTypeMixed CampaignType = "MIXED"
|
||||
)
|
||||
|
||||
// legacyCampaignTokens is what COINS was called before tokens became EnakCoin
|
||||
// (docs/prd-point-coin.md §10). Requests may still send it; it is stored as COINS.
|
||||
const legacyCampaignTokens = "TOKENS"
|
||||
|
||||
// NormalizeCampaignType maps the legacy TOKENS to COINS and leaves anything else as is.
|
||||
func NormalizeCampaignType(t string) CampaignType {
|
||||
if t == legacyCampaignTokens {
|
||||
return CampaignTypeCoins
|
||||
}
|
||||
return CampaignType(t)
|
||||
}
|
||||
|
||||
// NormalizeCampaignRewardType maps the legacy TOKENS to COINS and leaves anything
|
||||
// else as is.
|
||||
func NormalizeCampaignRewardType(t string) CampaignRewardType {
|
||||
if t == legacyCampaignTokens {
|
||||
return CampaignRewardTypeCoins
|
||||
}
|
||||
return CampaignRewardType(t)
|
||||
}
|
||||
|
||||
type RuleType string
|
||||
|
||||
const (
|
||||
@@ -31,7 +52,7 @@ type CampaignRewardType string
|
||||
|
||||
const (
|
||||
CampaignRewardTypePoints CampaignRewardType = "POINTS"
|
||||
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
|
||||
CampaignRewardTypeCoins CampaignRewardType = "COINS"
|
||||
CampaignRewardTypeReward CampaignRewardType = "REWARD"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
|
||||
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
|
||||
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
|
||||
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
|
||||
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
|
||||
|
||||
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
|
||||
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
|
||||
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
|
||||
}
|
||||
@@ -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,6 +34,8 @@ 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"`
|
||||
|
||||
@@ -43,6 +43,13 @@ func GetAllEntities() []interface{} {
|
||||
&NotificationDelivery{},
|
||||
&ProductOutletPrice{},
|
||||
&Expense{},
|
||||
&CashAdvance{},
|
||||
// Wallet entities
|
||||
&CustomerWallet{},
|
||||
&WalletTransaction{},
|
||||
&WalletLot{},
|
||||
&WalletLotAllocation{},
|
||||
&LoyaltySettingChange{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,14 +20,67 @@ 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"`
|
||||
}
|
||||
|
||||
type ExpenseAnalytics struct {
|
||||
Summary ExpenseAnalyticsSummary
|
||||
Data []ExpenseAnalyticsData
|
||||
CategoryData []ExpenseAnalyticsCategoryData
|
||||
ChartOfAccountData []ExpenseAnalyticsChartOfAccountData
|
||||
ItemData []ExpenseAnalyticsItemData
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsSummary struct {
|
||||
TotalExpenses float64
|
||||
TotalExpenseCount int64
|
||||
TotalTax float64
|
||||
AverageExpenseValue float64
|
||||
TotalCategories int64
|
||||
TotalItems int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsData struct {
|
||||
Date time.Time
|
||||
Expenses float64
|
||||
ExpenseCount int64
|
||||
Tax float64
|
||||
Items int64
|
||||
Categories int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsCategoryData struct {
|
||||
PurchaseCategoryID uuid.UUID
|
||||
PurchaseCategoryName string
|
||||
PurchaseCategoryType string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsChartOfAccountData struct {
|
||||
ChartOfAccountID uuid.UUID
|
||||
ChartOfAccountName string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
type ExpenseAnalyticsItemData struct {
|
||||
Item string
|
||||
TotalAmount float64
|
||||
ExpenseCount int64
|
||||
ItemCount int64
|
||||
}
|
||||
|
||||
func (e *Expense) BeforeCreate(tx *gorm.DB) error {
|
||||
if e.ID == uuid.Nil {
|
||||
e.ID = uuid.New()
|
||||
|
||||
@@ -9,17 +9,19 @@ import (
|
||||
)
|
||||
|
||||
type ExpenseItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
|
||||
Item string `gorm:"not null;size:255" json:"item"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
|
||||
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"`
|
||||
ExpenseID uuid.UUID `gorm:"type:uuid;not null;index" json:"expense_id"`
|
||||
ChartOfAccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"chart_of_account_id"`
|
||||
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id"`
|
||||
Item string `gorm:"not null;size:255" json:"item"`
|
||||
Description *string `gorm:"type:text" json:"description"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null;default:0" json:"amount"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
|
||||
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
|
||||
Expense *Expense `gorm:"foreignKey:ExpenseID" json:"expense,omitempty"`
|
||||
ChartOfAccount *ChartOfAccount `gorm:"foreignKey:ChartOfAccountID" json:"chart_of_account,omitempty"`
|
||||
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
|
||||
}
|
||||
|
||||
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -12,7 +12,7 @@ type GamePlay struct {
|
||||
GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"`
|
||||
CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"`
|
||||
PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"`
|
||||
TokenUsed int `gorm:"default:0" json:"token_used" validate:"min=0"`
|
||||
CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"`
|
||||
RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -39,4 +39,3 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -36,34 +36,36 @@ const (
|
||||
)
|
||||
|
||||
type InventoryMovement 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;not null;index" json:"outlet_id" validate:"required"`
|
||||
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
|
||||
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
|
||||
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
|
||||
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
|
||||
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
|
||||
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
|
||||
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
|
||||
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
|
||||
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
|
||||
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
|
||||
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
Notes *string `gorm:"type:text" json:"notes"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_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"`
|
||||
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id" validate:"required"`
|
||||
ItemID uuid.UUID `gorm:"type:uuid;not null;index" json:"item_id" validate:"required"`
|
||||
ItemType string `gorm:"not null;size:20" json:"item_type" validate:"required"` // "PRODUCT" or "INGREDIENT"
|
||||
MovementType InventoryMovementType `gorm:"not null;size:50" json:"movement_type" validate:"required"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null" json:"quantity" validate:"required"`
|
||||
PreviousQuantity float64 `gorm:"type:decimal(12,3)" json:"previous_quantity"`
|
||||
NewQuantity float64 `gorm:"type:decimal(12,3)" json:"new_quantity"`
|
||||
UnitCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"unit_cost"`
|
||||
TotalCost float64 `gorm:"type:decimal(12,2);default:0.00" json:"total_cost"`
|
||||
ReferenceType *InventoryMovementReferenceType `gorm:"size:50" json:"reference_type"`
|
||||
ReferenceID *uuid.UUID `gorm:"type:uuid;index" json:"reference_id"`
|
||||
PurchaseOrderItemID *uuid.UUID `gorm:"type:uuid;index" json:"purchase_order_item_id"`
|
||||
OrderID *uuid.UUID `gorm:"type:uuid;index" json:"order_id"`
|
||||
PaymentID *uuid.UUID `gorm:"type:uuid;index" json:"payment_id"`
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id" validate:"required"`
|
||||
Reason *string `gorm:"size:255" json:"reason"`
|
||||
Notes *string `gorm:"type:text" json:"notes"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
|
||||
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Product *Product `gorm:"foreignKey:ItemID" json:"product,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:ItemID" json:"ingredient,omitempty"`
|
||||
PurchaseOrderItem *PurchaseOrderItem `gorm:"foreignKey:PurchaseOrderItemID" json:"purchase_order_item,omitempty"`
|
||||
Order *Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (im *InventoryMovement) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -26,14 +26,14 @@ type OrderIngredientTransaction struct {
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
Organization Organization `gorm:"foreignKey:OrganizationID" json:"organization,omitempty"`
|
||||
Outlet *Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
OrderItem *OrderItem `gorm:"foreignKey:OrderItemID" json:"order_item,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"`
|
||||
Ingredient Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
CreatedByUser User `gorm:"foreignKey:CreatedBy" json:"created_by_user,omitempty"`
|
||||
}
|
||||
|
||||
func (oit *OrderIngredientTransaction) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -47,6 +48,8 @@ type OrderItem struct {
|
||||
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id" validate:"required"`
|
||||
ProductVariantID *uuid.UUID `gorm:"type:uuid;index" json:"product_variant_id"`
|
||||
Quantity int `gorm:"not null" json:"quantity" validate:"required,min=1"`
|
||||
Weight *float64 `gorm:"type:decimal(12,3)" json:"weight,omitempty"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id,omitempty"`
|
||||
UnitPrice float64 `gorm:"type:decimal(10,2);not null" json:"unit_price" validate:"required,min=0"`
|
||||
TotalPrice float64 `gorm:"type:decimal(10,2);not null" json:"total_price" validate:"required,min=0"`
|
||||
UnitCost float64 `gorm:"type:decimal(10,2);default:0.00" json:"unit_cost"`
|
||||
@@ -68,6 +71,7 @@ type OrderItem struct {
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
||||
ProductVariant *ProductVariant `gorm:"foreignKey:ProductVariantID" json:"product_variant,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (oi *OrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
@@ -81,8 +85,57 @@ func (OrderItem) TableName() string {
|
||||
return "order_items"
|
||||
}
|
||||
|
||||
// IsWeighed reports whether this line was sold by weight rather than by count.
|
||||
func (oi *OrderItem) IsWeighed() bool {
|
||||
return oi.Weight != nil
|
||||
}
|
||||
|
||||
// BillableQuantity is the multiplier for unit price and unit cost on this line:
|
||||
// the weighed amount for weight-based products, the item count otherwise.
|
||||
//
|
||||
// This is the ONLY place that decides between the two. Every price or cost
|
||||
// calculation must go through it — multiplying by Quantity directly would bill a
|
||||
// 4.2 ons fish as a single ons.
|
||||
//
|
||||
// Weighed lines are pinned to quantity = 1 by chk_order_items_weight_single_line,
|
||||
// so there is no ambiguity between counting and weighing.
|
||||
func (oi *OrderItem) BillableQuantity() float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(oi.Quantity)
|
||||
}
|
||||
|
||||
// BillableQuantityFor is BillableQuantity for a partial action on this line, such as
|
||||
// voiding or refunding `units` of it.
|
||||
//
|
||||
// Weighed lines are always acted on in full — quantity is 1, so any void or refund
|
||||
// covers the whole weighing — hence the full weight is returned regardless of units.
|
||||
func (oi *OrderItem) BillableQuantityFor(units int) float64 {
|
||||
if oi.Weight != nil {
|
||||
return *oi.Weight
|
||||
}
|
||||
return float64(units)
|
||||
}
|
||||
|
||||
// RoundMoney rounds to the 2 decimals that every monetary column stores, so the
|
||||
// value written back always matches what the database keeps.
|
||||
func RoundMoney(amount float64) float64 {
|
||||
return math.Round(amount*100) / 100
|
||||
}
|
||||
|
||||
// RoundWeight rounds to the 3 decimals of order_items.weight, so a validated value
|
||||
// is identical to the stored one.
|
||||
func RoundWeight(weight float64) float64 {
|
||||
return math.Round(weight*1000) / 1000
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalPrice() {
|
||||
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
|
||||
oi.TotalPrice = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CalculateTotalCost() {
|
||||
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
|
||||
}
|
||||
|
||||
func (oi *OrderItem) CanBeModified() bool {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package entities
|
||||
|
||||
import "testing"
|
||||
|
||||
func weightPtr(v float64) *float64 { return &v }
|
||||
|
||||
func TestBillableQuantity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item OrderItem
|
||||
want float64
|
||||
}{
|
||||
{"unit line counts items", OrderItem{Quantity: 3}, 3},
|
||||
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
|
||||
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.item.BillableQuantity(); got != tt.want {
|
||||
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A weighed line is voided or refunded whole, so the units asked for must not shrink
|
||||
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
|
||||
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
|
||||
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
|
||||
if got := weighed.BillableQuantityFor(1); got != 5.6 {
|
||||
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
|
||||
}
|
||||
|
||||
counted := OrderItem{Quantity: 3}
|
||||
if got := counted.BillableQuantityFor(2); got != 2 {
|
||||
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
|
||||
// must not be billed as a single ons.
|
||||
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 18900 {
|
||||
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 12600 {
|
||||
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
|
||||
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
|
||||
item.CalculateTotalPrice()
|
||||
item.CalculateTotalCost()
|
||||
|
||||
if item.TotalPrice != 75000 {
|
||||
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
|
||||
}
|
||||
if item.TotalCost != 30000 {
|
||||
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-rupiah residue must not survive into a decimal(10,2) column.
|
||||
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
|
||||
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
|
||||
item.CalculateTotalPrice()
|
||||
|
||||
if item.TotalPrice != 19066.5 {
|
||||
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeighed(t *testing.T) {
|
||||
if (&OrderItem{Quantity: 1}).IsWeighed() {
|
||||
t.Error("unit line reported as weighed")
|
||||
}
|
||||
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
|
||||
t.Error("weighed line reported as unit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundWeight(t *testing.T) {
|
||||
tests := []struct {
|
||||
in float64
|
||||
want float64
|
||||
}{
|
||||
{4.2, 4.2},
|
||||
{4.2374, 4.237},
|
||||
{4.2375, 4.238},
|
||||
{0.0004, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := RoundWeight(tt.in); got != tt.want {
|
||||
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,16 @@ const (
|
||||
PaymentMethodTypeCash PaymentMethodType = "cash"
|
||||
PaymentMethodTypeCard PaymentMethodType = "card"
|
||||
PaymentMethodTypeDigitalWallet PaymentMethodType = "digital_wallet"
|
||||
// The system method for paying with EnakPoint (docs/prd-point-coin.md F9). One per
|
||||
// organization; it cannot be created, deleted or retyped through the API.
|
||||
PaymentMethodTypePoint PaymentMethodType = "point"
|
||||
)
|
||||
|
||||
type PaymentMethod struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
OrganizationID uuid.UUID `gorm:"type:uuid;not null;index" json:"organization_id" validate:"required"`
|
||||
Name string `gorm:"not null;size:100" json:"name" validate:"required,min=1,max=100"`
|
||||
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
|
||||
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet point"`
|
||||
Processor *string `gorm:"size:100" json:"processor"`
|
||||
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
@@ -69,12 +72,16 @@ type Payment struct {
|
||||
SplitType *SplitType `gorm:"size:20" json:"split_type,omitempty"`
|
||||
SplitDescription *string `gorm:"size:255" json:"split_description,omitempty"`
|
||||
RefundAmount float64 `gorm:"type:decimal(10,2);default:0.00" json:"refund_amount"`
|
||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
// Set only for a payment with EnakPoint: how many were used, and the rupiah value of
|
||||
// one then, frozen so a refund returns exactly what was used.
|
||||
PointsUsed *int64 `json:"points_used,omitempty"`
|
||||
PointValue *float64 `gorm:"type:decimal(10,2)" json:"point_value,omitempty"`
|
||||
RefundReason *string `gorm:"size:255" json:"refund_reason,omitempty"`
|
||||
RefundedAt *time.Time `gorm:"" json:"refunded_at,omitempty"`
|
||||
RefundedBy *uuid.UUID `gorm:"type:uuid" json:"refunded_by,omitempty"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Order Order `gorm:"foreignKey:OrderID" json:"order,omitempty"`
|
||||
PaymentMethod PaymentMethod `gorm:"foreignKey:PaymentMethodID" json:"payment_method,omitempty"`
|
||||
|
||||
@@ -20,6 +20,7 @@ type Product struct {
|
||||
ImageURL *string `gorm:"size:500" json:"image_url"`
|
||||
PrinterType string `gorm:"size:50;default:'kitchen'" json:"printer_type"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid;index" json:"unit_id"`
|
||||
SellBy string `gorm:"size:20;default:'unit'" json:"sell_by"`
|
||||
HasIngredients bool `gorm:"default:false" json:"has_ingredients"`
|
||||
Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
|
||||
@@ -7,15 +7,15 @@ import (
|
||||
)
|
||||
|
||||
type ProductIngredient struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id" db:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity" db:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
OrganizationID uuid.UUID `json:"organization_id" db:"organization_id"`
|
||||
OutletID *uuid.UUID `json:"outlet_id" db:"outlet_id"`
|
||||
ProductID uuid.UUID `json:"product_id" db:"product_id"`
|
||||
IngredientID uuid.UUID `json:"ingredient_id" db:"ingredient_id"`
|
||||
Quantity float64 `json:"quantity" db:"quantity"`
|
||||
WastePercentage float64 `json:"waste_percentage" db:"waste_percentage"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
|
||||
// Relations
|
||||
Product *Product `json:"product,omitempty"`
|
||||
|
||||
@@ -34,4 +34,4 @@ func (pr *ProductRecipe) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
func (ProductRecipe) TableName() string {
|
||||
return "product_recipes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PurchaseCategoryType string
|
||||
|
||||
const (
|
||||
PurchaseCategoryTypeRawMaterial PurchaseCategoryType = "raw_material"
|
||||
PurchaseCategoryTypeExpense PurchaseCategoryType = "expense"
|
||||
)
|
||||
|
||||
type PurchaseCategoryPreset struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Code string `gorm:"not null;unique;size:100" json:"code"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
IsActive bool `gorm:"not null;default:true" json:"is_active"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
Parent *PurchaseCategoryPreset `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
}
|
||||
|
||||
func (p *PurchaseCategoryPreset) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.ID == uuid.Nil {
|
||||
p.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PurchaseCategoryPreset) TableName() string {
|
||||
return "purchase_category_presets"
|
||||
}
|
||||
|
||||
type PurchaseCategory 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"`
|
||||
PresetID *uuid.UUID `gorm:"type:uuid;index" json:"preset_id"`
|
||||
ParentID *uuid.UUID `gorm:"type:uuid;index" json:"parent_id"`
|
||||
Code string `gorm:"not null;size:100" json:"code"`
|
||||
Name string `gorm:"not null;size:255" json:"name"`
|
||||
Type PurchaseCategoryType `gorm:"not null;size:20" json:"type"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
IsSystem bool `gorm:"not null;default:false" json:"is_system"`
|
||||
IsActive bool `gorm:"not null;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"`
|
||||
Preset *PurchaseCategoryPreset `gorm:"foreignKey:PresetID" json:"preset,omitempty"`
|
||||
Parent *PurchaseCategory `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
|
||||
Children []PurchaseCategory `gorm:"foreignKey:ParentID" json:"children,omitempty"`
|
||||
}
|
||||
|
||||
func (c *PurchaseCategory) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (PurchaseCategory) TableName() string {
|
||||
return "purchase_categories"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -41,19 +52,21 @@ func (PurchaseOrder) TableName() string {
|
||||
}
|
||||
|
||||
type PurchaseOrderItem struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
||||
IngredientID uuid.UUID `gorm:"type:uuid;not null" json:"ingredient_id" validate:"required"`
|
||||
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
||||
Quantity float64 `gorm:"type:decimal(10,3);not null" json:"quantity" validate:"required,gt=0"`
|
||||
UnitID uuid.UUID `gorm:"type:uuid;not null" json:"unit_id" validate:"required"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
||||
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"`
|
||||
PurchaseOrderID uuid.UUID `gorm:"type:uuid;not null" json:"purchase_order_id" validate:"required"`
|
||||
IngredientID *uuid.UUID `gorm:"type:uuid" json:"ingredient_id" validate:"omitempty"`
|
||||
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_id" validate:"required"`
|
||||
Description *string `gorm:"type:text" json:"description" validate:"omitempty"`
|
||||
Quantity *float64 `gorm:"type:decimal(10,3)" json:"quantity" validate:"omitempty,gt=0"`
|
||||
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" validate:"omitempty"`
|
||||
Amount float64 `gorm:"type:decimal(15,2);not null" json:"amount" validate:"required,gte=0"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
PurchaseOrder *PurchaseOrder `gorm:"foreignKey:PurchaseOrderID" json:"purchase_order,omitempty"`
|
||||
Ingredient *Ingredient `gorm:"foreignKey:IngredientID" json:"ingredient,omitempty"`
|
||||
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
|
||||
Unit *Unit `gorm:"foreignKey:UnitID" json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
func (poi *PurchaseOrderItem) BeforeCreate(tx *gorm.DB) error {
|
||||
|
||||
@@ -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,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)
|
||||
@@ -210,3 +259,87 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
|
||||
contractResp := transformer.ProfitLossAnalyticsModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetProfitLossAnalytics")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetExclusiveSummaryPeriod(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExclusiveSummaryPeriodRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq, err := transformer.ExclusiveSummaryPeriodContractToModel(&req)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.analyticsService.GetExclusiveSummaryPeriod(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryPeriod", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
return
|
||||
}
|
||||
|
||||
contractResp := transformer.ExclusiveSummaryPeriodModelToContract(response)
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetExclusiveSummaryPeriod")
|
||||
}
|
||||
|
||||
func (h *AnalyticsHandler) GetExclusiveSummaryMonthly(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExclusiveSummaryMonthlyRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
return
|
||||
}
|
||||
|
||||
req.OrganizationID = contextInfo.OrganizationID
|
||||
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
|
||||
modelReq, err := transformer.ExclusiveSummaryMonthlyContractToModel(&req)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.analyticsService.GetExclusiveSummaryMonthly(ctx, modelReq)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetExclusiveSummaryMonthly", err.Error())}), "AnalyticsHandler::GetExclusiveSummaryMonthly")
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
@@ -99,7 +99,7 @@ func (h *ChartOfAccountTypeHandler) DeleteChartOfAccountType(c *gin.Context) {
|
||||
func (h *ChartOfAccountTypeHandler) ListChartOfAccountTypes(c *gin.Context) {
|
||||
// Parse query parameters
|
||||
filters := make(map[string]interface{})
|
||||
|
||||
|
||||
if isActive := c.Query("is_active"); isActive != "" {
|
||||
if isActiveBool, err := strconv.ParseBool(isActive); err == nil {
|
||||
filters["is_active"] = isActiveBool
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerDeviceHandler serves /customer/devices, where the customer app registers
|
||||
// the FCM token of each device it runs on.
|
||||
type CustomerDeviceHandler struct {
|
||||
devices service.CustomerDeviceService
|
||||
}
|
||||
|
||||
func NewCustomerDeviceHandler(devices service.CustomerDeviceService) *CustomerDeviceHandler {
|
||||
return &CustomerDeviceHandler{devices: devices}
|
||||
}
|
||||
|
||||
// Register is PUT /customer/devices.
|
||||
func (h *CustomerDeviceHandler) Register(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Register")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RegisterCustomerDeviceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "device_id and fcm_token are required"),
|
||||
}), "CustomerDeviceHandler::Register")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Register(c.Request.Context(), customerID, &req), "CustomerDeviceHandler::Register")
|
||||
}
|
||||
|
||||
// Unregister is DELETE /customer/devices/:device_id, for logout.
|
||||
func (h *CustomerDeviceHandler) Unregister(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerDeviceHandler::Unregister")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.devices.Unregister(c.Request.Context(), customerID, c.Param("device_id")), "CustomerDeviceHandler::Unregister")
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerOrderPaymentHandler serves POST /customer/orders/:id/pay-with-points
|
||||
// (docs/prd-point-coin.md F9). The body holds the PIN, so it is never logged.
|
||||
type CustomerOrderPaymentHandler struct {
|
||||
payments service.CustomerOrderPaymentService
|
||||
}
|
||||
|
||||
func NewCustomerOrderPaymentHandler(payments service.CustomerOrderPaymentService) *CustomerOrderPaymentHandler {
|
||||
return &CustomerOrderPaymentHandler{payments: payments}
|
||||
}
|
||||
|
||||
func (h *CustomerOrderPaymentHandler) PayWithPoints(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerOrderPaymentHandler::PayWithPoints")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
orderID, ok := parseUUIDParam(c, "id", "CustomerOrderPaymentHandler::PayWithPoints")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.PayWithPointsRequest
|
||||
if !bindPinRequest(c, &req, "CustomerOrderPaymentHandler::PayWithPoints") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.payments.PayWithPoints(c.Request.Context(), customerID, orderID, &req, pinRequestInfo(c)), "CustomerOrderPaymentHandler::PayWithPoints")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// CustomerPinHandler serves /customer/pin and the dashboard's PIN endpoints
|
||||
// (docs/prd-point-coin.md F11). Request bodies hold PINs, so nothing here logs a body,
|
||||
// and binding errors are reported without the values sent.
|
||||
type CustomerPinHandler struct {
|
||||
pinService service.CustomerPinService
|
||||
}
|
||||
|
||||
func NewCustomerPinHandler(pinService service.CustomerPinService) *CustomerPinHandler {
|
||||
return &CustomerPinHandler{pinService: pinService}
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) Status(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::Status")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.Status(c.Request.Context(), customerID), "CustomerPinHandler::Status")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) RequestOtp(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::RequestOtp")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RequestPinOtpRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::RequestOtp") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.RequestOtp(c.Request.Context(), customerID, &req), "CustomerPinHandler::RequestOtp")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) CreatePin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::CreatePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.CreateCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::CreatePin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.CreatePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::CreatePin")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) ChangePin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ChangePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ChangeCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::ChangePin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ChangePin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ChangePin")
|
||||
}
|
||||
|
||||
func (h *CustomerPinHandler) ResetPin(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::ResetPin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ResetCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::ResetPin") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ResetPin(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::ResetPin")
|
||||
}
|
||||
|
||||
// RemovePin is DELETE /marketing/customers/:id/pin.
|
||||
func (h *CustomerPinHandler) RemovePin(c *gin.Context) {
|
||||
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::RemovePin")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.RemoveCustomerPinRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::RemovePin") {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.RemovePin(ctx, appcontext.FromGinContext(ctx), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::RemovePin")
|
||||
}
|
||||
|
||||
// ListSecurityEvents is GET /marketing/customers/:id/security-events.
|
||||
func (h *CustomerPinHandler) ListSecurityEvents(c *gin.Context) {
|
||||
customerID, ok := parseUUIDParam(c, "id", "CustomerPinHandler::ListSecurityEvents")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.ListSecurityEvents(ctx, appcontext.FromGinContext(ctx), customerID, page, limit), "CustomerPinHandler::ListSecurityEvents")
|
||||
}
|
||||
|
||||
// bindPinRequest binds a JSON body. The error it reports names what is wrong, never the
|
||||
// values, since those can be PINs.
|
||||
func bindPinRequest(c *gin.Context, req interface{}, method string) bool {
|
||||
if err := c.ShouldBindJSON(req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "invalid request body: required fields are missing or have the wrong type"),
|
||||
}), method)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// customerIDFromGin reads the customer set by CustomerAuthMiddleware.
|
||||
func customerIDFromGin(c *gin.Context, method string) (uuid.UUID, bool) {
|
||||
raw, _ := c.Get("customer_id")
|
||||
s, _ := raw.(string)
|
||||
id, err := uuid.Parse(s)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func pinRequestInfo(c *gin.Context) models.CustomerPinRequestInfo {
|
||||
return models.CustomerPinRequestInfo{IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent()}
|
||||
}
|
||||
|
||||
// IssuePaymentCode is POST /customer/wallet/payment-code.
|
||||
func (h *CustomerPinHandler) IssuePaymentCode(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerPinHandler::IssuePaymentCode")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.IssuePaymentCodeRequest
|
||||
if !bindPinRequest(c, &req, "CustomerPinHandler::IssuePaymentCode") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.pinService.IssuePaymentCode(c.Request.Context(), customerID, &req, pinRequestInfo(c)), "CustomerPinHandler::IssuePaymentCode")
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
@@ -46,7 +51,7 @@ func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerPoints")
|
||||
return
|
||||
}
|
||||
@@ -80,7 +85,7 @@ func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) {
|
||||
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()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerTokens")
|
||||
return
|
||||
}
|
||||
@@ -114,7 +119,7 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) {
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
@@ -151,3 +156,51 @@ func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame")
|
||||
}
|
||||
|
||||
// GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest
|
||||
// first, filterable by currency, type and date (docs/prd-point-coin.md F6).
|
||||
func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
customerID, ok := c.Get("customer_id")
|
||||
customerIDStr, isString := customerID.(string)
|
||||
if !ok || !isString {
|
||||
logger.FromContext(ctx).Error("Customer ID not found in context")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.customerPointsService.GetCustomerWalletTransactions(ctx, customerIDStr, query)
|
||||
if err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWalletTransactions -> service call failed")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(walletErrorCode(err), constants.RequestEntity, err.Error()),
|
||||
}), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWalletTransactions")
|
||||
}
|
||||
|
||||
// walletErrorCode tells a bad request apart from a server failure on the wallet
|
||||
// endpoints.
|
||||
func walletErrorCode(err error) string {
|
||||
switch {
|
||||
case errors.Is(err, processor.ErrInvalidWalletQuery):
|
||||
return constants.ValidationErrorCode
|
||||
case errors.Is(err, repository.ErrWalletNotFound):
|
||||
return constants.NotFoundErrorCode
|
||||
default:
|
||||
return constants.InternalServerErrorCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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, /tokens
|
||||
// and /wallet shapes. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json") // the handlers log their errors; the server sets this up at startup
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, customer, other := uuid.New(), uuid.New(), uuid.New()
|
||||
customers := []uuid.UUID{customer, other}
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'wallet api test', 'basic')`, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'A'), (?, ?, 'B')`, customer, org, other, org)
|
||||
exec(`INSERT INTO organization_settings (organization_id, key, value) VALUES (?, ?, '100')`, org, constants.LoyaltyPointValueKey)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id IN ?)`, customers)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM customers WHERE id IN ?`, customers)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Balance: 300 earned in two lots that expire on the same Jakarta day but on
|
||||
// different UTC days, 8 coins, 50 paid, and 20 in a lot that has expired but
|
||||
// that the expiry job has not processed.
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
txm := repository.NewTxManager(db)
|
||||
next := time.Now().Year() + 1
|
||||
lateNightUTC := time.Date(next, 7, 1, 17, 30, 0, 0, time.UTC) // 2 July 00:30 WIB
|
||||
afternoonUTC := time.Date(next, 7, 2, 10, 0, 0, 0, time.UTC) // 2 July 17:00 WIB
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
outlet, order, payment := uuid.New(), uuid.New(), uuid.New()
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn, Amount: 300, ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: order, OutletID: &outlet, Description: "Belanja #ORD-1"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 200, ExpiresAt: &afternoonUTC}, {Amount: 100, ExpiresAt: &lateNightUTC}},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyCoin, Type: constants.WalletTxTypeMigration,
|
||||
Amount: 8, ReferenceType: constants.WalletRefTypeLegacyTokens, ReferenceID: customer,
|
||||
Description: "Saldo awal dari sistem lama"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := wallet.Debit(ctx, processor.WalletDebitInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypePayment,
|
||||
Amount: 50, ReferenceType: constants.WalletRefTypePayment, ReferenceID: payment, OutletID: &outlet,
|
||||
Description: "Bayar #ORD-2"}}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{
|
||||
WalletEntry: processor.WalletEntry{CustomerID: customer, Currency: constants.WalletCurrencyPoint,
|
||||
Type: constants.WalletTxTypeEarn, Amount: 20, ReferenceType: constants.WalletRefTypeOrder,
|
||||
ReferenceID: uuid.New(), OutletID: &outlet, Description: "Belanja #ORD-0"},
|
||||
Lots: []processor.WalletLotInput{{Amount: 20, ExpiresAt: &expired}},
|
||||
})
|
||||
return err
|
||||
}))
|
||||
|
||||
h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))),
|
||||
nil,
|
||||
)))
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(id uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) { c.Set("customer_id", id.String()) }
|
||||
}
|
||||
for _, who := range []struct {
|
||||
prefix string
|
||||
id uuid.UUID
|
||||
}{{"/a", customer}, {"/b", other}, {"/gone", uuid.New()}} {
|
||||
g := router.Group(who.prefix, as(who.id))
|
||||
g.GET("/points", h.GetCustomerPoints)
|
||||
g.GET("/tokens", h.GetCustomerTokens)
|
||||
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 and /tokens keep their shape and show the spendable balance.
|
||||
status, body := get("/a/points")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 250, inner(body)["total_points"], "300 - 50; the expired 20 is not spendable")
|
||||
assert.Len(t, inner(body)["points_history"], 3)
|
||||
|
||||
status, body = get("/a/tokens")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 8, inner(body)["total_tokens"])
|
||||
|
||||
// /wallet: new fields, with the old ones still there.
|
||||
status, body = get("/a/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
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", "total_tokens", "points_history", "tokens_history", "last_updated"} {
|
||||
assert.Contains(t, w, legacy)
|
||||
}
|
||||
assert.EqualValues(t, 250, w["total_points"])
|
||||
|
||||
recent := w["recent_transactions"].([]any)
|
||||
require.Len(t, recent, 4)
|
||||
for _, raw := range recent {
|
||||
tx := raw.(map[string]any)
|
||||
assert.NotContains(t, tx, "counterparty_customer_id")
|
||||
assert.NotContains(t, tx, "metadata")
|
||||
if tx["amount"].(float64) > 0 {
|
||||
assert.Contains(t, tx, "source", tx["type"])
|
||||
assert.NotContains(t, tx, "destination")
|
||||
} else {
|
||||
assert.Equal(t, map[string]any{"type": "PAYMENT", "id": payment.String()}, tx["destination"])
|
||||
assert.NotContains(t, tx, "source")
|
||||
}
|
||||
}
|
||||
|
||||
// History with filters.
|
||||
status, body = get("/a/wallet/transactions?currency=POINT&type=EARN&limit=1&page=2")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
page := body["data"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"page": float64(2), "limit": float64(1), "total_count": float64(2), "total_pages": float64(2)}, page["pagination"])
|
||||
rows := page["data"].([]any)
|
||||
require.Len(t, rows, 1)
|
||||
first := rows[0].(map[string]any)
|
||||
assert.Equal(t, "Belanja #ORD-1", first["description"], "newest first, so page 2 is the older earning")
|
||||
assert.Equal(t, map[string]any{"type": "ORDER", "id": order.String()}, first["source"])
|
||||
assert.Equal(t, lateNightUTC.Format(time.RFC3339), parseTime(t, first["expires_at"]).UTC().Format(time.RFC3339))
|
||||
assert.Len(t, first["lots"], 2)
|
||||
|
||||
today := time.Now().In(time.FixedZone("WIB", 7*3600)).Format("2006-01-02")
|
||||
status, body = get("/a/wallet/transactions?from=" + today + "&to=" + today + "&type=PAYMENT,MIGRATION")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 2, body["data"].(map[string]any)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
status, _ = get("/a/wallet/transactions?currency=GOLD")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
status, _ = get("/a/wallet/transactions?limit=abc")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// A customer who has never had a wallet sees zeros, not an error.
|
||||
status, body = get("/b/wallet")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0, inner(body)["point_balance"])
|
||||
assert.Equal(t, []any{}, inner(body)["recent_transactions"])
|
||||
status, body = get("/b/wallet/transactions")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, []any{}, body["data"].(map[string]any)["data"])
|
||||
|
||||
// A token for a customer that no longer exists.
|
||||
status, _ = get("/gone/wallet")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
}
|
||||
|
||||
func parseTime(t *testing.T, v any) time.Time {
|
||||
t.Helper()
|
||||
s, ok := v.(string)
|
||||
require.True(t, ok, "expected a time string, got %v", v)
|
||||
parsed, err := time.Parse(time.RFC3339Nano, s)
|
||||
require.NoError(t, err)
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// The header exchanges and transfers require (docs/prd-point-coin.md F4, F5), and the
|
||||
// X- form the rest of the API uses, which is accepted too.
|
||||
const (
|
||||
idempotencyKeyHeader = "Idempotency-Key"
|
||||
legacyIdempotencyKeyHeader = "X-Idempotency-Key"
|
||||
)
|
||||
|
||||
// CustomerWalletHandler serves what the customer app does with its own wallet. Bodies
|
||||
// hold the PIN, so they are never logged.
|
||||
type CustomerWalletHandler struct {
|
||||
wallets service.CustomerWalletService
|
||||
}
|
||||
|
||||
func NewCustomerWalletHandler(wallets service.CustomerWalletService) *CustomerWalletHandler {
|
||||
return &CustomerWalletHandler{wallets: wallets}
|
||||
}
|
||||
|
||||
// PreviewExchange is GET /customer/wallet/exchange/preview?coins=.
|
||||
func (h *CustomerWalletHandler) PreviewExchange(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::PreviewExchange")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var coins int64
|
||||
if raw := c.Query("coins"); raw != "" {
|
||||
var err error
|
||||
coins, err = strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "coins must be a whole number"),
|
||||
}), "CustomerWalletHandler::PreviewExchange")
|
||||
return
|
||||
}
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.PreviewExchange(c.Request.Context(), customerID, coins), "CustomerWalletHandler::PreviewExchange")
|
||||
}
|
||||
|
||||
// Exchange is POST /customer/wallet/exchange.
|
||||
func (h *CustomerWalletHandler) Exchange(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Exchange")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.ExchangeCoinsRequest
|
||||
if !bindPinRequest(c, &req, "CustomerWalletHandler::Exchange") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.Exchange(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Exchange")
|
||||
}
|
||||
|
||||
func idempotencyKey(c *gin.Context) string {
|
||||
if key := strings.TrimSpace(c.GetHeader(idempotencyKeyHeader)); key != "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimSpace(c.GetHeader(legacyIdempotencyKeyHeader))
|
||||
}
|
||||
|
||||
// TransferRecipient is GET /customer/wallet/transfer/recipient?phone=.
|
||||
func (h *CustomerWalletHandler) TransferRecipient(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::TransferRecipient")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.TransferRecipient(c.Request.Context(), customerID, c.Query("phone")), "CustomerWalletHandler::TransferRecipient")
|
||||
}
|
||||
|
||||
// Transfer is POST /customer/wallet/transfer.
|
||||
func (h *CustomerWalletHandler) Transfer(c *gin.Context) {
|
||||
customerID, ok := customerIDFromGin(c, "CustomerWalletHandler::Transfer")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req contract.TransferWalletRequest
|
||||
if !bindPinRequest(c, &req, "CustomerWalletHandler::Transfer") {
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, h.wallets.Transfer(c.Request.Context(), customerID, &req, idempotencyKey(c), pinRequestInfo(c)), "CustomerWalletHandler::Transfer")
|
||||
}
|
||||
@@ -199,3 +199,31 @@ func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::ListExpenses")
|
||||
}
|
||||
|
||||
func (h *ExpenseHandler) GetExpenseAnalytics(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.ExpenseAnalyticsRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpenseAnalytics -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpenseAnalytics")
|
||||
return
|
||||
}
|
||||
|
||||
if contextInfo.OutletID != uuid.Nil {
|
||||
outletID := contextInfo.OutletID.String()
|
||||
req.OutletID = &outletID
|
||||
} else if outletID := c.Query("outlet_id"); outletID != "" {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
|
||||
expenseResponse := h.expenseService.GetExpenseAnalytics(ctx, contextInfo, &req)
|
||||
if expenseResponse.HasErrors() {
|
||||
errorResp := expenseResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpenseAnalytics -> Failed to get expense analytics from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpenseAnalytics")
|
||||
}
|
||||
|
||||
@@ -275,4 +275,3 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context)
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives GET/PUT /outlets/:id/loyalty-settings over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOutletLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, otherOrg, outlet, admin := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, org, otherOrg)
|
||||
exec(`INSERT INTO outlets (id, organization_id, name) VALUES (?, ?, 'Kemang')`, outlet, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
db.Exec(`DELETE FROM outlet_settings WHERE outlet_id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM outlets WHERE id = ?`, outlet)
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{org, otherOrg})
|
||||
})
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(orgID uuid.UUID, role string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, orgID.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string]struct {
|
||||
org uuid.UUID
|
||||
role string
|
||||
}{"/manager": {org, "manager"}, "/purchasing": {org, "purchasing"}, "/other": {otherOrg, "admin"}} {
|
||||
g := router.Group(prefix, as(who.org, who.role))
|
||||
g.GET("/outlets/:outlet_id/loyalty-settings", h.GetOutletSettings)
|
||||
g.PUT("/outlets/:outlet_id/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOutletSettings)
|
||||
}
|
||||
call := func(method, path string, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
path := "/outlets/" + outlet.String() + "/loyalty-settings"
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
// An outlet without settings shows every default and 1% cashback.
|
||||
status, body := call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(100), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["point"])
|
||||
assert.Equal(t, map[string]any{"enabled": false, "earn_per_amount": float64(25000), "earn_value": float64(1), "min_order_amount": float64(0), "max_per_order": nil}, got["coin"])
|
||||
assert.Equal(t, map[string]any{"accept_payment": false, "min_payment_points": float64(1), "max_payment_percent": float64(100)}, got["point_payment"])
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.EqualValues(t, 1, got["point_cashback_percent"])
|
||||
|
||||
// A partial update keeps everything it does not mention.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"point": {"enabled": true, "earn_per_amount": 1000}, "coin": {"max_per_order": 3}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["point"].(map[string]any)["enabled"])
|
||||
assert.EqualValues(t, 1000, got["point"].(map[string]any)["earn_per_amount"])
|
||||
assert.EqualValues(t, 1, got["point"].(map[string]any)["earn_value"], "untouched")
|
||||
assert.EqualValues(t, 25000, got["coin"].(map[string]any)["earn_per_amount"], "untouched")
|
||||
assert.EqualValues(t, 3, got["coin"].(map[string]any)["max_per_order"])
|
||||
assert.EqualValues(t, 0.1, got["point_cashback_percent"], "1 point per Rp 1.000 at Rp 1 a point")
|
||||
assert.Len(t, got["changes"], 3)
|
||||
|
||||
// The cashback follows the organization's point value.
|
||||
orgSettings, err := settings.Organization(context.Background(), org)
|
||||
require.NoError(t, err)
|
||||
orgSettings.PointValue = 5
|
||||
_, err = settings.UpdateOrganization(context.Background(), org, admin, *orgSettings)
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0.5, data(body)["point_cashback_percent"])
|
||||
|
||||
// Sending back what GET returned changes nothing.
|
||||
echo, err := json.Marshal(data(body))
|
||||
require.NoError(t, err)
|
||||
status, body = call(http.MethodPut, "/manager"+path, string(echo))
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Empty(t, data(body)["changes"])
|
||||
|
||||
// null clears a limit.
|
||||
status, body = call(http.MethodPut, "/manager"+path, `{"coin": {"max_per_order": null}}`)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Nil(t, data(body)["coin"].(map[string]any)["max_per_order"])
|
||||
|
||||
// Values out of bounds, unknown fields and bad JSON are refused and change nothing.
|
||||
for name, bad := range map[string]string{
|
||||
"earn_per_amount 0": `{"point": {"earn_per_amount": 0}}`,
|
||||
"negative earn_value": `{"coin": {"earn_value": -1}}`,
|
||||
"negative min_order": `{"point": {"min_order_amount": -5}}`,
|
||||
"negative max_per_order": `{"point": {"max_per_order": -1}}`,
|
||||
"payment percent over 100": `{"point_payment": {"max_payment_percent": 101}}`,
|
||||
"unknown field": `{"point": {"earn_per_amout": 50}}`,
|
||||
"wrong type": `{"point": {"enabled": "yes"}}`,
|
||||
"not json": `enabled=true`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager"+path, bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, body = call(http.MethodGet, "/manager"+path, "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1000, data(body)["point"].(map[string]any)["earn_per_amount"])
|
||||
|
||||
// Purchasing staff can read but not change; another organization sees nothing.
|
||||
status, _ = call(http.MethodPut, "/purchasing"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, _ = call(http.MethodGet, "/other"+path, "")
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodPut, "/other"+path, `{"point": {"enabled": false}}`)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = call(http.MethodGet, "/manager/outlets/not-a-uuid/loyalty-settings", "")
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Every change is in the history with who made it.
|
||||
history, err := settings.ListChanges(context.Background(), org, &outlet, 1, 100)
|
||||
require.NoError(t, err)
|
||||
assert.EqualValues(t, 4, history.Pagination.Total, "three keys, then the limit cleared")
|
||||
for _, c := range history.Data {
|
||||
assert.Equal(t, admin, c.ChangedBy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// loyaltySettingsBodyLimit caps a settings body; a real one is well under 2 KB.
|
||||
const loyaltySettingsBodyLimit = 64 << 10
|
||||
|
||||
// LoyaltySettingsHandler serves the loyalty settings (docs/prd-point-coin.md F1, F2).
|
||||
type LoyaltySettingsHandler struct {
|
||||
loyaltySettingsService service.LoyaltySettingsService
|
||||
}
|
||||
|
||||
func NewLoyaltySettingsHandler(loyaltySettingsService service.LoyaltySettingsService) *LoyaltySettingsHandler {
|
||||
return &LoyaltySettingsHandler{loyaltySettingsService: loyaltySettingsService}
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::GetOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.GetOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::GetOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::GetOutletSettings")
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) UpdateOutletSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
outletID, ok := parseUUIDParam(c, "outlet_id", "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
|
||||
}), "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOutletSettings(ctx, appcontext.FromGinContext(ctx), outletID, body)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOutletSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOutletSettings")
|
||||
}
|
||||
|
||||
// parseUUIDParam reads a UUID path parameter, answering 400 itself when it is not one.
|
||||
func parseUUIDParam(c *gin.Context, name, method string) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param(name))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid "+name),
|
||||
}), method)
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (h *LoyaltySettingsHandler) GetOrganizationSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.GetOrganizationSettings(ctx, appcontext.FromGinContext(ctx)), "LoyaltySettingsHandler::GetOrganizationSettings")
|
||||
}
|
||||
|
||||
// UpdateOrganizationSettings is PUT /marketing/loyalty-settings. With ?dry_run=true it
|
||||
// saves nothing and returns what would change and what the balances in circulation
|
||||
// would be worth, for the warning the dashboard shows before saving (F2).
|
||||
func (h *LoyaltySettingsHandler) UpdateOrganizationSettings(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, loyaltySettingsBodyLimit))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "unable to read request body"),
|
||||
}), "LoyaltySettingsHandler::UpdateOrganizationSettings")
|
||||
return
|
||||
}
|
||||
response := h.loyaltySettingsService.UpdateOrganizationSettings(ctx, appcontext.FromGinContext(ctx), body, c.Query("dry_run") == "true")
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("LoyaltySettingsHandler::UpdateOrganizationSettings -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "LoyaltySettingsHandler::UpdateOrganizationSettings")
|
||||
}
|
||||
|
||||
// ListHistory is GET /marketing/loyalty-settings/history, optionally for one outlet
|
||||
// (?outlet_id=).
|
||||
func (h *LoyaltySettingsHandler) ListHistory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var outletID *uuid.UUID
|
||||
if raw := c.Query("outlet_id"); raw != "" {
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet_id"),
|
||||
}), "LoyaltySettingsHandler::ListHistory")
|
||||
return
|
||||
}
|
||||
outletID = &id
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
util.HandleResponse(c.Writer, c.Request, h.loyaltySettingsService.ListHistory(ctx, appcontext.FromGinContext(ctx), outletID, page, limit), "LoyaltySettingsHandler::ListHistory")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/middleware"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
)
|
||||
|
||||
// Drives GET/PUT /marketing/loyalty-settings and its history over HTTP down to
|
||||
// Postgres. Needs TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestOrganizationLoyaltySettingsEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
org, admin, customer := uuid.New(), uuid.New(), uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic')`, org)
|
||||
exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM loyalty_setting_changes WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM organization_settings WHERE organization_id = ?`, org)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM organizations WHERE id = ?`, org)
|
||||
})
|
||||
|
||||
// Balances in circulation: 50.000 EnakPoint and 105 EnakCoin.
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db))
|
||||
for _, c := range []struct {
|
||||
currency, ref string
|
||||
amount int64
|
||||
}{{"POINT", "LEGACY_POINTS", 50000}, {"COIN", "LEGACY_TOKENS", 105}} {
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: c.currency, Type: "MIGRATION", Amount: c.amount,
|
||||
ReferenceType: c.ref, ReferenceID: uuid.New(), Description: "Saldo awal",
|
||||
Metadata: map[string]interface{}{"frozen": true}}})
|
||||
return err
|
||||
}))
|
||||
}
|
||||
|
||||
settings := processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), txm)
|
||||
h := NewLoyaltySettingsHandler(service.NewLoyaltySettingsService(settings, repository.NewWalletQueryRepository(db)))
|
||||
auth := middleware.NewAuthMiddleware(nil)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
for prefix, role := range map[string]string{"/manager": "manager", "/purchasing": "purchasing"} {
|
||||
role := role
|
||||
g := router.Group(prefix, func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserRoleKey, role)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
})
|
||||
g.GET("/loyalty-settings", h.GetOrganizationSettings)
|
||||
g.PUT("/loyalty-settings", auth.RequireLoyaltyManager(), h.UpdateOrganizationSettings)
|
||||
g.GET("/loyalty-settings/history", h.ListHistory)
|
||||
}
|
||||
call := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
data := func(body map[string]any) map[string]any { return body["data"].(map[string]any) }
|
||||
|
||||
status, body := call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got := data(body)
|
||||
assert.EqualValues(t, 1, got["point_value"])
|
||||
assert.Equal(t, map[string]any{"coin_amount": float64(1), "point_amount": float64(1)}, got["exchange"])
|
||||
assert.Equal(t, true, got["transfer"].(map[string]any)["enabled"])
|
||||
assert.Equal(t, "MONTH", got["point_expiry"].(map[string]any)["unit"])
|
||||
impact := got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["outstanding_points"])
|
||||
assert.EqualValues(t, impact["point_rupiah_before"], impact["point_rupiah_after"])
|
||||
|
||||
// A dry run shows what the balances in circulation would be worth, and saves nothing.
|
||||
change := `{"point_value": 100, "exchange": {"coin_amount": 10, "point_amount": 1}}`
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", change)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
got = data(body)
|
||||
assert.Equal(t, true, got["dry_run"])
|
||||
impact = got["impact"].(map[string]any)
|
||||
assert.EqualValues(t, 50000, impact["point_rupiah_before"])
|
||||
assert.EqualValues(t, 5000000, impact["point_rupiah_after"])
|
||||
assert.EqualValues(t, 105, impact["coins_as_points_before"])
|
||||
assert.EqualValues(t, 10, impact["coins_as_points_after"])
|
||||
assert.Len(t, got["changes"], 2)
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 1, data(body)["point_value"], "a dry run saves nothing")
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 0, data(body)["pagination"].(map[string]any)["total_count"])
|
||||
|
||||
// Saving records every change with who made it.
|
||||
status, body = call(http.MethodPut, "/manager/loyalty-settings", change)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, false, data(body)["dry_run"])
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings/history", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
history := data(body)["data"].([]any)
|
||||
require.Len(t, history, 2)
|
||||
var keys []string
|
||||
for _, raw := range history {
|
||||
row := raw.(map[string]any)
|
||||
keys = append(keys, row["key"].(string))
|
||||
assert.Equal(t, admin.String(), row["changed_by"])
|
||||
assert.Nil(t, row["old_value"], "was on its default")
|
||||
assert.Nil(t, row["outlet_id"])
|
||||
}
|
||||
assert.ElementsMatch(t, []string{"loyalty.point.value", "loyalty.exchange.coin_amount"}, keys)
|
||||
|
||||
// Nothing already written changes: balances and ledger rows keep their numbers.
|
||||
var rows []struct {
|
||||
Amount int64
|
||||
Metadata string
|
||||
}
|
||||
require.NoError(t, db.Raw(`SELECT amount, metadata::text AS metadata FROM wallet_transactions WHERE customer_id = ? ORDER BY currency`, customer).Scan(&rows).Error)
|
||||
require.Len(t, rows, 2)
|
||||
assert.Equal(t, int64(105), rows[0].Amount)
|
||||
assert.Equal(t, int64(50000), rows[1].Amount)
|
||||
for _, r := range rows {
|
||||
assert.Contains(t, r.Metadata, `"frozen": true`)
|
||||
}
|
||||
|
||||
// Out of bounds, unknown fields and the wrong role are refused.
|
||||
for name, bad := range map[string]string{
|
||||
"point value 0": `{"point_value": 0}`,
|
||||
"exchange 0": `{"exchange": {"coin_amount": 0}}`,
|
||||
"expiry unit YEAR": `{"coin_expiry": {"unit": "YEAR"}}`,
|
||||
"unknown field": `{"point_valeu": 5}`,
|
||||
} {
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings", bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
status, _ = call(http.MethodPut, "/manager/loyalty-settings?dry_run=true", bad)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name+" (dry run)")
|
||||
}
|
||||
status, _ = call(http.MethodPut, "/purchasing/loyalty-settings", `{"point_value": 5}`)
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
status, body = call(http.MethodGet, "/manager/loyalty-settings", "")
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.EqualValues(t, 100, data(body)["point_value"])
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/transformer"
|
||||
"apskel-pos-be/internal/util"
|
||||
@@ -202,6 +205,11 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
if err := h.orderService.RefundOrder(ctx, id, modelReq, userID); err != nil {
|
||||
// Refusing to hand EnakPoint back as cash is a bad request, not a server fault.
|
||||
if errors.Is(err, processor.ErrPointPaymentRejected) {
|
||||
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::RefundOrder")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::RefundOrder", err.Error())}), "OrderHandler::RefundOrder")
|
||||
return
|
||||
}
|
||||
@@ -225,6 +233,11 @@ func (h *OrderHandler) CreatePayment(c *gin.Context) {
|
||||
|
||||
response, err := h.orderService.CreatePayment(c.Request.Context(), modelReq)
|
||||
if err != nil {
|
||||
// A refused EnakPoint payment is the cashier's or customer's to fix, not a server fault.
|
||||
if errors.Is(err, processor.ErrPointPaymentRejected) {
|
||||
util.HandleResponse(c.Writer, c.Request, service.PointPaymentErrorResponse(err), "OrderHandler::CreatePayment")
|
||||
return
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::CreatePayment", err.Error())}), "OrderHandler::CreatePayment")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -113,6 +113,16 @@ func (h *PaymentMethodHandler) ListPaymentMethods(c *gin.Context) {
|
||||
|
||||
req.OrganizationID = &contextInfo.OrganizationID
|
||||
|
||||
// At the cashier, EnakPoint is listed only where the outlet accepts it (F9).
|
||||
if outletStr := c.Query("outlet_id"); outletStr != "" {
|
||||
if outletID, err := uuid.Parse(outletStr); err == nil {
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
} else if contextInfo.OutletID != uuid.Nil {
|
||||
outletID := contextInfo.OutletID
|
||||
req.OutletID = &outletID
|
||||
}
|
||||
|
||||
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
req.IsActive = &isActive
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
)
|
||||
|
||||
// PointPaymentHandler serves GET /orders/:id/point-payment/preview
|
||||
// (docs/prd-point-coin.md F9).
|
||||
type PointPaymentHandler struct {
|
||||
pointPaymentService service.PointPaymentService
|
||||
}
|
||||
|
||||
func NewPointPaymentHandler(pointPaymentService service.PointPaymentService) *PointPaymentHandler {
|
||||
return &PointPaymentHandler{pointPaymentService: pointPaymentService}
|
||||
}
|
||||
|
||||
func (h *PointPaymentHandler) Preview(c *gin.Context) {
|
||||
orderID, ok := parseUUIDParam(c, "id", "PointPaymentHandler::Preview")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
util.HandleResponse(c.Writer, c.Request, h.pointPaymentService.Preview(ctx, appcontext.FromGinContext(ctx), orderID), "PointPaymentHandler::Preview")
|
||||
}
|
||||
@@ -219,4 +219,4 @@ func (h *ProductRecipeHandler) BulkCreate(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(recipes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"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 PurchaseCategoryHandler struct {
|
||||
purchaseCategoryService service.PurchaseCategoryService
|
||||
purchaseCategoryValidator validator.PurchaseCategoryValidator
|
||||
}
|
||||
|
||||
func NewPurchaseCategoryHandler(purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator) *PurchaseCategoryHandler {
|
||||
return &PurchaseCategoryHandler{
|
||||
purchaseCategoryService: purchaseCategoryService,
|
||||
purchaseCategoryValidator: purchaseCategoryValidator,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) CreatePurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
var req contract.CreatePurchaseCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::CreatePurchaseCategory -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateCreatePurchaseCategoryRequest(&req); validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::CreatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.CreatePurchaseCategory(ctx, contextInfo, &req)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::CreatePurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) UpdatePurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
categoryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.UpdatePurchaseCategoryRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.FromContext(ctx).WithError(err).Error("PurchaseCategoryHandler::UpdatePurchaseCategory -> request binding failed")
|
||||
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateUpdatePurchaseCategoryRequest(&req); validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.UpdatePurchaseCategory(ctx, contextInfo, categoryID, &req)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::UpdatePurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) DeletePurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
categoryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::DeletePurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.DeletePurchaseCategory(ctx, contextInfo, categoryID)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::DeletePurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) GetPurchaseCategory(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
categoryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid purchase category ID")
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::GetPurchaseCategory")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.GetPurchaseCategoryByID(ctx, contextInfo, categoryID)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::GetPurchaseCategory")
|
||||
}
|
||||
|
||||
func (h *PurchaseCategoryHandler) ListPurchaseCategories(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
req := &contract.ListPurchaseCategoriesRequest{
|
||||
Page: 1,
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
|
||||
if parentID, err := uuid.Parse(parentIDStr); err == nil {
|
||||
req.ParentID = &parentID
|
||||
}
|
||||
}
|
||||
|
||||
if categoryType := c.Query("type"); categoryType != "" {
|
||||
req.Type = categoryType
|
||||
}
|
||||
|
||||
if search := c.Query("search"); search != "" {
|
||||
req.Search = search
|
||||
}
|
||||
|
||||
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
|
||||
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
|
||||
req.IsActive = &isActive
|
||||
}
|
||||
}
|
||||
|
||||
if validationError, validationErrorCode := h.purchaseCategoryValidator.ValidateListPurchaseCategoriesRequest(req); validationError != nil {
|
||||
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "PurchaseCategoryHandler::ListPurchaseCategories")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.purchaseCategoryService.ListPurchaseCategories(ctx, contextInfo, req)
|
||||
util.HandleResponse(c.Writer, c.Request, response, "PurchaseCategoryHandler::ListPurchaseCategories")
|
||||
}
|
||||
@@ -176,6 +176,20 @@ func (h *PurchaseOrderHandler) ListPurchaseOrders(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
if team := c.Query("team"); team != "" {
|
||||
req.Team = team
|
||||
}
|
||||
|
||||
if teamScope := c.Query("team_scope"); teamScope != "" {
|
||||
req.TeamScope = teamScope
|
||||
}
|
||||
|
||||
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
|
||||
@@ -224,6 +238,21 @@ func (h *PurchaseOrderHandler) GetPurchaseOrdersByStatus(c *gin.Context) {
|
||||
util.HandleResponse(c.Writer, c.Request, poResponse, "PurchaseOrderHandler::GetPurchaseOrdersByStatus")
|
||||
}
|
||||
|
||||
// ListPurchaseTeams serves the team picker for the purchase form: the parent
|
||||
// categories of the caller's outlet, plus Pusat.
|
||||
func (h *PurchaseOrderHandler) ListPurchaseTeams(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
teamsResponse := h.purchaseOrderService.ListPurchaseTeams(ctx, contextInfo)
|
||||
if teamsResponse.HasErrors() {
|
||||
errorResp := teamsResponse.GetErrors()[0]
|
||||
logger.FromContext(ctx).WithError(errorResp).Error("PurchaseOrderHandler::ListPurchaseTeams -> Failed to list purchase teams from service")
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, teamsResponse, "PurchaseOrderHandler::ListPurchaseTeams")
|
||||
}
|
||||
|
||||
func (h *PurchaseOrderHandler) GetOverduePurchaseOrders(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
@@ -66,3 +66,35 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
|
||||
"file_name": fileName,
|
||||
}), "ReportHandler::GetDailyTransactionReportPDF")
|
||||
}
|
||||
|
||||
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
ci := appcontext.FromGinContext(ctx)
|
||||
|
||||
outletID := h.resolveOutletID(c, ci.OutletID)
|
||||
var dayPtr *time.Time
|
||||
if d := c.Query("date"); d != "" {
|
||||
if t, err := time.Parse("2006-01-02", d); err == nil {
|
||||
dayPtr = &t
|
||||
}
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserByID(ctx, ci.UserID)
|
||||
var genBy string
|
||||
if err != nil {
|
||||
genBy = ci.UserID.String()
|
||||
} else {
|
||||
genBy = user.Name
|
||||
}
|
||||
|
||||
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
|
||||
return
|
||||
}
|
||||
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
|
||||
"url": publicURL,
|
||||
"file_name": fileName,
|
||||
}), "ReportHandler::GetProfitLossReportPDF")
|
||||
}
|
||||
|
||||
@@ -344,6 +344,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
|
||||
ProductID: item.ProductID,
|
||||
ProductVariantID: item.ProductVariantID,
|
||||
Quantity: item.Quantity,
|
||||
Weight: item.Weight,
|
||||
Notes: item.Notes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/mappers"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
|
||||
@@ -49,8 +52,14 @@ func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) {
|
||||
// Play the spin game
|
||||
response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq)
|
||||
if err != nil {
|
||||
// Not enough EnakCoin, an inactive game or a prize that just ran out is the
|
||||
// customer's to fix, not a server failure.
|
||||
code := constants.InternalServerErrorCode
|
||||
if errors.Is(err, processor.ErrGamePlayRefused) {
|
||||
code = constants.ValidationErrorCode
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.InternalServerErrorCode, constants.SpinGameHandlerEntity, err.Error()),
|
||||
contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()),
|
||||
}), "SpinGameHandler::PlaySpinGame")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
applogger "apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/processor"
|
||||
"apskel-pos-be/internal/repository"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// Drives the dashboard wallet endpoints over HTTP down to Postgres. Needs
|
||||
// TEST_DATABASE_URL pointing at a migrated database; see
|
||||
// internal/repository/wallet_repository_test.go.
|
||||
func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
applogger.Setup("fatal", "json")
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
|
||||
orgA, orgB := uuid.New(), uuid.New()
|
||||
adminA, adminB := uuid.New(), uuid.New()
|
||||
customer := uuid.New()
|
||||
exec := func(q string, args ...any) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec(q, args...).Error)
|
||||
}
|
||||
exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'A', 'basic'), (?, 'B', 'basic')`, orgA, orgB)
|
||||
exec(`INSERT INTO users (id, organization_id, name, email, password_hash, role) VALUES
|
||||
(?, ?, 'Admin Satu', ?, 'x', 'admin'), (?, ?, 'Admin Lain', ?, 'x', 'admin')`,
|
||||
adminA, orgA, adminA.String()+"@test", adminB, orgB, adminB.String()+"@test")
|
||||
exec(`INSERT INTO customers (id, organization_id, name, phone_number) VALUES (?, ?, 'Budi Santoso', '081234567890')`, customer, orgA)
|
||||
t.Cleanup(func() {
|
||||
db.Exec(`DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE customer_id = ?)`, customer)
|
||||
db.Exec(`DELETE FROM wallet_lots WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM wallet_transactions WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customer_wallets WHERE customer_id = ?`, customer)
|
||||
db.Exec(`DELETE FROM customers WHERE id = ?`, customer)
|
||||
db.Exec(`DELETE FROM users WHERE id IN ?`, []uuid.UUID{adminA, adminB})
|
||||
db.Exec(`DELETE FROM organizations WHERE id IN ?`, []uuid.UUID{orgA, orgB})
|
||||
})
|
||||
|
||||
walletRepo := repository.NewWalletRepository(db)
|
||||
queryRepo := repository.NewWalletQueryRepository(db)
|
||||
txm := repository.NewTxManager(db)
|
||||
wallet := processor.NewWalletProcessor(walletRepo)
|
||||
require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error {
|
||||
outlet := uuid.New()
|
||||
_, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{
|
||||
CustomerID: customer, Currency: constants.WalletCurrencyPoint, Type: constants.WalletTxTypeEarn,
|
||||
Amount: 100, ReferenceType: constants.WalletRefTypeOrder, ReferenceID: uuid.New(), OutletID: &outlet,
|
||||
Description: "Belanja #ORD-1"}})
|
||||
return err
|
||||
}))
|
||||
|
||||
h := NewWalletAdminHandler(
|
||||
service.NewWalletAdminService(processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(db), queryRepo, wallet, txm), processor.NewWalletTraceProcessor(repository.NewWalletTraceRepository(db))),
|
||||
validator.NewWalletValidator(),
|
||||
)
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
as := func(org, user uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String())
|
||||
ctx = context.WithValue(ctx, appcontext.UserIDKey, user.String())
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
for prefix, who := range map[string][2]uuid.UUID{"/a": {orgA, adminA}, "/b": {orgB, adminB}} {
|
||||
g := router.Group(prefix, as(who[0], who[1]))
|
||||
g.GET("/customers/:id/wallet", h.GetCustomerWallet)
|
||||
g.POST("/customers/:id/wallet/adjust", h.AdjustCustomerWallet)
|
||||
}
|
||||
call := func(method, path string, body any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
require.NoError(t, json.NewEncoder(&buf).Encode(body))
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String())
|
||||
return rec.Code, out
|
||||
}
|
||||
adjust := func(prefix string, body map[string]any) (int, map[string]any) {
|
||||
return call(http.MethodPost, prefix+"/customers/"+customer.String()+"/wallet/adjust", body)
|
||||
}
|
||||
spendable := func() float64 {
|
||||
t.Helper()
|
||||
status, body := call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet", nil)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
return body["data"].(map[string]any)["spendable_point_balance"].(float64)
|
||||
}
|
||||
|
||||
// Add 50.
|
||||
status, body := adjust("/a", map[string]any{"currency": "point", "amount": 50, "reason": "komplain #45"})
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
tx := body["data"].(map[string]any)["transaction"].(map[string]any)
|
||||
assert.Equal(t, "ADJUSTMENT", tx["type"])
|
||||
assert.EqualValues(t, 50, tx["amount"])
|
||||
assert.Equal(t, "komplain #45", tx["reason"])
|
||||
assert.Equal(t, "Koreksi oleh admin: komplain #45", tx["description"])
|
||||
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, tx["created_by"])
|
||||
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, tx["source"])
|
||||
assert.EqualValues(t, 150, body["data"].(map[string]any)["spendable_point_balance"])
|
||||
|
||||
// Taking more than the customer can spend is refused and changes nothing.
|
||||
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -151, "reason": "salah input"})
|
||||
assert.Equal(t, http.StatusBadRequest, status, body)
|
||||
assert.Equal(t, float64(150), spendable())
|
||||
|
||||
// Taking away, retried with the same key, happens once.
|
||||
for i, wantReplayed := range []bool{false, true} {
|
||||
status, body = adjust("/a", map[string]any{"currency": "POINT", "amount": -30, "reason": "salah input", "idempotency_key": "req-1"})
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
assert.Equal(t, wantReplayed, body["data"].(map[string]any)["replayed"], "call %d", i+1)
|
||||
}
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// Bad requests.
|
||||
for name, req := range map[string]map[string]any{
|
||||
"cash-out": {"currency": "POINT", "amount": -10, "reason": "pencairan saldo"},
|
||||
"zero amount": {"currency": "POINT", "amount": 0, "reason": "x"},
|
||||
"no reason": {"currency": "POINT", "amount": 10},
|
||||
"bad currency": {"currency": "GOLD", "amount": 10, "reason": "x"},
|
||||
} {
|
||||
status, _ = adjust("/a", req)
|
||||
assert.Equal(t, http.StatusBadRequest, status, name)
|
||||
}
|
||||
status, _ = call(http.MethodPost, "/a/customers/not-a-uuid/wallet/adjust", map[string]any{"currency": "POINT", "amount": 1, "reason": "x"})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// Another organization's admin cannot see or touch this customer.
|
||||
status, _ = call(http.MethodGet, "/b/customers/"+customer.String()+"/wallet", nil)
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
status, _ = adjust("/b", map[string]any{"currency": "POINT", "amount": 1000, "reason": "x"})
|
||||
assert.Equal(t, http.StatusNotFound, status)
|
||||
assert.Equal(t, float64(120), spendable())
|
||||
|
||||
// The dashboard view.
|
||||
status, body = call(http.MethodGet, "/a/customers/"+customer.String()+"/wallet?type=ADJUSTMENT", nil)
|
||||
require.Equal(t, http.StatusOK, status, body)
|
||||
w := body["data"].(map[string]any)
|
||||
assert.Equal(t, map[string]any{"id": customer.String(), "name": "Budi Santoso", "phone": "081234567890"}, w["customer"])
|
||||
assert.EqualValues(t, 120, w["point_balance"])
|
||||
assert.EqualValues(t, 120, w["spendable_point_balance"])
|
||||
lots := w["lots"].([]any)
|
||||
var lotTotal float64
|
||||
for _, l := range lots {
|
||||
lotTotal += l.(map[string]any)["remaining_amount"].(float64)
|
||||
}
|
||||
assert.Equal(t, float64(120), lotTotal)
|
||||
|
||||
rows := w["transactions"].(map[string]any)["data"].([]any)
|
||||
require.Len(t, rows, 2, "the two adjustments, newest first")
|
||||
newest := rows[0].(map[string]any)
|
||||
assert.EqualValues(t, -30, newest["amount"])
|
||||
assert.Equal(t, "salah input", newest["reason"])
|
||||
assert.Equal(t, map[string]any{"id": adminA.String(), "name": "Admin Satu"}, newest["created_by"])
|
||||
assert.Equal(t, map[string]any{"type": "USER", "id": adminA.String()}, newest["destination"])
|
||||
|
||||
// The customer's own history shows the adjustment too, with the reason in the
|
||||
// description and without the admin's identity fields.
|
||||
customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor(
|
||||
processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil)))
|
||||
crouter := gin.New()
|
||||
crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions)
|
||||
rec := httptest.NewRecorder()
|
||||
crouter.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/wallet/transactions?type=ADJUSTMENT", nil))
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var cbody map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cbody))
|
||||
crows := cbody["data"].(map[string]any)["data"].([]any)
|
||||
require.Len(t, crows, 2)
|
||||
first := crows[0].(map[string]any)
|
||||
assert.Equal(t, "Koreksi oleh admin: salah input", first["description"])
|
||||
assert.NotContains(t, first, "created_by")
|
||||
assert.NotContains(t, first, "reason")
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"apskel-pos-be/internal/appcontext"
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/contract"
|
||||
"apskel-pos-be/internal/logger"
|
||||
"apskel-pos-be/internal/models"
|
||||
"apskel-pos-be/internal/service"
|
||||
"apskel-pos-be/internal/util"
|
||||
"apskel-pos-be/internal/validator"
|
||||
)
|
||||
|
||||
// WalletAdminHandler serves /marketing/customers/:id/wallet (docs/prd-point-coin.md F7).
|
||||
type WalletAdminHandler struct {
|
||||
walletAdminService service.WalletAdminService
|
||||
walletValidator validator.WalletValidator
|
||||
}
|
||||
|
||||
func NewWalletAdminHandler(walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator) *WalletAdminHandler {
|
||||
return &WalletAdminHandler{walletAdminService: walletAdminService, walletValidator: walletValidator}
|
||||
}
|
||||
|
||||
func (h *WalletAdminHandler) GetCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
customerID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
|
||||
}), "WalletAdminHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
var query models.ListCustomerWalletTransactionsQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "WalletAdminHandler::GetCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.walletAdminService.GetCustomerWallet(ctx, contextInfo, customerID, query)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::GetCustomerWallet -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::GetCustomerWallet")
|
||||
}
|
||||
|
||||
func (h *WalletAdminHandler) AdjustCustomerWallet(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
contextInfo := appcontext.FromGinContext(ctx)
|
||||
|
||||
customerID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid customer ID"),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.AdjustCustomerWalletRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
if validationError, code := h.walletValidator.ValidateAdjustRequest(&req); validationError != nil {
|
||||
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
|
||||
contract.NewResponseError(code, constants.RequestEntity, validationError.Error()),
|
||||
}), "WalletAdminHandler::AdjustCustomerWallet")
|
||||
return
|
||||
}
|
||||
|
||||
response := h.walletAdminService.AdjustCustomerWallet(ctx, contextInfo, customerID, &req)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::AdjustCustomerWallet -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::AdjustCustomerWallet")
|
||||
}
|
||||
|
||||
// TraceTransaction is GET /marketing/wallet-transactions/:id/trace.
|
||||
func (h *WalletAdminHandler) TraceTransaction(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
transactionID, ok := parseUUIDParam(c, "id", "WalletAdminHandler::TraceTransaction")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := h.walletAdminService.TraceTransaction(ctx, appcontext.FromGinContext(ctx), transactionID)
|
||||
if response.HasErrors() {
|
||||
logger.FromContext(ctx).WithError(response.GetErrors()[0]).Error("WalletAdminHandler::TraceTransaction -> service call failed")
|
||||
}
|
||||
util.HandleResponse(c.Writer, c.Request, response, "WalletAdminHandler::TraceTransaction")
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
|
||||
return &entities.Campaign{
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Type: entities.CampaignType(request.Type),
|
||||
Type: entities.NormalizeCampaignType(request.Type),
|
||||
StartDate: request.StartDate,
|
||||
EndDate: request.EndDate,
|
||||
IsActive: request.IsActive,
|
||||
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
|
||||
ID: request.ID,
|
||||
Name: request.Name,
|
||||
Description: request.Description,
|
||||
Type: entities.CampaignType(request.Type),
|
||||
Type: entities.NormalizeCampaignType(request.Type),
|
||||
StartDate: request.StartDate,
|
||||
EndDate: request.EndDate,
|
||||
IsActive: request.IsActive,
|
||||
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
|
||||
CampaignID: campaignID,
|
||||
RuleType: entities.RuleType(request.RuleType),
|
||||
ConditionValue: request.ConditionValue,
|
||||
RewardType: entities.CampaignRewardType(request.RewardType),
|
||||
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
|
||||
RewardValue: request.RewardValue,
|
||||
RewardSubtype: rewardSubtype,
|
||||
RewardRefID: request.RewardRefID,
|
||||
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
|
||||
CampaignID: campaignID,
|
||||
RuleType: entities.RuleType(request.RuleType),
|
||||
ConditionValue: request.ConditionValue,
|
||||
RewardType: entities.CampaignRewardType(request.RewardType),
|
||||
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
|
||||
RewardValue: request.RewardValue,
|
||||
RewardSubtype: rewardSubtype,
|
||||
RewardRefID: request.RewardRefID,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
"apskel-pos-be/internal/models"
|
||||
)
|
||||
|
||||
// Amounts are money rounded to two decimals, so anything under half a cent apart
|
||||
// is the same figure. Comparing them directly would leave an advance a hundredth of
|
||||
// a rupiah short of settled.
|
||||
const cashAdvanceAmountEpsilon = 0.005
|
||||
|
||||
// cashAdvanceTeamFromEntity renders the team an advance was handed to. Unlike a
|
||||
// purchase order there is always one, so this never returns nil. The category name
|
||||
// is only filled in when TeamCategory was preloaded.
|
||||
func cashAdvanceTeamFromEntity(entity *entities.CashAdvance) *models.PurchaseTeam {
|
||||
team := &models.PurchaseTeam{Scope: entity.TeamScope}
|
||||
|
||||
switch entity.TeamScope {
|
||||
case constants.PurchaseTeamScopeCentral:
|
||||
team.Name = constants.PurchaseTeamCentralName
|
||||
case constants.PurchaseTeamScopeCategory:
|
||||
team.CategoryID = entity.TeamCategoryID
|
||||
if entity.TeamCategory != nil {
|
||||
team.Name = entity.TeamCategory.Name
|
||||
}
|
||||
}
|
||||
|
||||
return team
|
||||
}
|
||||
|
||||
// cashAdvanceSettlementStatus reads the money rather than any stored flag: cash is
|
||||
// accounted for by spending charged to the advance plus what was handed back. A
|
||||
// team that overspent still counts as settled — the excess is owed back to them.
|
||||
func cashAdvanceSettlementStatus(amount, settled, returned float64) string {
|
||||
accounted := settled + returned
|
||||
|
||||
switch {
|
||||
case accounted <= cashAdvanceAmountEpsilon:
|
||||
return constants.CashAdvanceSettlementOpen
|
||||
case amount-accounted > cashAdvanceAmountEpsilon:
|
||||
return constants.CashAdvanceSettlementPartial
|
||||
default:
|
||||
return constants.CashAdvanceSettlementSettled
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntityToModel(entity *entities.CashAdvance) *models.CashAdvance {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CashAdvance{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
Amount: entity.Amount,
|
||||
ReturnedAmount: entity.ReturnedAmount,
|
||||
IssuedDate: entity.IssuedDate,
|
||||
DueDate: entity.DueDate,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntityToResponse(entity *entities.CashAdvance) *models.CashAdvanceResponse {
|
||||
if entity == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &models.CashAdvanceResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
CodeNumber: entity.CodeNumber,
|
||||
TeamScope: entity.TeamScope,
|
||||
TeamCategoryID: entity.TeamCategoryID,
|
||||
Amount: entity.Amount,
|
||||
SettledAmount: entity.SettledAmount,
|
||||
ReturnedAmount: entity.ReturnedAmount,
|
||||
RemainingAmount: entity.Amount - entity.SettledAmount - entity.ReturnedAmount,
|
||||
SettlementStatus: cashAdvanceSettlementStatus(entity.Amount, entity.SettledAmount, entity.ReturnedAmount),
|
||||
IssuedDate: entity.IssuedDate,
|
||||
DueDate: entity.DueDate,
|
||||
Status: entity.Status,
|
||||
Description: entity.Description,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
Team: cashAdvanceTeamFromEntity(entity),
|
||||
}
|
||||
}
|
||||
|
||||
func CashAdvanceEntitiesToResponses(entities []*entities.CashAdvance) []*models.CashAdvanceResponse {
|
||||
if entities == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
responses := make([]*models.CashAdvanceResponse, len(entities))
|
||||
for i, entity := range entities {
|
||||
responses[i] = CashAdvanceEntityToResponse(entity)
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func CashAdvanceSettlementEntitiesToModels(settlements []*entities.CashAdvanceSettlement) []models.CashAdvanceSettlement {
|
||||
if settlements == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]models.CashAdvanceSettlement, len(settlements))
|
||||
for i, settlement := range settlements {
|
||||
result[i] = models.CashAdvanceSettlement{
|
||||
Type: settlement.Type,
|
||||
ID: settlement.ID,
|
||||
Number: settlement.Number,
|
||||
Date: settlement.Date,
|
||||
Amount: settlement.Amount,
|
||||
Status: settlement.Status,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"apskel-pos-be/internal/constants"
|
||||
"apskel-pos-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCashAdvanceSettlementStatusFollowsTheMoney(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
amount float64
|
||||
settled float64
|
||||
returned float64
|
||||
expectedStatus string
|
||||
expectedRemaining float64
|
||||
}{
|
||||
{
|
||||
name: "nothing spent or returned is still open",
|
||||
amount: 500000,
|
||||
expectedStatus: constants.CashAdvanceSettlementOpen,
|
||||
expectedRemaining: 500000,
|
||||
},
|
||||
{
|
||||
name: "some spending leaves it partial",
|
||||
amount: 500000,
|
||||
settled: 200000,
|
||||
expectedStatus: constants.CashAdvanceSettlementPartial,
|
||||
expectedRemaining: 300000,
|
||||
},
|
||||
{
|
||||
name: "cash handed back counts the same as spending",
|
||||
amount: 500000,
|
||||
settled: 300000,
|
||||
returned: 100000,
|
||||
expectedStatus: constants.CashAdvanceSettlementPartial,
|
||||
expectedRemaining: 100000,
|
||||
},
|
||||
{
|
||||
name: "spending plus cash back covering the advance settles it",
|
||||
amount: 500000,
|
||||
settled: 420000,
|
||||
returned: 80000,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: 0,
|
||||
},
|
||||
{
|
||||
// The team paid the difference out of pocket, so the outlet owes them.
|
||||
name: "overspending settles the cash advance and goes negative",
|
||||
amount: 500000,
|
||||
settled: 620000,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: -120000,
|
||||
},
|
||||
{
|
||||
// Two decimals of money should not leave a cash advance a fraction short.
|
||||
name: "a rounding crumb short still settles",
|
||||
amount: 100000,
|
||||
settled: 99999.999,
|
||||
expectedStatus: constants.CashAdvanceSettlementSettled,
|
||||
expectedRemaining: 0.001,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := CashAdvanceEntityToResponse(&entities.CashAdvance{
|
||||
ID: uuid.New(),
|
||||
TeamScope: constants.PurchaseTeamScopeCentral,
|
||||
Amount: tt.amount,
|
||||
SettledAmount: tt.settled,
|
||||
ReturnedAmount: tt.returned,
|
||||
})
|
||||
|
||||
require.Equal(t, tt.expectedStatus, response.SettlementStatus)
|
||||
require.InDelta(t, tt.expectedRemaining, response.RemainingAmount, 0.0001)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCashAdvanceTeamAlwaysRendered(t *testing.T) {
|
||||
categoryID := uuid.New()
|
||||
|
||||
central := CashAdvanceEntityToResponse(&entities.CashAdvance{TeamScope: constants.PurchaseTeamScopeCentral})
|
||||
require.NotNil(t, central.Team)
|
||||
require.Equal(t, constants.PurchaseTeamCentralName, central.Team.Name)
|
||||
require.Nil(t, central.Team.CategoryID)
|
||||
|
||||
category := CashAdvanceEntityToResponse(&entities.CashAdvance{
|
||||
TeamScope: constants.PurchaseTeamScopeCategory,
|
||||
TeamCategoryID: &categoryID,
|
||||
TeamCategory: &entities.Category{ID: categoryID, Name: "Dapur"},
|
||||
})
|
||||
require.NotNil(t, category.Team)
|
||||
require.Equal(t, "Dapur", category.Team.Name)
|
||||
require.Equal(t, &categoryID, category.Team.CategoryID)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
|
||||
return &entities.Category{
|
||||
OrganizationID: req.OrganizationID,
|
||||
OutletID: req.OutletID,
|
||||
ParentID: req.ParentID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Order: req.Order,
|
||||
@@ -85,10 +86,19 @@ func CategoryEntityToResponse(entity *entities.Category) *models.CategoryRespons
|
||||
}
|
||||
}
|
||||
|
||||
// Parent name is only available when the Parent association is preloaded
|
||||
var parentName *string
|
||||
if entity.Parent != nil {
|
||||
name := entity.Parent.Name
|
||||
parentName = &name
|
||||
}
|
||||
|
||||
return &models.CategoryResponse{
|
||||
ID: entity.ID,
|
||||
OrganizationID: entity.OrganizationID,
|
||||
OutletID: entity.OutletID,
|
||||
ParentID: entity.ParentID,
|
||||
ParentName: parentName,
|
||||
Name: entity.Name,
|
||||
Description: entity.Description,
|
||||
ImageURL: imageURL,
|
||||
@@ -127,6 +137,10 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
|
||||
if req.OutletID != nil {
|
||||
entity.OutletID = req.OutletID
|
||||
}
|
||||
|
||||
if req.ParentID != nil {
|
||||
entity.ParentID = req.ParentID
|
||||
}
|
||||
}
|
||||
|
||||
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {
|
||||
|
||||
@@ -66,6 +66,7 @@ func ExpenseEntityToResponse(entity *entities.Expense) *models.ExpenseResponse {
|
||||
Tax: entity.Tax,
|
||||
Total: entity.Total,
|
||||
Reserved1: entity.Reserved1,
|
||||
CashAdvanceID: entity.CashAdvanceID,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
@@ -95,20 +96,27 @@ func ExpenseItemEntityToResponse(entity *entities.ExpenseItem) *models.ExpenseIt
|
||||
}
|
||||
|
||||
response := &models.ExpenseItemResponse{
|
||||
ID: entity.ID,
|
||||
ExpenseID: entity.ExpenseID,
|
||||
ChartOfAccountID: entity.ChartOfAccountID,
|
||||
Item: entity.Item,
|
||||
Description: entity.Description,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
ID: entity.ID,
|
||||
ExpenseID: entity.ExpenseID,
|
||||
ChartOfAccountID: entity.ChartOfAccountID,
|
||||
PurchaseCategoryID: entity.PurchaseCategoryID,
|
||||
Item: entity.Item,
|
||||
Description: entity.Description,
|
||||
Amount: entity.Amount,
|
||||
CreatedAt: entity.CreatedAt,
|
||||
UpdatedAt: entity.UpdatedAt,
|
||||
}
|
||||
|
||||
if entity.ChartOfAccount != nil {
|
||||
response.ChartOfAccountName = entity.ChartOfAccount.Name
|
||||
}
|
||||
|
||||
if entity.PurchaseCategory != nil {
|
||||
response.PurchaseCategoryName = entity.PurchaseCategory.Name
|
||||
response.PurchaseCategoryType = string(entity.PurchaseCategory.Type)
|
||||
response.PurchaseCategory = PurchaseCategoryEntityToResponse(entity.PurchaseCategory)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse {
|
||||
GameID: gamePlay.GameID,
|
||||
CustomerID: gamePlay.CustomerID,
|
||||
PrizeID: gamePlay.PrizeID,
|
||||
TokenUsed: gamePlay.TokenUsed,
|
||||
CoinsUsed: gamePlay.CoinsUsed,
|
||||
RandomSeed: gamePlay.RandomSeed,
|
||||
CreatedAt: gamePlay.CreatedAt,
|
||||
Game: ToGameResponse(&gamePlay.Game),
|
||||
@@ -48,7 +48,7 @@ func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay {
|
||||
return &entities.GamePlay{
|
||||
GameID: req.GameID,
|
||||
CustomerID: req.CustomerID,
|
||||
TokenUsed: req.TokenUsed,
|
||||
CoinsUsed: req.CoinsUsed,
|
||||
RandomSeed: req.RandomSeed,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user