Compare commits

..
Author SHA1 Message Date
ryan 15805a4853 Implement FCM 2026-05-09 14:18:36 +07:00
372 changed files with 1672 additions and 32889 deletions
+1 -6
View File
@@ -7,9 +7,4 @@ config/env/*
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
*firebase-adminsdk*.json
-1
View File
@@ -1 +0,0 @@
{}
+1 -1
View File
@@ -1,5 +1,5 @@
# 1) Build stage
FROM golang:1.24-alpine AS build
FROM golang:1.21-alpine AS build
RUN apk --no-cache add ca-certificates tzdata git curl
WORKDIR /src
COPY go.mod go.sum ./
+2 -36
View File
@@ -1,21 +1,9 @@
#PROJECT_NAME = "enaklo-pos-backend"
# ─── 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
@@ -28,21 +16,16 @@ endif
.SILENT: help
help:
@echo
@echo "Usage: make [command] [ENV=staging|production]"
@echo "Usage: make [command]"
@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"
@@ -100,19 +83,6 @@ migration-up:
migration-down:
@migrate -database $(DB_URL) -path ./migrations down 1
# Force migration to specific version
.SILENT: migration-force
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)
@@ -138,11 +108,7 @@ fmt:
@go fmt ./...
start:
ENV_MODE=$(ENV) go run cmd/server/main.go
.SILENT: run
run:
ENV_MODE=$(ENV) go run cmd/server/main.go
go run main.go --env-path .env
# Default
+63 -125
View File
@@ -15,19 +15,15 @@ Makefile requires installed dependecies:
```shell
$ make
Usage: make [command] [ENV=staging|production]
Usage: make [command]
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
@@ -40,16 +36,24 @@ Commands:
## HTTP Server
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
$ ./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).
```shell
# 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 before and start server
$ ./bin/http-server
# Start with the production config
$ ENV_MODE=production ./bin/http-server
# Expose env vars from the file and start server
$ ./bin/http-server --env-path ./config/env/.env
```
## API Docs
@@ -120,7 +124,7 @@ Handler → Service → Processor → Repository
## API Endpoints
### Health Check
- `GET /health` - Health check endpoint (registered at the root, not under `/api/v1`)
- `GET /api/v1/health` - Health check endpoint
### Organizations
- `POST /api/v1/organizations` - Create organization
@@ -153,139 +157,73 @@ Handler → Service → Processor → Repository
- `PUT /api/v1/order-items/{id}` - Update order item
- `DELETE /api/v1/order-items/{id}` - Remove order item
## Running the Application
### Prerequisites
| 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) |
### 1. Clone & install dependencies
## Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd apskel-pos-backend
go mod download
```
### 2. Configuration
2. **Install dependencies**
```bash
go mod tidy
```
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.
3. **Set up database**
```bash
# Set your PostgreSQL database URL
export DATABASE_URL="postgres://username:password@localhost:5432/apskel_pos?sslmode=disable"
```
The file is selected by the `ENV_MODE` environment variable:
4. **Run migrations**
```bash
make migration-up
```
| `ENV_MODE` | Config file loaded |
|------------|--------------------|
| `local` | `infra/local.yaml` |
| `development` | `infra/development.yaml` |
| `staging` *(default)* | `infra/staging.yaml` |
| `production` | `infra/production.yaml` |
## Usage
Any other/unset value falls back to `staging`. Only `staging.yaml` and `production.yaml` are
committed — for `local`/`development` copy one of them first:
### Development
```bash
cp infra/staging.yaml infra/local.yaml
# 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
```
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):
### Using Make Commands
```bash
make migration-up # staging DB (default)
make migration-up ENV=production # production DB
# Run the application
make start
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
# 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
```
### 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:4000/api/v1/organizations \
curl -X POST http://localhost:8080/api/v1/organizations \
-H "Content-Type: application/json" \
-d '{
"name": "My Restaurant",
@@ -295,7 +233,7 @@ curl -X POST http://localhost:4000/api/v1/organizations \
### Create User
```bash
curl -X POST http://localhost:4000/api/v1/users \
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"organization_id": "uuid-here",
@@ -309,7 +247,7 @@ curl -X POST http://localhost:4000/api/v1/users \
### Create Order with Items
```bash
curl -X POST http://localhost:4000/api/v1/orders \
curl -X POST http://localhost:8080/api/v1/orders \
-H "Content-Type: application/json" \
-d '{
"outlet_id": "uuid-here",
-73
View File
@@ -1,73 +0,0 @@
// 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")
}
+4 -5
View File
@@ -12,14 +12,13 @@ import (
const (
YAML_PATH = "infra/%s"
ENV_MODE = "ENV_MODE"
DEFAULT_ENV_MODE = "staging"
DEFAULT_ENV_MODE = "development"
)
var (
validEnvMode = map[string]struct{}{
"local": {},
"development": {},
"staging": {},
"production": {},
}
)
@@ -32,7 +31,7 @@ type Config struct {
Log Log `mapstructure:"log"`
S3Config S3Config `mapstructure:"s3"`
Fonnte Fonnte `mapstructure:"fonnte"`
FCM FCM `mapstructure:"fcm"`
Firebase Firebase `mapstructure:"firebase"`
}
var (
@@ -98,6 +97,6 @@ func (c *Config) GetFonnte() *Fonnte {
return &c.Fonnte
}
func (c *Config) GetFCM() *FCM {
return &c.FCM
func (c *Config) GetFirebase() *Firebase {
return &c.Firebase
}
-14
View File
@@ -1,14 +0,0 @@
package config
type FCM struct {
CredentialsFile string `mapstructure:"credentials_file"`
ProjectID string `mapstructure:"project_id"`
}
func (f *FCM) GetCredentialsFile() string {
return f.CredentialsFile
}
func (f *FCM) GetProjectID() string {
return f.ProjectID
}
+9
View File
@@ -0,0 +1,9 @@
package config
type Firebase struct {
CredentialsFile string `mapstructure:"credentials_file"`
}
func (f *Firebase) GetCredentialsFile() string {
return f.CredentialsFile
}
-1
View File
@@ -4,5 +4,4 @@ type Server struct {
Port string `mapstructure:"port"`
BaseUrl string `mapstructure:"common-url"`
LocalUrl string `mapstructure:"local-url"`
SelfOrderUrl string `mapstructure:"self-order-url"`
}
+7 -45
View File
@@ -2,61 +2,23 @@
set -euo pipefail
APP_NAME="apskel-pos"
# ─── 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 ($ENV_MODE)..."
docker build --target production -t "$IMAGE_NAME" .
echo "🐳 Building Docker image (production target)..."
docker build --target production -t $APP_NAME:latest .
echo "🛑 Stopping and removing old container..."
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
docker rm -f $APP_NAME 2>/dev/null || true
echo "🚀 Running new container..."
docker run -d --name "$CONTAINER_NAME" \
-p "$PORT:4000" \
docker run -d --name $APP_NAME \
-p $PORT:$PORT \
-e TZ=Asia/Jakarta \
-e ENV_MODE="$ENV_MODE" \
-v "$(pwd)/infra":/infra:ro \
-v "$(pwd)/templates":/templates:ro \
"$IMAGE_NAME"
$APP_NAME:latest
echo ""
echo "✅ Deployment $ENV_MODE complete."
echo " Container : $CONTAINER_NAME"
echo " Port : $PORT"
echo "✅ Deployment complete."
-279
View File
@@ -1,279 +0,0 @@
# 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
-362
View File
@@ -1,362 +0,0 @@
# 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.
-2
View File
@@ -76,7 +76,6 @@ 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
@@ -108,7 +107,6 @@ 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
-4
View File
@@ -74,8 +74,6 @@ 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=
@@ -351,8 +349,6 @@ 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=
@@ -1,7 +1,6 @@
server:
base-url:
local-url:
self-order-url:
port: 4000
jwt:
@@ -9,7 +8,7 @@ jwt:
expires-ttl: 144000
secret: "5Lm25V3Qd7aut8dr4QUxm5PZUrSFs"
refresh_token:
expires-ttl: 7776000
expires-ttl: 7776000 # 3 months in minutes (90 days * 24 hours * 60 minutes)
secret: "R3fr3sh_T0k3n_S3cr3t_K3y_2024_P0S"
customer:
expires-ttl: 7776000
@@ -21,7 +20,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
@@ -29,7 +28,7 @@ postgresql:
debug: false
redis:
host: 194.233.78.1
host: 127.0.0.1
port: 6379
password: "CmICdmnX1EZPhVBYzQPEGw==U"
db: 0
@@ -45,17 +44,16 @@ 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: "info"
log_format: 'json'
log_level: 'debug'
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"
firebase:
credentials_file: "apskel-pos-v2-firebase-adminsdk-fbsvc-ae00499526.json"
-61
View File
@@ -1,61 +0,0 @@
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"
+13 -181
View File
@@ -30,9 +30,6 @@ type App struct {
redisClient *redis.Client
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 {
@@ -46,24 +43,6 @@ func NewApp(db *gorm.DB, redisClient *redis.Client) *App {
func (a *App) Initialize(cfg *config.Config) error {
repos := a.initRepositories()
processors := a.initProcessors(cfg, repos)
// Initialize omset milestone scheduler
a.omsetScheduler = service.NewOmsetMilestoneScheduler(
repos.organizationRepo,
repos.outletRepo,
repos.userRepo,
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)
@@ -77,7 +56,7 @@ func (a *App) Initialize(cfg *config.Config) error {
repos.userRepo,
repos.sessionRepo,
repos.orderRepo,
services.productOutletPriceService,
processors.fcmClient,
)
a.router = router.NewRouter(
@@ -119,8 +98,6 @@ func (a *App) Initialize(cfg *config.Config) error {
validators.vendorValidator,
services.purchaseOrderService,
validators.purchaseOrderValidator,
services.purchaseCategoryService,
validators.purchaseCategoryValidator,
services.unitConverterService,
validators.unitConverterValidator,
services.chartOfAccountTypeService,
@@ -142,39 +119,13 @@ func (a *App) Initialize(cfg *config.Config) error {
services.customerPointsService,
services.spinGameService,
middleware.customerAuthMiddleware,
services.userDeviceService,
validators.userDeviceValidator,
services.notificationService,
validators.notificationValidator,
services.productOutletPriceService,
validators.productOutletPriceValidator,
selfOrderHandler,
services.expenseService,
validators.expenseValidator,
services.cashAdvanceService,
validators.cashAdvanceValidator,
services.walletAdminService,
validators.walletValidator,
services.loyaltySettingsService,
services.customerPinService,
a.redisClient,
)
return nil
}
func (a *App) Start(port string) error {
// Start the omset milestone scheduler (checks every 5 minutes for daily omset milestones)
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()
a.server = &http.Server{
@@ -210,15 +161,6 @@ func (a *App) Start(port string) error {
}
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)
}
@@ -247,7 +189,6 @@ type repositories struct {
productRecipeRepo *repository.ProductRecipeRepository
vendorRepo *repository.VendorRepositoryImpl
purchaseOrderRepo *repository.PurchaseOrderRepositoryImpl
purchaseCategoryRepo *repository.PurchaseCategoryRepositoryImpl
unitConverterRepo *repository.IngredientUnitConverterRepositoryImpl
chartOfAccountTypeRepo *repository.ChartOfAccountTypeRepositoryImpl
chartOfAccountRepo *repository.ChartOfAccountRepositoryImpl
@@ -267,16 +208,6 @@ type repositories struct {
otpRepo repository.OtpRepository
sessionRepo repository.SessionRepository
txManager *repository.TxManager
userDeviceRepo *repository.UserDeviceRepositoryImpl
notificationRepo *repository.NotificationRepositoryImpl
notificationReceiverRepo *repository.NotificationReceiverRepositoryImpl
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 {
@@ -305,7 +236,6 @@ 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),
@@ -325,16 +255,6 @@ func (a *App) initRepositories() *repositories {
otpRepo: repository.NewOtpRepository(a.db),
sessionRepo: repository.NewSessionRepository(a.redisClient),
txManager: repository.NewTxManager(a.db),
userDeviceRepo: repository.NewUserDeviceRepositoryImpl(a.db),
notificationRepo: repository.NewNotificationRepository(a.db),
notificationReceiverRepo: repository.NewNotificationReceiverRepository(a.db),
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),
}
}
@@ -358,7 +278,6 @@ type processors struct {
productRecipeProcessor *processor.ProductRecipeProcessorImpl
vendorProcessor *processor.VendorProcessorImpl
purchaseOrderProcessor *processor.PurchaseOrderProcessorImpl
purchaseCategoryProcessor *processor.PurchaseCategoryProcessorImpl
unitConverterProcessor *processor.IngredientUnitConverterProcessorImpl
chartOfAccountTypeProcessor *processor.ChartOfAccountTypeProcessorImpl
chartOfAccountProcessor *processor.ChartOfAccountProcessorImpl
@@ -377,56 +296,37 @@ type processors struct {
customerPointsProcessor *processor.CustomerPointsProcessor
otpProcessor processor.OtpProcessor
fileClient processor.FileClient
fcmClient client.FcmClient
inventoryMovementService service.InventoryMovementService
userDeviceProcessor *processor.UserDeviceProcessorImpl
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
}
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
fileClient := client.NewFileClient(cfg.S3Config)
fonnteClient := client.NewFonnteClient(cfg.GetFonnte())
fcmClient := client.NewFcmClient(cfg.GetFirebase())
otpProcessor := processor.NewOtpProcessor(fonnteClient, repos.otpRepo)
// Customer PIN (docs/prd-point-coin.md F11)
customerPinProcessor := processor.NewCustomerPinProcessor(repository.NewCustomerPinRepository(a.db), otpProcessor, otpProcessor)
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)
return &processors{
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.organizationRepo, repos.outletRepo),
organizationProcessor: processor.NewOrganizationProcessorImpl(repos.organizationRepo, repos.outletRepo, repos.userRepo),
outletProcessor: processor.NewOutletProcessorImpl(repos.outletRepo),
outletSettingProcessor: processor.NewOutletSettingProcessorImpl(repos.outletSettingRepo, repos.outletRepo),
categoryProcessor: processor.NewCategoryProcessorImpl(repos.categoryRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo, repos.productOutletPriceRepo),
productProcessor: processor.NewProductProcessorImpl(repos.productRepo, repos.categoryRepo, repos.productVariantRepo, repos.inventoryRepo, repos.outletRepo),
productVariantProcessor: processor.NewProductVariantProcessorImpl(repos.productVariantRepo, repos.productRepo),
inventoryProcessor: processor.NewInventoryProcessorImpl(repos.inventoryRepo, repos.productRepo, repos.outletRepo, repos.ingredientRepo, repos.inventoryMovementRepo),
orderProcessor: orderProcessor,
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo, loyaltySettingsProcessor),
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),
paymentMethodProcessor: processor.NewPaymentMethodProcessorImpl(repos.paymentMethodRepo),
fileProcessor: processor.NewFileProcessorImpl(repos.fileRepo, fileClient),
customerProcessor: processor.NewCustomerProcessor(repos.customerRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo, repos.expenseRepo),
analyticsProcessor: processor.NewAnalyticsProcessorImpl(repos.analyticsRepo),
tableProcessor: processor.NewTableProcessor(repos.tableRepo, repos.orderRepo),
unitProcessor: processor.NewUnitProcessor(repos.unitRepo),
ingredientProcessor: processor.NewIngredientProcessor(repos.ingredientRepo, repos.unitRepo, repos.ingredientCompositionRepo),
productRecipeProcessor: processor.NewProductRecipeProcessor(repos.productRecipeRepo, repos.productRepo, repos.ingredientRepo),
vendorProcessor: processor.NewVendorProcessorImpl(repos.vendorRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.purchaseCategoryRepo, repos.categoryRepo, repos.cashAdvanceRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
purchaseCategoryProcessor: processor.NewPurchaseCategoryProcessorImpl(repos.purchaseCategoryRepo),
purchaseOrderProcessor: processor.NewPurchaseOrderProcessorImpl(repos.purchaseOrderRepo, repos.vendorRepo, repos.ingredientRepo, repos.unitRepo, repos.fileRepo, inventoryMovementService, repos.unitConverterRepo),
unitConverterProcessor: processor.NewIngredientUnitConverterProcessorImpl(repos.unitConverterRepo, repos.ingredientRepo, repos.unitRepo),
chartOfAccountTypeProcessor: processor.NewChartOfAccountTypeProcessorImpl(repos.chartOfAccountTypeRepo),
chartOfAccountProcessor: processor.NewChartOfAccountProcessorImpl(repos.chartOfAccountRepo, repos.chartOfAccountTypeRepo),
@@ -442,21 +342,11 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
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(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo),
customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo, repos.gameRepo),
otpProcessor: otpProcessor,
fileClient: fileClient,
fcmClient: fcmClient,
inventoryMovementService: inventoryMovementService,
userDeviceProcessor: processor.NewUserDeviceProcessorImpl(repos.userDeviceRepo),
notificationProcessor: buildNotificationProcessor(cfg, repos),
productOutletPriceProcessor: processor.NewProductOutletPriceProcessorImpl(repos.productOutletPriceRepo, repos.productRepo, repos.outletRepo),
expenseProcessor: processor.NewExpenseProcessorImpl(repos.expenseRepo, repos.purchaseCategoryRepo, repos.cashAdvanceRepo),
cashAdvanceProcessor: processor.NewCashAdvanceProcessorImpl(repos.cashAdvanceRepo, repos.categoryRepo),
walletProcessor: processor.NewWalletProcessor(repos.walletRepo),
loyaltySettingsProcessor: loyaltySettingsProcessor,
earningProcessor: earningProcessor,
customerPinProcessor: customerPinProcessor,
paymentCodeProcessor: paymentCodeProcessor,
walletAdminProcessor: processor.NewWalletAdminProcessor(repository.NewWalletAdminRepository(a.db), repos.walletQueryRepo, processor.NewWalletProcessor(repos.walletRepo), repos.txManager),
}
}
@@ -482,7 +372,6 @@ type services struct {
productRecipeService *service.ProductRecipeServiceImpl
vendorService *service.VendorServiceImpl
purchaseOrderService *service.PurchaseOrderServiceImpl
purchaseCategoryService service.PurchaseCategoryService
unitConverterService *service.IngredientUnitConverterServiceImpl
chartOfAccountTypeService service.ChartOfAccountTypeService
chartOfAccountService service.ChartOfAccountService
@@ -494,19 +383,11 @@ type services struct {
customerAuthService service.CustomerAuthService
customerPointsService service.CustomerPointsService
spinGameService service.SpinGameService
userDeviceService service.UserDeviceService
notificationService service.NotificationService
productOutletPriceService service.ProductOutletPriceService
expenseService *service.ExpenseServiceImpl
cashAdvanceService *service.CashAdvanceServiceImpl
walletAdminService *service.WalletAdminServiceImpl
loyaltySettingsService *service.LoyaltySettingsServiceImpl
customerPinService *service.CustomerPinServiceImpl
}
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
authConfig := cfg.Auth()
authService := service.NewAuthService(processors.userProcessor, processors.userDeviceProcessor, authConfig)
authService := service.NewAuthService(processors.userProcessor, authConfig)
organizationService := service.NewOrganizationService(processors.organizationProcessor)
outletService := service.NewOutletService(processors.outletProcessor)
outletSettingService := service.NewOutletSettingService(processors.outletSettingProcessor)
@@ -514,7 +395,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productService := service.NewProductService(processors.productProcessor)
productVariantService := service.NewProductVariantService(processors.productVariantProcessor)
inventoryService := service.NewInventoryService(processors.inventoryProcessor)
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo) // Will be updated after orderIngredientTransactionService is created
orderService := service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, nil, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo) // Will be updated after orderIngredientTransactionService is created
paymentMethodService := service.NewPaymentMethodService(processors.paymentMethodProcessor)
fileService := service.NewFileServiceImpl(processors.fileProcessor)
var customerService service.CustomerService = service.NewCustomerService(processors.customerProcessor)
@@ -526,7 +407,6 @@ 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)
@@ -538,11 +418,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor)
customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor)
spinGameService := service.NewSpinGameService(processors.gamePlayProcessor, repos.txManager)
userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor)
notificationService := service.NewNotificationService(processors.notificationProcessor)
// Update order service with order ingredient transaction service
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo, processors.notificationProcessor, repos.userRepo)
orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager, repos.sessionRepo)
return &services{
userService: service.NewUserService(processors.userProcessor),
@@ -566,7 +444,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
productRecipeService: productRecipeService,
vendorService: vendorService,
purchaseOrderService: purchaseOrderService,
purchaseCategoryService: purchaseCategoryService,
unitConverterService: unitConverterService,
chartOfAccountTypeService: chartOfAccountTypeService,
chartOfAccountService: chartOfAccountService,
@@ -578,14 +455,6 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
customerAuthService: customerAuthService,
customerPointsService: customerPointsService,
spinGameService: spinGameService,
userDeviceService: userDeviceService,
notificationService: notificationService,
productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor),
expenseService: service.NewExpenseService(processors.expenseProcessor),
cashAdvanceService: service.NewCashAdvanceService(processors.cashAdvanceProcessor),
walletAdminService: service.NewWalletAdminService(processors.walletAdminProcessor),
loyaltySettingsService: service.NewLoyaltySettingsService(processors.loyaltySettingsProcessor, repos.walletQueryRepo),
customerPinService: service.NewCustomerPinService(processors.customerPinProcessor, processors.paymentCodeProcessor),
}
}
@@ -616,7 +485,6 @@ type validators struct {
tableValidator *validator.TableValidator
vendorValidator *validator.VendorValidatorImpl
purchaseOrderValidator *validator.PurchaseOrderValidatorImpl
purchaseCategoryValidator *validator.PurchaseCategoryValidatorImpl
unitConverterValidator *validator.IngredientUnitConverterValidatorImpl
chartOfAccountTypeValidator *validator.ChartOfAccountTypeValidatorImpl
chartOfAccountValidator *validator.ChartOfAccountValidatorImpl
@@ -626,12 +494,6 @@ type validators struct {
rewardValidator validator.RewardValidator
campaignValidator validator.CampaignValidator
customerAuthValidator validator.CustomerAuthValidator
userDeviceValidator *validator.UserDeviceValidatorImpl
notificationValidator *validator.NotificationValidatorImpl
productOutletPriceValidator *validator.ProductOutletPriceValidatorImpl
expenseValidator *validator.ExpenseValidatorImpl
cashAdvanceValidator *validator.CashAdvanceValidatorImpl
walletValidator *validator.WalletValidatorImpl
}
func (a *App) initValidators() *validators {
@@ -650,7 +512,6 @@ 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),
@@ -660,34 +521,5 @@ func (a *App) initValidators() *validators {
rewardValidator: validator.NewRewardValidator(),
campaignValidator: validator.NewCampaignValidator(),
customerAuthValidator: validator.NewCustomerAuthValidator(),
userDeviceValidator: validator.NewUserDeviceValidator(),
notificationValidator: validator.NewNotificationValidator(),
productOutletPriceValidator: validator.NewProductOutletPriceValidator(),
expenseValidator: validator.NewExpenseValidator(),
cashAdvanceValidator: validator.NewCashAdvanceValidator(),
walletValidator: validator.NewWalletValidator(),
}
}
// 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
}
}
return processor.NewNotificationProcessor(
repos.notificationRepo,
repos.notificationReceiverRepo,
repos.notificationDeliveryRepo,
repos.userDeviceRepo,
repos.userRepo,
fcmClient,
)
}
+34 -93
View File
@@ -3,140 +3,81 @@ package client
import (
"context"
"fmt"
"log"
"apskel-pos-be/config"
firebase "firebase.google.com/go/v4"
"firebase.google.com/go/v4/messaging"
"google.golang.org/api/option"
)
type FCMConfig interface {
GetCredentialsFile() string
GetProjectID() string
}
type FCMClient interface {
SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error
SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error
type FcmClient interface {
SendMulticastNotification(ctx context.Context, tokens []string, title string, body string) error
}
type fcmClient struct {
messaging *messaging.Client
messagingClient *messaging.Client
}
func NewFCMClient(cfg FCMConfig) (FCMClient, error) {
ctx := context.Background()
func NewFcmClient(cfg *config.Firebase) FcmClient {
if cfg == nil || cfg.GetCredentialsFile() == "" {
log.Println("FCM: credentials file not configured, FCM client is disabled")
return &fcmClient{messagingClient: nil}
}
opt := option.WithCredentialsFile(cfg.GetCredentialsFile())
app, err := firebase.NewApp(ctx, &firebase.Config{
ProjectID: cfg.GetProjectID(),
}, opt)
app, err := firebase.NewApp(context.Background(), nil, opt)
if err != nil {
return nil, fmt.Errorf("failed to initialize firebase app: %w", err)
log.Printf("FCM: failed to initialize Firebase app: %v", err)
return &fcmClient{messagingClient: nil}
}
msgClient, err := app.Messaging(ctx)
client, err := app.Messaging(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to initialize firebase messaging client: %w", err)
log.Printf("FCM: failed to create messaging client: %v", err)
return &fcmClient{messagingClient: nil}
}
return &fcmClient{
messaging: msgClient,
}, nil
}
// SendNotification sends a push notification to a single device token.
func (f *fcmClient) SendNotification(ctx context.Context, token string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Token: token,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM notification: %w", err)
log.Println("FCM: client initialized successfully")
return &fcmClient{messagingClient: client}
}
func (c *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string) error {
if c.messagingClient == nil {
log.Println("FCM: client not initialized, skipping notification")
return nil
}
// SendMulticastNotification sends a push notification to multiple device tokens.
func (f *fcmClient) SendMulticastNotification(ctx context.Context, tokens []string, title string, body string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
message := &messaging.MulticastMessage{
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Tokens: tokens,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
batchResp, err := f.messaging.SendEachForMulticast(ctx, message)
response, err := c.messagingClient.SendMulticast(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM multicast notification: %w", err)
return fmt.Errorf("FCM: failed to send multicast notification: %w", err)
}
if batchResp.FailureCount > 0 {
return fmt.Errorf("FCM multicast: %d/%d messages failed to send", batchResp.FailureCount, len(tokens))
if response.FailureCount > 0 {
log.Printf("FCM: %d tokens failed out of %d", response.FailureCount, len(tokens))
for i, resp := range response.Responses {
if !resp.Success {
log.Printf("FCM: token[%d] failed: %v", i, resp.Error)
}
return nil
}
// SendToTopic sends a push notification to all devices subscribed to a topic.
func (f *fcmClient) SendToTopic(ctx context.Context, topic string, title string, body string, data map[string]string) error {
message := &messaging.Message{
Topic: topic,
Notification: &messaging.Notification{
Title: title,
Body: body,
},
Data: data,
Android: &messaging.AndroidConfig{
Priority: "high",
},
APNS: &messaging.APNSConfig{
Payload: &messaging.APNSPayload{
Aps: &messaging.Aps{
Sound: "default",
},
},
},
}
_, err := f.messaging.Send(ctx, message)
if err != nil {
return fmt.Errorf("failed to send FCM topic notification: %w", err)
}
log.Printf("FCM: sent %d/%d notifications successfully", response.SuccessCount, len(tokens))
return nil
}
-9
View File
@@ -1,9 +0,0 @@
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
)
-62
View File
@@ -1,62 +0,0 @@
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"
)
-21
View File
@@ -12,12 +12,6 @@ const (
ValidationErrorCode = "304"
InvalidFieldErrorCode = "305"
NotFoundErrorCode = "404"
// PIN outcomes the customer app tells apart (docs/prd-point-coin.md §9).
PinNotSetErrorCode = "PIN_NOT_SET"
PinInvalidErrorCode = "PIN_INVALID"
PinLockedErrorCode = "PIN_LOCKED"
TransferBlockedErrorCode = "TRANSFER_BLOCKED"
TooManyRequestsErrorCode = "429"
)
const (
@@ -46,7 +40,6 @@ 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"
@@ -63,15 +56,6 @@ const (
CampaignRuleEntity = "campaign_rule"
CustomerEntity = "customer"
SpinGameHandlerEntity = "spin_game_handler"
UserDeviceServiceEntity = "user_device_service"
NotificationServiceEntity = "notification_service"
NotificationHandlerEntity = "notification_handler"
ProductOutletPriceServiceEntity = "product_outlet_price_service"
ExpenseServiceEntity = "expense_service"
CashAdvanceServiceEntity = "cash_advance_service"
WalletServiceEntity = "wallet_service"
LoyaltySettingsServiceEntity = "loyalty_settings_service"
CustomerPinServiceEntity = "customer_pin_service"
)
var HttpErrorMap = map[string]int{
@@ -81,11 +65,6 @@ var HttpErrorMap = map[string]int{
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
-28
View File
@@ -1,28 +0,0 @@
package constants
type ExpenseStatus string
const (
ExpenseStatusDraft ExpenseStatus = "draft"
ExpenseStatusSent ExpenseStatus = "sent"
ExpenseStatusApproved ExpenseStatus = "approved"
ExpenseStatusCancel ExpenseStatus = "cancel"
)
func GetAllExpenseStatuses() []ExpenseStatus {
return []ExpenseStatus{
ExpenseStatusDraft,
ExpenseStatusSent,
ExpenseStatusApproved,
ExpenseStatusCancel,
}
}
func IsValidExpenseStatus(status ExpenseStatus) bool {
for _, validStatus := range GetAllExpenseStatuses() {
if status == validStatus {
return true
}
}
return false
}
-71
View File
@@ -1,71 +0,0 @@
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)
)
-3
View File
@@ -8,9 +8,6 @@ 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
-17
View File
@@ -1,17 +0,0 @@
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
}
-19
View File
@@ -1,19 +0,0 @@
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"
)
-4
View File
@@ -7,8 +7,6 @@ const (
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
)
func GetAllUserRoles() []UserRole {
@@ -17,8 +15,6 @@ func GetAllUserRoles() []UserRole {
RoleManager,
RoleCashier,
RoleWaiter,
RoleOwner,
RolePurchasing,
}
}
-44
View File
@@ -1,44 +0,0 @@
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"
)
+11 -389
View File
@@ -8,7 +8,7 @@ import (
type PaymentMethodAnalyticsRequest struct {
OrganizationID uuid.UUID `form:"organization_id"`
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `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"`
@@ -18,7 +18,6 @@ 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"`
@@ -46,7 +45,7 @@ type PaymentMethodAnalyticsData struct {
type SalesAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `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"`
@@ -55,7 +54,6 @@ 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"`
@@ -85,95 +83,10 @@ type SalesAnalyticsData struct {
NetSales float64 `json:"net_sales"`
}
type PurchasingAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
// 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"`
Summary PurchasingSummary `json:"summary"`
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"`
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 {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
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"`
}
// ProductAnalyticsRequest represents the request for product analytics
type ProductAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
Limit int `form:"limit,default=1000" validate:"min=1,max=1000"`
@@ -183,7 +96,6 @@ 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"`
@@ -193,12 +105,10 @@ 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"`
@@ -213,7 +123,7 @@ type ProductAnalyticsData struct {
// ProductAnalyticsPerCategoryRequest represents the request for product analytics per category
type ProductAnalyticsPerCategoryRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
@@ -222,7 +132,6 @@ 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"`
@@ -240,140 +149,10 @@ 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
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `form:"outlet_id,omitempty"`
DateFrom string `form:"date_from" validate:"required"`
DateTo string `form:"date_to" validate:"required"`
}
@@ -382,7 +161,6 @@ 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"`
@@ -399,52 +177,30 @@ type DashboardOverview struct {
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"`
}
// ProfitLossAnalyticsRequest represents the request for profit and loss analytics
type ProfitLossAnalyticsRequest struct {
OrganizationID uuid.UUID
OutletID *string `form:"outlet_id,omitempty"`
OutletID *uuid.UUID `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"`
}
// ProfitLossAnalyticsResponse represents the response for profit and loss analytics
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"`
Summary ProfitLossSummary `json:"summary"`
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"`
}
// ProfitLossSummary represents the summary of profit and loss analytics
type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
@@ -459,6 +215,7 @@ type ProfitLossSummary struct {
ProfitabilityRatio float64 `json:"profitability_ratio"`
}
// ProfitLossData represents individual profit and loss data point by time period
type ProfitLossData struct {
Date time.Time `json:"date"`
Revenue float64 `json:"revenue"`
@@ -472,6 +229,7 @@ type ProfitLossData struct {
Orders int64 `json:"orders"`
}
// ProductProfitData represents profit data for individual products
type ProductProfitData struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
@@ -486,139 +244,3 @@ type ProductProfitData struct {
AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"`
}
type ProfitLossSummaryRow struct {
ID string `json:"id"`
Label string `json:"label"`
IsBold bool `json:"is_bold"`
TodayNominal float64 `json:"today_nominal"`
TodayPct float64 `json:"today_pct"`
MtdNominal float64 `json:"mtd_nominal"`
MtdPct float64 `json:"mtd_pct"`
SubItems []ProfitLossSummaryRow `json:"sub_items,omitempty"`
}
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"`
}
+5 -5
View File
@@ -12,7 +12,7 @@ import (
type CreateCampaignRequest struct {
Name string `json:"name" binding:"required,min=1,max=150"`
Description *string `json:"description,omitempty"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS COINS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS 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 COINS TOKENS MIXED"`
Type string `json:"type" binding:"required,oneof=REWARD POINTS 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 COINS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS 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 COINS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS 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 COINS TOKENS REWARD"`
RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"`
RewardValue *int64 `json:"reward_value,omitempty"`
RewardSubtype *string `json:"reward_subtype,omitempty"`
RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"`
@@ -1,92 +0,0 @@
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"`
}
-10
View File
@@ -10,8 +10,6 @@ type CreateCategoryRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -20,17 +18,12 @@ type UpdateCategoryRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Description *string `json:"description,omitempty"`
BusinessType *string `json:"business_type,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Order *int `json:"order,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
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"`
@@ -41,9 +34,6 @@ type ListCategoriesRequest struct {
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"`
@@ -1,33 +0,0 @@
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"`
}
-167
View File
@@ -1,167 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
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"`
// 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"`
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"`
// 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"`
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 {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID uuid.UUID `json:"outlet_id"`
Receiver string `json:"receiver"`
TransactionDate time.Time `json:"transaction_date"`
CodeNumber string `json:"code_number"`
Status string `json:"status"`
Description *string `json:"description"`
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"`
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 {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
Search string `json:"search,omitempty"`
OutletID string `json:"outlet_id,omitempty"`
Status string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved cancel"`
StartDate string `json:"start_date,omitempty"`
EndDate string `json:"end_date,omitempty"`
}
type ListExpenseResponse struct {
Expenses []ExpenseResponse `json:"expenses"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
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"`
}
@@ -77,7 +77,8 @@ 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"`
}
@@ -1,92 +0,0 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
// ---- Request contracts ----
type SendNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ReceiverIDs []uuid.UUID `json:"receiver_ids" validate:"required,min=1"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type BroadcastNotificationRequest struct {
Title string `json:"title" validate:"required,min=1,max=255"`
Body string `json:"body" validate:"required"`
Type string `json:"type,omitempty" validate:"omitempty,max=100"`
Category string `json:"category,omitempty" validate:"omitempty,max=100"`
Priority entities.NotificationPriority `json:"priority,omitempty" validate:"omitempty,oneof=low normal high"`
ImageURL string `json:"image_url,omitempty" validate:"omitempty,max=512"`
ActionURL string `json:"action_url,omitempty" validate:"omitempty,max=512"`
NotifiableType string `json:"notifiable_type,omitempty" validate:"omitempty,max=100"`
NotifiableID *uuid.UUID `json:"notifiable_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
ScheduledAt *time.Time `json:"scheduled_at,omitempty"`
ExpiredAt *time.Time `json:"expired_at,omitempty"`
}
type ListNotificationsRequest struct {
Page int `form:"page" validate:"min=1"`
Limit int `form:"limit" validate:"min=1,max=100"`
IsRead *bool `form:"is_read"`
}
// ---- Response contracts ----
type NotificationResponse struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Type string `json:"type"`
Category string `json:"category"`
Priority entities.NotificationPriority `json:"priority"`
ImageURL string `json:"image_url"`
ActionURL string `json:"action_url"`
NotifiableType string `json:"notifiable_type"`
NotifiableID *uuid.UUID `json:"notifiable_id"`
Data map[string]interface{} `json:"data"`
ScheduledAt *time.Time `json:"scheduled_at"`
SentAt *time.Time `json:"sent_at"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedBy *uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type NotificationReceiverResponse struct {
ID uuid.UUID `json:"id"`
NotificationID uuid.UUID `json:"notification_id"`
UserID uuid.UUID `json:"user_id"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Notification *NotificationResponse `json:"notification,omitempty"`
}
type ListNotificationsResponse struct {
Notifications []*NotificationReceiverResponse `json:"notifications"`
TotalCount int64 `json:"total_count"`
UnreadCount int64 `json:"unread_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
+1 -16
View File
@@ -43,10 +43,7 @@ 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"`
// 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
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"`
@@ -92,10 +89,6 @@ 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 {
@@ -105,14 +98,7 @@ type OrderItemResponse struct {
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"`
@@ -122,7 +108,6 @@ type OrderItemResponse struct {
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"`
}
+2 -4
View File
@@ -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 point"`
Type *string `json:"type,omitempty" validate:"omitempty,oneof=cash card digital_wallet qr edc"`
Processor *string `json:"processor,omitempty" validate:"omitempty,max=100"`
Configuration map[string]interface{} `json:"configuration,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
@@ -38,9 +38,7 @@ type PaymentMethodResponse struct {
type ListPaymentMethodsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
// 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"`
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"`
+5 -18
View File
@@ -8,7 +8,6 @@ import (
type CreateProductRequest struct {
CategoryID uuid.UUID `json:"category_id" validate:"required"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
SKU *string `json:"sku,omitempty"`
Name string `json:"name" validate:"required,min=1,max=255"`
Description *string `json:"description,omitempty"`
@@ -17,19 +16,15 @@ type CreateProductRequest struct {
BusinessType *string `json:"business_type,omitempty"`
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
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"`
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"`
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"`
CreateInventory bool `json:"create_inventory,omitempty"`
InitialStock *int `json:"initial_stock,omitempty" validate:"omitempty,min=0"` // Initial stock quantity for all outlets
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Reorder level for all outlets
CreateInventory bool `json:"create_inventory,omitempty"` // Whether to create inventory records for all outlets
}
type UpdateProductRequest struct {
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
SKU *string `json:"sku,omitempty"`
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
@@ -39,12 +34,10 @@ type UpdateProductRequest struct {
BusinessType *string `json:"business_type,omitempty"`
ImageURL *string `json:"image_url,omitempty" validate:"omitempty,max=500"`
PrinterType *string `json:"printer_type,omitempty" validate:"omitempty,max=50"`
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"`
// Stock management fields
ReorderLevel *int `json:"reorder_level,omitempty" validate:"omitempty,min=0"` // Update reorder level for all existing inventory records
}
type CreateProductVariantRequest struct {
@@ -71,15 +64,10 @@ type ProductResponse struct {
Name string `json:"name"`
Description *string `json:"description"`
Price float64 `json:"price"`
OutletPrice *float64 `json:"outlet_price,omitempty"`
OutletPrices []ProductOutletPriceResponse `json:"outlet_prices,omitempty"`
Cost float64 `json:"cost"`
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"`
CreatedAt time.Time `json:"created_at"`
@@ -101,7 +89,6 @@ type ProductVariantResponse struct {
type ListProductsRequest struct {
OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
CategoryID *uuid.UUID `json:"category_id,omitempty"`
BusinessType string `json:"business_type,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
@@ -1,46 +0,0 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker bool `json:"print_to_checker"`
}
type UpdateProductOutletPriceRequest struct {
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker *bool `json:"print_to_checker"`
}
type ProductOutletPriceResponse struct {
ID uuid.UUID `json:"id,omitempty"`
ProductID uuid.UUID `json:"product_id,omitempty"`
OutletID uuid.UUID `json:"outlet_id"`
OutletName string `json:"outlet_name,omitempty"`
Price float64 `json:"price"`
PrintToChecker bool `json:"print_to_checker"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type ListProductOutletPricesResponse struct {
Prices []ProductOutletPriceResponse `json:"prices"`
TotalCount int `json:"total_count"`
}
type BulkCreateProductOutletPriceRequest struct {
ProductID uuid.UUID `json:"product_id" validate:"required"`
Prices []CreateProductOutletPricePerOutletRequest `json:"prices" validate:"required,dive"`
}
type CreateProductOutletPricePerOutletRequest struct {
OutletID uuid.UUID `json:"outlet_id" validate:"required"`
Price float64 `json:"price" validate:"required,min=0"`
PrintToChecker bool `json:"print_to_checker"`
}
+1 -1
View File
@@ -54,7 +54,7 @@ type ProductRecipeIngredientResponse struct {
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
Name string `json:"name"`
UnitID *uuid.UUID `json:"unit_id"`
UnitID uuid.UUID `json:"unit_id"`
Cost float64 `json:"cost"`
Stock float64 `json:"stock"`
IsSemiFinished bool `json:"is_semi_finished"`
@@ -1,57 +0,0 @@
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"`
}
+10 -49
View File
@@ -7,28 +7,22 @@ import (
)
type CreatePurchaseOrderRequest struct {
VendorID *uuid.UUID `json:"vendor_id,omitempty" validate:"omitempty"`
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
DueDate string `json:"due_date" validate:"required"` // 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,omitempty" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id" validate:"required"`
IngredientID uuid.UUID `json:"ingredient_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"`
Quantity float64 `json:"quantity" validate:"required,gt=0"`
UnitID uuid.UUID `json:"unit_id" validate:"required"`
Amount float64 `json:"amount" validate:"required,gte=0"`
}
@@ -40,11 +34,6 @@ type UpdatePurchaseOrderRequest struct {
Reference *string `json:"reference,omitempty" validate:"omitempty,max=100"`
Status *string `json:"status,omitempty" validate:"omitempty,oneof=draft sent approved received cancelled"`
Message *string `json:"message,omitempty" validate:"omitempty"`
// An empty string clears the team; omitting the field leaves it untouched.
TeamScope *string `json:"team_scope,omitempty" validate:"omitempty"`
TeamCategoryID *uuid.UUID `json:"team_category_id,omitempty" validate:"omitempty"`
// An all-zero uuid unlinks the cash advance; omitting the field leaves it untouched.
CashAdvanceID *uuid.UUID `json:"cash_advance_id,omitempty" validate:"omitempty"`
Items []UpdatePurchaseOrderItemRequest `json:"items,omitempty" validate:"omitempty,dive"`
AttachmentFileIDs []uuid.UUID `json:"attachment_file_ids,omitempty"`
}
@@ -52,7 +41,6 @@ type UpdatePurchaseOrderRequest struct {
type UpdatePurchaseOrderItemRequest struct {
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"`
@@ -62,52 +50,32 @@ type UpdatePurchaseOrderItemRequest struct {
type PurchaseOrderResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id"`
VendorID *uuid.UUID `json:"vendor_id"`
VendorID uuid.UUID `json:"vendor_id"`
PONumber string `json:"po_number"`
TransactionDate time.Time `json:"transaction_date"`
DueDate *time.Time `json:"due_date"`
DueDate time.Time `json:"due_date"`
Reference *string `json:"reference"`
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"`
PurchaseCategoryID uuid.UUID `json:"purchase_category_id"`
IngredientID uuid.UUID `json:"ingredient_id"`
Description *string `json:"description"`
Quantity *float64 `json:"quantity"`
UnitID *uuid.UUID `json:"unit_id"`
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"`
}
@@ -125,13 +93,6 @@ type ListPurchaseOrdersRequest struct {
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"`
}
+34 -5
View File
@@ -1,6 +1,8 @@
package contract
import (
"time"
"github.com/google/uuid"
)
@@ -49,8 +51,6 @@ type SelfOrderMenuVariant struct {
type SelfOrderCreateOrderRequest struct {
SessionID string `json:"session_id" validate:"required"`
CustomerName string `json:"customer_name" validate:"required"`
OrderType string `json:"order_type" validate:"required,oneof=dine_in takeaway delivery"`
OrderItems []SelfOrderCreateOrderItem `json:"order_items" validate:"required,min=1,dive"`
}
@@ -58,12 +58,11 @@ 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"`
}
type SelfOrderListCategoriesRequest struct {
OrganizationID string `form:"organization_id" validate:"required"`
OrganizationID string `form:"organisasi_id" validate:"required"`
OutletID string `form:"outlet_id" validate:"required"`
}
@@ -79,5 +78,35 @@ type SelfOrderListCategoriesResponse struct {
}
type SelfOrderListOrdersResponse struct {
Orders []OrderResponse `json:"orders"`
Orders []SelfOrderOrderItem `json:"orders"`
}
type SelfOrderOrderItem struct {
ID uuid.UUID `json:"id"`
OrderNumber string `json:"order_number"`
TableNumber *string `json:"table_number,omitempty"`
OrderType string `json:"order_type"`
Status string `json:"status"`
Subtotal float64 `json:"subtotal"`
TaxAmount float64 `json:"tax_amount"`
DiscountAmount float64 `json:"discount_amount"`
TotalAmount float64 `json:"total_amount"`
RemainingAmount float64 `json:"remaining_amount"`
PaymentStatus string `json:"payment_status"`
IsVoid bool `json:"is_void"`
IsRefund bool `json:"is_refund"`
Items []SelfOrderOrderLineItem `json:"items,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type SelfOrderOrderLineItem struct {
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
ProductVariantID *uuid.UUID `json:"product_variant_id,omitempty"`
ProductVariantNam *string `json:"product_variant_name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Notes *string `json:"notes,omitempty"`
Status string `json:"status"`
}
+3 -9
View File
@@ -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 owner purchasing"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
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 owner purchasing"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"`
@@ -37,13 +37,7 @@ type UpdateUserOutletRequest struct {
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
DeviceID string `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
DeviceType string `json:"device_type,omitempty"`
Platform string `json:"platform,omitempty"`
FCMToken string `json:"fcm_token,omitempty"`
AppVersion string `json:"app_version,omitempty"`
OsVersion string `json:"os_version,omitempty"`
FcmToken *string `json:"fcm_token,omitempty"`
}
type LoginResponse struct {
-59
View File
@@ -1,59 +0,0 @@
package contract
import (
"time"
"apskel-pos-be/internal/entities"
"github.com/google/uuid"
)
type RegisterUserDeviceRequest struct {
DeviceID string `json:"device_id" validate:"required,min=1,max=255"`
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UpdateUserDeviceRequest struct {
DeviceName string `json:"device_name,omitempty" validate:"omitempty,max=255"`
DeviceType entities.DeviceType `json:"device_type,omitempty" validate:"omitempty,oneof=mobile tablet desktop"`
Platform entities.DevicePlatform `json:"platform,omitempty" validate:"omitempty,oneof=android ios web"`
FCMToken string `json:"fcm_token,omitempty" validate:"omitempty,max=512"`
AppVersion string `json:"app_version,omitempty" validate:"omitempty,max=50"`
OsVersion string `json:"os_version,omitempty" validate:"omitempty,max=50"`
}
type UserDeviceResponse struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
DeviceType entities.DeviceType `json:"device_type"`
Platform entities.DevicePlatform `json:"platform"`
FCMToken string `json:"fcm_token"`
AppVersion string `json:"app_version"`
OsVersion string `json:"os_version"`
IPAddress string `json:"ip_address"`
LastActiveAt *time.Time `json:"last_active_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListUserDevicesRequest struct {
Page int `json:"page" validate:"min=1"`
Limit int `json:"limit" validate:"min=1,max=100"`
UserID string `json:"user_id,omitempty"`
Platform string `json:"platform,omitempty"`
}
type ListUserDevicesResponse struct {
Devices []UserDeviceResponse `json:"devices"`
TotalCount int `json:"total_count"`
Page int `json:"page"`
Limit int `json:"limit"`
TotalPages int `json:"total_pages"`
}
-14
View File
@@ -1,14 +0,0 @@
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"`
}
+40 -222
View File
@@ -27,94 +27,14 @@ type SalesAnalytics struct {
NetSales float64 `json:"net_sales"`
}
// PurchaseTeamFilter narrows purchasing figures to a single team: a parent
// category, Pusat, or the purchases that carry no team at all. A nil filter
// leaves the figures spanning every team.
type PurchaseTeamFilter struct {
Scope string
CategoryID *uuid.UUID
}
// PurchasingAnalytics represents purchasing analytics data
type PurchasingAnalytics struct {
OutletName *string `json:"outlet_name,omitempty"`
Summary PurchasingSummary `json:"summary"`
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"`
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 {
IngredientID uuid.UUID `json:"ingredient_id"`
IngredientName string `json:"ingredient_name"`
Quantity float64 `json:"quantity"`
TotalCost float64 `json:"total_cost"`
AverageUnitCost float64 `json:"average_unit_cost"`
PurchaseOrderCount int64 `json:"purchase_order_count"`
}
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"`
}
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"`
@@ -138,39 +58,6 @@ 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"`
@@ -179,125 +66,56 @@ type DashboardOverview struct {
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"`
}
// ProfitLossAnalytics represents profit and loss analytics data
type ProfitLossAnalytics struct {
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
Summary ProfitLossSummary `json:"summary"`
Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"`
}
// ProfitLossSummary represents profit and loss summary data
type ProfitLossSummary struct {
TotalRevenue float64
TotalCost float64
GrossProfit float64
GrossProfitMargin float64
TotalTax float64
TotalDiscount float64
NetProfit float64
NetProfitMargin float64
TotalOrders int64
AverageProfit float64
ProfitabilityRatio float64
TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"`
GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 `json:"gross_profit_margin"`
TotalTax float64 `json:"total_tax"`
TotalDiscount float64 `json:"total_discount"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
TotalOrders int64 `json:"total_orders"`
AverageProfit float64 `json:"average_profit"`
ProfitabilityRatio float64 `json:"profitability_ratio"`
}
// ProfitLossData represents profit and loss data by time period
type ProfitLossData struct {
Date time.Time
Revenue float64
Cost float64
GrossProfit float64
GrossProfitMargin float64
Tax float64
Discount float64
NetProfit float64
NetProfitMargin float64
Orders int64
Date time.Time `json:"date"`
Revenue float64 `json:"revenue"`
Cost float64 `json:"cost"`
GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 `json:"gross_profit_margin"`
Tax float64 `json:"tax"`
Discount float64 `json:"discount"`
NetProfit float64 `json:"net_profit"`
NetProfitMargin float64 `json:"net_profit_margin"`
Orders int64 `json:"orders"`
}
// ProductProfitData represents profit data for individual products
type ProductProfitData struct {
ProductID uuid.UUID
ProductName string
CategoryID uuid.UUID
CategoryName string
QuantitySold int64
Revenue float64
Cost float64
GrossProfit float64
GrossProfitMargin float64
AveragePrice float64
AverageCost float64
ProfitPerUnit float64
}
type ExpenseCategoryTotal struct {
CategoryName string
Amount float64
}
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
ProductID uuid.UUID `json:"product_id"`
ProductName string `json:"product_name"`
CategoryID uuid.UUID `json:"category_id"`
CategoryName string `json:"category_name"`
QuantitySold int64 `json:"quantity_sold"`
Revenue float64 `json:"revenue"`
Cost float64 `json:"cost"`
GrossProfit float64 `json:"gross_profit"`
GrossProfitMargin float64 `json:"gross_profit_margin"`
AveragePrice float64 `json:"average_price"`
AverageCost float64 `json:"average_cost"`
ProfitPerUnit float64 `json:"profit_per_unit"`
}
+2 -23
View File
@@ -12,31 +12,10 @@ type CampaignType string
const (
CampaignTypeReward CampaignType = "REWARD"
CampaignTypePoints CampaignType = "POINTS"
CampaignTypeCoins CampaignType = "COINS"
CampaignTypeTokens CampaignType = "TOKENS"
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 (
@@ -52,7 +31,7 @@ type CampaignRewardType string
const (
CampaignRewardTypePoints CampaignRewardType = "POINTS"
CampaignRewardTypeCoins CampaignRewardType = "COINS"
CampaignRewardTypeTokens CampaignRewardType = "TOKENS"
CampaignRewardTypeReward CampaignRewardType = "REWARD"
)
-18
View File
@@ -1,18 +0,0 @@
package entities
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeCampaignTypesMapsLegacyTokensToCoins(t *testing.T) {
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("TOKENS"))
assert.Equal(t, CampaignTypeCoins, NormalizeCampaignType("COINS"))
assert.Equal(t, CampaignTypePoints, NormalizeCampaignType("POINTS"))
assert.Equal(t, CampaignType(""), NormalizeCampaignType(""), "an empty filter stays empty")
assert.Equal(t, CampaignRewardTypeCoins, NormalizeCampaignRewardType("TOKENS"))
assert.Equal(t, CampaignRewardTypeReward, NormalizeCampaignRewardType("REWARD"))
assert.Equal(t, CampaignRewardType(""), NormalizeCampaignRewardType(""))
}
-63
View File
@@ -1,63 +0,0 @@
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"`
}
-3
View File
@@ -33,9 +33,6 @@ func (m *Metadata) Scan(value interface{}) error {
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"`
-14
View File
@@ -36,20 +36,6 @@ func GetAllEntities() []interface{} {
&CampaignRule{},
&OtpSession{},
// Analytics entities are not database tables, they are query results
&UserDevice{},
// Notification entities
&Notification{},
&NotificationReceiver{},
&NotificationDelivery{},
&ProductOutletPrice{},
&Expense{},
&CashAdvance{},
// Wallet entities
&CustomerWallet{},
&WalletTransaction{},
&WalletLot{},
&WalletLotAllocation{},
&LoyaltySettingChange{},
}
}
-93
View File
@@ -1,93 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type Expense 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"`
Receiver string `gorm:"not null;size:255" json:"receiver"`
TransactionDate time.Time `gorm:"type:date;not null" json:"transaction_date"`
CodeNumber string `gorm:"not null;size:50" json:"code_number"`
Status string `gorm:"not null;size:20;default:'draft'" json:"status"`
Description *string `gorm:"type:text" json:"description"`
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"`
// 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()
}
return nil
}
func (Expense) TableName() string {
return "expenses"
}
-36
View File
@@ -1,36 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
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"`
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"`
PurchaseCategory *PurchaseCategory `gorm:"foreignKey:PurchaseCategoryID" json:"purchase_category,omitempty"`
}
func (e *ExpenseItem) BeforeCreate(tx *gorm.DB) error {
if e.ID == uuid.Nil {
e.ID = uuid.New()
}
return nil
}
func (ExpenseItem) TableName() string {
return "expense_items"
}
+1 -1
View File
@@ -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;index" json:"unit_id"`
UnitID uuid.UUID `gorm:"type:uuid;not null;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,3 +39,4 @@ func (iuc *IngredientUnitConverter) BeforeCreate() error {
}
return nil
}
-2
View File
@@ -49,7 +49,6 @@ type InventoryMovement struct {
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"`
@@ -62,7 +61,6 @@ type InventoryMovement struct {
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"`
@@ -1,34 +0,0 @@
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"
}
-150
View File
@@ -1,150 +0,0 @@
package entities
import (
"database/sql/driver"
"encoding/json"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type NotificationPriority string
type NotificationDeliveryStatus string
type NotificationChannel string
type NotificationProvider string
const (
NotificationPriorityLow NotificationPriority = "low"
NotificationPriorityNormal NotificationPriority = "normal"
NotificationPriorityHigh NotificationPriority = "high"
NotificationDeliveryStatusPending NotificationDeliveryStatus = "pending"
NotificationDeliveryStatusSent NotificationDeliveryStatus = "sent"
NotificationDeliveryStatusDelivered NotificationDeliveryStatus = "delivered"
NotificationDeliveryStatusFailed NotificationDeliveryStatus = "failed"
NotificationChannelPush NotificationChannel = "push"
NotificationChannelWebsocket NotificationChannel = "websocket"
NotificationChannelEmail NotificationChannel = "email"
NotificationProviderFirebase NotificationProvider = "firebase"
)
// NotificationData is a JSON-serializable map for extra notification payload.
type NotificationData map[string]interface{}
func (d NotificationData) Value() (driver.Value, error) {
if d == nil {
return nil, nil
}
return json.Marshal(d)
}
func (d *NotificationData) Scan(value interface{}) error {
if value == nil {
*d = nil
return nil
}
bytes, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(bytes, d)
}
// Notification is the master notification record.
type Notification struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
Title string `gorm:"not null;size:255" json:"title"`
Body string `gorm:"type:text" json:"body"`
Type string `gorm:"size:100" json:"type"`
Category string `gorm:"size:100" json:"category"`
Priority NotificationPriority `gorm:"size:50;default:'normal'" json:"priority"`
ImageURL string `gorm:"size:512" json:"image_url"`
ActionURL string `gorm:"size:512" json:"action_url"`
NotifiableType string `gorm:"size:100" json:"notifiable_type"`
NotifiableID *uuid.UUID `gorm:"type:uuid" json:"notifiable_id"`
Data NotificationData `gorm:"type:jsonb" json:"data"`
ScheduledAt *time.Time `gorm:"type:timestamptz" json:"scheduled_at"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
ExpiredAt *time.Time `gorm:"type:timestamptz" json:"expired_at"`
CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
Receivers []*NotificationReceiver `gorm:"foreignKey:NotificationID" json:"receivers,omitempty"`
}
func (n *Notification) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (Notification) TableName() string {
return "notifications"
}
// NotificationReceiver links a notification to a specific user.
type NotificationReceiver struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
IsRead bool `gorm:"default:false" json:"is_read"`
ReadAt *time.Time `gorm:"type:timestamptz" json:"read_at"`
IsDeleted bool `gorm:"default:false" json:"is_deleted"`
DeletedAt *time.Time `gorm:"type:timestamptz" json:"deleted_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Notification *Notification `gorm:"foreignKey:NotificationID" json:"notification,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Deliveries []*NotificationDelivery `gorm:"foreignKey:NotificationReceiverID" json:"deliveries,omitempty"`
}
func (n *NotificationReceiver) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationReceiver) TableName() string {
return "notification_receivers"
}
// NotificationDelivery tracks per-device delivery attempts.
type NotificationDelivery struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
NotificationReceiverID uuid.UUID `gorm:"type:uuid;not null;index" json:"notification_receiver_id"`
UserDeviceID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_device_id"`
Channel NotificationChannel `gorm:"size:50;default:'push'" json:"channel"`
DeliveryStatus NotificationDeliveryStatus `gorm:"size:50;default:'pending'" json:"delivery_status"`
Provider NotificationProvider `gorm:"size:50" json:"provider"`
ProviderMessageID string `gorm:"size:255" json:"provider_message_id"`
SentAt *time.Time `gorm:"type:timestamptz" json:"sent_at"`
DeliveredAt *time.Time `gorm:"type:timestamptz" json:"delivered_at"`
FailedAt *time.Time `gorm:"type:timestamptz" json:"failed_at"`
FailureReason string `gorm:"type:text" json:"failure_reason"`
RetryCount int `gorm:"default:0" json:"retry_count"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
NotificationReceiver *NotificationReceiver `gorm:"foreignKey:NotificationReceiverID" json:"notification_receiver,omitempty"`
UserDevice *UserDevice `gorm:"foreignKey:UserDeviceID" json:"user_device,omitempty"`
}
func (n *NotificationDelivery) BeforeCreate(tx *gorm.DB) error {
if n.ID == uuid.Nil {
n.ID = uuid.New()
}
return nil
}
func (NotificationDelivery) TableName() string {
return "notification_deliveries"
}
+1 -54
View File
@@ -4,7 +4,6 @@ import (
"database/sql/driver"
"encoding/json"
"errors"
"math"
"time"
"github.com/google/uuid"
@@ -48,8 +47,6 @@ 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"`
@@ -71,7 +68,6 @@ 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 {
@@ -85,57 +81,8 @@ 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 = RoundMoney(oi.BillableQuantity() * oi.UnitPrice)
}
func (oi *OrderItem) CalculateTotalCost() {
oi.TotalCost = RoundMoney(oi.BillableQuantity() * oi.UnitCost)
oi.TotalPrice = float64(oi.Quantity) * oi.UnitPrice
}
func (oi *OrderItem) CanBeModified() bool {
-104
View File
@@ -1,104 +0,0 @@
package entities
import "testing"
func weightPtr(v float64) *float64 { return &v }
func TestBillableQuantity(t *testing.T) {
tests := []struct {
name string
item OrderItem
want float64
}{
{"unit line counts items", OrderItem{Quantity: 3}, 3},
{"weighed line uses the weight", OrderItem{Quantity: 1, Weight: weightPtr(4.2)}, 4.2},
{"weight of zero is still a weighed line", OrderItem{Quantity: 1, Weight: weightPtr(0)}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.item.BillableQuantity(); got != tt.want {
t.Errorf("BillableQuantity() = %v, want %v", got, tt.want)
}
})
}
}
// A weighed line is voided or refunded whole, so the units asked for must not shrink
// the amount: refunding "1" of a 4.2 ons line refunds all 4.2 ons.
func TestBillableQuantityForIgnoresUnitsOnWeighedLines(t *testing.T) {
weighed := OrderItem{Quantity: 1, Weight: weightPtr(5.6)}
if got := weighed.BillableQuantityFor(1); got != 5.6 {
t.Errorf("weighed BillableQuantityFor(1) = %v, want 5.6", got)
}
counted := OrderItem{Quantity: 3}
if got := counted.BillableQuantityFor(2); got != 2 {
t.Errorf("unit BillableQuantityFor(2) = %v, want 2", got)
}
}
// The regression this whole feature exists to prevent: a 4.2 ons fish priced per ons
// must not be billed as a single ons.
func TestCalculateTotalPriceForWeighedLine(t *testing.T) {
item := OrderItem{Quantity: 1, Weight: weightPtr(4.2), UnitPrice: 4500, UnitCost: 3000}
item.CalculateTotalPrice()
item.CalculateTotalCost()
if item.TotalPrice != 18900 {
t.Errorf("TotalPrice = %v, want 18900", item.TotalPrice)
}
if item.TotalCost != 12600 {
t.Errorf("TotalCost = %v, want 12600", item.TotalCost)
}
}
func TestCalculateTotalPriceForUnitLine(t *testing.T) {
item := OrderItem{Quantity: 3, UnitPrice: 25000, UnitCost: 10000}
item.CalculateTotalPrice()
item.CalculateTotalCost()
if item.TotalPrice != 75000 {
t.Errorf("TotalPrice = %v, want 75000", item.TotalPrice)
}
if item.TotalCost != 30000 {
t.Errorf("TotalCost = %v, want 30000", item.TotalCost)
}
}
// Sub-rupiah residue must not survive into a decimal(10,2) column.
func TestCalculateTotalPriceRoundsToStoredPrecision(t *testing.T) {
item := OrderItem{Quantity: 1, Weight: weightPtr(4.237), UnitPrice: 4500}
item.CalculateTotalPrice()
if item.TotalPrice != 19066.5 {
t.Errorf("TotalPrice = %v, want 19066.5", item.TotalPrice)
}
}
func TestIsWeighed(t *testing.T) {
if (&OrderItem{Quantity: 1}).IsWeighed() {
t.Error("unit line reported as weighed")
}
if !(&OrderItem{Quantity: 1, Weight: weightPtr(1.5)}).IsWeighed() {
t.Error("weighed line reported as unit")
}
}
func TestRoundWeight(t *testing.T) {
tests := []struct {
in float64
want float64
}{
{4.2, 4.2},
{4.2374, 4.237},
{4.2375, 4.238},
{0.0004, 0},
}
for _, tt := range tests {
if got := RoundWeight(tt.in); got != tt.want {
t.Errorf("RoundWeight(%v) = %v, want %v", tt.in, got, tt.want)
}
}
}
+1 -8
View File
@@ -13,16 +13,13 @@ 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 point"`
Type PaymentMethodType `gorm:"not null;size:50" json:"type" validate:"required,oneof=cash card digital_wallet"`
Processor *string `gorm:"size:100" json:"processor"`
Configuration Metadata `gorm:"type:jsonb;default:'{}'" json:"configuration"`
IsActive bool `gorm:"default:true" json:"is_active"`
@@ -72,10 +69,6 @@ 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"`
// 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"`
-2
View File
@@ -20,7 +20,6 @@ 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"`
@@ -34,7 +33,6 @@ type Product struct {
ProductRecipes []ProductRecipe `gorm:"foreignKey:ProductID" json:"product_recipes,omitempty"`
Inventory []Inventory `gorm:"foreignKey:ProductID" json:"inventory,omitempty"`
OrderItems []OrderItem `gorm:"foreignKey:ProductID" json:"order_items,omitempty"`
ProductOutletPrices []ProductOutletPrice `gorm:"foreignKey:ProductID" json:"product_outlet_prices,omitempty"`
}
func (p *Product) BeforeCreate(tx *gorm.DB) error {
-32
View File
@@ -1,32 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ProductOutletPrice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
ProductID uuid.UUID `gorm:"type:uuid;not null;index" json:"product_id"`
OutletID uuid.UUID `gorm:"type:uuid;not null;index" json:"outlet_id"`
Price float64 `gorm:"type:decimal(10,2);not null" json:"price"`
PrintToChecker bool `gorm:"not null;default:true" json:"print_to_checker"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
Product Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
Outlet Outlet `gorm:"foreignKey:OutletID" json:"outlet,omitempty"`
}
func (p *ProductOutletPrice) BeforeCreate(tx *gorm.DB) error {
if p.ID == uuid.Nil {
p.ID = uuid.New()
}
return nil
}
func (ProductOutletPrice) TableName() string {
return "product_outlet_prices"
}
-71
View File
@@ -1,71 +0,0 @@
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"
}
+5 -18
View File
@@ -11,30 +11,19 @@ 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"`
OutletID *uuid.UUID `gorm:"type:uuid;index" json:"outlet_id" validate:"omitempty"`
VendorID *uuid.UUID `gorm:"type:uuid" json:"vendor_id" validate:"omitempty"`
VendorID uuid.UUID `gorm:"type:uuid;not null" json:"vendor_id" validate:"required"`
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"`
DueDate time.Time `gorm:"type:date;not null" json:"due_date" validate:"required"`
Reference *string `gorm:"size:100" json:"reference" validate:"omitempty,max=100"`
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"`
// 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"`
}
@@ -54,18 +43,16 @@ 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" json:"ingredient_id" validate:"omitempty"`
PurchaseCategoryID uuid.UUID `gorm:"type:uuid;not null;index" json:"purchase_category_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)" json:"quantity" validate:"omitempty,gt=0"`
UnitID *uuid.UUID `gorm:"type:uuid" json:"unit_id" 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"`
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"`
}
+2 -3
View File
@@ -17,8 +17,6 @@ const (
RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
)
type Permissions map[string]interface{}
@@ -48,9 +46,10 @@ 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 owner purchasing"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"`
FcmToken *string `gorm:"size:512" json:"fcm_token,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"`
-50
View File
@@ -1,50 +0,0 @@
package entities
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type DeviceType string
type DevicePlatform string
const (
DeviceTypeMobile DeviceType = "mobile"
DeviceTypeTablet DeviceType = "tablet"
DeviceTypeDesktop DeviceType = "desktop"
DevicePlatformAndroid DevicePlatform = "android"
DevicePlatformIOS DevicePlatform = "ios"
DevicePlatformWeb DevicePlatform = "web"
)
type UserDevice struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"`
DeviceID string `gorm:"not null;size:255;index" json:"device_id"`
DeviceName string `gorm:"size:255" json:"device_name"`
DeviceType DeviceType `gorm:"size:50" json:"device_type"`
Platform DevicePlatform `gorm:"size:50" json:"platform"`
FCMToken string `gorm:"size:512" json:"fcm_token"`
AppVersion string `gorm:"size:50" json:"app_version"`
OsVersion string `gorm:"size:50" json:"os_version"`
IPAddress string `gorm:"size:45" json:"ip_address"`
LastActiveAt *time.Time `gorm:"type:timestamptz" json:"last_active_at"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}
func (u *UserDevice) BeforeCreate(tx *gorm.DB) error {
if u.ID == uuid.Nil {
u.ID = uuid.New()
}
return nil
}
func (UserDevice) TableName() string {
return "user_devices"
}
-113
View File
@@ -1,113 +0,0 @@
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"
}
+5 -175
View File
@@ -8,7 +8,6 @@ import (
"apskel-pos-be/internal/util"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type AnalyticsHandler struct {
@@ -26,17 +25,6 @@ func NewAnalyticsHandler(
}
}
func (h *AnalyticsHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) *string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
return &outletIDStr
}
if contextOutletID != uuid.Nil {
s := contextOutletID.String()
return &s
}
return nil
}
func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
@@ -48,7 +36,7 @@ func (h *AnalyticsHandler) GetPaymentMethodAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.PaymentMethodAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPaymentMethodAnalytics(ctx, modelReq)
@@ -72,7 +60,7 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.SalesAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetSalesAnalytics(ctx, modelReq)
@@ -85,30 +73,6 @@ func (h *AnalyticsHandler) GetSalesAnalytics(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetSalesAnalytics")
}
func (h *AnalyticsHandler) GetPurchasingAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.PurchasingAnalyticsRequest
if err := c.ShouldBindQuery(&req); err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq := transformer.PurchasingAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetPurchasingAnalytics(ctx, modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "AnalyticsHandler::GetPurchasingAnalytics", err.Error())}), "AnalyticsHandler::GetPurchasingAnalytics")
return
}
contractResp := transformer.PurchasingAnalyticsModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "AnalyticsHandler::GetPurchasingAnalytics")
}
func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
@@ -120,7 +84,7 @@ func (h *AnalyticsHandler) GetProductAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.ProductAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetProductAnalytics(ctx, modelReq)
@@ -144,7 +108,7 @@ func (h *AnalyticsHandler) GetProductAnalyticsPerCategory(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.ProductAnalyticsPerCategoryContractToModel(&req)
response, err := h.analyticsService.GetProductAnalyticsPerCategory(ctx, modelReq)
@@ -157,55 +121,6 @@ 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)
@@ -217,7 +132,7 @@ func (h *AnalyticsHandler) GetDashboardAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
req.OutletID = &contextInfo.OutletID
modelReq := transformer.DashboardAnalyticsContractToModel(&req)
response, err := h.analyticsService.GetDashboardAnalytics(ctx, modelReq)
@@ -241,7 +156,6 @@ func (h *AnalyticsHandler) GetProfitLossAnalytics(c *gin.Context) {
}
req.OrganizationID = contextInfo.OrganizationID
req.OutletID = h.resolveOutletID(c, contextInfo.OutletID)
modelReq, err := transformer.ProfitLossAnalyticsContractToModel(&req)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("invalid_request", "AnalyticsHandler::GetProfitLossAnalytics", err.Error())}), "AnalyticsHandler::GetProfitLossAnalytics")
@@ -259,87 +173,3 @@ 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")
}
-232
View File
@@ -1,232 +0,0 @@
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")
}
-27
View File
@@ -44,11 +44,6 @@ func (h *CategoryHandler) CreateCategory(c *gin.Context) {
return
}
// Inject outlet_id from context if user has one and request doesn't provide it
if req.OutletID == nil && contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
validationError, validationErrorCode := h.categoryValidator.ValidateCreateCategoryRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
@@ -154,11 +149,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
OrganizationID: &contextInfo.OrganizationID,
}
// Inject outlet_id from context if user has one
if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
// Parse query parameters
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil {
@@ -186,23 +176,6 @@ func (h *CategoryHandler) ListCategories(c *gin.Context) {
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if parentIDStr := c.Query("parent_id"); parentIDStr != "" {
if parentID, err := uuid.Parse(parentIDStr); err == nil {
req.ParentID = &parentID
}
}
// type=parent -> top level categories only
// type=child -> leaf categories (sub categories + top level ones without children)
if categoryType := c.Query("type"); categoryType != "" {
req.Type = categoryType
}
validationError, validationErrorCode := h.categoryValidator.ValidateListCategoriesRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("CategoryHandler::ListCategories -> request validation failed")
+1 -3
View File
@@ -1,8 +1,6 @@
package handler
import (
"apskel-pos-be/internal/logger"
"fmt"
"net/http"
"time"
)
@@ -49,7 +47,7 @@ func (m *CommonMiddleware) Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
logger.FromContext(r.Context()).Error("Recovery", fmt.Sprintf("panic recovered: %v", err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
-151
View File
@@ -1,151 +0,0 @@
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")
}
+3 -56
View File
@@ -1,14 +1,9 @@
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"
@@ -51,7 +46,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(walletErrorCode(err), constants.RequestEntity, err.Error()),
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerPoints")
return
}
@@ -85,7 +80,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(walletErrorCode(err), constants.RequestEntity, err.Error()),
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerTokens")
return
}
@@ -119,7 +114,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(walletErrorCode(err), constants.RequestEntity, err.Error()),
contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()),
}), "CustomerPointsHandler::GetCustomerWallet")
return
}
@@ -156,51 +151,3 @@ 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
}
}
-218
View File
@@ -1,218 +0,0 @@
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
}
-229
View File
@@ -1,229 +0,0 @@
package handler
import (
"apskel-pos-be/internal/appcontext"
"apskel-pos-be/internal/util"
"strconv"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ExpenseHandler struct {
expenseService service.ExpenseService
expenseValidator validator.ExpenseValidator
}
func NewExpenseHandler(
expenseService service.ExpenseService,
expenseValidator validator.ExpenseValidator,
) *ExpenseHandler {
return &ExpenseHandler{
expenseService: expenseService,
expenseValidator: expenseValidator,
}
}
func (h *ExpenseHandler) CreateExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.CreateExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::CreateExpense -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
return
}
validationError, validationErrorCode := h.expenseValidator.ValidateCreateExpenseRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::CreateExpense")
return
}
expenseResponse := h.expenseService.CreateExpense(ctx, contextInfo, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::CreateExpense -> Failed to create expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::CreateExpense")
}
func (h *ExpenseHandler) UpdateExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
var req contract.UpdateExpenseRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::UpdateExpense -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
validationError, validationErrorCode := h.expenseValidator.ValidateUpdateExpenseRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::UpdateExpense")
return
}
expenseResponse := h.expenseService.UpdateExpense(ctx, contextInfo, expenseID, &req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::UpdateExpense -> Failed to update expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::UpdateExpense")
}
func (h *ExpenseHandler) DeleteExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::DeleteExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::DeleteExpense")
return
}
expenseResponse := h.expenseService.DeleteExpense(ctx, contextInfo, expenseID)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::DeleteExpense -> Failed to delete expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::DeleteExpense")
}
func (h *ExpenseHandler) GetExpense(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
expenseIDStr := c.Param("id")
expenseID, err := uuid.Parse(expenseIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("ExpenseHandler::GetExpense -> Invalid expense ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid expense ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::GetExpense")
return
}
expenseResponse := h.expenseService.GetExpenseByID(ctx, contextInfo, expenseID)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::GetExpense -> Failed to get expense from service")
}
util.HandleResponse(c.Writer, c.Request, expenseResponse, "ExpenseHandler::GetExpense")
}
func (h *ExpenseHandler) ListExpenses(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListExpenseRequest{
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
}
}
if search := c.Query("search"); search != "" {
req.Search = search
}
if status := c.Query("status"); status != "" {
req.Status = status
}
// Prioritize outlet_id from context (e.g. outlet-scoped user),
// fall back to query param if context has no outlet.
if contextInfo.OutletID != uuid.Nil {
req.OutletID = contextInfo.OutletID.String()
} else if outletID := c.Query("outlet_id"); outletID != "" {
req.OutletID = outletID
}
if startDate := c.Query("start_date"); startDate != "" {
req.StartDate = startDate
}
if endDate := c.Query("end_date"); endDate != "" {
req.EndDate = endDate
}
validationError, validationErrorCode := h.expenseValidator.ValidateListExpenseRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ExpenseHandler::ListExpenses")
return
}
expenseResponse := h.expenseService.ListExpenses(ctx, contextInfo, req)
if expenseResponse.HasErrors() {
errorResp := expenseResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ExpenseHandler::ListExpenses -> Failed to list expenses from service")
}
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,3 +275,4 @@ func (h *IngredientUnitConverterHandler) GetUnitsByIngredientID(c *gin.Context)
util.HandleResponse(c.Writer, c.Request, unitsResponse, "IngredientUnitConverterHandler::GetUnitsByIngredientID")
}
@@ -1,169 +0,0 @@
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)
}
}
@@ -1,117 +0,0 @@
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")
}
@@ -1,180 +0,0 @@
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"])
}
-190
View File
@@ -1,190 +0,0 @@
package handler
import (
"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 NotificationHandler struct {
notificationService service.NotificationService
notificationValidator validator.NotificationValidator
}
func NewNotificationHandler(
notificationService service.NotificationService,
notificationValidator validator.NotificationValidator,
) *NotificationHandler {
return &NotificationHandler{
notificationService: notificationService,
notificationValidator: notificationValidator,
}
}
// Send godoc
// POST /api/v1/notifications/send
// Sends a notification to specific users.
func (h *NotificationHandler) Send(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.SendNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Send -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Send")
return
}
if validationErr, errCode := h.notificationValidator.ValidateSendRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Send")
return
}
resp := h.notificationService.Send(ctx, &req, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Send -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Send")
}
// Broadcast godoc
// POST /api/v1/notifications/broadcast
// Sends a notification to all active users in the caller's organization.
func (h *NotificationHandler) Broadcast(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.BroadcastNotificationRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Broadcast -> request binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Broadcast")
return
}
if validationErr, errCode := h.notificationValidator.ValidateBroadcastRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::Broadcast")
return
}
resp := h.notificationService.Broadcast(ctx, &req, contextInfo.OrganizationID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Broadcast -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Broadcast")
}
// List godoc
// GET /api/v1/notifications
// Returns paginated notifications for the authenticated user.
func (h *NotificationHandler) List(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := contract.ListNotificationsRequest{
Page: 1,
Limit: 20,
}
if err := c.ShouldBindQuery(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::List -> query binding failed")
validationErr := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::List")
return
}
if validationErr, errCode := h.notificationValidator.ValidateListRequest(&req); validationErr != nil {
respErr := contract.NewResponseError(errCode, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{respErr}), "NotificationHandler::List")
return
}
resp := h.notificationService.ListForUser(ctx, &req, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::List")
}
// GetByID godoc
// GET /api/v1/notifications/:id
func (h *NotificationHandler) GetByID(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::GetByID -> invalid notification ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::GetByID")
return
}
resp := h.notificationService.GetByID(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::GetByID")
}
// MarkAsRead godoc
// PUT /api/v1/notifications/:id/read
func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::MarkAsRead -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::MarkAsRead")
return
}
resp := h.notificationService.MarkAsRead(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::MarkAsRead -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAsRead")
}
// MarkAllAsRead godoc
// PUT /api/v1/notifications/read-all
func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
resp := h.notificationService.MarkAllAsRead(ctx, contextInfo.UserID)
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::MarkAllAsRead")
}
// Delete godoc
// DELETE /api/v1/notifications/:id
func (h *NotificationHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
idStr := c.Param("id")
receiverID, err := uuid.Parse(idStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("NotificationHandler::Delete -> invalid receiver ID")
validationErr := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid notification receiver ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationErr}), "NotificationHandler::Delete")
return
}
resp := h.notificationService.DeleteForUser(ctx, receiverID, contextInfo.UserID)
if resp.HasErrors() {
logger.FromContext(ctx).WithError(resp.GetErrors()[0]).Error("NotificationHandler::Delete -> service error")
}
util.HandleResponse(c.Writer, c.Request, resp, "NotificationHandler::Delete")
}
-4
View File
@@ -137,10 +137,6 @@ func (h *OrderHandler) ListOrders(c *gin.Context) {
}
modelReq.OrganizationID = &contextInfo.OrganizationID
if modelReq.OutletID == nil && contextInfo.OutletID != uuid.Nil {
modelReq.OutletID = &contextInfo.OutletID
}
response, err := h.orderService.ListOrders(c.Request.Context(), modelReq)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "OrderHandler::ListOrders", err.Error())}), "OrderHandler::ListOrders")
@@ -113,16 +113,6 @@ 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
+2 -95
View File
@@ -60,7 +60,6 @@ func (h *ProductHandler) CreateProduct(c *gin.Context) {
func (h *ProductHandler) UpdateProduct(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
@@ -86,7 +85,7 @@ func (h *ProductHandler) UpdateProduct(c *gin.Context) {
return
}
productResponse := h.productService.UpdateProduct(ctx, contextInfo, productID, &req)
productResponse := h.productService.UpdateProduct(ctx, productID, &req)
if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::UpdateProduct -> Failed to update product from service")
@@ -118,7 +117,6 @@ func (h *ProductHandler) DeleteProduct(c *gin.Context) {
func (h *ProductHandler) GetProduct(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
productIDStr := c.Param("id")
productID, err := uuid.Parse(productIDStr)
@@ -129,7 +127,7 @@ func (h *ProductHandler) GetProduct(c *gin.Context) {
return
}
productResponse := h.productService.GetProductByID(ctx, productID, contextInfo.OutletID)
productResponse := h.productService.GetProductByID(ctx, productID)
if productResponse.HasErrors() {
errorResp := productResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::GetProduct -> Failed to get product from service")
@@ -186,97 +184,6 @@ func (h *ProductHandler) ListProducts(c *gin.Context) {
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
} else if contextInfo.OutletID != uuid.Nil {
req.OutletID = &contextInfo.OutletID
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
}
}
if maxPriceStr := c.Query("max_price"); maxPriceStr != "" {
if maxPrice, err := strconv.ParseFloat(maxPriceStr, 64); err == nil {
req.MaxPrice = &maxPrice
}
}
validationError, validationErrorCode := h.productValidator.ValidateListProductsRequest(req)
if validationError != nil {
logger.FromContext(ctx).WithError(validationError).Error("ProductHandler::ListProducts -> request validation failed")
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductHandler::ListProducts")
return
}
productsResponse := h.productService.ListProducts(ctx, req)
if productsResponse.HasErrors() {
errorResp := productsResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("ProductHandler::ListProducts -> Failed to list products from service")
}
util.HandleResponse(c.Writer, c.Request, productsResponse, "ProductHandler::ListProducts")
}
func (h *ProductHandler) ListProductAll(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
req := &contract.ListProductsRequest{
Page: 1,
Limit: 10,
OrganizationID: &contextInfo.OrganizationID,
}
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 search := c.Query("search"); search != "" {
req.Search = search
}
if businessType := c.Query("business_type"); businessType != "" {
req.BusinessType = businessType
}
if organizationIDStr := c.Query("organization_id"); organizationIDStr != "" {
if organizationID, err := uuid.Parse(organizationIDStr); err == nil {
req.OrganizationID = &organizationID
}
}
if categoryIDStr := c.Query("category_id"); categoryIDStr != "" {
if categoryID, err := uuid.Parse(categoryIDStr); err == nil {
req.CategoryID = &categoryID
}
}
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
req.IsActive = &isActive
}
}
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if outletID, err := uuid.Parse(outletIDStr); err == nil {
req.OutletID = &outletID
}
}
if minPriceStr := c.Query("min_price"); minPriceStr != "" {
if minPrice, err := strconv.ParseFloat(minPriceStr, 64); err == nil {
req.MinPrice = &minPrice
@@ -1,135 +0,0 @@
package handler
import (
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/service"
"apskel-pos-be/internal/util"
"apskel-pos-be/internal/validator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ProductOutletPriceHandler struct {
service service.ProductOutletPriceService
validator validator.ProductOutletPriceValidator
}
func NewProductOutletPriceHandler(svc service.ProductOutletPriceService, v validator.ProductOutletPriceValidator) *ProductOutletPriceHandler {
return &ProductOutletPriceHandler{
service: svc,
validator: v,
}
}
func (h *ProductOutletPriceHandler) Upsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.CreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::Upsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
if validationErr, code := h.validator.ValidateCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Upsert")
return
}
resp := h.service.Upsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Upsert")
}
func (h *ProductOutletPriceHandler) GetByProductAndOutlet(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProductAndOutlet")
return
}
resp := h.service.GetByProductAndOutlet(ctx, productID, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProductAndOutlet")
}
func (h *ProductOutletPriceHandler) GetByProduct(c *gin.Context) {
ctx := c.Request.Context()
productIDStr := c.Param("product_id")
productID, err := uuid.Parse(productIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid product ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByProduct")
return
}
resp := h.service.GetByProduct(ctx, productID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByProduct")
}
func (h *ProductOutletPriceHandler) GetByOutlet(c *gin.Context) {
ctx := c.Request.Context()
outletIDStr := c.Param("outlet_id")
outletID, err := uuid.Parse(outletIDStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid outlet ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::GetByOutlet")
return
}
resp := h.service.GetByOutlet(ctx, outletID)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::GetByOutlet")
}
func (h *ProductOutletPriceHandler) Delete(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := uuid.Parse(idStr)
if err != nil {
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::Delete")
return
}
resp := h.service.Delete(ctx, id)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::Delete")
}
func (h *ProductOutletPriceHandler) BulkUpsert(c *gin.Context) {
ctx := c.Request.Context()
var req contract.BulkCreateProductOutletPriceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("ProductOutletPriceHandler::BulkUpsert -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
if validationErr, code := h.validator.ValidateBulkCreateRequest(&req); validationErr != nil {
validationResponseError := contract.NewResponseError(code, constants.RequestEntity, validationErr.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "ProductOutletPriceHandler::BulkUpsert")
return
}
resp := h.service.BulkUpsert(ctx, &req)
util.HandleResponse(c.Writer, c.Request, resp, "ProductOutletPriceHandler::BulkUpsert")
}
@@ -1,160 +0,0 @@
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,20 +176,6 @@ 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
@@ -238,21 +224,6 @@ 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)
+1 -49
View File
@@ -8,7 +8,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ReportHandler struct {
@@ -20,26 +19,11 @@ func NewReportHandler(reportService service.ReportService, userService UserServi
return &ReportHandler{reportService: reportService, userService: userService}
}
func (h *ReportHandler) resolveOutletID(c *gin.Context, contextOutletID uuid.UUID) string {
if outletIDStr := c.Query("outlet_id"); outletIDStr != "" {
if _, err := uuid.Parse(outletIDStr); err == nil {
return outletIDStr
}
}
if pathOutletID := c.Param("outlet_id"); pathOutletID != "" {
return pathOutletID
}
if contextOutletID != uuid.Nil {
return contextOutletID.String()
}
return ""
}
func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := h.resolveOutletID(c, ci.OutletID)
outletID := c.Param("outlet_id")
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
@@ -66,35 +50,3 @@ 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")
}
+78 -37
View File
@@ -1,11 +1,11 @@
package handler
import (
"apskel-pos-be/internal/client"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/contract"
"apskel-pos-be/internal/entities"
"apskel-pos-be/internal/logger"
"apskel-pos-be/internal/mappers"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/pkg/tabletoken"
"apskel-pos-be/internal/processor"
@@ -15,6 +15,7 @@ import (
"apskel-pos-be/internal/util"
"context"
"fmt"
"log"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -29,7 +30,7 @@ type SelfOrderHandler struct {
userRepo processor.UserRepository
sessionRepo repository.SessionRepository
orderRepo repository.OrderRepository
productOutletPriceService service.ProductOutletPriceService
fcmClient client.FcmClient
}
func NewSelfOrderHandler(
@@ -41,7 +42,7 @@ func NewSelfOrderHandler(
userRepo processor.UserRepository,
sessionRepo repository.SessionRepository,
orderRepo repository.OrderRepository,
productOutletPriceService service.ProductOutletPriceService,
fcmClient client.FcmClient,
) *SelfOrderHandler {
return &SelfOrderHandler{
orderService: orderService,
@@ -52,7 +53,7 @@ func NewSelfOrderHandler(
userRepo: userRepo,
sessionRepo: sessionRepo,
orderRepo: orderRepo,
productOutletPriceService: productOutletPriceService,
fcmClient: fcmClient,
}
}
@@ -219,29 +220,16 @@ func (h *SelfOrderHandler) GetMenu(c *gin.Context) {
return
}
menu := h.buildMenuResponse(ctx, outlet, table, catList.Categories, prodList.Products)
menu := h.buildMenuResponse(outlet, table, catList.Categories, prodList.Products)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(menu), "SelfOrderHandler::GetMenu")
}
func (h *SelfOrderHandler) buildMenuResponse(
ctx context.Context,
outlet *entities.Outlet,
table *entities.Table,
categories []contract.CategoryResponse,
products []contract.ProductResponse,
) *contract.SelfOrderMenuResponse {
outletPriceMap := make(map[uuid.UUID]float64)
if h.productOutletPriceService != nil {
priceResp := h.productOutletPriceService.GetByOutlet(ctx, outlet.ID)
if priceResp != nil && !priceResp.HasErrors() {
if priceList, ok := priceResp.Data.(*contract.ListProductOutletPricesResponse); ok {
for _, p := range priceList.Prices {
outletPriceMap[p.ProductID] = p.Price
}
}
}
}
productMap := make(map[uuid.UUID][]contract.ProductResponse)
for _, p := range products {
productMap[p.CategoryID] = append(productMap[p.CategoryID], p)
@@ -252,15 +240,11 @@ func (h *SelfOrderHandler) buildMenuResponse(
menuItems := make([]contract.SelfOrderMenuItem, 0)
if prods, ok := productMap[cat.ID]; ok {
for _, p := range prods {
price := p.Price
if outletPrice, exists := outletPriceMap[p.ID]; exists {
price = outletPrice
}
item := contract.SelfOrderMenuItem{
ID: p.ID,
Name: p.Name,
Description: p.Description,
Price: price,
Price: p.Price,
ImageURL: p.ImageURL,
}
for _, v := range p.Variants {
@@ -344,7 +328,6 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
ProductID: item.ProductID,
ProductVariantID: item.ProductVariantID,
Quantity: item.Quantity,
Weight: item.Weight,
Notes: item.Notes,
})
}
@@ -352,7 +335,6 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
metadata := make(map[string]interface{})
metadata["self_order"] = true
metadata["session_id"] = session.ID
metadata["customer_name"] = req.CustomerName
tableID := table.ID
modelReq := &models.CreateOrderRequest{
@@ -360,7 +342,7 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
UserID: userID,
TableID: &tableID,
TableNumber: &table.TableName,
OrderType: constants.OrderType(req.OrderType),
OrderType: constants.OrderTypeDineIn,
OrderItems: orderItems,
Metadata: metadata,
}
@@ -374,13 +356,41 @@ func (h *SelfOrderHandler) CreateOrder(c *gin.Context) {
return
}
go h.sendNewOrderNotification(context.Background(), table.OrganizationID, table.TableName, len(req.OrderItems))
contractResp := transformer.OrderModelToContract(response)
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResp), "SelfOrderHandler::CreateOrder")
}
func (h *SelfOrderHandler) sendNewOrderNotification(ctx context.Context, organizationID uuid.UUID, tableName string, itemCount int) {
users, err := h.userRepo.GetUsersWithFcmTokenByOrganization(ctx, organizationID)
if err != nil {
log.Printf("SelfOrderHandler::sendNewOrderNotification -> failed to get users with FCM token: %v", err)
return
}
tokens := make([]string, 0, len(users))
for _, u := range users {
if u.FcmToken != nil && *u.FcmToken != "" {
tokens = append(tokens, *u.FcmToken)
}
}
if len(tokens) == 0 {
return
}
title := "Order Baru"
body := fmt.Sprintf("Order baru dari Meja %s — %d item", tableName, itemCount)
if err := h.fcmClient.SendMulticastNotification(ctx, tokens, title, body); err != nil {
log.Printf("SelfOrderHandler::sendNewOrderNotification -> failed to send FCM notification: %v", err)
}
}
func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
ctx := c.Request.Context()
sessionID := c.Param("session_id")
sessionID := c.Param("sessionId")
if sessionID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
@@ -413,15 +423,47 @@ func (h *SelfOrderHandler) GetOrdersBySession(c *gin.Context) {
return
}
modelOrders := mappers.OrderEntitiesToResponses(orders)
contractOrders := make([]contract.OrderResponse, len(modelOrders))
for i := range modelOrders {
contractOrders[i] = *transformer.OrderModelToContract(&modelOrders[i])
resp := &contract.SelfOrderListOrdersResponse{
Orders: make([]contract.SelfOrderOrderItem, 0, len(orders)),
}
for _, o := range orders {
item := contract.SelfOrderOrderItem{
ID: o.ID,
OrderNumber: o.OrderNumber,
TableNumber: o.TableNumber,
OrderType: string(o.OrderType),
Status: string(o.Status),
Subtotal: o.Subtotal,
TaxAmount: o.TaxAmount,
DiscountAmount: o.DiscountAmount,
TotalAmount: o.TotalAmount,
RemainingAmount: o.RemainingAmount,
PaymentStatus: string(o.PaymentStatus),
IsVoid: o.IsVoid,
IsRefund: o.IsRefund,
CreatedAt: o.CreatedAt,
}
for _, oi := range o.OrderItems {
lineItem := contract.SelfOrderOrderLineItem{
ProductID: oi.ProductID,
Quantity: oi.Quantity,
UnitPrice: oi.UnitPrice,
TotalPrice: oi.TotalPrice,
Notes: oi.Notes,
Status: string(oi.Status),
ProductVariantID: oi.ProductVariantID,
}
if oi.Product.ID != uuid.Nil {
lineItem.ProductName = oi.Product.Name
}
if oi.ProductVariant != nil {
lineItem.ProductVariantNam = &oi.ProductVariant.Name
}
item.Items = append(item.Items, lineItem)
}
resp.Orders = append(resp.Orders, item)
}
resp := &contract.SelfOrderListOrdersResponse{
Orders: contractOrders,
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(resp), "SelfOrderHandler::GetOrdersBySession")
}
@@ -430,7 +472,6 @@ func (h *SelfOrderHandler) validateCreateOrderRequest(req *contract.SelfOrderCre
return fmt.Errorf("session_id is required")
}
if len(req.OrderItems) == 0 {
return fmt.Errorf("at least one order item is required")
}
for i, item := range req.OrderItems {
@@ -458,7 +499,7 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
if req.OrganizationID == "" {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organization_id is required"),
contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "organisasi_id is required"),
}), "SelfOrderHandler::ListCategories")
return
}
@@ -473,7 +514,7 @@ func (h *SelfOrderHandler) ListCategories(c *gin.Context) {
orgID, err := uuid.Parse(req.OrganizationID)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organization_id format"),
contract.NewResponseError(constants.ValidationErrorCode, constants.RequestEntity, "invalid organisasi_id format"),
}), "SelfOrderHandler::ListCategories")
return
}
+5 -10
View File
@@ -19,14 +19,14 @@ import (
type TableHandler struct {
tableService TableService
tableValidator *validator.TableValidator
selfOrderURL string
baseURL string
}
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, selfOrderURL string) *TableHandler {
func NewTableHandler(tableService TableService, tableValidator *validator.TableValidator, baseURL string) *TableHandler {
return &TableHandler{
tableService: tableService,
tableValidator: tableValidator,
selfOrderURL: selfOrderURL,
baseURL: baseURL,
}
}
@@ -150,11 +150,6 @@ func (h *TableHandler) List(c *gin.Context) {
Limit: 100,
}
// Fallback to context outlet ID if not provided in query
if query.OutletID == "" && contextInfo.OutletID != uuid.Nil {
query.OutletID = contextInfo.OutletID.String()
}
if pageStr := c.Query("page"); pageStr != "" {
if page, err := strconv.Atoi(pageStr); err == nil && page > 0 {
query.Page = page
@@ -317,7 +312,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
return
}
selfOrderURLResult := fmt.Sprintf("%s/menu?token=%s", h.selfOrderURL, token)
selfOrderURL := fmt.Sprintf("%s/api/v1/self-order/table/%s", h.baseURL, token)
size := 256
if sizeStr := c.Query("size"); sizeStr != "" {
@@ -326,7 +321,7 @@ func (h *TableHandler) GenerateQRCode(c *gin.Context) {
}
}
pngBytes, err := qrcode.GeneratePNG(selfOrderURLResult, size)
pngBytes, err := qrcode.GeneratePNG(selfOrderURL, size)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("TableHandler::GenerateQRCode -> QR generation failed")
validationResponseError := contract.NewResponseError(constants.InternalServerErrorCode, constants.TableEntity, "Failed to generate QR code")
-215
View File
@@ -1,215 +0,0 @@
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 UserDeviceHandler struct {
userDeviceService service.UserDeviceService
userDeviceValidator validator.UserDeviceValidator
}
func NewUserDeviceHandler(
userDeviceService service.UserDeviceService,
userDeviceValidator validator.UserDeviceValidator,
) *UserDeviceHandler {
return &UserDeviceHandler{
userDeviceService: userDeviceService,
userDeviceValidator: userDeviceValidator,
}
}
func (h *UserDeviceHandler) RegisterDevice(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
var req contract.RegisterUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::RegisterDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateRegisterDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::RegisterDevice")
return
}
deviceResponse := h.userDeviceService.RegisterDevice(ctx, contextInfo.UserID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::RegisterDevice -> Failed to register device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::RegisterDevice")
}
func (h *UserDeviceHandler) UpdateDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
var req contract.UpdateUserDeviceRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::UpdateDevice -> request binding failed")
validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, "Invalid request body")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateUpdateDeviceRequest(&req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::UpdateDevice")
return
}
deviceResponse := h.userDeviceService.UpdateDevice(ctx, deviceID, &req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::UpdateDevice -> Failed to update device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::UpdateDevice")
}
func (h *UserDeviceHandler) DeleteDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::DeleteDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::DeleteDevice")
return
}
deviceResponse := h.userDeviceService.DeleteDevice(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::DeleteDevice -> Failed to delete device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::DeleteDevice")
}
func (h *UserDeviceHandler) GetDevice(c *gin.Context) {
ctx := c.Request.Context()
deviceIDStr := c.Param("id")
deviceID, err := uuid.Parse(deviceIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevice -> Invalid device ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid device ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevice")
return
}
deviceResponse := h.userDeviceService.GetDeviceByID(ctx, deviceID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevice -> Failed to get device from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevice")
}
func (h *UserDeviceHandler) GetMyDevices(c *gin.Context) {
ctx := c.Request.Context()
contextInfo := appcontext.FromGinContext(ctx)
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, contextInfo.UserID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetMyDevices -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetMyDevices")
}
func (h *UserDeviceHandler) GetDevicesByUser(c *gin.Context) {
ctx := c.Request.Context()
userIDStr := c.Param("user_id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
logger.FromContext(ctx).WithError(err).Error("UserDeviceHandler::GetDevicesByUser -> Invalid user ID")
validationResponseError := contract.NewResponseError(constants.MalformedFieldErrorCode, constants.RequestEntity, "Invalid user ID")
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::GetDevicesByUser")
return
}
deviceResponse := h.userDeviceService.GetDevicesByUserID(ctx, userID)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::GetDevicesByUser -> Failed to get devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::GetDevicesByUser")
}
func (h *UserDeviceHandler) ListDevices(c *gin.Context) {
ctx := c.Request.Context()
req := &contract.ListUserDevicesRequest{
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
}
}
if userID := c.Query("user_id"); userID != "" {
req.UserID = userID
}
if platform := c.Query("platform"); platform != "" {
req.Platform = platform
}
validationError, validationErrorCode := h.userDeviceValidator.ValidateListDevicesRequest(req)
if validationError != nil {
validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error())
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "UserDeviceHandler::ListDevices")
return
}
deviceResponse := h.userDeviceService.ListDevices(ctx, req)
if deviceResponse.HasErrors() {
errorResp := deviceResponse.GetErrors()[0]
logger.FromContext(ctx).WithError(errorResp).Error("UserDeviceHandler::ListDevices -> Failed to list devices from service")
}
util.HandleResponse(c.Writer, c.Request, deviceResponse, "UserDeviceHandler::ListDevices")
}
-203
View File
@@ -1,203 +0,0 @@
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)),
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")
}
-85
View File
@@ -1,85 +0,0 @@
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")
}
+4 -4
View File
@@ -71,7 +71,7 @@ func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaig
return &entities.Campaign{
Name: request.Name,
Description: request.Description,
Type: entities.NormalizeCampaignType(request.Type),
Type: entities.CampaignType(request.Type),
StartDate: request.StartDate,
EndDate: request.EndDate,
IsActive: request.IsActive,
@@ -91,7 +91,7 @@ func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entiti
ID: request.ID,
Name: request.Name,
Description: request.Description,
Type: entities.NormalizeCampaignType(request.Type),
Type: entities.CampaignType(request.Type),
StartDate: request.StartDate,
EndDate: request.EndDate,
IsActive: request.IsActive,
@@ -118,7 +118,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.
CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardType: entities.CampaignRewardType(request.RewardType),
RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID,
@@ -142,7 +142,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai
CampaignID: campaignID,
RuleType: entities.RuleType(request.RuleType),
ConditionValue: request.ConditionValue,
RewardType: entities.NormalizeCampaignRewardType(request.RewardType),
RewardType: entities.CampaignRewardType(request.RewardType),
RewardValue: request.RewardValue,
RewardSubtype: rewardSubtype,
RewardRefID: request.RewardRefID,
-128
View File
@@ -1,128 +0,0 @@
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
}
@@ -1,101 +0,0 @@
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)
}
+8 -28
View File
@@ -13,12 +13,11 @@ func CategoryEntityToModel(entity *entities.Category) *models.Category {
return &models.Category{
ID: entity.ID,
OrganizationID: entity.OrganizationID,
OutletID: entity.OutletID,
Name: entity.Name,
Description: entity.Description,
ImageURL: nil,
Order: entity.Order,
IsActive: true,
ImageURL: nil, // Entity doesn't have ImageURL, model does
Order: entity.Order, // Entity doesn't have SortOrder, model does
IsActive: true, // Entity doesn't have IsActive, default to true
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -33,14 +32,14 @@ func CategoryModelToEntity(model *models.Category) *entities.Category {
if model.ImageURL != nil {
metadata["image_url"] = *model.ImageURL
}
// metadata["sort_order"] = model.SortOrder
return &entities.Category{
ID: model.ID,
OrganizationID: model.OrganizationID,
OutletID: model.OutletID,
Name: model.Name,
Description: model.Description,
BusinessType: "restaurant",
BusinessType: "restaurant", // Default business type
Order: model.Order,
Metadata: metadata,
CreatedAt: model.CreatedAt,
@@ -57,15 +56,14 @@ func CreateCategoryRequestToEntity(req *models.CreateCategoryRequest) *entities.
if req.ImageURL != nil {
metadata["image_url"] = *req.ImageURL
}
// metadata["sort_order"] = req.SortOrder
return &entities.Category{
OrganizationID: req.OrganizationID,
OutletID: req.OutletID,
ParentID: req.ParentID,
Name: req.Name,
Description: req.Description,
Order: req.Order,
BusinessType: "restaurant",
BusinessType: "restaurant", // Default business type
Metadata: metadata,
}
}
@@ -86,24 +84,14 @@ 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,
Order: entity.Order,
IsActive: true,
IsActive: true, // Default to true since entity doesn't have this field
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}
@@ -133,14 +121,6 @@ func UpdateCategoryEntityFromRequest(entity *entities.Category, req *models.Upda
if req.Order != nil {
entity.Order = *req.Order
}
if req.OutletID != nil {
entity.OutletID = req.OutletID
}
if req.ParentID != nil {
entity.ParentID = req.ParentID
}
}
func CategoryEntitiesToModels(entities []*entities.Category) []*models.Category {

Some files were not shown because too many files have changed in this diff Show More