Mhamdans17 commited on
Commit ·
edcf070
0
Parent(s):
feat: migrate warungpos backend to Go with Tripay
Browse files- .env.example +27 -0
- .gitignore +29 -0
- Dockerfile +36 -0
- config/config.go +85 -0
- config/database.go +63 -0
- controllers/auth_controller.go +576 -0
- controllers/company_controller.go +675 -0
- controllers/fee_controller.go +243 -0
- controllers/order_controller.go +759 -0
- controllers/product_controller.go +483 -0
- controllers/report_controller.go +240 -0
- go.mod +52 -0
- go.sum +111 -0
- main.go +210 -0
- middleware/auth.go +189 -0
- middleware/cors.go +21 -0
- models/models.go +129 -0
- services/cloudinary.go +81 -0
- services/tripay.go +170 -0
- services/whatsapp.go +214 -0
.env.example
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Database (PostgreSQL)
|
| 2 |
+
# Catatan: Gunakan driver postgres:// untuk Go, bukan postgresql+psycopg://
|
| 3 |
+
DATABASE_URL=postgres://warungpos:warungpos123@127.0.0.1:5432/warungpos_db?sslmode=disable
|
| 4 |
+
|
| 5 |
+
# Tripay Payment Gateway
|
| 6 |
+
TRIPAY_MERCHANT_CODE=Txxxx
|
| 7 |
+
TRIPAY_API_KEY=your-api-key-here
|
| 8 |
+
TRIPAY_PRIVATE_KEY=your-private-key-here
|
| 9 |
+
TRIPAY_IS_PRODUCTION=false
|
| 10 |
+
|
| 11 |
+
# App
|
| 12 |
+
PORT=7860
|
| 13 |
+
APP_PORT=8003
|
| 14 |
+
APP_BASE_URL=http://localhost:8003
|
| 15 |
+
STORE_NAME=Warung POS
|
| 16 |
+
|
| 17 |
+
# JWT
|
| 18 |
+
JWT_SECRET_KEY=change-me-to-a-secure-secret-key-32-chars
|
| 19 |
+
JWT_EXPIRY_HOURS=24
|
| 20 |
+
|
| 21 |
+
# WhatsApp Notification via Fonnte
|
| 22 |
+
FONNTE_TOKEN=your-fonnte-token-here
|
| 23 |
+
|
| 24 |
+
# Cloudinary (Media Storage)
|
| 25 |
+
CLOUDINARY_CLOUD_NAME=your-cloud-name
|
| 26 |
+
CLOUDINARY_API_KEY=your-api-key
|
| 27 |
+
CLOUDINARY_API_SECRET=your-api-secret
|
.gitignore
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Binaries
|
| 2 |
+
main_bin
|
| 3 |
+
server_bin
|
| 4 |
+
*.exe
|
| 5 |
+
*.exe~
|
| 6 |
+
*.dll
|
| 7 |
+
*.so
|
| 8 |
+
*.dylib
|
| 9 |
+
bin/
|
| 10 |
+
|
| 11 |
+
# Environment variables (contains sensitive secrets!)
|
| 12 |
+
.env
|
| 13 |
+
.env.local
|
| 14 |
+
.env.development.local
|
| 15 |
+
.env.test.local
|
| 16 |
+
.env.production.local
|
| 17 |
+
|
| 18 |
+
# IDEs and editors
|
| 19 |
+
.idea/
|
| 20 |
+
.vscode/
|
| 21 |
+
*.suo
|
| 22 |
+
*.ntvs*
|
| 23 |
+
*.njsproj
|
| 24 |
+
*.sln
|
| 25 |
+
*.sw?
|
| 26 |
+
|
| 27 |
+
# OS files
|
| 28 |
+
.DS_Store
|
| 29 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build the Go binary
|
| 2 |
+
FROM golang:1.22-alpine AS builder
|
| 3 |
+
|
| 4 |
+
# Install build dependencies
|
| 5 |
+
RUN apk add --no-cache git
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Copy module files and download dependencies
|
| 10 |
+
COPY go.mod go.sum ./
|
| 11 |
+
RUN go mod download
|
| 12 |
+
|
| 13 |
+
# Copy the rest of the application source code
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Compile a static Go binary optimized for linux
|
| 17 |
+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o main .
|
| 18 |
+
|
| 19 |
+
# Stage 2: Create a minimal production container
|
| 20 |
+
FROM alpine:3.19
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# Install timezone data and CA certificates (essential for Midtrans/Fonnte HTTPS)
|
| 25 |
+
RUN apk --no-cache add ca-certificates tzdata
|
| 26 |
+
ENV TZ=Asia/Jakarta
|
| 27 |
+
|
| 28 |
+
# Copy the compiled binary from the builder stage
|
| 29 |
+
COPY --from=builder /app/main .
|
| 30 |
+
|
| 31 |
+
# Expose port 7860 (Hugging Face Spaces requirement)
|
| 32 |
+
ENV PORT=7860
|
| 33 |
+
EXPOSE 7860
|
| 34 |
+
|
| 35 |
+
# Run the Go binary
|
| 36 |
+
CMD ["./main"]
|
config/config.go
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"log"
|
| 5 |
+
"os"
|
| 6 |
+
"strconv"
|
| 7 |
+
|
| 8 |
+
"github.com/joho/godotenv"
|
| 9 |
+
)
|
| 10 |
+
|
| 11 |
+
type Config struct {
|
| 12 |
+
DatabaseURL string
|
| 13 |
+
TripayMerchantCode string
|
| 14 |
+
TripayAPIKey string
|
| 15 |
+
TripayPrivateKey string
|
| 16 |
+
TripayIsProduction bool
|
| 17 |
+
AppPort int
|
| 18 |
+
Port string // Hugging Face Spaces port (default 7860)
|
| 19 |
+
AppBaseURL string
|
| 20 |
+
JWTSecretKey string
|
| 21 |
+
JWTExpiryHours int
|
| 22 |
+
FonnteToken string
|
| 23 |
+
StoreName string
|
| 24 |
+
CloudinaryCloudName string
|
| 25 |
+
CloudinaryAPIKey string
|
| 26 |
+
CloudinaryAPISecret string
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
var GlobalConfig *Config
|
| 30 |
+
|
| 31 |
+
func LoadConfig() {
|
| 32 |
+
// Muat file .env jika ada (terutama untuk dev lokal)
|
| 33 |
+
if err := godotenv.Load(); err != nil {
|
| 34 |
+
log.Println("Info: File .env tidak ditemukan, menggunakan variable OS")
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
GlobalConfig = &Config{
|
| 38 |
+
DatabaseURL: getEnv("DATABASE_URL", "postgresql://warungpos:warungpos123@db:5432/warungpos_db"),
|
| 39 |
+
TripayMerchantCode: getEnv("TRIPAY_MERCHANT_CODE", ""),
|
| 40 |
+
TripayAPIKey: getEnv("TRIPAY_API_KEY", ""),
|
| 41 |
+
TripayPrivateKey: getEnv("TRIPAY_PRIVATE_KEY", ""),
|
| 42 |
+
TripayIsProduction: getEnvBool("TRIPAY_IS_PRODUCTION", false),
|
| 43 |
+
AppPort: getEnvInt("APP_PORT", 8003),
|
| 44 |
+
Port: getEnv("PORT", "7860"), // Default port Hugging Face Spaces
|
| 45 |
+
AppBaseURL: getEnv("APP_BASE_URL", "http://localhost:8003"),
|
| 46 |
+
JWTSecretKey: getEnv("JWT_SECRET_KEY", "default-secret-key-change-me"),
|
| 47 |
+
JWTExpiryHours: getEnvInt("JWT_EXPIRY_HOURS", 24),
|
| 48 |
+
FonnteToken: getEnv("FONNTE_TOKEN", ""),
|
| 49 |
+
StoreName: getEnv("STORE_NAME", "WarungPOS"),
|
| 50 |
+
CloudinaryCloudName: getEnv("CLOUDINARY_CLOUD_NAME", ""),
|
| 51 |
+
CloudinaryAPIKey: getEnv("CLOUDINARY_API_KEY", ""),
|
| 52 |
+
CloudinaryAPISecret: getEnv("CLOUDINARY_API_SECRET", ""),
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func getEnv(key, fallback string) string {
|
| 57 |
+
if value, exists := os.LookupEnv(key); exists {
|
| 58 |
+
return value
|
| 59 |
+
}
|
| 60 |
+
return fallback
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
func getEnvInt(key string, fallback int) int {
|
| 64 |
+
str := getEnv(key, "")
|
| 65 |
+
if str == "" {
|
| 66 |
+
return fallback
|
| 67 |
+
}
|
| 68 |
+
val, err := strconv.Atoi(str)
|
| 69 |
+
if err != nil {
|
| 70 |
+
return fallback
|
| 71 |
+
}
|
| 72 |
+
return val
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
func getEnvBool(key string, fallback bool) bool {
|
| 76 |
+
str := getEnv(key, "")
|
| 77 |
+
if str == "" {
|
| 78 |
+
return fallback
|
| 79 |
+
}
|
| 80 |
+
val, err := strconv.ParseBool(str)
|
| 81 |
+
if err != nil {
|
| 82 |
+
return fallback
|
| 83 |
+
}
|
| 84 |
+
return val
|
| 85 |
+
}
|
config/database.go
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package config
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"log"
|
| 5 |
+
"strings"
|
| 6 |
+
"time"
|
| 7 |
+
|
| 8 |
+
"gorm.io/driver/postgres"
|
| 9 |
+
"gorm.io/gorm"
|
| 10 |
+
"gorm.io/gorm/logger"
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
var DB *gorm.DB
|
| 14 |
+
|
| 15 |
+
func ConnectDatabase() {
|
| 16 |
+
dsn := GlobalConfig.DatabaseURL
|
| 17 |
+
|
| 18 |
+
// Bersihkan skema psycopg dari Python jika ada
|
| 19 |
+
if strings.HasPrefix(dsn, "postgresql+psycopg://") {
|
| 20 |
+
dsn = strings.Replace(dsn, "postgresql+psycopg://", "postgres://", 1)
|
| 21 |
+
} else if strings.HasPrefix(dsn, "postgresql://") {
|
| 22 |
+
dsn = strings.Replace(dsn, "postgresql://", "postgres://", 1)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
log.Printf("Connecting to database: %s", sanitizeDSN(dsn))
|
| 26 |
+
|
| 27 |
+
var err error
|
| 28 |
+
DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{
|
| 29 |
+
Logger: logger.Default.LogMode(logger.Info),
|
| 30 |
+
})
|
| 31 |
+
|
| 32 |
+
if err != nil {
|
| 33 |
+
log.Fatalf("Gagal menghubungkan ke database: %v", err)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
log.Println("Berhasil terhubung ke database!")
|
| 37 |
+
|
| 38 |
+
// Konfigurasi connection pool
|
| 39 |
+
sqlDB, err := DB.DB()
|
| 40 |
+
if err != nil {
|
| 41 |
+
log.Fatalf("Gagal mengambil instance SQL DB: %v", err)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Atur batas pool
|
| 45 |
+
sqlDB.SetMaxIdleConns(10)
|
| 46 |
+
sqlDB.SetMaxOpenConns(50)
|
| 47 |
+
sqlDB.SetConnMaxLifetime(time.Hour)
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
func sanitizeDSN(dsn string) string {
|
| 51 |
+
// Sembunyikan password dalam log demi alasan keamanan
|
| 52 |
+
if strings.Contains(dsn, "@") {
|
| 53 |
+
parts := strings.Split(dsn, "@")
|
| 54 |
+
credentials := strings.Split(parts[0], "://")
|
| 55 |
+
if len(credentials) == 2 {
|
| 56 |
+
userPass := strings.Split(credentials[1], ":")
|
| 57 |
+
if len(userPass) == 2 {
|
| 58 |
+
return credentials[0] + "://" + userPass[0] + ":******@" + parts[1]
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
return dsn
|
| 63 |
+
}
|
controllers/auth_controller.go
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package controllers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"log"
|
| 6 |
+
"math/rand"
|
| 7 |
+
"net/http"
|
| 8 |
+
"strconv"
|
| 9 |
+
"time"
|
| 10 |
+
|
| 11 |
+
"service-warungpos-go/config"
|
| 12 |
+
"service-warungpos-go/middleware"
|
| 13 |
+
"service-warungpos-go/models"
|
| 14 |
+
"service-warungpos-go/services"
|
| 15 |
+
|
| 16 |
+
"github.com/gin-gonic/gin"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
// SeedDefaultAdmin inisialisasi default super admin jika belum ada
|
| 20 |
+
func SeedDefaultAdmin() {
|
| 21 |
+
var count int64
|
| 22 |
+
config.DB.Model(&models.User{}).Where("email = ?", "admin@warungpos.com").Count(&count)
|
| 23 |
+
if count == 0 {
|
| 24 |
+
hashedPassword, _ := middleware.HashPassword("admin123")
|
| 25 |
+
admin := models.User{
|
| 26 |
+
Name: "Admin WarungPOS",
|
| 27 |
+
Email: "admin@warungpos.com",
|
| 28 |
+
Password: hashedPassword,
|
| 29 |
+
Role: "super_admin",
|
| 30 |
+
CompanyID: nil,
|
| 31 |
+
BranchID: nil,
|
| 32 |
+
IsActive: true,
|
| 33 |
+
}
|
| 34 |
+
if err := config.DB.Create(&admin).Error; err != nil {
|
| 35 |
+
log.Printf("[SEED] Gagal membuat default admin: %v", err)
|
| 36 |
+
} else {
|
| 37 |
+
log.Println("[SEED] Sukses membuat default admin!")
|
| 38 |
+
}
|
| 39 |
+
} else {
|
| 40 |
+
log.Println("[SEED] Admin default sudah terbuat.")
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Request & Response DTOs
|
| 45 |
+
type LoginRequest struct {
|
| 46 |
+
Email string `json:"email" binding:"required"`
|
| 47 |
+
Password string `json:"password" binding:"required"`
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
type UserResponse struct {
|
| 51 |
+
ID uint `json:"id"`
|
| 52 |
+
Name string `json:"name"`
|
| 53 |
+
Email string `json:"email"`
|
| 54 |
+
Phone string `json:"phone"`
|
| 55 |
+
Role string `json:"role"`
|
| 56 |
+
CompanyID *uint `json:"company_id"`
|
| 57 |
+
BranchID *uint `json:"branch_id"`
|
| 58 |
+
IsActive bool `json:"is_active"`
|
| 59 |
+
CompanyName string `json:"company_name,omitempty"`
|
| 60 |
+
BranchName string `json:"branch_name,omitempty"`
|
| 61 |
+
CreatedAt time.Time `json:"created_at"`
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
type TokenResponse struct {
|
| 65 |
+
AccessToken string `json:"access_token"`
|
| 66 |
+
TokenType string `json:"token_type"`
|
| 67 |
+
User UserResponse `json:"user"`
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
type RegisterRequest struct {
|
| 71 |
+
Name string `json:"name" binding:"required"`
|
| 72 |
+
Email string `json:"email" binding:"required,email"`
|
| 73 |
+
Password string `json:"password" binding:"required"`
|
| 74 |
+
Role string `json:"role"`
|
| 75 |
+
CompanyID *uint `json:"company_id"`
|
| 76 |
+
BranchID *uint `json:"branch_id"`
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
type TenantRegistrationRequest struct {
|
| 80 |
+
CompanyName string `json:"company_name" binding:"required"`
|
| 81 |
+
OwnerName string `json:"owner_name" binding:"required"`
|
| 82 |
+
OwnerEmail string `json:"owner_email" binding:"required,email"`
|
| 83 |
+
OwnerPhone string `json:"owner_phone" binding:"required"`
|
| 84 |
+
OwnerPassword string `json:"owner_password" binding:"required"`
|
| 85 |
+
BankAccountNumber string `json:"bank_account_number"`
|
| 86 |
+
BankName string `json:"bank_name"`
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
type VerifyOTPRequest struct {
|
| 90 |
+
Phone string `json:"phone" binding:"required"`
|
| 91 |
+
OTPCode string `json:"otp_code" binding:"required"`
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
type ResendOTPRequest struct {
|
| 95 |
+
Phone string `json:"phone" binding:"required"`
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
type UpdateUserRequest struct {
|
| 99 |
+
Name string `json:"name"`
|
| 100 |
+
Email string `json:"email"`
|
| 101 |
+
Password string `json:"password"`
|
| 102 |
+
Role string `json:"role"`
|
| 103 |
+
IsActive *bool `json:"is_active"`
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
type UpdateKasirRequest struct {
|
| 107 |
+
Name string `json:"name"`
|
| 108 |
+
Email string `json:"email"`
|
| 109 |
+
Password string `json:"password"`
|
| 110 |
+
BranchID *uint `json:"branch_id"`
|
| 111 |
+
IsActive *bool `json:"is_active"`
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
func toUserResponse(user *models.User) UserResponse {
|
| 115 |
+
resp := UserResponse{
|
| 116 |
+
ID: user.ID,
|
| 117 |
+
Name: user.Name,
|
| 118 |
+
Email: user.Email,
|
| 119 |
+
Phone: user.Phone,
|
| 120 |
+
Role: user.Role,
|
| 121 |
+
CompanyID: user.CompanyID,
|
| 122 |
+
BranchID: user.BranchID,
|
| 123 |
+
IsActive: user.IsActive,
|
| 124 |
+
CreatedAt: user.CreatedAt,
|
| 125 |
+
}
|
| 126 |
+
if user.Company != nil {
|
| 127 |
+
resp.CompanyName = user.Company.Name
|
| 128 |
+
}
|
| 129 |
+
if user.Branch != nil {
|
| 130 |
+
resp.BranchName = user.Branch.Name
|
| 131 |
+
}
|
| 132 |
+
return resp
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
// Route Handlers
|
| 136 |
+
func Login(c *gin.Context) {
|
| 137 |
+
var req LoginRequest
|
| 138 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 139 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Data tidak valid"})
|
| 140 |
+
return
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
var user models.User
|
| 144 |
+
if err := config.DB.Preload("Company").Preload("Branch").Where("email = ?", req.Email).First(&user).Error; err != nil {
|
| 145 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Email atau password salah"})
|
| 146 |
+
return
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
if !middleware.VerifyPassword(req.Password, user.Password) {
|
| 150 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Email atau password salah"})
|
| 151 |
+
return
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
// Cek status keaktifan user
|
| 155 |
+
if !user.IsActive {
|
| 156 |
+
if user.Role == "owner" && user.CompanyID != nil {
|
| 157 |
+
var company models.Company
|
| 158 |
+
if err := config.DB.First(&company, *user.CompanyID).Error; err == nil {
|
| 159 |
+
if company.ApprovedAt != nil {
|
| 160 |
+
// Pernah diapprove tapi sekarang dinonaktifkan
|
| 161 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "ACCOUNT_DEACTIVATED"})
|
| 162 |
+
return
|
| 163 |
+
} else {
|
| 164 |
+
// Belum diapprove super_admin
|
| 165 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "PENDING_APPROVAL"})
|
| 166 |
+
return
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
c.JSON(http.StatusForbidden, gin.H{"detail": "Akun tidak aktif, hubungi admin"})
|
| 171 |
+
return
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
token, err := middleware.CreateAccessToken(user.ID, user.Role, user.CompanyID)
|
| 175 |
+
if err != nil {
|
| 176 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal men-generate token"})
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
c.JSON(http.StatusOK, TokenResponse{
|
| 181 |
+
AccessToken: token,
|
| 182 |
+
TokenType: "bearer",
|
| 183 |
+
User: toUserResponse(&user),
|
| 184 |
+
})
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
func Register(c *gin.Context) {
|
| 188 |
+
var req RegisterRequest
|
| 189 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 190 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 191 |
+
return
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
var existingCount int64
|
| 195 |
+
config.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&existingCount)
|
| 196 |
+
if existingCount > 0 {
|
| 197 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah terdaftar"})
|
| 198 |
+
return
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
hashedPassword, _ := middleware.HashPassword(req.Password)
|
| 202 |
+
role := "kasir"
|
| 203 |
+
if req.Role != "" {
|
| 204 |
+
role = req.Role
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
newUser := models.User{
|
| 208 |
+
Name: req.Name,
|
| 209 |
+
Email: req.Email,
|
| 210 |
+
Password: hashedPassword,
|
| 211 |
+
Role: role,
|
| 212 |
+
CompanyID: req.CompanyID,
|
| 213 |
+
BranchID: req.BranchID,
|
| 214 |
+
IsActive: true,
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if err := config.DB.Create(&newUser).Error; err != nil {
|
| 218 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan user"})
|
| 219 |
+
return
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
c.JSON(http.StatusCreated, toUserResponse(&newUser))
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
func RegisterTenant(c *gin.Context) {
|
| 226 |
+
var req TenantRegistrationRequest
|
| 227 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 228 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 229 |
+
return
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
var existingCount int64
|
| 233 |
+
config.DB.Model(&models.User{}).Where("email = ?", req.OwnerEmail).Count(&existingCount)
|
| 234 |
+
if existingCount > 0 {
|
| 235 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah digunakan!"})
|
| 236 |
+
return
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
tx := config.DB.Begin()
|
| 240 |
+
defer func() {
|
| 241 |
+
if r := recover(); r != nil {
|
| 242 |
+
tx.Rollback()
|
| 243 |
+
}
|
| 244 |
+
}()
|
| 245 |
+
|
| 246 |
+
// 1. Buat Company
|
| 247 |
+
comp := models.Company{
|
| 248 |
+
Name: req.CompanyName,
|
| 249 |
+
BankAccountNumber: req.BankAccountNumber,
|
| 250 |
+
BankName: req.BankName,
|
| 251 |
+
Email: req.OwnerEmail,
|
| 252 |
+
Phone: req.OwnerPhone,
|
| 253 |
+
IsActive: false,
|
| 254 |
+
}
|
| 255 |
+
if err := tx.Create(&comp).Error; err != nil {
|
| 256 |
+
tx.Rollback()
|
| 257 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat perusahaan"})
|
| 258 |
+
return
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
// 2. Buat Owner User
|
| 262 |
+
hashedPassword, _ := middleware.HashPassword(req.OwnerPassword)
|
| 263 |
+
owner := models.User{
|
| 264 |
+
Name: req.OwnerName,
|
| 265 |
+
Email: req.OwnerEmail,
|
| 266 |
+
Password: hashedPassword,
|
| 267 |
+
Phone: req.OwnerPhone,
|
| 268 |
+
Role: "owner",
|
| 269 |
+
CompanyID: &comp.ID,
|
| 270 |
+
IsActive: false,
|
| 271 |
+
}
|
| 272 |
+
if err := tx.Create(&owner).Error; err != nil {
|
| 273 |
+
tx.Rollback()
|
| 274 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat akun owner"})
|
| 275 |
+
return
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// 3. Buat OTP Entry
|
| 279 |
+
code := fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
| 280 |
+
otpEntry := models.OTP{
|
| 281 |
+
PhoneNumber: req.OwnerPhone,
|
| 282 |
+
OTPCode: code,
|
| 283 |
+
IsVerified: false,
|
| 284 |
+
ExpiresAt: time.Now().Add(5 * time.Minute),
|
| 285 |
+
}
|
| 286 |
+
if err := tx.Create(&otpEntry).Error; err != nil {
|
| 287 |
+
tx.Rollback()
|
| 288 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat OTP"})
|
| 289 |
+
return
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if err := tx.Commit().Error; err != nil {
|
| 293 |
+
tx.Rollback()
|
| 294 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal melakukan registrasi"})
|
| 295 |
+
return
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
// Kirim Notifikasi OTP via WA secara async agar request HTTP cepat
|
| 299 |
+
go services.SendOtpWa(req.OwnerPhone, code)
|
| 300 |
+
|
| 301 |
+
c.JSON(http.StatusCreated, gin.H{
|
| 302 |
+
"status": "success",
|
| 303 |
+
"message": "OTP sent to phone",
|
| 304 |
+
})
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
func VerifyOTP(c *gin.Context) {
|
| 308 |
+
var req VerifyOTPRequest
|
| 309 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 310 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 311 |
+
return
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
var record models.OTP
|
| 315 |
+
if err := config.DB.Where("phone_number = ? AND otp_code = ? AND is_verified = ?", req.Phone, req.OTPCode, false).Order("id desc").First(&record).Error; err != nil {
|
| 316 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Kode OTP salah atau tidak ditemukan"})
|
| 317 |
+
return
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
if record.ExpiresAt.Before(time.Now()) {
|
| 321 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Kode OTP sudah kadaluarsa"})
|
| 322 |
+
return
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
record.IsVerified = true
|
| 326 |
+
config.DB.Save(&record)
|
| 327 |
+
|
| 328 |
+
c.JSON(http.StatusOK, gin.H{
|
| 329 |
+
"status": "success",
|
| 330 |
+
"message": "Nomor Hape diverifikasi! Tunggu Approve Super Admin.",
|
| 331 |
+
})
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
func ResendOTP(c *gin.Context) {
|
| 335 |
+
var req ResendOTPRequest
|
| 336 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 337 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 338 |
+
return
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
var lastOTP models.OTP
|
| 342 |
+
err := config.DB.Where("phone_number = ? AND is_verified = ?", req.Phone, false).Order("id desc").First(&lastOTP).Error
|
| 343 |
+
if err == nil {
|
| 344 |
+
// Cooldown 3 menit (ExpiresAt - 5 menit = CreatedAt)
|
| 345 |
+
createdAt := lastOTP.ExpiresAt.Add(-5 * time.Minute)
|
| 346 |
+
timeSince := time.Since(createdAt)
|
| 347 |
+
if timeSince < 3*time.Minute {
|
| 348 |
+
remaining := int(180 - timeSince.Seconds())
|
| 349 |
+
c.JSON(http.StatusTooManyRequests, gin.H{
|
| 350 |
+
"detail": fmt.Sprintf("Tunggu %d detik lagi sebelum kirim ulang OTP", remaining),
|
| 351 |
+
})
|
| 352 |
+
return
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
code := fmt.Sprintf("%06d", rand.Intn(900000)+100000)
|
| 357 |
+
otpEntry := models.OTP{
|
| 358 |
+
PhoneNumber: req.Phone,
|
| 359 |
+
OTPCode: code,
|
| 360 |
+
IsVerified: false,
|
| 361 |
+
ExpiresAt: time.Now().Add(5 * time.Minute),
|
| 362 |
+
}
|
| 363 |
+
if err := config.DB.Create(&otpEntry).Error; err != nil {
|
| 364 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengirim ulang OTP"})
|
| 365 |
+
return
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
go services.SendOtpWa(req.Phone, code)
|
| 369 |
+
|
| 370 |
+
c.JSON(http.StatusOK, gin.H{
|
| 371 |
+
"status": "success",
|
| 372 |
+
"message": "OTP baru telah dikirim ke WhatsApp Anda",
|
| 373 |
+
})
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
func GetMe(c *gin.Context) {
|
| 377 |
+
userVal, _ := c.Get("user")
|
| 378 |
+
user := userVal.(*models.User)
|
| 379 |
+
|
| 380 |
+
resp := toUserResponse(user)
|
| 381 |
+
|
| 382 |
+
// Attach Company & Branch name jika ada
|
| 383 |
+
if user.CompanyID != nil {
|
| 384 |
+
var company models.Company
|
| 385 |
+
if err := config.DB.First(&company, *user.CompanyID).Error; err == nil {
|
| 386 |
+
resp.CompanyName = company.Name
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
if user.BranchID != nil {
|
| 391 |
+
var branch models.Branch
|
| 392 |
+
if err := config.DB.First(&branch, *user.BranchID).Error; err == nil {
|
| 393 |
+
resp.BranchName = branch.Name
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
c.JSON(http.StatusOK, resp)
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
func ListUsers(c *gin.Context) {
|
| 401 |
+
var users []models.User
|
| 402 |
+
if err := config.DB.Preload("Company").Preload("Branch").Find(&users).Error; err != nil {
|
| 403 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data user"})
|
| 404 |
+
return
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
var resp []UserResponse
|
| 408 |
+
for _, u := range users {
|
| 409 |
+
resp = append(resp, toUserResponse(&u))
|
| 410 |
+
}
|
| 411 |
+
c.JSON(http.StatusOK, resp)
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
func UpdateUser(c *gin.Context) {
|
| 415 |
+
userIDStr := c.Param("user_id")
|
| 416 |
+
userID, err := strconv.Atoi(userIDStr)
|
| 417 |
+
if err != nil {
|
| 418 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID user tidak valid"})
|
| 419 |
+
return
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
var req UpdateUserRequest
|
| 423 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 424 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 425 |
+
return
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
var user models.User
|
| 429 |
+
if err := config.DB.First(&user, userID).Error; err != nil {
|
| 430 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "User tidak ditemukan"})
|
| 431 |
+
return
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
if req.Name != "" {
|
| 435 |
+
user.Name = req.Name
|
| 436 |
+
}
|
| 437 |
+
if req.Email != "" {
|
| 438 |
+
var count int64
|
| 439 |
+
config.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count)
|
| 440 |
+
if count > 0 {
|
| 441 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah digunakan user lain"})
|
| 442 |
+
return
|
| 443 |
+
}
|
| 444 |
+
user.Email = req.Email
|
| 445 |
+
}
|
| 446 |
+
if req.Password != "" {
|
| 447 |
+
hashed, _ := middleware.HashPassword(req.Password)
|
| 448 |
+
user.Password = hashed
|
| 449 |
+
}
|
| 450 |
+
if req.Role != "" {
|
| 451 |
+
user.Role = req.Role
|
| 452 |
+
}
|
| 453 |
+
if req.IsActive != nil {
|
| 454 |
+
user.IsActive = *req.IsActive
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
if err := config.DB.Save(&user).Error; err != nil {
|
| 458 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update user"})
|
| 459 |
+
return
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
c.JSON(http.StatusOK, toUserResponse(&user))
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// Owner endpoints for Kasir Management
|
| 466 |
+
func ListKasir(c *gin.Context) {
|
| 467 |
+
userVal, _ := c.Get("user")
|
| 468 |
+
owner := userVal.(*models.User)
|
| 469 |
+
|
| 470 |
+
var kasir []models.User
|
| 471 |
+
if err := config.DB.Preload("Branch").Where("company_id = ? AND role = ?", *owner.CompanyID, "kasir").Find(&kasir).Error; err != nil {
|
| 472 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data kasir"})
|
| 473 |
+
return
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
var resp []UserResponse
|
| 477 |
+
for _, k := range kasir {
|
| 478 |
+
resp = append(resp, toUserResponse(&k))
|
| 479 |
+
}
|
| 480 |
+
c.JSON(http.StatusOK, resp)
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
func CreateKasir(c *gin.Context) {
|
| 484 |
+
userVal, _ := c.Get("user")
|
| 485 |
+
owner := userVal.(*models.User)
|
| 486 |
+
|
| 487 |
+
var req RegisterRequest
|
| 488 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 489 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 490 |
+
return
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
if req.Role != "" && req.Role != "kasir" {
|
| 494 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Owner hanya bisa membuat role 'kasir'"})
|
| 495 |
+
return
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
var existingCount int64
|
| 499 |
+
config.DB.Model(&models.User{}).Where("email = ?", req.Email).Count(&existingCount)
|
| 500 |
+
if existingCount > 0 {
|
| 501 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah terdaftar"})
|
| 502 |
+
return
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
hashedPassword, _ := middleware.HashPassword(req.Password)
|
| 506 |
+
newUser := models.User{
|
| 507 |
+
Name: req.Name,
|
| 508 |
+
Email: req.Email,
|
| 509 |
+
Password: hashedPassword,
|
| 510 |
+
Role: "kasir",
|
| 511 |
+
CompanyID: owner.CompanyID,
|
| 512 |
+
BranchID: req.BranchID,
|
| 513 |
+
IsActive: true,
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
if err := config.DB.Create(&newUser).Error; err != nil {
|
| 517 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan kasir"})
|
| 518 |
+
return
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
c.JSON(http.StatusCreated, toUserResponse(&newUser))
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
func UpdateKasir(c *gin.Context) {
|
| 525 |
+
userVal, _ := c.Get("user")
|
| 526 |
+
owner := userVal.(*models.User)
|
| 527 |
+
|
| 528 |
+
kasirIDStr := c.Param("user_id")
|
| 529 |
+
kasirID, err := strconv.Atoi(kasirIDStr)
|
| 530 |
+
if err != nil {
|
| 531 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kasir tidak valid"})
|
| 532 |
+
return
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
var req UpdateKasirRequest
|
| 536 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 537 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 538 |
+
return
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
var kasir models.User
|
| 542 |
+
if err := config.DB.Where("id = ? AND company_id = ? AND role = ?", kasirID, *owner.CompanyID, "kasir").First(&kasir).Error; err != nil {
|
| 543 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Kasir tidak ditemukan di perusahaan ini"})
|
| 544 |
+
return
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
if req.Name != "" {
|
| 548 |
+
kasir.Name = req.Name
|
| 549 |
+
}
|
| 550 |
+
if req.Email != "" {
|
| 551 |
+
var count int64
|
| 552 |
+
config.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, kasirID).Count(&count)
|
| 553 |
+
if count > 0 {
|
| 554 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Email sudah digunakan user lain"})
|
| 555 |
+
return
|
| 556 |
+
}
|
| 557 |
+
kasir.Email = req.Email
|
| 558 |
+
}
|
| 559 |
+
if req.Password != "" {
|
| 560 |
+
hashed, _ := middleware.HashPassword(req.Password)
|
| 561 |
+
kasir.Password = hashed
|
| 562 |
+
}
|
| 563 |
+
if req.BranchID != nil {
|
| 564 |
+
kasir.BranchID = req.BranchID
|
| 565 |
+
}
|
| 566 |
+
if req.IsActive != nil {
|
| 567 |
+
kasir.IsActive = *req.IsActive
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
if err := config.DB.Save(&kasir).Error; err != nil {
|
| 571 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update kasir"})
|
| 572 |
+
return
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
c.JSON(http.StatusOK, toUserResponse(&kasir))
|
| 576 |
+
}
|
controllers/company_controller.go
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package controllers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strconv"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"service-warungpos-go/config"
|
| 10 |
+
"service-warungpos-go/models"
|
| 11 |
+
"service-warungpos-go/services"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
// DTOs & Custom Structs
|
| 17 |
+
type CompanyResponse struct {
|
| 18 |
+
ID uint `json:"id"`
|
| 19 |
+
Name string `json:"name"`
|
| 20 |
+
BankAccountNumber string `json:"bank_account_number"`
|
| 21 |
+
BankName string `json:"bank_name"`
|
| 22 |
+
Address string `json:"address"`
|
| 23 |
+
Email string `json:"email"`
|
| 24 |
+
Phone string `json:"phone"`
|
| 25 |
+
LogoURL string `json:"logo_url"`
|
| 26 |
+
IsActive bool `json:"is_active"`
|
| 27 |
+
FeePercentage float64 `json:"fee_percentage"`
|
| 28 |
+
ApprovedAt *time.Time `json:"approved_at"`
|
| 29 |
+
OwnerVerified *bool `json:"owner_verified,omitempty"`
|
| 30 |
+
OwnerName string `json:"owner_name,omitempty"`
|
| 31 |
+
OwnerEmail string `json:"owner_email,omitempty"`
|
| 32 |
+
OwnerPhone string `json:"owner_phone,omitempty"`
|
| 33 |
+
CreatedAt time.Time `json:"created_at"`
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
type CompanyCreateRequest struct {
|
| 37 |
+
Name string `json:"name" binding:"required"`
|
| 38 |
+
BankAccountNumber string `json:"bank_account_number"`
|
| 39 |
+
BankName string `json:"bank_name"`
|
| 40 |
+
Address string `json:"address"`
|
| 41 |
+
Email string `json:"email"`
|
| 42 |
+
Phone string `json:"phone"`
|
| 43 |
+
LogoURL string `json:"logo_url"`
|
| 44 |
+
FeePercentage float64 `json:"fee_percentage"`
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
type BranchCreateRequest struct {
|
| 48 |
+
Name string `json:"name" binding:"required"`
|
| 49 |
+
Address string `json:"address"`
|
| 50 |
+
Phone string `json:"phone"`
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
type BranchResponse struct {
|
| 54 |
+
ID uint `json:"id"`
|
| 55 |
+
CompanyID uint `json:"company_id"`
|
| 56 |
+
Name string `json:"name"`
|
| 57 |
+
Address string `json:"address"`
|
| 58 |
+
Phone string `json:"phone"`
|
| 59 |
+
IsActive bool `json:"is_active"`
|
| 60 |
+
CreatedAt time.Time `json:"created_at"`
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
type BranchProductResponse struct {
|
| 64 |
+
ID uint `json:"id"`
|
| 65 |
+
Name string `json:"name"`
|
| 66 |
+
Price int `json:"price"`
|
| 67 |
+
CategoryID *uint `json:"category_id"`
|
| 68 |
+
ImageURL string `json:"image_url"`
|
| 69 |
+
IsAvailable bool `json:"is_available"`
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// ==================== COMPANY CONTROLLER ====================
|
| 73 |
+
|
| 74 |
+
func ListCompanies(c *gin.Context) {
|
| 75 |
+
var companies []models.Company
|
| 76 |
+
if err := config.DB.Find(&companies).Error; err != nil {
|
| 77 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data perusahaan"})
|
| 78 |
+
return
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
var resp []CompanyResponse
|
| 82 |
+
for _, comp := range companies {
|
| 83 |
+
var owner models.User
|
| 84 |
+
err := config.DB.Where("company_id = ? AND role = ?", comp.ID, "owner").First(&owner).Error
|
| 85 |
+
|
| 86 |
+
var ownerVerified *bool
|
| 87 |
+
var ownerName, ownerEmail, ownerPhone string
|
| 88 |
+
|
| 89 |
+
if err == nil {
|
| 90 |
+
ownerName = owner.Name
|
| 91 |
+
ownerEmail = owner.Email
|
| 92 |
+
ownerPhone = owner.Phone
|
| 93 |
+
|
| 94 |
+
if owner.Phone != "" {
|
| 95 |
+
var otp models.OTP
|
| 96 |
+
otpErr := config.DB.Where("phone_number = ? AND is_verified = ?", owner.Phone, true).First(&otp).Error
|
| 97 |
+
isVerified := otpErr == nil
|
| 98 |
+
ownerVerified = &isVerified
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
resp = append(resp, CompanyResponse{
|
| 103 |
+
ID: comp.ID,
|
| 104 |
+
Name: comp.Name,
|
| 105 |
+
BankAccountNumber: comp.BankAccountNumber,
|
| 106 |
+
BankName: comp.BankName,
|
| 107 |
+
Address: comp.Address,
|
| 108 |
+
Email: comp.Email,
|
| 109 |
+
Phone: comp.Phone,
|
| 110 |
+
LogoURL: comp.LogoURL,
|
| 111 |
+
IsActive: comp.IsActive,
|
| 112 |
+
FeePercentage: comp.FeePercentage,
|
| 113 |
+
ApprovedAt: comp.ApprovedAt,
|
| 114 |
+
OwnerVerified: ownerVerified,
|
| 115 |
+
OwnerName: ownerName,
|
| 116 |
+
OwnerEmail: ownerEmail,
|
| 117 |
+
OwnerPhone: ownerPhone,
|
| 118 |
+
CreatedAt: comp.CreatedAt,
|
| 119 |
+
})
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
c.JSON(http.StatusOK, resp)
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
func GetMyCompany(c *gin.Context) {
|
| 126 |
+
userVal, _ := c.Get("user")
|
| 127 |
+
user := userVal.(*models.User)
|
| 128 |
+
|
| 129 |
+
if user.CompanyID == nil {
|
| 130 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Tidak terhubung ke perusahaan"})
|
| 131 |
+
return
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
var company models.Company
|
| 135 |
+
if err := config.DB.First(&company, *user.CompanyID).Error; err != nil {
|
| 136 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Perusahaan tidak ditemukan"})
|
| 137 |
+
return
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
c.JSON(http.StatusOK, company)
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
func GetCompany(c *gin.Context) {
|
| 144 |
+
companyIDStr := c.Param("company_id")
|
| 145 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 146 |
+
if err != nil {
|
| 147 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 148 |
+
return
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
var company models.Company
|
| 152 |
+
if err := config.DB.First(&company, companyID).Error; err != nil {
|
| 153 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Company not found"})
|
| 154 |
+
return
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
c.JSON(http.StatusOK, company)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
func CreateCompany(c *gin.Context) {
|
| 161 |
+
var req CompanyCreateRequest
|
| 162 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 163 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 164 |
+
return
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
var count int64
|
| 168 |
+
config.DB.Model(&models.Company{}).Where("name = ?", req.Name).Count(&count)
|
| 169 |
+
if count > 0 {
|
| 170 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Company name already exists"})
|
| 171 |
+
return
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
newComp := models.Company{
|
| 175 |
+
Name: req.Name,
|
| 176 |
+
BankAccountNumber: req.BankAccountNumber,
|
| 177 |
+
BankName: req.BankName,
|
| 178 |
+
Address: req.Address,
|
| 179 |
+
Email: req.Email,
|
| 180 |
+
Phone: req.Phone,
|
| 181 |
+
LogoURL: req.LogoURL,
|
| 182 |
+
IsActive: true,
|
| 183 |
+
FeePercentage: req.FeePercentage,
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if err := config.DB.Create(&newComp).Error; err != nil {
|
| 187 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membuat perusahaan baru"})
|
| 188 |
+
return
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
c.JSON(http.StatusCreated, newComp)
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
func UpdateCompany(c *gin.Context) {
|
| 195 |
+
companyIDStr := c.Param("company_id")
|
| 196 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 197 |
+
if err != nil {
|
| 198 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 199 |
+
return
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
var req CompanyCreateRequest
|
| 203 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 204 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 205 |
+
return
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
var company models.Company
|
| 209 |
+
if err := config.DB.First(&company, companyID).Error; err != nil {
|
| 210 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Company not found"})
|
| 211 |
+
return
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
if req.Name != "" {
|
| 215 |
+
var count int64
|
| 216 |
+
config.DB.Model(&models.Company{}).Where("name = ? AND id != ?", req.Name, companyID).Count(&count)
|
| 217 |
+
if count > 0 {
|
| 218 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Company name already exists"})
|
| 219 |
+
return
|
| 220 |
+
}
|
| 221 |
+
company.Name = req.Name
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
company.BankAccountNumber = req.BankAccountNumber
|
| 225 |
+
company.BankName = req.BankName
|
| 226 |
+
company.Address = req.Address
|
| 227 |
+
company.Email = req.Email
|
| 228 |
+
company.Phone = req.Phone
|
| 229 |
+
company.LogoURL = req.LogoURL
|
| 230 |
+
company.FeePercentage = req.FeePercentage
|
| 231 |
+
|
| 232 |
+
if err := config.DB.Save(&company).Error; err != nil {
|
| 233 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update perusahaan"})
|
| 234 |
+
return
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
c.JSON(http.StatusOK, company)
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
func ToggleActiveCompany(c *gin.Context) {
|
| 241 |
+
companyIDStr := c.Param("company_id")
|
| 242 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 243 |
+
if err != nil {
|
| 244 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 245 |
+
return
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
var company models.Company
|
| 249 |
+
if err := config.DB.First(&company, companyID).Error; err != nil {
|
| 250 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Company not found"})
|
| 251 |
+
return
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
tx := config.DB.Begin()
|
| 255 |
+
defer func() {
|
| 256 |
+
if r := recover(); r != nil {
|
| 257 |
+
tx.Rollback()
|
| 258 |
+
}
|
| 259 |
+
}()
|
| 260 |
+
|
| 261 |
+
company.IsActive = !company.IsActive
|
| 262 |
+
tx.Save(&company)
|
| 263 |
+
|
| 264 |
+
// Ubah status keaktifan owner juga
|
| 265 |
+
var owner models.User
|
| 266 |
+
if err := tx.Where("company_id = ? AND role = ?", companyID, "owner").First(&owner).Error; err == nil {
|
| 267 |
+
owner.IsActive = company.IsActive
|
| 268 |
+
tx.Save(&owner)
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
if err := tx.Commit().Error; err != nil {
|
| 272 |
+
tx.Rollback()
|
| 273 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal melakukan toggle status"})
|
| 274 |
+
return
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
status := "dinonaktifkan"
|
| 278 |
+
if company.IsActive {
|
| 279 |
+
status = "diaktifkan"
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
c.JSON(http.StatusOK, gin.H{
|
| 283 |
+
"message": fmt.Sprintf("Perusahaan %s berhasil %s", company.Name, status),
|
| 284 |
+
"is_active": company.IsActive,
|
| 285 |
+
})
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
func DeleteCompany(c *gin.Context) {
|
| 289 |
+
companyIDStr := c.Param("company_id")
|
| 290 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 291 |
+
if err != nil {
|
| 292 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 293 |
+
return
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
var company models.Company
|
| 297 |
+
if err := config.DB.First(&company, companyID).Error; err != nil {
|
| 298 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Company not found"})
|
| 299 |
+
return
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
tx := config.DB.Begin()
|
| 303 |
+
defer func() {
|
| 304 |
+
if r := recover(); r != nil {
|
| 305 |
+
tx.Rollback()
|
| 306 |
+
}
|
| 307 |
+
}()
|
| 308 |
+
|
| 309 |
+
// Ambil semua user terkait company ini, lalu hapus record OTP mereka
|
| 310 |
+
var users []models.User
|
| 311 |
+
tx.Where("company_id = ?", companyID).Find(&users)
|
| 312 |
+
for _, u := range users {
|
| 313 |
+
if u.Phone != "" {
|
| 314 |
+
tx.Where("phone_number = ?", u.Phone).Delete(&models.OTP{})
|
| 315 |
+
}
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Cascading deletes Company (users, branches, products, categories, dll)
|
| 319 |
+
if err := tx.Delete(&company).Error; err != nil {
|
| 320 |
+
tx.Rollback()
|
| 321 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menghapus data perusahaan"})
|
| 322 |
+
return
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
tx.Commit()
|
| 326 |
+
|
| 327 |
+
c.JSON(http.StatusOK, gin.H{
|
| 328 |
+
"message": fmt.Sprintf("Perusahaan '%s' dan seluruh data terkait berhasil dihapus", company.Name),
|
| 329 |
+
})
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
func RejectCompany(c *gin.Context) {
|
| 333 |
+
companyIDStr := c.Param("company_id")
|
| 334 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 335 |
+
if err != nil {
|
| 336 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 337 |
+
return
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
var company models.Company
|
| 341 |
+
if err := config.DB.First(&company, companyID).Error; err != nil {
|
| 342 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Company not found"})
|
| 343 |
+
return
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if company.IsActive {
|
| 347 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Tidak bisa menolak perusahaan yang sudah aktif"})
|
| 348 |
+
return
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
tx := config.DB.Begin()
|
| 352 |
+
defer func() {
|
| 353 |
+
if r := recover(); r != nil {
|
| 354 |
+
tx.Rollback()
|
| 355 |
+
}
|
| 356 |
+
}()
|
| 357 |
+
|
| 358 |
+
var users []models.User
|
| 359 |
+
tx.Where("company_id = ?", companyID).Find(&users)
|
| 360 |
+
for _, u := range users {
|
| 361 |
+
if u.Phone != "" {
|
| 362 |
+
tx.Where("phone_number = ?", u.Phone).Delete(&models.OTP{})
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
tx.Delete(&company)
|
| 367 |
+
tx.Commit()
|
| 368 |
+
|
| 369 |
+
c.JSON(http.StatusOK, gin.H{
|
| 370 |
+
"message": fmt.Sprintf("Pendaftaran '%s' ditolak dan dihapus dari sistem", company.Name),
|
| 371 |
+
})
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
func ApproveCompany(c *gin.Context) {
|
| 375 |
+
companyIDStr := c.Param("company_id")
|
| 376 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 377 |
+
if err != nil {
|
| 378 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 379 |
+
return
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
tx := config.DB.Begin()
|
| 383 |
+
defer func() {
|
| 384 |
+
if r := recover(); r != nil {
|
| 385 |
+
tx.Rollback()
|
| 386 |
+
}
|
| 387 |
+
}()
|
| 388 |
+
|
| 389 |
+
var company models.Company
|
| 390 |
+
if err := tx.First(&company, companyID).Error; err != nil {
|
| 391 |
+
tx.Rollback()
|
| 392 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Company not found"})
|
| 393 |
+
return
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
var owner models.User
|
| 397 |
+
if err := tx.Where("company_id = ? AND role = ?", companyID, "owner").First(&owner).Error; err != nil {
|
| 398 |
+
tx.Rollback()
|
| 399 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Company ini belum memiliki Owner"})
|
| 400 |
+
return
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
now := time.Now()
|
| 404 |
+
company.IsActive = true
|
| 405 |
+
company.ApprovedAt = &now
|
| 406 |
+
tx.Save(&company)
|
| 407 |
+
|
| 408 |
+
owner.IsActive = true
|
| 409 |
+
tx.Save(&owner)
|
| 410 |
+
|
| 411 |
+
if err := tx.Commit().Error; err != nil {
|
| 412 |
+
tx.Rollback()
|
| 413 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyetujui perusahaan"})
|
| 414 |
+
return
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
// Kirim Notifikasi WA (Async)
|
| 418 |
+
go services.SendApprovalWa(owner.Phone, company.Name, owner.Email)
|
| 419 |
+
|
| 420 |
+
c.JSON(http.StatusOK, gin.H{
|
| 421 |
+
"message": "Perusahaan dan owner berhasil disetujui",
|
| 422 |
+
})
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// ==================== BRANCH CONTROLLER ====================
|
| 426 |
+
|
| 427 |
+
func ListBranches(c *gin.Context) {
|
| 428 |
+
userVal, _ := c.Get("user")
|
| 429 |
+
owner := userVal.(*models.User)
|
| 430 |
+
|
| 431 |
+
var branches []models.Branch
|
| 432 |
+
if err := config.DB.Where("company_id = ?", *owner.CompanyID).Find(&branches).Error; err != nil {
|
| 433 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data cabang"})
|
| 434 |
+
return
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
c.JSON(http.StatusOK, branches)
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
func CreateBranch(c *gin.Context) {
|
| 441 |
+
userVal, _ := c.Get("user")
|
| 442 |
+
owner := userVal.(*models.User)
|
| 443 |
+
|
| 444 |
+
var req BranchCreateRequest
|
| 445 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 446 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 447 |
+
return
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
var count int64
|
| 451 |
+
config.DB.Model(&models.Branch{}).Where("company_id = ? AND name = ?", *owner.CompanyID, req.Name).Count(&count)
|
| 452 |
+
if count > 0 {
|
| 453 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Nama cabang sudah ada"})
|
| 454 |
+
return
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
newBranch := models.Branch{
|
| 458 |
+
CompanyID: *owner.CompanyID,
|
| 459 |
+
Name: req.Name,
|
| 460 |
+
Address: req.Address,
|
| 461 |
+
Phone: req.Phone,
|
| 462 |
+
IsActive: true,
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
if err := config.DB.Create(&newBranch).Error; err != nil {
|
| 466 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan cabang baru"})
|
| 467 |
+
return
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
c.JSON(http.StatusCreated, newBranch)
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
func UpdateBranch(c *gin.Context) {
|
| 474 |
+
userVal, _ := c.Get("user")
|
| 475 |
+
owner := userVal.(*models.User)
|
| 476 |
+
|
| 477 |
+
branchIDStr := c.Param("branch_id")
|
| 478 |
+
branchID, err := strconv.Atoi(branchIDStr)
|
| 479 |
+
if err != nil {
|
| 480 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID cabang tidak valid"})
|
| 481 |
+
return
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
var req BranchCreateRequest
|
| 485 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 486 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 487 |
+
return
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
var branch models.Branch
|
| 491 |
+
if err := config.DB.Where("id = ? AND company_id = ?", branchID, *owner.CompanyID).First(&branch).Error; err != nil {
|
| 492 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Cabang tidak ditemukan"})
|
| 493 |
+
return
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
if req.Name != "" {
|
| 497 |
+
var count int64
|
| 498 |
+
config.DB.Model(&models.Branch{}).Where("company_id = ? AND name = ? AND id != ?", *owner.CompanyID, req.Name, branchID).Count(&count)
|
| 499 |
+
if count > 0 {
|
| 500 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Nama cabang sudah ada"})
|
| 501 |
+
return
|
| 502 |
+
}
|
| 503 |
+
branch.Name = req.Name
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
branch.Address = req.Address
|
| 507 |
+
branch.Phone = req.Phone
|
| 508 |
+
|
| 509 |
+
if err := config.DB.Save(&branch).Error; err != nil {
|
| 510 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update cabang"})
|
| 511 |
+
return
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
c.JSON(http.StatusOK, branch)
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
func DeleteBranch(c *gin.Context) {
|
| 518 |
+
userVal, _ := c.Get("user")
|
| 519 |
+
owner := userVal.(*models.User)
|
| 520 |
+
|
| 521 |
+
branchIDStr := c.Param("branch_id")
|
| 522 |
+
branchID, err := strconv.Atoi(branchIDStr)
|
| 523 |
+
if err != nil {
|
| 524 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID cabang tidak valid"})
|
| 525 |
+
return
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
var branch models.Branch
|
| 529 |
+
if err := config.DB.Where("id = ? AND company_id = ?", branchID, *owner.CompanyID).First(&branch).Error; err != nil {
|
| 530 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Cabang tidak ditemukan"})
|
| 531 |
+
return
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
branch.IsActive = false
|
| 535 |
+
config.DB.Save(&branch)
|
| 536 |
+
|
| 537 |
+
c.JSON(http.StatusOK, gin.H{"message": "Cabang dinonaktifkan"})
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
func ToggleBranchStatus(c *gin.Context) {
|
| 541 |
+
userVal, _ := c.Get("user")
|
| 542 |
+
owner := userVal.(*models.User)
|
| 543 |
+
|
| 544 |
+
branchIDStr := c.Param("branch_id")
|
| 545 |
+
branchID, err := strconv.Atoi(branchIDStr)
|
| 546 |
+
if err != nil {
|
| 547 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID cabang tidak valid"})
|
| 548 |
+
return
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
var branch models.Branch
|
| 552 |
+
if err := config.DB.Where("id = ? AND company_id = ?", branchID, *owner.CompanyID).First(&branch).Error; err != nil {
|
| 553 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Cabang tidak ditemukan"})
|
| 554 |
+
return
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
branch.IsActive = !branch.IsActive
|
| 558 |
+
config.DB.Save(&branch)
|
| 559 |
+
|
| 560 |
+
status := "dinonaktifkan"
|
| 561 |
+
if branch.IsActive {
|
| 562 |
+
status = "diaktifkan"
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
c.JSON(http.StatusOK, gin.H{
|
| 566 |
+
"message": fmt.Sprintf("Cabang berhasil %s", status),
|
| 567 |
+
"is_active": branch.IsActive,
|
| 568 |
+
})
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
func GetBranchProducts(c *gin.Context) {
|
| 572 |
+
userVal, _ := c.Get("user")
|
| 573 |
+
owner := userVal.(*models.User)
|
| 574 |
+
|
| 575 |
+
branchIDStr := c.Param("branch_id")
|
| 576 |
+
branchID, err := strconv.Atoi(branchIDStr)
|
| 577 |
+
if err != nil {
|
| 578 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID cabang tidak valid"})
|
| 579 |
+
return
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
// Pastikan cabang milik perusahaan owner
|
| 583 |
+
var branch models.Branch
|
| 584 |
+
if err := config.DB.Where("id = ? AND company_id = ?", branchID, *owner.CompanyID).First(&branch).Error; err != nil {
|
| 585 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Cabang tidak ditemukan"})
|
| 586 |
+
return
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
// Ambil semua produk aktif
|
| 590 |
+
var products []models.Product
|
| 591 |
+
if err := config.DB.Where("company_id = ? AND is_active = ?", *owner.CompanyID, true).Find(&products).Error; err != nil {
|
| 592 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data produk"})
|
| 593 |
+
return
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
// Ambil relasi availability
|
| 597 |
+
var branchProducts []models.BranchProduct
|
| 598 |
+
config.DB.Where("branch_id = ?", branchID).Find(&branchProducts)
|
| 599 |
+
|
| 600 |
+
bpMap := make(map[uint]bool)
|
| 601 |
+
for _, bp := range branchProducts {
|
| 602 |
+
bpMap[bp.ProductID] = bp.IsAvailable
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
var resp []BranchProductResponse
|
| 606 |
+
for _, p := range products {
|
| 607 |
+
isAvailable, exists := bpMap[p.ID]
|
| 608 |
+
if !exists {
|
| 609 |
+
isAvailable = true // Default tersedia
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
resp = append(resp, BranchProductResponse{
|
| 613 |
+
ID: p.ID,
|
| 614 |
+
Name: p.Name,
|
| 615 |
+
Price: p.Price,
|
| 616 |
+
CategoryID: p.CategoryID,
|
| 617 |
+
ImageURL: p.ImageURL,
|
| 618 |
+
IsAvailable: isAvailable,
|
| 619 |
+
})
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
c.JSON(http.StatusOK, resp)
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
func ToggleProductAvailability(c *gin.Context) {
|
| 626 |
+
userVal, _ := c.Get("user")
|
| 627 |
+
owner := userVal.(*models.User)
|
| 628 |
+
|
| 629 |
+
branchIDStr := c.Param("branch_id")
|
| 630 |
+
branchID, err := strconv.Atoi(branchIDStr)
|
| 631 |
+
if err != nil {
|
| 632 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID cabang tidak valid"})
|
| 633 |
+
return
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
productIDStr := c.Param("product_id")
|
| 637 |
+
productID, err := strconv.Atoi(productIDStr)
|
| 638 |
+
if err != nil {
|
| 639 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"})
|
| 640 |
+
return
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
var branch models.Branch
|
| 644 |
+
if err := config.DB.Where("id = ? AND company_id = ?", branchID, *owner.CompanyID).First(&branch).Error; err != nil {
|
| 645 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Cabang tidak ditemukan"})
|
| 646 |
+
return
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
var product models.Product
|
| 650 |
+
if err := config.DB.Where("id = ? AND company_id = ?", productID, *owner.CompanyID).First(&product).Error; err != nil {
|
| 651 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"})
|
| 652 |
+
return
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
var bp models.BranchProduct
|
| 656 |
+
err = config.DB.Where("branch_id = ? AND product_id = ?", branchID, productID).First(&bp).Error
|
| 657 |
+
|
| 658 |
+
if err == nil {
|
| 659 |
+
bp.IsAvailable = !bp.IsAvailable
|
| 660 |
+
config.DB.Save(&bp)
|
| 661 |
+
} else {
|
| 662 |
+
// Belum pernah diset -> default True, jadi toggle = False
|
| 663 |
+
bp = models.BranchProduct{
|
| 664 |
+
BranchID: uint(branchID),
|
| 665 |
+
ProductID: uint(productID),
|
| 666 |
+
IsAvailable: false,
|
| 667 |
+
}
|
| 668 |
+
config.DB.Create(&bp)
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
c.JSON(http.StatusOK, gin.H{
|
| 672 |
+
"message": "Status diperbarui",
|
| 673 |
+
"is_available": bp.IsAvailable,
|
| 674 |
+
})
|
| 675 |
+
}
|
controllers/fee_controller.go
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package controllers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strconv"
|
| 7 |
+
"time"
|
| 8 |
+
|
| 9 |
+
"service-warungpos-go/config"
|
| 10 |
+
"service-warungpos-go/models"
|
| 11 |
+
|
| 12 |
+
"github.com/gin-gonic/gin"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
type FeeSummaryData struct {
|
| 16 |
+
TotalTransactions int64 `json:"total_transactions"`
|
| 17 |
+
TotalRevenue int `json:"total_revenue"`
|
| 18 |
+
TotalFee int `json:"total_fee"`
|
| 19 |
+
TotalCash int `json:"total_cash"`
|
| 20 |
+
TotalMidtrans int `json:"total_midtrans"`
|
| 21 |
+
FeeCash int `json:"fee_cash"`
|
| 22 |
+
FeeMidtrans int `json:"fee_midtrans"`
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
type CompanyFeeRow struct {
|
| 26 |
+
CompanyID uint `json:"company_id"`
|
| 27 |
+
CompanyName string `json:"company_name"`
|
| 28 |
+
FeePercentage float64 `json:"fee_percentage"`
|
| 29 |
+
TotalTransactions int64 `json:"total_transactions"`
|
| 30 |
+
TotalRevenue int `json:"total_revenue"`
|
| 31 |
+
TotalFee int `json:"total_fee"`
|
| 32 |
+
TotalCash int `json:"total_cash"`
|
| 33 |
+
TotalMidtrans int `json:"total_midtrans"`
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
type BranchFeeRow struct {
|
| 37 |
+
CompanyID uint `json:"company_id"`
|
| 38 |
+
CompanyName string `json:"company_name"`
|
| 39 |
+
BranchID *uint `json:"branch_id"`
|
| 40 |
+
BranchName string `json:"branch_name"`
|
| 41 |
+
TotalTransactions int64 `json:"total_transactions"`
|
| 42 |
+
TotalRevenue int `json:"total_revenue"`
|
| 43 |
+
TotalFee int `json:"total_fee"`
|
| 44 |
+
TotalCash int `json:"total_cash"`
|
| 45 |
+
TotalMidtrans int `json:"total_midtrans"`
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
type DailyFeeRow struct {
|
| 49 |
+
Day time.Time `json:"day"`
|
| 50 |
+
CompanyID uint `json:"company_id"`
|
| 51 |
+
CompanyName string `json:"company_name"`
|
| 52 |
+
FeePercentage float64 `json:"fee_percentage"`
|
| 53 |
+
TotalTransactions int64 `json:"total_transactions"`
|
| 54 |
+
TotalRevenue int `json:"total_revenue"`
|
| 55 |
+
TotalFee int `json:"total_fee"`
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
func GetFeeReport(c *gin.Context) {
|
| 59 |
+
dateFrom := c.Query("date_from")
|
| 60 |
+
dateTo := c.Query("date_to")
|
| 61 |
+
companyIDStr := c.Query("company_id")
|
| 62 |
+
|
| 63 |
+
today := time.Now()
|
| 64 |
+
var dFrom, dTo time.Time
|
| 65 |
+
var err error
|
| 66 |
+
|
| 67 |
+
if dateFrom != "" {
|
| 68 |
+
dFrom, err = time.Parse("2006-01-02", dateFrom)
|
| 69 |
+
if err != nil {
|
| 70 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Format date_from salah, gunakan YYYY-MM-DD"})
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
} else {
|
| 74 |
+
// Default 30 hari lalu
|
| 75 |
+
dFrom = today.AddDate(0, 0, -29)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
if dateTo != "" {
|
| 79 |
+
dTo, err = time.Parse("2006-01-02", dateTo)
|
| 80 |
+
if err != nil {
|
| 81 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Format date_to salah, gunakan YYYY-MM-DD"})
|
| 82 |
+
return
|
| 83 |
+
}
|
| 84 |
+
} else {
|
| 85 |
+
dTo = today
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
loc, _ := time.LoadLocation("Asia/Jakarta")
|
| 89 |
+
if loc == nil {
|
| 90 |
+
loc = time.Local
|
| 91 |
+
}
|
| 92 |
+
startDt := time.Date(dFrom.Year(), dFrom.Month(), dFrom.Day(), 0, 0, 0, 0, loc)
|
| 93 |
+
endDt := time.Date(dTo.Year(), dTo.Month(), dTo.Day(), 23, 59, 59, 0, loc)
|
| 94 |
+
|
| 95 |
+
baseQuery := config.DB.Table("orders").
|
| 96 |
+
Where("orders.payment_status = ? AND orders.created_at >= ? AND orders.created_at <= ?", "paid", startDt, endDt)
|
| 97 |
+
|
| 98 |
+
if companyIDStr != "" {
|
| 99 |
+
if cID, err := strconv.Atoi(companyIDStr); err == nil {
|
| 100 |
+
baseQuery = baseQuery.Where("orders.company_id = ?", cID)
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// 1. Summary keseluruhan
|
| 105 |
+
var summary FeeSummaryData
|
| 106 |
+
baseQuery.Select(`
|
| 107 |
+
count(orders.id) as total_transactions,
|
| 108 |
+
coalesce(sum(orders.total_amount), 0) as total_revenue,
|
| 109 |
+
coalesce(sum(orders.platform_fee), 0) as total_fee,
|
| 110 |
+
coalesce(sum(case when orders.payment_method = 'cash' then orders.total_amount else 0 end), 0) as total_cash,
|
| 111 |
+
coalesce(sum(case when orders.payment_method = 'midtrans' then orders.total_amount else 0 end), 0) as total_midtrans,
|
| 112 |
+
coalesce(sum(case when orders.payment_method = 'cash' then orders.platform_fee else 0 end), 0) as fee_cash,
|
| 113 |
+
coalesce(sum(case when orders.payment_method = 'midtrans' then orders.platform_fee else 0 end), 0) as fee_midtrans
|
| 114 |
+
`).Scan(&summary)
|
| 115 |
+
|
| 116 |
+
// 2. Summary per perusahaan
|
| 117 |
+
var perCompany []CompanyFeeRow
|
| 118 |
+
baseQuery.Select(`
|
| 119 |
+
orders.company_id,
|
| 120 |
+
companies.name as company_name,
|
| 121 |
+
companies.fee_percentage,
|
| 122 |
+
count(orders.id) as total_transactions,
|
| 123 |
+
coalesce(sum(orders.total_amount), 0) as total_revenue,
|
| 124 |
+
coalesce(sum(orders.platform_fee), 0) as total_fee,
|
| 125 |
+
coalesce(sum(case when orders.payment_method = 'cash' then orders.total_amount else 0 end), 0) as total_cash,
|
| 126 |
+
coalesce(sum(case when orders.payment_method = 'midtrans' then orders.total_amount else 0 end), 0) as total_midtrans
|
| 127 |
+
`).
|
| 128 |
+
Joins("join companies on companies.id = orders.company_id").
|
| 129 |
+
Group("orders.company_id, companies.name, companies.fee_percentage").
|
| 130 |
+
Order("total_fee desc").
|
| 131 |
+
Scan(&perCompany)
|
| 132 |
+
|
| 133 |
+
// 3. Summary per cabang
|
| 134 |
+
var perBranch []BranchFeeRow
|
| 135 |
+
baseQuery.Select(`
|
| 136 |
+
orders.company_id,
|
| 137 |
+
companies.name as company_name,
|
| 138 |
+
orders.branch_id,
|
| 139 |
+
coalesce(branches.name, 'Tanpa Cabang') as branch_name,
|
| 140 |
+
count(orders.id) as total_transactions,
|
| 141 |
+
coalesce(sum(orders.total_amount), 0) as total_revenue,
|
| 142 |
+
coalesce(sum(orders.platform_fee), 0) as total_fee,
|
| 143 |
+
coalesce(sum(case when orders.payment_method = 'cash' then orders.total_amount else 0 end), 0) as total_cash,
|
| 144 |
+
coalesce(sum(case when orders.payment_method = 'midtrans' then orders.total_amount else 0 end), 0) as total_midtrans
|
| 145 |
+
`).
|
| 146 |
+
Joins("join companies on companies.id = orders.company_id").
|
| 147 |
+
Joins("left join branches on branches.id = orders.branch_id").
|
| 148 |
+
Group("orders.company_id, companies.name, orders.branch_id, branches.name").
|
| 149 |
+
Order("orders.company_id, total_revenue desc").
|
| 150 |
+
Scan(&perBranch)
|
| 151 |
+
|
| 152 |
+
// 4. Daily breakdown
|
| 153 |
+
var dailyBreakdown []DailyFeeRow
|
| 154 |
+
// SQL DATE(created_at) di PostgreSQL
|
| 155 |
+
baseQuery.Select(`
|
| 156 |
+
DATE(orders.created_at) as day,
|
| 157 |
+
orders.company_id,
|
| 158 |
+
companies.name as company_name,
|
| 159 |
+
companies.fee_percentage,
|
| 160 |
+
count(orders.id) as total_transactions,
|
| 161 |
+
coalesce(sum(orders.total_amount), 0) as total_revenue,
|
| 162 |
+
coalesce(sum(orders.platform_fee), 0) as total_fee
|
| 163 |
+
`).
|
| 164 |
+
Joins("join companies on companies.id = orders.company_id").
|
| 165 |
+
Group("DATE(orders.created_at), orders.company_id, companies.name, companies.fee_percentage").
|
| 166 |
+
Order("day desc").
|
| 167 |
+
Scan(&dailyBreakdown)
|
| 168 |
+
|
| 169 |
+
// Buat daily_breakdown response yang clean format tanggalnya
|
| 170 |
+
type DailyBreakdownResp struct {
|
| 171 |
+
Date string `json:"date"`
|
| 172 |
+
CompanyID uint `json:"company_id"`
|
| 173 |
+
CompanyName string `json:"company_name"`
|
| 174 |
+
FeePercentage float64 `json:"fee_percentage"`
|
| 175 |
+
TotalTransactions int64 `json:"total_transactions"`
|
| 176 |
+
TotalRevenue int `json:"total_revenue"`
|
| 177 |
+
TotalFee int `json:"total_fee"`
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
var dailyResp []DailyBreakdownResp
|
| 181 |
+
for _, row := range dailyBreakdown {
|
| 182 |
+
dailyResp = append(dailyResp, DailyBreakdownResp{
|
| 183 |
+
Date: row.Day.Format("2006-01-02"),
|
| 184 |
+
CompanyID: row.CompanyID,
|
| 185 |
+
CompanyName: row.CompanyName,
|
| 186 |
+
FeePercentage: row.FeePercentage,
|
| 187 |
+
TotalTransactions: row.TotalTransactions,
|
| 188 |
+
TotalRevenue: row.TotalRevenue,
|
| 189 |
+
TotalFee: row.TotalFee,
|
| 190 |
+
})
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
c.JSON(http.StatusOK, gin.H{
|
| 194 |
+
"period": gin.H{
|
| 195 |
+
"date_from": dFrom.Format("2006-01-02"),
|
| 196 |
+
"date_to": dTo.Format("2006-01-02"),
|
| 197 |
+
},
|
| 198 |
+
"summary": summary,
|
| 199 |
+
"per_company": perCompany,
|
| 200 |
+
"per_branch": perBranch,
|
| 201 |
+
"daily_breakdown": dailyResp,
|
| 202 |
+
})
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
func SetCompanyFee(c *gin.Context) {
|
| 206 |
+
companyIDStr := c.Param("company_id")
|
| 207 |
+
companyID, err := strconv.Atoi(companyIDStr)
|
| 208 |
+
if err != nil {
|
| 209 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID perusahaan tidak valid"})
|
| 210 |
+
return
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
feePctStr := c.Query("fee_percentage")
|
| 214 |
+
if feePctStr == "" {
|
| 215 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Query parameter fee_percentage wajib diisi"})
|
| 216 |
+
return
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
feePct, err := strconv.ParseFloat(feePctStr, 64)
|
| 220 |
+
if err != nil || feePct < 0 || feePct > 100 {
|
| 221 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Fee percentage harus berupa angka antara 0 dan 100"})
|
| 222 |
+
return
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
var company models.Company
|
| 226 |
+
if err := config.DB.First(&company, companyID).Error; err != nil {
|
| 227 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Perusahaan tidak ditemukan"})
|
| 228 |
+
return
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
company.FeePercentage = feePct
|
| 232 |
+
if err := config.DB.Save(&company).Error; err != nil {
|
| 233 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan fee perusahaan"})
|
| 234 |
+
return
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
c.JSON(http.StatusOK, gin.H{
|
| 238 |
+
"message": fmt.Sprintf("Fee perusahaan '%s' berhasil diatur ke %.2f%%", company.Name, company.FeePercentage),
|
| 239 |
+
"company_id": company.ID,
|
| 240 |
+
"company_name": company.Name,
|
| 241 |
+
"fee_percentage": company.FeePercentage,
|
| 242 |
+
})
|
| 243 |
+
}
|
controllers/order_controller.go
ADDED
|
@@ -0,0 +1,759 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package controllers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"encoding/json"
|
| 5 |
+
"fmt"
|
| 6 |
+
"io"
|
| 7 |
+
"log"
|
| 8 |
+
"math/rand"
|
| 9 |
+
"net/http"
|
| 10 |
+
"strconv"
|
| 11 |
+
"time"
|
| 12 |
+
|
| 13 |
+
"service-warungpos-go/config"
|
| 14 |
+
"service-warungpos-go/models"
|
| 15 |
+
"service-warungpos-go/services"
|
| 16 |
+
|
| 17 |
+
"github.com/gin-gonic/gin"
|
| 18 |
+
"gorm.io/gorm"
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
// DTOs untuk Order & Payment
|
| 22 |
+
type OrderItemCreate struct {
|
| 23 |
+
ProductID uint `json:"product_id" binding:"required"`
|
| 24 |
+
Quantity int `json:"quantity" binding:"required,gt=0"`
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
type OrderCreateRequest struct {
|
| 28 |
+
BranchID *uint `json:"branch_id"`
|
| 29 |
+
Items []OrderItemCreate `json:"items" binding:"required,dive"`
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
type OrderItemResponse struct {
|
| 33 |
+
ID uint `json:"id"`
|
| 34 |
+
ProductID uint `json:"product_id"`
|
| 35 |
+
ProductName string `json:"product_name"`
|
| 36 |
+
Quantity int `json:"quantity"`
|
| 37 |
+
UnitPrice int `json:"unit_price"`
|
| 38 |
+
Subtotal int `json:"subtotal"`
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
type OrderResponse struct {
|
| 42 |
+
ID uint `json:"id"`
|
| 43 |
+
OrderNumber string `json:"order_number"`
|
| 44 |
+
BranchID *uint `json:"branch_id"`
|
| 45 |
+
BranchName string `json:"branch_name"`
|
| 46 |
+
TotalAmount int `json:"total_amount"`
|
| 47 |
+
PaidAmount int `json:"paid_amount"`
|
| 48 |
+
ChangeAmount int `json:"change_amount"`
|
| 49 |
+
PaymentMethod string `json:"payment_method"`
|
| 50 |
+
PaymentStatus string `json:"payment_status"`
|
| 51 |
+
MidtransOrderID string `json:"midtrans_order_id"`
|
| 52 |
+
MidtransToken string `json:"midtrans_token"`
|
| 53 |
+
Items []OrderItemResponse `json:"items"`
|
| 54 |
+
CreatedAt time.Time `json:"created_at"`
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
type CashPaymentRequest struct {
|
| 58 |
+
OrderID uint `json:"order_id" binding:"required"`
|
| 59 |
+
PaidAmount int `json:"paid_amount" binding:"required,gt=0"`
|
| 60 |
+
CustomerName string `json:"customer_name"`
|
| 61 |
+
CustomerPhone string `json:"customer_phone"`
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
type MidtransPaymentRequest struct {
|
| 65 |
+
OrderID uint `json:"order_id" binding:"required"`
|
| 66 |
+
CustomerName string `json:"customer_name"`
|
| 67 |
+
CustomerEmail string `json:"customer_email"`
|
| 68 |
+
CustomerPhone string `json:"customer_phone"`
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
type PaymentStatusResponse struct {
|
| 72 |
+
OrderID uint `json:"order_id"`
|
| 73 |
+
OrderNumber string `json:"order_number"`
|
| 74 |
+
PaymentStatus string `json:"payment_status"`
|
| 75 |
+
PaymentMethod string `json:"payment_method"`
|
| 76 |
+
TotalAmount int `json:"total_amount"`
|
| 77 |
+
PaidAmount int `json:"paid_amount"`
|
| 78 |
+
ChangeAmount int `json:"change_amount"`
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
func generateOrderNumber() string {
|
| 82 |
+
now := time.Now()
|
| 83 |
+
dateStr := now.Format("20060102")
|
| 84 |
+
// Hex unik dari random number
|
| 85 |
+
uniqueVal := rand.Intn(65536)
|
| 86 |
+
return fmt.Sprintf("WP-%s-%04X", dateStr, uniqueVal)
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
func toOrderResponse(order *models.Order) OrderResponse {
|
| 90 |
+
var itemsResp []OrderItemResponse
|
| 91 |
+
for _, item := range order.Items {
|
| 92 |
+
pName := "Produk Terhapus"
|
| 93 |
+
if item.Product != nil {
|
| 94 |
+
pName = item.Product.Name
|
| 95 |
+
}
|
| 96 |
+
itemsResp = append(itemsResp, OrderItemResponse{
|
| 97 |
+
ID: item.ID,
|
| 98 |
+
ProductID: item.ProductID,
|
| 99 |
+
ProductName: pName,
|
| 100 |
+
Quantity: item.Quantity,
|
| 101 |
+
UnitPrice: item.UnitPrice,
|
| 102 |
+
Subtotal: item.Subtotal,
|
| 103 |
+
})
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
bName := ""
|
| 107 |
+
if order.Branch != nil {
|
| 108 |
+
bName = order.Branch.Name
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
return OrderResponse{
|
| 112 |
+
ID: order.ID,
|
| 113 |
+
OrderNumber: order.OrderNumber,
|
| 114 |
+
BranchID: order.BranchID,
|
| 115 |
+
BranchName: bName,
|
| 116 |
+
TotalAmount: order.TotalAmount,
|
| 117 |
+
PaidAmount: order.PaidAmount,
|
| 118 |
+
ChangeAmount: order.ChangeAmount,
|
| 119 |
+
PaymentMethod: order.PaymentMethod,
|
| 120 |
+
PaymentStatus: order.PaymentStatus,
|
| 121 |
+
MidtransOrderID: order.MidtransOrderID,
|
| 122 |
+
MidtransToken: order.MidtransToken,
|
| 123 |
+
Items: itemsResp,
|
| 124 |
+
CreatedAt: order.CreatedAt,
|
| 125 |
+
}
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// ==================== ORDER CONTROLLER ====================
|
| 129 |
+
|
| 130 |
+
func CreateOrder(c *gin.Context) {
|
| 131 |
+
userVal, _ := c.Get("user")
|
| 132 |
+
user := userVal.(*models.User)
|
| 133 |
+
|
| 134 |
+
var req OrderCreateRequest
|
| 135 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 136 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 137 |
+
return
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// Tentukan branch_id yang berlaku
|
| 141 |
+
var effectiveBranchID *uint
|
| 142 |
+
if user.Role == "owner" {
|
| 143 |
+
effectiveBranchID = req.BranchID
|
| 144 |
+
} else {
|
| 145 |
+
effectiveBranchID = user.BranchID
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
orderNumber := generateOrderNumber()
|
| 149 |
+
|
| 150 |
+
tx := config.DB.Begin()
|
| 151 |
+
defer func() {
|
| 152 |
+
if r := recover(); r != nil {
|
| 153 |
+
tx.Rollback()
|
| 154 |
+
}
|
| 155 |
+
}()
|
| 156 |
+
|
| 157 |
+
order := models.Order{
|
| 158 |
+
CompanyID: *user.CompanyID,
|
| 159 |
+
BranchID: effectiveBranchID,
|
| 160 |
+
OrderNumber: orderNumber,
|
| 161 |
+
TotalAmount: 0,
|
| 162 |
+
PaymentStatus: "pending",
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
if err := tx.Create(&order).Error; err != nil {
|
| 166 |
+
tx.Rollback()
|
| 167 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan order"})
|
| 168 |
+
return
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
total := 0
|
| 172 |
+
for _, item := range req.Items {
|
| 173 |
+
var product models.Product
|
| 174 |
+
if err := tx.Where("id = ? AND company_id = ?", item.ProductID, *user.CompanyID).First(&product).Error; err != nil {
|
| 175 |
+
tx.Rollback()
|
| 176 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk dengan ID %d tidak ditemukan", item.ProductID)})
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
if !product.IsActive {
|
| 181 |
+
tx.Rollback()
|
| 182 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk '%s' sudah tidak aktif", product.Name)})
|
| 183 |
+
return
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
// Periksa dan kurangi stok jika stok terbatas (bukan unlimited)
|
| 187 |
+
if !product.IsUnlimitedStock {
|
| 188 |
+
if product.Stock < item.Quantity {
|
| 189 |
+
tx.Rollback()
|
| 190 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Stok '%s' tidak cukup (sisa: %d)", product.Name, product.Stock)})
|
| 191 |
+
return
|
| 192 |
+
}
|
| 193 |
+
product.Stock -= item.Quantity
|
| 194 |
+
if err := tx.Save(&product).Error; err != nil {
|
| 195 |
+
tx.Rollback()
|
| 196 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memperbarui stok produk"})
|
| 197 |
+
return
|
| 198 |
+
}
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
subtotal := product.Price * item.Quantity
|
| 202 |
+
orderItem := models.OrderItem{
|
| 203 |
+
OrderID: order.ID,
|
| 204 |
+
ProductID: product.ID,
|
| 205 |
+
Quantity: item.Quantity,
|
| 206 |
+
UnitPrice: product.Price,
|
| 207 |
+
Subtotal: subtotal,
|
| 208 |
+
}
|
| 209 |
+
if err := tx.Create(&orderItem).Error; err != nil {
|
| 210 |
+
tx.Rollback()
|
| 211 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan item order"})
|
| 212 |
+
return
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
total += subtotal
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// Update total amount order
|
| 219 |
+
order.TotalAmount = total
|
| 220 |
+
if err := tx.Save(&order).Error; err != nil {
|
| 221 |
+
tx.Rollback()
|
| 222 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update total transaksi"})
|
| 223 |
+
return
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
if err := tx.Commit().Error; err != nil {
|
| 227 |
+
tx.Rollback()
|
| 228 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memproses transaksi"})
|
| 229 |
+
return
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
// Load relationships untuk response
|
| 233 |
+
config.DB.Preload("Branch").Preload("Items.Product").First(&order, order.ID)
|
| 234 |
+
|
| 235 |
+
c.JSON(http.StatusCreated, toOrderResponse(&order))
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
func ListOrders(c *gin.Context) {
|
| 239 |
+
userVal, _ := c.Get("user")
|
| 240 |
+
user := userVal.(*models.User)
|
| 241 |
+
|
| 242 |
+
skipStr := c.DefaultQuery("skip", "0")
|
| 243 |
+
limitStr := c.DefaultQuery("limit", "50")
|
| 244 |
+
dateFrom := c.Query("date_from") // YYYY-MM-DD
|
| 245 |
+
dateTo := c.Query("date_to") // YYYY-MM-DD
|
| 246 |
+
branchFilter := c.Query("branch_id")
|
| 247 |
+
|
| 248 |
+
skip, _ := strconv.Atoi(skipStr)
|
| 249 |
+
limit, _ := strconv.Atoi(limitStr)
|
| 250 |
+
|
| 251 |
+
query := config.DB.Preload("Branch").Preload("Items.Product").
|
| 252 |
+
Where("company_id = ?", *user.CompanyID).
|
| 253 |
+
Order("created_at desc")
|
| 254 |
+
|
| 255 |
+
if dateFrom != "" {
|
| 256 |
+
dtFrom, err := time.Parse("2006-01-02", dateFrom)
|
| 257 |
+
if err == nil {
|
| 258 |
+
// Awal hari (00:00:00)
|
| 259 |
+
query = query.Where("created_at >= ?", dtFrom)
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
if dateTo != "" {
|
| 264 |
+
dtTo, err := time.Parse("2006-01-02", dateTo)
|
| 265 |
+
if err == nil {
|
| 266 |
+
// Akhir hari (23:59:59)
|
| 267 |
+
dtToEnd := dtTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
|
| 268 |
+
query = query.Where("created_at <= ?", dtToEnd)
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
if branchFilter != "" {
|
| 273 |
+
if bID, err := strconv.Atoi(branchFilter); err == nil {
|
| 274 |
+
query = query.Where("branch_id = ?", bID)
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
var orders []models.Order
|
| 279 |
+
if err := query.Offset(skip).Limit(limit).Find(&orders).Error; err != nil {
|
| 280 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil histori order"})
|
| 281 |
+
return
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
var resp []OrderResponse
|
| 285 |
+
for _, o := range orders {
|
| 286 |
+
resp = append(resp, toOrderResponse(&o))
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
c.JSON(http.StatusOK, resp)
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
func GetOrder(c *gin.Context) {
|
| 293 |
+
userVal, _ := c.Get("user")
|
| 294 |
+
user := userVal.(*models.User)
|
| 295 |
+
|
| 296 |
+
orderIDStr := c.Param("order_id")
|
| 297 |
+
orderID, err := strconv.Atoi(orderIDStr)
|
| 298 |
+
if err != nil {
|
| 299 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID order tidak valid"})
|
| 300 |
+
return
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
var order models.Order
|
| 304 |
+
if err := config.DB.Preload("Branch").Preload("Items.Product").Where("id = ? AND company_id = ?", orderID, *user.CompanyID).First(&order).Error; err != nil {
|
| 305 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"})
|
| 306 |
+
return
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
c.JSON(http.StatusOK, toOrderResponse(&order))
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
// ==================== PAYMENT CONTROLLER ====================
|
| 313 |
+
|
| 314 |
+
func PayCash(c *gin.Context) {
|
| 315 |
+
userVal, _ := c.Get("user")
|
| 316 |
+
user := userVal.(*models.User)
|
| 317 |
+
|
| 318 |
+
var req CashPaymentRequest
|
| 319 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 320 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 321 |
+
return
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
tx := config.DB.Begin()
|
| 325 |
+
defer func() {
|
| 326 |
+
if r := recover(); r != nil {
|
| 327 |
+
tx.Rollback()
|
| 328 |
+
}
|
| 329 |
+
}()
|
| 330 |
+
|
| 331 |
+
var order models.Order
|
| 332 |
+
if err := tx.Preload("Items.Product").Where("id = ? AND company_id = ?", req.OrderID, *user.CompanyID).First(&order).Error; err != nil {
|
| 333 |
+
tx.Rollback()
|
| 334 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"})
|
| 335 |
+
return
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
if order.PaymentStatus == "paid" {
|
| 339 |
+
tx.Rollback()
|
| 340 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"})
|
| 341 |
+
return
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
if req.PaidAmount < order.TotalAmount {
|
| 345 |
+
tx.Rollback()
|
| 346 |
+
c.JSON(http.StatusBadRequest, gin.H{
|
| 347 |
+
"detail": fmt.Sprintf("Uang kurang! Total: %s, Dibayar: %s, Kurang: %s",
|
| 348 |
+
fmtRupiah(order.TotalAmount),
|
| 349 |
+
fmtRupiah(req.PaidAmount),
|
| 350 |
+
fmtRupiah(order.TotalAmount-req.PaidAmount),
|
| 351 |
+
),
|
| 352 |
+
})
|
| 353 |
+
return
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
// Update order data
|
| 357 |
+
order.PaymentMethod = "cash"
|
| 358 |
+
order.PaymentStatus = "paid"
|
| 359 |
+
order.PaidAmount = req.PaidAmount
|
| 360 |
+
order.ChangeAmount = req.PaidAmount - order.TotalAmount
|
| 361 |
+
|
| 362 |
+
// Hitung fee platform berdasarkan Company
|
| 363 |
+
var company models.Company
|
| 364 |
+
if err := tx.First(&company, *user.CompanyID).Error; err == nil {
|
| 365 |
+
order.PlatformFee = int(float64(order.TotalAmount) * company.FeePercentage / 100)
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
if err := tx.Save(&order).Error; err != nil {
|
| 369 |
+
tx.Rollback()
|
| 370 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan pembayaran"})
|
| 371 |
+
return
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
if err := tx.Commit().Error; err != nil {
|
| 375 |
+
tx.Rollback()
|
| 376 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memproses pembayaran"})
|
| 377 |
+
return
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// Kirim Notifikasi WA (Async)
|
| 381 |
+
if req.CustomerPhone != "" {
|
| 382 |
+
go func() {
|
| 383 |
+
var waItems []services.WAItem
|
| 384 |
+
for _, item := range order.Items {
|
| 385 |
+
pName := "Item"
|
| 386 |
+
if item.Product != nil {
|
| 387 |
+
pName = item.Product.Name
|
| 388 |
+
}
|
| 389 |
+
waItems = append(waItems, services.WAItem{
|
| 390 |
+
Name: pName,
|
| 391 |
+
Quantity: item.Quantity,
|
| 392 |
+
Price: item.UnitPrice,
|
| 393 |
+
})
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
var branchName string
|
| 397 |
+
if order.BranchID != nil {
|
| 398 |
+
var branch models.Branch
|
| 399 |
+
if err := config.DB.First(&branch, *order.BranchID).Error; err == nil {
|
| 400 |
+
branchName = branch.Name
|
| 401 |
+
}
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
services.SendPaymentNotification(
|
| 405 |
+
req.CustomerPhone,
|
| 406 |
+
order.OrderNumber,
|
| 407 |
+
order.TotalAmount,
|
| 408 |
+
"cash",
|
| 409 |
+
req.CustomerName,
|
| 410 |
+
waItems,
|
| 411 |
+
order.ChangeAmount,
|
| 412 |
+
company.Name,
|
| 413 |
+
branchName,
|
| 414 |
+
)
|
| 415 |
+
}()
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
c.JSON(http.StatusOK, PaymentStatusResponse{
|
| 419 |
+
OrderID: order.ID,
|
| 420 |
+
OrderNumber: order.OrderNumber,
|
| 421 |
+
PaymentStatus: order.PaymentStatus,
|
| 422 |
+
PaymentMethod: order.PaymentMethod,
|
| 423 |
+
TotalAmount: order.TotalAmount,
|
| 424 |
+
PaidAmount: order.PaidAmount,
|
| 425 |
+
ChangeAmount: order.ChangeAmount,
|
| 426 |
+
})
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
func PayTripay(c *gin.Context) {
|
| 430 |
+
userVal, _ := c.Get("user")
|
| 431 |
+
user := userVal.(*models.User)
|
| 432 |
+
|
| 433 |
+
var req MidtransPaymentRequest // Gunakan request DTO yang sama karena field input sama
|
| 434 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 435 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 436 |
+
return
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
var order models.Order
|
| 440 |
+
if err := config.DB.Where("id = ? AND company_id = ?", req.OrderID, *user.CompanyID).First(&order).Error; err != nil {
|
| 441 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"})
|
| 442 |
+
return
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
if order.PaymentStatus == "paid" {
|
| 446 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"})
|
| 447 |
+
return
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
// Load order items untuk dikirim ke Tripay
|
| 451 |
+
var orderItems []models.OrderItem
|
| 452 |
+
config.DB.Preload("Product").Where("order_id = ?", order.ID).Find(&orderItems)
|
| 453 |
+
|
| 454 |
+
var tripayItems []services.TripayItem
|
| 455 |
+
for _, item := range orderItems {
|
| 456 |
+
skuStr := ""
|
| 457 |
+
if item.Product != nil && item.Product.SKU != nil {
|
| 458 |
+
skuStr = *item.Product.SKU
|
| 459 |
+
}
|
| 460 |
+
pName := "Product"
|
| 461 |
+
if item.Product != nil {
|
| 462 |
+
pName = item.Product.Name
|
| 463 |
+
}
|
| 464 |
+
tripayItems = append(tripayItems, services.TripayItem{
|
| 465 |
+
SKU: skuStr,
|
| 466 |
+
Name: pName,
|
| 467 |
+
Price: item.UnitPrice,
|
| 468 |
+
Quantity: item.Quantity,
|
| 469 |
+
})
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
// Request Tripay Transaction (Default ke QRIS yang paling populer)
|
| 473 |
+
tripayRes, err := services.CreateTripayTransaction(
|
| 474 |
+
order.OrderNumber,
|
| 475 |
+
order.TotalAmount,
|
| 476 |
+
"QRIS",
|
| 477 |
+
req.CustomerName,
|
| 478 |
+
req.CustomerEmail,
|
| 479 |
+
req.CustomerPhone,
|
| 480 |
+
tripayItems,
|
| 481 |
+
)
|
| 482 |
+
if err != nil {
|
| 483 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Gagal membuat transaksi Tripay: %v", err)})
|
| 484 |
+
return
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
// Simpan Tripay details ke DB
|
| 488 |
+
order.MidtransOrderID = tripayRes.Data.Reference // Simpan nomor referensi Tripay di midtrans_order_id demi kecocokan DB
|
| 489 |
+
order.MidtransToken = tripayRes.Data.CheckoutURL // Simpan checkout_url di midtrans_token demi kecocokan DB
|
| 490 |
+
order.PaymentMethod = "tripay"
|
| 491 |
+
config.DB.Save(&order)
|
| 492 |
+
|
| 493 |
+
// Kembalikan JSON yang kompatibel dengan format respons frontend agar tidak merusak UI kasir
|
| 494 |
+
c.JSON(http.StatusOK, gin.H{
|
| 495 |
+
"order_id": order.ID,
|
| 496 |
+
"order_number": order.OrderNumber,
|
| 497 |
+
"snap_token": tripayRes.Data.Reference,
|
| 498 |
+
"redirect_url": tripayRes.Data.CheckoutURL,
|
| 499 |
+
"total_amount": order.TotalAmount,
|
| 500 |
+
})
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
func TripayWebhook(c *gin.Context) {
|
| 504 |
+
// Baca raw body untuk verifikasi signature
|
| 505 |
+
rawBody, err := io.ReadAll(c.Request.Body)
|
| 506 |
+
if err != nil {
|
| 507 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Gagal membaca request body"})
|
| 508 |
+
return
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
// Parse JSON manual
|
| 512 |
+
var notification map[string]interface{}
|
| 513 |
+
if err := json.Unmarshal(rawBody, ¬ification); err != nil {
|
| 514 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Request body harus berupa JSON yang valid"})
|
| 515 |
+
return
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
// Validasi header signature
|
| 519 |
+
signature := c.GetHeader("X-Callback-Signature")
|
| 520 |
+
if signature == "" {
|
| 521 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Header X-Callback-Signature tidak ditemukan"})
|
| 522 |
+
return
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
log.Printf("[WEBHOOK] Tripay payload: %s", string(rawBody))
|
| 526 |
+
|
| 527 |
+
// Verifikasi Signature keaslian Tripay
|
| 528 |
+
if !services.VerifyTripaySignature(rawBody, signature) {
|
| 529 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Signature verifikasi gagal/tidak valid"})
|
| 530 |
+
return
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
merchantRefVal, ok := notification["merchant_ref"]
|
| 534 |
+
if !ok || merchantRefVal == nil {
|
| 535 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Payload webhook tidak valid: merchant_ref wajib ada"})
|
| 536 |
+
return
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
merchantRefStr := merchantRefVal.(string)
|
| 540 |
+
tripayStatus, _ := notification["status"].(string)
|
| 541 |
+
|
| 542 |
+
log.Printf("[WEBHOOK] Tripay: merchant_ref=%s, status=%s", merchantRefStr, tripayStatus)
|
| 543 |
+
|
| 544 |
+
tx := config.DB.Begin()
|
| 545 |
+
defer func() {
|
| 546 |
+
if r := recover(); r != nil {
|
| 547 |
+
tx.Rollback()
|
| 548 |
+
}
|
| 549 |
+
}()
|
| 550 |
+
|
| 551 |
+
var order models.Order
|
| 552 |
+
if err := tx.Preload("Items.Product").Where("order_number = ?", merchantRefStr).First(&order).Error; err != nil {
|
| 553 |
+
tx.Rollback()
|
| 554 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"})
|
| 555 |
+
return
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
oldStatus := order.PaymentStatus
|
| 559 |
+
|
| 560 |
+
// Map status Tripay: PAID, EXPIRED, FAILED
|
| 561 |
+
switch tripayStatus {
|
| 562 |
+
case "PAID":
|
| 563 |
+
order.PaymentStatus = "paid"
|
| 564 |
+
order.PaidAmount = order.TotalAmount
|
| 565 |
+
case "EXPIRED", "FAILED":
|
| 566 |
+
order.PaymentStatus = "failed"
|
| 567 |
+
if oldStatus != "failed" {
|
| 568 |
+
restoreStock(tx, &order)
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
// Jika sukses terbayar, hitung platform fee
|
| 573 |
+
if order.PaymentStatus == "paid" && oldStatus != "paid" {
|
| 574 |
+
var company models.Company
|
| 575 |
+
if err := tx.First(&company, order.CompanyID).Error; err == nil {
|
| 576 |
+
order.PlatformFee = int(float64(order.TotalAmount) * company.FeePercentage / 100)
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
if err := tx.Save(&order).Error; err != nil {
|
| 581 |
+
tx.Rollback()
|
| 582 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memproses status webhook"})
|
| 583 |
+
return
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
if err := tx.Commit().Error; err != nil {
|
| 587 |
+
tx.Rollback()
|
| 588 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan commit webhook"})
|
| 589 |
+
return
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
log.Printf("[WEBHOOK] Order %s di-update via Tripay: %s -> %s", order.OrderNumber, oldStatus, order.PaymentStatus)
|
| 593 |
+
|
| 594 |
+
// Kirim Notifikasi WA struk belanja jika pembayaran berhasil
|
| 595 |
+
if order.PaymentStatus == "paid" && oldStatus != "paid" {
|
| 596 |
+
go func() {
|
| 597 |
+
custPhone, _ := notification["customer_phone"].(string)
|
| 598 |
+
custName, _ := notification["customer_name"].(string)
|
| 599 |
+
if custName == "" {
|
| 600 |
+
custName = "Customer"
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
var waItems []services.WAItem
|
| 604 |
+
for _, item := range order.Items {
|
| 605 |
+
pName := "Item"
|
| 606 |
+
if item.Product != nil {
|
| 607 |
+
pName = item.Product.Name
|
| 608 |
+
}
|
| 609 |
+
waItems = append(waItems, services.WAItem{
|
| 610 |
+
Name: pName,
|
| 611 |
+
Quantity: item.Quantity,
|
| 612 |
+
Price: item.UnitPrice,
|
| 613 |
+
})
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
var company models.Company
|
| 617 |
+
config.DB.First(&company, order.CompanyID)
|
| 618 |
+
|
| 619 |
+
var branchName string
|
| 620 |
+
if order.BranchID != nil {
|
| 621 |
+
var branch models.Branch
|
| 622 |
+
if err := config.DB.First(&branch, *order.BranchID).Error; err == nil {
|
| 623 |
+
branchName = branch.Name
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
if custPhone != "" {
|
| 628 |
+
services.SendPaymentNotification(
|
| 629 |
+
custPhone,
|
| 630 |
+
order.OrderNumber,
|
| 631 |
+
order.TotalAmount,
|
| 632 |
+
"tripay",
|
| 633 |
+
custName,
|
| 634 |
+
waItems,
|
| 635 |
+
0,
|
| 636 |
+
company.Name,
|
| 637 |
+
branchName,
|
| 638 |
+
)
|
| 639 |
+
}
|
| 640 |
+
}()
|
| 641 |
+
}
|
| 642 |
+
|
| 643 |
+
c.JSON(http.StatusOK, gin.H{
|
| 644 |
+
"success": true,
|
| 645 |
+
"order_number": order.OrderNumber,
|
| 646 |
+
})
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
func GetPaymentStatus(c *gin.Context) {
|
| 650 |
+
userVal, _ := c.Get("user")
|
| 651 |
+
user := userVal.(*models.User)
|
| 652 |
+
|
| 653 |
+
orderIDStr := c.Param("order_id")
|
| 654 |
+
orderID, err := strconv.Atoi(orderIDStr)
|
| 655 |
+
if err != nil {
|
| 656 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID order tidak valid"})
|
| 657 |
+
return
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
var order models.Order
|
| 661 |
+
if err := config.DB.Where("id = ? AND company_id = ?", orderID, *user.CompanyID).First(&order).Error; err != nil {
|
| 662 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"})
|
| 663 |
+
return
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
c.JSON(http.StatusOK, PaymentStatusResponse{
|
| 667 |
+
OrderID: order.ID,
|
| 668 |
+
OrderNumber: order.OrderNumber,
|
| 669 |
+
PaymentStatus: order.PaymentStatus,
|
| 670 |
+
PaymentMethod: order.PaymentMethod,
|
| 671 |
+
TotalAmount: order.TotalAmount,
|
| 672 |
+
PaidAmount: order.PaidAmount,
|
| 673 |
+
ChangeAmount: order.ChangeAmount,
|
| 674 |
+
})
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
func CancelMidtransOrder(c *gin.Context) {
|
| 678 |
+
userVal, _ := c.Get("user")
|
| 679 |
+
user := userVal.(*models.User)
|
| 680 |
+
|
| 681 |
+
orderIDStr := c.Param("order_id")
|
| 682 |
+
orderID, err := strconv.Atoi(orderIDStr)
|
| 683 |
+
if err != nil {
|
| 684 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID order tidak valid"})
|
| 685 |
+
return
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
tx := config.DB.Begin()
|
| 689 |
+
defer func() {
|
| 690 |
+
if r := recover(); r != nil {
|
| 691 |
+
tx.Rollback()
|
| 692 |
+
}
|
| 693 |
+
}()
|
| 694 |
+
|
| 695 |
+
var order models.Order
|
| 696 |
+
if err := tx.Preload("Items.Product").Where("id = ? AND company_id = ?", orderID, *user.CompanyID).First(&order).Error; err != nil {
|
| 697 |
+
tx.Rollback()
|
| 698 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"})
|
| 699 |
+
return
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
if order.PaymentStatus == "paid" {
|
| 703 |
+
tx.Rollback()
|
| 704 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"})
|
| 705 |
+
return
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
if order.PaymentStatus == "cancelled" {
|
| 709 |
+
tx.Rollback()
|
| 710 |
+
c.JSON(http.StatusOK, gin.H{
|
| 711 |
+
"status": "cancelled",
|
| 712 |
+
"order_id": order.ID,
|
| 713 |
+
"order_number": order.OrderNumber,
|
| 714 |
+
"payment_status": order.PaymentStatus,
|
| 715 |
+
})
|
| 716 |
+
return
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
oldStatus := order.PaymentStatus
|
| 720 |
+
order.PaymentStatus = "cancelled"
|
| 721 |
+
|
| 722 |
+
if oldStatus != "cancelled" {
|
| 723 |
+
restoreStock(tx, &order)
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
if err := tx.Save(&order).Error; err != nil {
|
| 727 |
+
tx.Rollback()
|
| 728 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membatalkan order"})
|
| 729 |
+
return
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
if err := tx.Commit().Error; err != nil {
|
| 733 |
+
tx.Rollback()
|
| 734 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan proses pembatalan"})
|
| 735 |
+
return
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
c.JSON(http.StatusOK, gin.H{
|
| 739 |
+
"status": "cancelled",
|
| 740 |
+
"order_id": order.ID,
|
| 741 |
+
"order_number": order.OrderNumber,
|
| 742 |
+
"payment_status": order.PaymentStatus,
|
| 743 |
+
})
|
| 744 |
+
}
|
| 745 |
+
|
| 746 |
+
// Helpers
|
| 747 |
+
func restoreStock(tx *gorm.DB, order *models.Order) {
|
| 748 |
+
// GORM raw query atau update
|
| 749 |
+
for _, item := range order.Items {
|
| 750 |
+
var product models.Product
|
| 751 |
+
if err := tx.First(&product, item.ProductID).Error; err == nil {
|
| 752 |
+
if !product.IsUnlimitedStock {
|
| 753 |
+
product.Stock += item.Quantity
|
| 754 |
+
tx.Save(&product)
|
| 755 |
+
log.Printf("[RESTORE] Stok '%s' dikembalikan +%d (sekarang: %d)", product.Name, item.Quantity, product.Stock)
|
| 756 |
+
}
|
| 757 |
+
}
|
| 758 |
+
}
|
| 759 |
+
}
|
controllers/product_controller.go
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package controllers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strconv"
|
| 7 |
+
|
| 8 |
+
"service-warungpos-go/config"
|
| 9 |
+
"service-warungpos-go/models"
|
| 10 |
+
"service-warungpos-go/services"
|
| 11 |
+
|
| 12 |
+
"github.com/gin-gonic/gin"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
// DTOs untuk Kategori & Produk
|
| 16 |
+
type CategoryCreateRequest struct {
|
| 17 |
+
Name string `json:"name" binding:"required"`
|
| 18 |
+
Description string `json:"description"`
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
type CategoryResponse struct {
|
| 22 |
+
ID uint `json:"id"`
|
| 23 |
+
CompanyID uint `json:"company_id"`
|
| 24 |
+
Name string `json:"name"`
|
| 25 |
+
Description string `json:"description"`
|
| 26 |
+
CreatedAt string `json:"created_at"`
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
type ProductCreateRequest struct {
|
| 30 |
+
Name string `json:"name" binding:"required"`
|
| 31 |
+
SKU *string `json:"sku"`
|
| 32 |
+
Price int `json:"price" binding:"required"`
|
| 33 |
+
Stock int `json:"stock"`
|
| 34 |
+
IsUnlimitedStock bool `json:"is_unlimited_stock"`
|
| 35 |
+
CategoryID *uint `json:"category_id"`
|
| 36 |
+
ImageURL string `json:"image_url"`
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// ==================== IMAGE UPLOAD ====================
|
| 40 |
+
|
| 41 |
+
func UploadProductImage(c *gin.Context) {
|
| 42 |
+
file, err := c.FormFile("file")
|
| 43 |
+
if err != nil {
|
| 44 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "File tidak ditemukan dalam request"})
|
| 45 |
+
return
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Validasi jenis file
|
| 49 |
+
allowedTypes := map[string]bool{
|
| 50 |
+
"image/jpeg": true,
|
| 51 |
+
"image/png": true,
|
| 52 |
+
"image/webp": true,
|
| 53 |
+
"image/gif": true,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
contentType := file.Header.Get("Content-Type")
|
| 57 |
+
if !allowedTypes[contentType] {
|
| 58 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Hanya file gambar (JPG, PNG, WebP, GIF) yang diizinkan"})
|
| 59 |
+
return
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
// Validasi ukuran (maksimal 5MB)
|
| 63 |
+
if file.Size > 5*1024*1024 {
|
| 64 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Ukuran gambar maksimal 5MB"})
|
| 65 |
+
return
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Upload langsung ke Cloudinary
|
| 69 |
+
secureURL, err := services.UploadToCloudinary(file)
|
| 70 |
+
if err != nil {
|
| 71 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": fmt.Sprintf("Gagal upload gambar: %v", err)})
|
| 72 |
+
return
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
c.JSON(http.StatusOK, gin.H{
|
| 76 |
+
"image_url": secureURL,
|
| 77 |
+
})
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// ==================== CATEGORY CONTROLLER ====================
|
| 81 |
+
|
| 82 |
+
func ListCategories(c *gin.Context) {
|
| 83 |
+
userVal, _ := c.Get("user")
|
| 84 |
+
user := userVal.(*models.User)
|
| 85 |
+
|
| 86 |
+
var branchID *uint
|
| 87 |
+
// Periksa query param branch_id
|
| 88 |
+
if qBranchID := c.Query("branch_id"); qBranchID != "" {
|
| 89 |
+
if val, err := strconv.ParseUint(qBranchID, 10, 32); err == nil {
|
| 90 |
+
uVal := uint(val)
|
| 91 |
+
branchID = &uVal
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// Jika kasir, paksa filter berdasarkan branch_id kasir
|
| 96 |
+
if user.Role == "kasir" && user.BranchID != nil {
|
| 97 |
+
branchID = user.BranchID
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
var allCategories []models.Category
|
| 101 |
+
if err := config.DB.Where("company_id = ?", *user.CompanyID).Find(&allCategories).Error; err != nil {
|
| 102 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data kategori"})
|
| 103 |
+
return
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// Jika tidak ada filter branch_id, kembalikan semua kategori langsung
|
| 107 |
+
if branchID == nil {
|
| 108 |
+
c.JSON(http.StatusOK, allCategories)
|
| 109 |
+
return
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// Ambil daftar product_id yang tidak tersedia di cabang ini
|
| 113 |
+
var unavailableProductIDs []uint
|
| 114 |
+
config.DB.Model(&models.BranchProduct{}).
|
| 115 |
+
Where("branch_id = ? AND is_available = ?", *branchID, false).
|
| 116 |
+
Pluck("product_id", &unavailableProductIDs)
|
| 117 |
+
|
| 118 |
+
// Cari kategori dari produk aktif yang tersedia di cabang ini
|
| 119 |
+
var availableCategoryIDs []uint
|
| 120 |
+
query := config.DB.Model(&models.Product{}).
|
| 121 |
+
Where("company_id = ? AND is_active = ?", *user.CompanyID, true)
|
| 122 |
+
|
| 123 |
+
if len(unavailableProductIDs) > 0 {
|
| 124 |
+
query = query.Where("id NOT IN ?", unavailableProductIDs)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
query.Pluck("distinct category_id", &availableCategoryIDs)
|
| 128 |
+
|
| 129 |
+
// Filter allCategories agar hanya mengembalikan kategori yang memiliki produk tersedia di cabang
|
| 130 |
+
catMap := make(map[uint]bool)
|
| 131 |
+
for _, catID := range availableCategoryIDs {
|
| 132 |
+
catMap[catID] = true
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
var resp []models.Category
|
| 136 |
+
for _, cat := range allCategories {
|
| 137 |
+
if catMap[cat.ID] {
|
| 138 |
+
resp = append(resp, cat)
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
c.JSON(http.StatusOK, resp)
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
func GetCategory(c *gin.Context) {
|
| 146 |
+
userVal, _ := c.Get("user")
|
| 147 |
+
user := userVal.(*models.User)
|
| 148 |
+
|
| 149 |
+
catIDStr := c.Param("category_id")
|
| 150 |
+
catID, err := strconv.Atoi(catIDStr)
|
| 151 |
+
if err != nil {
|
| 152 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kategori tidak valid"})
|
| 153 |
+
return
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
var category models.Category
|
| 157 |
+
if err := config.DB.Where("id = ? AND company_id = ?", catID, *user.CompanyID).First(&category).Error; err != nil {
|
| 158 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Kategori tidak ditemukan"})
|
| 159 |
+
return
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
c.JSON(http.StatusOK, category)
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
func CreateCategory(c *gin.Context) {
|
| 166 |
+
userVal, _ := c.Get("user")
|
| 167 |
+
user := userVal.(*models.User)
|
| 168 |
+
|
| 169 |
+
var req CategoryCreateRequest
|
| 170 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 171 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Cek duplikasi nama kategori di perusahaan yang sama
|
| 176 |
+
var count int64
|
| 177 |
+
config.DB.Model(&models.Category{}).Where("name = ? AND company_id = ?", req.Name, *user.CompanyID).Count(&count)
|
| 178 |
+
if count > 0 {
|
| 179 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Kategori '%s' sudah ada", req.Name)})
|
| 180 |
+
return
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
newCat := models.Category{
|
| 184 |
+
CompanyID: *user.CompanyID,
|
| 185 |
+
Name: req.Name,
|
| 186 |
+
Description: req.Description,
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
if err := config.DB.Create(&newCat).Error; err != nil {
|
| 190 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan kategori baru"})
|
| 191 |
+
return
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
c.JSON(http.StatusCreated, newCat)
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
func UpdateCategory(c *gin.Context) {
|
| 198 |
+
userVal, _ := c.Get("user")
|
| 199 |
+
user := userVal.(*models.User)
|
| 200 |
+
|
| 201 |
+
catIDStr := c.Param("category_id")
|
| 202 |
+
catID, err := strconv.Atoi(catIDStr)
|
| 203 |
+
if err != nil {
|
| 204 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kategori tidak valid"})
|
| 205 |
+
return
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
var req CategoryCreateRequest
|
| 209 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 210 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 211 |
+
return
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
var category models.Category
|
| 215 |
+
if err := config.DB.Where("id = ? AND company_id = ?", catID, *user.CompanyID).First(&category).Error; err != nil {
|
| 216 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Kategori tidak ditemukan"})
|
| 217 |
+
return
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
if req.Name != "" {
|
| 221 |
+
var count int64
|
| 222 |
+
config.DB.Model(&models.Category{}).Where("name = ? AND company_id = ? AND id != ?", req.Name, *user.CompanyID, catID).Count(&count)
|
| 223 |
+
if count > 0 {
|
| 224 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Kategori '%s' sudah ada", req.Name)})
|
| 225 |
+
return
|
| 226 |
+
}
|
| 227 |
+
category.Name = req.Name
|
| 228 |
+
}
|
| 229 |
+
category.Description = req.Description
|
| 230 |
+
|
| 231 |
+
if err := config.DB.Save(&category).Error; err != nil {
|
| 232 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update kategori"})
|
| 233 |
+
return
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
c.JSON(http.StatusOK, category)
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
func DeleteCategory(c *gin.Context) {
|
| 240 |
+
userVal, _ := c.Get("user")
|
| 241 |
+
user := userVal.(*models.User)
|
| 242 |
+
|
| 243 |
+
catIDStr := c.Param("category_id")
|
| 244 |
+
catID, err := strconv.Atoi(catIDStr)
|
| 245 |
+
if err != nil {
|
| 246 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID kategori tidak valid"})
|
| 247 |
+
return
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
var category models.Category
|
| 251 |
+
if err := config.DB.Preload("Products").Where("id = ? AND company_id = ?", catID, *user.CompanyID).First(&category).Error; err != nil {
|
| 252 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Kategori tidak ditemukan"})
|
| 253 |
+
return
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
// Cek apakah masih ada produk aktif/tidak aktif terikat ke kategori ini
|
| 257 |
+
if len(category.Products) > 0 {
|
| 258 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Kategori masih memiliki %d produk. Pindahkan produk dulu.", len(category.Products))})
|
| 259 |
+
return
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
if err := config.DB.Delete(&category).Error; err != nil {
|
| 263 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menghapus kategori"})
|
| 264 |
+
return
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
c.JSON(http.StatusOK, gin.H{
|
| 268 |
+
"message": fmt.Sprintf("Kategori '%s' berhasil dihapus", category.Name),
|
| 269 |
+
})
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// ==================== PRODUCT CONTROLLER ====================
|
| 273 |
+
|
| 274 |
+
func ListProducts(c *gin.Context) {
|
| 275 |
+
userVal, _ := c.Get("user")
|
| 276 |
+
user := userVal.(*models.User)
|
| 277 |
+
|
| 278 |
+
var branchID *uint
|
| 279 |
+
// Cek filter branch_id query param (owner di POS)
|
| 280 |
+
if qBranchID := c.Query("branch_id"); qBranchID != "" {
|
| 281 |
+
if val, err := strconv.ParseUint(qBranchID, 10, 32); err == nil {
|
| 282 |
+
uVal := uint(val)
|
| 283 |
+
branchID = &uVal
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
// Kasir terikat branch
|
| 288 |
+
if user.Role == "kasir" && user.BranchID != nil {
|
| 289 |
+
branchID = user.BranchID
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
categoryFilter := c.Query("category_id")
|
| 293 |
+
activeOnlyStr := c.DefaultQuery("active_only", "true")
|
| 294 |
+
activeOnly := activeOnlyStr == "true"
|
| 295 |
+
|
| 296 |
+
var products []models.Product
|
| 297 |
+
query := config.DB.Preload("Category").Where("company_id = ?", *user.CompanyID)
|
| 298 |
+
|
| 299 |
+
if activeOnly {
|
| 300 |
+
query = query.Where("is_active = ?", true)
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
if categoryFilter != "" {
|
| 304 |
+
if catID, err := strconv.Atoi(categoryFilter); err == nil {
|
| 305 |
+
query = query.Where("category_id = ?", catID)
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
if err := query.Find(&products).Error; err != nil {
|
| 310 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data produk"})
|
| 311 |
+
return
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
// Filter ketersediaan cabang (BranchProduct)
|
| 315 |
+
if branchID != nil {
|
| 316 |
+
var branchProducts []models.BranchProduct
|
| 317 |
+
config.DB.Where("branch_id = ?", *branchID).Find(&branchProducts)
|
| 318 |
+
|
| 319 |
+
bpMap := make(map[uint]bool)
|
| 320 |
+
for _, bp := range branchProducts {
|
| 321 |
+
bpMap[bp.ProductID] = bp.IsAvailable
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
var filteredProducts []models.Product
|
| 325 |
+
for _, p := range products {
|
| 326 |
+
isAvailable, exists := bpMap[p.ID]
|
| 327 |
+
// Jika belum disetting di cabang (exists=false), default-nya adalah tersedia (isAvailable=true)
|
| 328 |
+
if !exists || isAvailable {
|
| 329 |
+
filteredProducts = append(filteredProducts, p)
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
c.JSON(http.StatusOK, filteredProducts)
|
| 333 |
+
return
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
c.JSON(http.StatusOK, products)
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
func GetProduct(c *gin.Context) {
|
| 340 |
+
userVal, _ := c.Get("user")
|
| 341 |
+
user := userVal.(*models.User)
|
| 342 |
+
|
| 343 |
+
productIDStr := c.Param("product_id")
|
| 344 |
+
productID, err := strconv.Atoi(productIDStr)
|
| 345 |
+
if err != nil {
|
| 346 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"})
|
| 347 |
+
return
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
var product models.Product
|
| 351 |
+
if err := config.DB.Preload("Category").Where("id = ? AND company_id = ?", productID, *user.CompanyID).First(&product).Error; err != nil {
|
| 352 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"})
|
| 353 |
+
return
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
c.JSON(http.StatusOK, product)
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
func CreateProduct(c *gin.Context) {
|
| 360 |
+
userVal, _ := c.Get("user")
|
| 361 |
+
user := userVal.(*models.User)
|
| 362 |
+
|
| 363 |
+
var req ProductCreateRequest
|
| 364 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 365 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 366 |
+
return
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// Verifikasi SKU jika diisi
|
| 370 |
+
if req.SKU != nil && *req.SKU != "" {
|
| 371 |
+
var count int64
|
| 372 |
+
config.DB.Model(&models.Product{}).Where("sku = ?", *req.SKU).Count(&count)
|
| 373 |
+
if count > 0 {
|
| 374 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk dengan SKU '%s' sudah ada", *req.SKU)})
|
| 375 |
+
return
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
newProduct := models.Product{
|
| 380 |
+
CompanyID: *user.CompanyID,
|
| 381 |
+
Name: req.Name,
|
| 382 |
+
SKU: req.SKU,
|
| 383 |
+
Price: req.Price,
|
| 384 |
+
Stock: req.Stock,
|
| 385 |
+
IsUnlimitedStock: req.IsUnlimitedStock,
|
| 386 |
+
CategoryID: req.CategoryID,
|
| 387 |
+
ImageURL: req.ImageURL,
|
| 388 |
+
IsActive: true,
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
if err := config.DB.Create(&newProduct).Error; err != nil {
|
| 392 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan produk baru"})
|
| 393 |
+
return
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
// Preload Category sebelum me-return response agar response-nya lengkap
|
| 397 |
+
config.DB.Preload("Category").First(&newProduct, newProduct.ID)
|
| 398 |
+
|
| 399 |
+
c.JSON(http.StatusCreated, newProduct)
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
func UpdateProduct(c *gin.Context) {
|
| 403 |
+
userVal, _ := c.Get("user")
|
| 404 |
+
user := userVal.(*models.User)
|
| 405 |
+
|
| 406 |
+
productIDStr := c.Param("product_id")
|
| 407 |
+
productID, err := strconv.Atoi(productIDStr)
|
| 408 |
+
if err != nil {
|
| 409 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"})
|
| 410 |
+
return
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
var req ProductCreateRequest
|
| 414 |
+
if err := c.ShouldBindJSON(&req); err != nil {
|
| 415 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
| 416 |
+
return
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
var product models.Product
|
| 420 |
+
if err := config.DB.Where("id = ? AND company_id = ?", productID, *user.CompanyID).First(&product).Error; err != nil {
|
| 421 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"})
|
| 422 |
+
return
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
// Verifikasi SKU jika berubah
|
| 426 |
+
if req.SKU != nil && *req.SKU != "" && (product.SKU == nil || *product.SKU != *req.SKU) {
|
| 427 |
+
var count int64
|
| 428 |
+
config.DB.Model(&models.Product{}).Where("sku = ? AND id != ?", *req.SKU, productID).Count(&count)
|
| 429 |
+
if count > 0 {
|
| 430 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk dengan SKU '%s' sudah ada", *req.SKU)})
|
| 431 |
+
return
|
| 432 |
+
}
|
| 433 |
+
product.SKU = req.SKU
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
product.Name = req.Name
|
| 437 |
+
product.Price = req.Price
|
| 438 |
+
product.Stock = req.Stock
|
| 439 |
+
product.IsUnlimitedStock = req.IsUnlimitedStock
|
| 440 |
+
product.CategoryID = req.CategoryID
|
| 441 |
+
|
| 442 |
+
if req.ImageURL != "" {
|
| 443 |
+
product.ImageURL = req.ImageURL
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
if err := config.DB.Save(&product).Error; err != nil {
|
| 447 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update produk"})
|
| 448 |
+
return
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
config.DB.Preload("Category").First(&product, product.ID)
|
| 452 |
+
|
| 453 |
+
c.JSON(http.StatusOK, product)
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
func DeleteProduct(c *gin.Context) {
|
| 457 |
+
userVal, _ := c.Get("user")
|
| 458 |
+
user := userVal.(*models.User)
|
| 459 |
+
|
| 460 |
+
productIDStr := c.Param("product_id")
|
| 461 |
+
productID, err := strconv.Atoi(productIDStr)
|
| 462 |
+
if err != nil {
|
| 463 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "ID produk tidak valid"})
|
| 464 |
+
return
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
var product models.Product
|
| 468 |
+
if err := config.DB.Where("id = ? AND company_id = ?", productID, *user.CompanyID).First(&product).Error; err != nil {
|
| 469 |
+
c.JSON(http.StatusNotFound, gin.H{"detail": "Produk tidak ditemukan"})
|
| 470 |
+
return
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
// Soft delete (matikan is_active)
|
| 474 |
+
product.IsActive = false
|
| 475 |
+
if err := config.DB.Save(&product).Error; err != nil {
|
| 476 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menghapus produk"})
|
| 477 |
+
return
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
c.JSON(http.StatusOK, gin.H{
|
| 481 |
+
"message": "Produk berhasil dinonaktifkan",
|
| 482 |
+
})
|
| 483 |
+
}
|
controllers/report_controller.go
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package controllers
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"fmt"
|
| 5 |
+
"net/http"
|
| 6 |
+
"strconv"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"service-warungpos-go/config"
|
| 11 |
+
"service-warungpos-go/models"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
type TopProductRow struct {
|
| 17 |
+
ProductName string `json:"product_name"`
|
| 18 |
+
TotalQuantity int `json:"total_quantity"`
|
| 19 |
+
TotalRevenue int `json:"total_revenue"`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
type SummaryTopProduct struct {
|
| 23 |
+
Name string `json:"name"`
|
| 24 |
+
TotalSold int `json:"total_sold"`
|
| 25 |
+
TotalRevenue int `json:"total_revenue"`
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
type ReportOrderRow struct {
|
| 29 |
+
OrderNumber string `json:"order_number"`
|
| 30 |
+
TotalAmount int `json:"total_amount"`
|
| 31 |
+
PaymentMethod string `json:"payment_method"`
|
| 32 |
+
CreatedAt time.Time `json:"created_at"`
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
func getDayRange(targetDate time.Time) (time.Time, time.Time) {
|
| 36 |
+
// Buat range awal dan akhir hari di timezone lokal/WIB (Asia/Jakarta)
|
| 37 |
+
loc, _ := time.LoadLocation("Asia/Jakarta")
|
| 38 |
+
if loc == nil {
|
| 39 |
+
loc = time.Local
|
| 40 |
+
}
|
| 41 |
+
start := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, loc)
|
| 42 |
+
end := start.Add(24 * time.Hour)
|
| 43 |
+
return start, end
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
func fmtRupiah(amount int) string {
|
| 47 |
+
// Helper formatting rupiah untuk internal log atau respons
|
| 48 |
+
str := fmt.Sprintf("%d", amount)
|
| 49 |
+
var result []string
|
| 50 |
+
length := len(str)
|
| 51 |
+
for i := length; i > 0; i -= 3 {
|
| 52 |
+
start := i - 3
|
| 53 |
+
if start < 0 {
|
| 54 |
+
start = 0
|
| 55 |
+
}
|
| 56 |
+
result = append([]string{str[start:i]}, result...)
|
| 57 |
+
}
|
| 58 |
+
return "Rp " + strings.Join(result, ".")
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Helper strings.Join untuk formatting di atas
|
| 62 |
+
|
| 63 |
+
func DailyReport(c *gin.Context) {
|
| 64 |
+
userVal, _ := c.Get("user")
|
| 65 |
+
user := userVal.(*models.User)
|
| 66 |
+
|
| 67 |
+
dateQuery := c.Query("date") // YYYY-MM-DD
|
| 68 |
+
branchQuery := c.Query("branch_id")
|
| 69 |
+
|
| 70 |
+
var targetDate time.Time
|
| 71 |
+
var err error
|
| 72 |
+
if dateQuery != "" {
|
| 73 |
+
targetDate, err = time.Parse("2006-01-02", dateQuery)
|
| 74 |
+
if err != nil {
|
| 75 |
+
c.JSON(http.StatusBadRequest, gin.H{"detail": "Format tanggal salah, gunakan YYYY-MM-DD"})
|
| 76 |
+
return
|
| 77 |
+
}
|
| 78 |
+
} else {
|
| 79 |
+
// Default: Hari ini
|
| 80 |
+
targetDate = time.Now()
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
startDt, endDt := getDayRange(targetDate)
|
| 84 |
+
|
| 85 |
+
query := config.DB.Where("company_id = ? AND created_at >= ? AND created_at < ? AND payment_status = ?", *user.CompanyID, startDt, endDt, "paid")
|
| 86 |
+
|
| 87 |
+
if branchQuery != "" {
|
| 88 |
+
if bID, err := strconv.Atoi(branchQuery); err == nil {
|
| 89 |
+
query = query.Where("branch_id = ?", bID)
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
var orders []models.Order
|
| 94 |
+
if err := query.Find(&orders).Error; err != nil {
|
| 95 |
+
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil data laporan harian"})
|
| 96 |
+
return
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
totalRevenue := 0
|
| 100 |
+
totalCash := 0
|
| 101 |
+
totalMidtrans := 0
|
| 102 |
+
totalPlatformFee := 0
|
| 103 |
+
|
| 104 |
+
var orderRows []ReportOrderRow
|
| 105 |
+
for _, o := range orders {
|
| 106 |
+
totalRevenue += o.TotalAmount
|
| 107 |
+
if o.PaymentMethod == "cash" {
|
| 108 |
+
totalCash += o.TotalAmount
|
| 109 |
+
} else if o.PaymentMethod == "midtrans" {
|
| 110 |
+
totalMidtrans += o.TotalAmount
|
| 111 |
+
}
|
| 112 |
+
totalPlatformFee += o.PlatformFee
|
| 113 |
+
|
| 114 |
+
orderRows = append(orderRows, ReportOrderRow{
|
| 115 |
+
OrderNumber: o.OrderNumber,
|
| 116 |
+
TotalAmount: o.TotalAmount,
|
| 117 |
+
PaymentMethod: o.PaymentMethod,
|
| 118 |
+
CreatedAt: o.CreatedAt,
|
| 119 |
+
})
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Query Top 5 produk terlaris hari ini
|
| 123 |
+
var topProducts []TopProductRow
|
| 124 |
+
topQuery := config.DB.Table("products").
|
| 125 |
+
Select("products.name as product_name, sum(order_items.quantity) as total_quantity, sum(order_items.subtotal) as total_revenue").
|
| 126 |
+
Joins("join order_items on products.id = order_items.product_id").
|
| 127 |
+
Joins("join orders on order_items.order_id = orders.id").
|
| 128 |
+
Where("orders.company_id = ? AND orders.payment_status = ? AND orders.created_at >= ? AND orders.created_at < ?", *user.CompanyID, "paid", startDt, endDt)
|
| 129 |
+
|
| 130 |
+
if branchQuery != "" {
|
| 131 |
+
if bID, err := strconv.Atoi(branchQuery); err == nil {
|
| 132 |
+
topQuery = topQuery.Where("orders.branch_id = ?", bID)
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
topQuery.Group("products.name").
|
| 137 |
+
Order("total_quantity desc").
|
| 138 |
+
Limit(5).
|
| 139 |
+
Scan(&topProducts)
|
| 140 |
+
|
| 141 |
+
c.JSON(http.StatusOK, gin.H{
|
| 142 |
+
"date": targetDate.Format("2006-01-02"),
|
| 143 |
+
"total_transactions": len(orders),
|
| 144 |
+
"total_revenue": totalRevenue,
|
| 145 |
+
"total_cash": totalCash,
|
| 146 |
+
"total_midtrans": totalMidtrans,
|
| 147 |
+
"total_platform_fee": totalPlatformFee,
|
| 148 |
+
"top_products": topProducts,
|
| 149 |
+
"orders": orderRows,
|
| 150 |
+
})
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
func SummaryReport(c *gin.Context) {
|
| 154 |
+
userVal, _ := c.Get("user")
|
| 155 |
+
user := userVal.(*models.User)
|
| 156 |
+
|
| 157 |
+
branchQuery := c.Query("branch_id")
|
| 158 |
+
|
| 159 |
+
// 1. Total Semua Transaksi Sukses
|
| 160 |
+
totalOrders := int64(0)
|
| 161 |
+
totalRevenue := int64(0)
|
| 162 |
+
|
| 163 |
+
totalQuery := config.DB.Model(&models.Order{}).
|
| 164 |
+
Where("company_id = ? AND payment_status = ?", *user.CompanyID, "paid")
|
| 165 |
+
|
| 166 |
+
if branchQuery != "" {
|
| 167 |
+
if bID, err := strconv.Atoi(branchQuery); err == nil {
|
| 168 |
+
totalQuery = totalQuery.Where("branch_id = ?", bID)
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
totalQuery.Count(&totalOrders)
|
| 173 |
+
totalQuery.Select("coalesce(sum(total_amount), 0)").Row().Scan(&totalRevenue)
|
| 174 |
+
|
| 175 |
+
// 2. Total Produk Aktif yang Tersedia
|
| 176 |
+
var totalProducts int64
|
| 177 |
+
prodQuery := config.DB.Model(&models.Product{}).
|
| 178 |
+
Where("company_id = ? AND is_active = ?", *user.CompanyID, true)
|
| 179 |
+
|
| 180 |
+
if branchQuery != "" {
|
| 181 |
+
if bID, err := strconv.Atoi(branchQuery); err == nil {
|
| 182 |
+
// Subquery pengecualian produk tidak aktif di cabang ini
|
| 183 |
+
subQuery := config.DB.Model(&models.BranchProduct{}).
|
| 184 |
+
Select("product_id").
|
| 185 |
+
Where("branch_id = ? AND is_available = ?", bID, false)
|
| 186 |
+
prodQuery = prodQuery.Where("id NOT IN (?)", subQuery)
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
prodQuery.Count(&totalProducts)
|
| 190 |
+
|
| 191 |
+
// 3. Top 5 Produk Terlaris All-Time
|
| 192 |
+
var topProducts []SummaryTopProduct
|
| 193 |
+
topQuery := config.DB.Table("products").
|
| 194 |
+
Select("products.name as name, sum(order_items.quantity) as total_sold, sum(order_items.subtotal) as total_revenue").
|
| 195 |
+
Joins("join order_items on products.id = order_items.product_id").
|
| 196 |
+
Joins("join orders on order_items.order_id = orders.id").
|
| 197 |
+
Where("orders.company_id = ? AND orders.payment_status = ?", *user.CompanyID, "paid")
|
| 198 |
+
|
| 199 |
+
if branchQuery != "" {
|
| 200 |
+
if bID, err := strconv.Atoi(branchQuery); err == nil {
|
| 201 |
+
topQuery = topQuery.Where("orders.branch_id = ?", bID)
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
topQuery.Group("products.name").
|
| 206 |
+
Order("total_sold desc").
|
| 207 |
+
Limit(5).
|
| 208 |
+
Scan(&topProducts)
|
| 209 |
+
|
| 210 |
+
// 4. Laporan Hari Ini
|
| 211 |
+
todayOrders := int64(0)
|
| 212 |
+
todayRevenue := int64(0)
|
| 213 |
+
|
| 214 |
+
startToday, endToday := getDayRange(time.Now())
|
| 215 |
+
|
| 216 |
+
todayQuery := config.DB.Model(&models.Order{}).
|
| 217 |
+
Where("company_id = ? AND payment_status = ? AND created_at >= ? AND created_at < ?", *user.CompanyID, "paid", startToday, endToday)
|
| 218 |
+
|
| 219 |
+
if branchQuery != "" {
|
| 220 |
+
if bID, err := strconv.Atoi(branchQuery); err == nil {
|
| 221 |
+
todayQuery = todayQuery.Where("branch_id = ?", bID)
|
| 222 |
+
}
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
todayQuery.Count(&todayOrders)
|
| 226 |
+
todayQuery.Select("coalesce(sum(total_amount), 0)").Row().Scan(&todayRevenue)
|
| 227 |
+
|
| 228 |
+
c.JSON(http.StatusOK, gin.H{
|
| 229 |
+
"all_time": gin.H{
|
| 230 |
+
"total_orders": totalOrders,
|
| 231 |
+
"total_revenue": totalRevenue,
|
| 232 |
+
},
|
| 233 |
+
"today": gin.H{
|
| 234 |
+
"total_orders": todayOrders,
|
| 235 |
+
"total_revenue": todayRevenue,
|
| 236 |
+
},
|
| 237 |
+
"total_products": totalProducts,
|
| 238 |
+
"top_products": topProducts,
|
| 239 |
+
})
|
| 240 |
+
}
|
go.mod
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
module service-warungpos-go
|
| 2 |
+
|
| 3 |
+
go 1.26.3
|
| 4 |
+
|
| 5 |
+
require (
|
| 6 |
+
github.com/bytedance/gopkg v0.1.3 // indirect
|
| 7 |
+
github.com/bytedance/sonic v1.15.0 // indirect
|
| 8 |
+
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
| 9 |
+
github.com/cloudinary/cloudinary-go/v2 v2.16.0 // indirect
|
| 10 |
+
github.com/cloudwego/base64x v0.1.6 // indirect
|
| 11 |
+
github.com/creasty/defaults v1.7.0 // indirect
|
| 12 |
+
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
| 13 |
+
github.com/gin-contrib/sse v1.1.0 // indirect
|
| 14 |
+
github.com/gin-gonic/gin v1.12.0 // indirect
|
| 15 |
+
github.com/go-playground/locales v0.14.1 // indirect
|
| 16 |
+
github.com/go-playground/universal-translator v0.18.1 // indirect
|
| 17 |
+
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
| 18 |
+
github.com/goccy/go-json v0.10.5 // indirect
|
| 19 |
+
github.com/goccy/go-yaml v1.19.2 // indirect
|
| 20 |
+
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
| 21 |
+
github.com/google/uuid v1.5.0 // indirect
|
| 22 |
+
github.com/gorilla/schema v1.4.1 // indirect
|
| 23 |
+
github.com/jackc/pgpassfile v1.0.0 // indirect
|
| 24 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
| 25 |
+
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
| 26 |
+
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
| 27 |
+
github.com/jinzhu/inflection v1.0.0 // indirect
|
| 28 |
+
github.com/jinzhu/now v1.1.5 // indirect
|
| 29 |
+
github.com/joho/godotenv v1.5.1 // indirect
|
| 30 |
+
github.com/json-iterator/go v1.1.12 // indirect
|
| 31 |
+
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
| 32 |
+
github.com/leodido/go-urn v1.4.0 // indirect
|
| 33 |
+
github.com/mattn/go-isatty v0.0.20 // indirect
|
| 34 |
+
github.com/midtrans/midtrans-go v1.3.8 // indirect
|
| 35 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
| 36 |
+
github.com/modern-go/reflect2 v1.0.2 // indirect
|
| 37 |
+
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
| 38 |
+
github.com/quic-go/qpack v0.6.0 // indirect
|
| 39 |
+
github.com/quic-go/quic-go v0.59.0 // indirect
|
| 40 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
| 41 |
+
github.com/ugorji/go/codec v1.3.1 // indirect
|
| 42 |
+
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
| 43 |
+
golang.org/x/arch v0.22.0 // indirect
|
| 44 |
+
golang.org/x/crypto v0.52.0 // indirect
|
| 45 |
+
golang.org/x/net v0.54.0 // indirect
|
| 46 |
+
golang.org/x/sync v0.20.0 // indirect
|
| 47 |
+
golang.org/x/sys v0.45.0 // indirect
|
| 48 |
+
golang.org/x/text v0.37.0 // indirect
|
| 49 |
+
google.golang.org/protobuf v1.36.10 // indirect
|
| 50 |
+
gorm.io/driver/postgres v1.6.0 // indirect
|
| 51 |
+
gorm.io/gorm v1.31.1 // indirect
|
| 52 |
+
)
|
go.sum
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
| 2 |
+
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
| 3 |
+
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
| 4 |
+
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
| 5 |
+
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
| 6 |
+
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
| 7 |
+
github.com/cloudinary/cloudinary-go/v2 v2.16.0 h1:0irPbKwRB6V6sdP9+a4P4D5sRUYHXriG5GfTjVq4YBI=
|
| 8 |
+
github.com/cloudinary/cloudinary-go/v2 v2.16.0/go.mod h1:ireC4gqVetsjVhYlwjUJwKTbZuWjEIynbR9zQTlqsvo=
|
| 9 |
+
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
| 10 |
+
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
| 11 |
+
github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA=
|
| 12 |
+
github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM=
|
| 13 |
+
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 14 |
+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
| 15 |
+
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
| 16 |
+
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
| 17 |
+
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
| 18 |
+
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
| 19 |
+
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
| 20 |
+
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
| 21 |
+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
| 22 |
+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
| 23 |
+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
| 24 |
+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
| 25 |
+
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
| 26 |
+
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
| 27 |
+
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
| 28 |
+
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
| 29 |
+
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
| 30 |
+
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
| 31 |
+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
| 32 |
+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
| 33 |
+
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
| 34 |
+
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
| 35 |
+
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
| 36 |
+
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
|
| 37 |
+
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
|
| 38 |
+
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
| 39 |
+
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
| 40 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
| 41 |
+
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
| 42 |
+
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
| 43 |
+
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
| 44 |
+
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
| 45 |
+
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
| 46 |
+
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
| 47 |
+
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
| 48 |
+
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
| 49 |
+
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
| 50 |
+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
| 51 |
+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
| 52 |
+
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
| 53 |
+
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
| 54 |
+
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
| 55 |
+
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
| 56 |
+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
| 57 |
+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
| 58 |
+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
| 59 |
+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
| 60 |
+
github.com/midtrans/midtrans-go v1.3.8 h1:r6eq51LJwbMQ05dBF3Twg99u45G3pLxP5INYoqOoNzU=
|
| 61 |
+
github.com/midtrans/midtrans-go v1.3.8/go.mod h1:5hN2oiZDP3/SwSBxHPTg8eC/RVoRE9DXQOY1Ah9au10=
|
| 62 |
+
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 63 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
| 64 |
+
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
| 65 |
+
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
| 66 |
+
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
| 67 |
+
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
| 68 |
+
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
| 69 |
+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
| 70 |
+
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
| 71 |
+
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
| 72 |
+
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
| 73 |
+
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
| 74 |
+
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
| 75 |
+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
| 76 |
+
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
| 77 |
+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
| 78 |
+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
| 79 |
+
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 80 |
+
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
| 81 |
+
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
| 82 |
+
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
| 83 |
+
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
| 84 |
+
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
| 85 |
+
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
| 86 |
+
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
| 87 |
+
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
| 88 |
+
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
| 89 |
+
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
| 90 |
+
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
| 91 |
+
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
| 92 |
+
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
| 93 |
+
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
| 94 |
+
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
| 95 |
+
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
| 96 |
+
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
| 97 |
+
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
| 98 |
+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
| 99 |
+
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
| 100 |
+
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
| 101 |
+
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
| 102 |
+
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
| 103 |
+
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
| 104 |
+
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
| 105 |
+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
| 106 |
+
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 107 |
+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
| 108 |
+
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
| 109 |
+
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
| 110 |
+
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
| 111 |
+
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
main.go
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package main
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"log"
|
| 5 |
+
"net/http"
|
| 6 |
+
|
| 7 |
+
"service-warungpos-go/config"
|
| 8 |
+
"service-warungpos-go/controllers"
|
| 9 |
+
"service-warungpos-go/middleware"
|
| 10 |
+
"service-warungpos-go/models"
|
| 11 |
+
|
| 12 |
+
"github.com/gin-gonic/gin"
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
func main() {
|
| 16 |
+
log.Println("Starting WarungPOS Go Service...")
|
| 17 |
+
|
| 18 |
+
// 1. Muat Config
|
| 19 |
+
config.LoadConfig()
|
| 20 |
+
|
| 21 |
+
// 2. Koneksi Database Supabase
|
| 22 |
+
config.ConnectDatabase()
|
| 23 |
+
|
| 24 |
+
// 3. Auto-Migration GORM untuk kelancaran Supabase DB
|
| 25 |
+
log.Println("Running Auto-Migration...")
|
| 26 |
+
err := config.DB.AutoMigrate(
|
| 27 |
+
&models.Company{},
|
| 28 |
+
&models.Branch{},
|
| 29 |
+
&models.User{},
|
| 30 |
+
&models.Category{},
|
| 31 |
+
&models.Product{},
|
| 32 |
+
&models.BranchProduct{},
|
| 33 |
+
&models.Order{},
|
| 34 |
+
&models.OrderItem{},
|
| 35 |
+
&models.OTP{},
|
| 36 |
+
)
|
| 37 |
+
if err != nil {
|
| 38 |
+
log.Fatalf("Gagal melakukan Auto-Migration: %v", err)
|
| 39 |
+
}
|
| 40 |
+
log.Println("Auto-Migration Selesai dengan Sukses!")
|
| 41 |
+
|
| 42 |
+
// 4. Seeding super_admin default jika belum ada
|
| 43 |
+
controllers.SeedDefaultAdmin()
|
| 44 |
+
|
| 45 |
+
// 5. Tripay integration (No SDK init needed, we use plain HTTP Client!)
|
| 46 |
+
|
| 47 |
+
// 6. Router Setup menggunakan Gin
|
| 48 |
+
r := gin.New()
|
| 49 |
+
|
| 50 |
+
// Global Middlewares
|
| 51 |
+
r.Use(gin.Logger())
|
| 52 |
+
r.Use(gin.Recovery())
|
| 53 |
+
r.Use(middleware.CORSMiddleware())
|
| 54 |
+
|
| 55 |
+
// Health Checks & Health check root
|
| 56 |
+
r.GET("/", func(c *gin.Context) {
|
| 57 |
+
c.JSON(http.StatusOK, gin.H{
|
| 58 |
+
"service": "WarungPOS API (Go Version)",
|
| 59 |
+
"version": "1.0.0",
|
| 60 |
+
"status": "running",
|
| 61 |
+
"docs": "/docs",
|
| 62 |
+
})
|
| 63 |
+
})
|
| 64 |
+
|
| 65 |
+
r.GET("/health", func(c *gin.Context) {
|
| 66 |
+
c.JSON(http.StatusOK, gin.H{"status": "healthy"})
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
// API Routing Group
|
| 70 |
+
api := r.Group("/api")
|
| 71 |
+
{
|
| 72 |
+
// === AUTHENTICATION ROUTES ===
|
| 73 |
+
auth := api.Group("/auth")
|
| 74 |
+
{
|
| 75 |
+
auth.POST("/login", controllers.Login)
|
| 76 |
+
auth.POST("/register-tenant", controllers.RegisterTenant)
|
| 77 |
+
auth.POST("/verify-otp", controllers.VerifyOTP)
|
| 78 |
+
auth.POST("/resend-otp", controllers.ResendOTP)
|
| 79 |
+
|
| 80 |
+
// Authenticated Auth Routes
|
| 81 |
+
auth.Use(middleware.GetCurrentUser())
|
| 82 |
+
{
|
| 83 |
+
auth.GET("/me", controllers.GetMe)
|
| 84 |
+
auth.POST("/register", middleware.RequireSuperAdmin(), controllers.Register)
|
| 85 |
+
auth.GET("/users", middleware.RequireSuperAdmin(), controllers.ListUsers)
|
| 86 |
+
auth.PUT("/users/:user_id", middleware.RequireSuperAdmin(), controllers.UpdateUser)
|
| 87 |
+
|
| 88 |
+
// Owner routes for kasir
|
| 89 |
+
auth.GET("/kasir", middleware.RequireOwner(), controllers.ListKasir)
|
| 90 |
+
auth.POST("/kasir", middleware.RequireOwner(), controllers.CreateKasir)
|
| 91 |
+
auth.PUT("/kasir/:user_id", middleware.RequireOwner(), controllers.UpdateKasir)
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
// === COMPANY & BRANCH ROUTES ===
|
| 96 |
+
companies := api.Group("/companies")
|
| 97 |
+
{
|
| 98 |
+
companies.Use(middleware.GetCurrentUser())
|
| 99 |
+
{
|
| 100 |
+
companies.GET("/me", controllers.GetMyCompany)
|
| 101 |
+
companies.GET("/", middleware.RequireSuperAdmin(), controllers.ListCompanies)
|
| 102 |
+
companies.GET("/:company_id", middleware.RequireSuperAdmin(), controllers.GetCompany)
|
| 103 |
+
companies.POST("/", middleware.RequireSuperAdmin(), controllers.CreateCompany)
|
| 104 |
+
companies.PUT("/:company_id", middleware.RequireSuperAdmin(), controllers.UpdateCompany)
|
| 105 |
+
companies.PUT("/:company_id/toggle-active", middleware.RequireSuperAdmin(), controllers.ToggleActiveCompany)
|
| 106 |
+
companies.DELETE("/:company_id", middleware.RequireSuperAdmin(), controllers.DeleteCompany)
|
| 107 |
+
companies.PUT("/:company_id/reject", middleware.RequireSuperAdmin(), controllers.RejectCompany)
|
| 108 |
+
companies.PUT("/:company_id/approve", middleware.RequireSuperAdmin(), controllers.ApproveCompany)
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
branches := api.Group("/branches")
|
| 113 |
+
{
|
| 114 |
+
branches.Use(middleware.GetCurrentUser(), middleware.RequireOwner())
|
| 115 |
+
{
|
| 116 |
+
branches.GET("/", controllers.ListBranches)
|
| 117 |
+
branches.POST("/", controllers.CreateBranch)
|
| 118 |
+
branches.PUT("/:branch_id", controllers.UpdateBranch)
|
| 119 |
+
branches.DELETE("/:branch_id", controllers.DeleteBranch)
|
| 120 |
+
branches.PUT("/:branch_id/toggle", controllers.ToggleBranchStatus)
|
| 121 |
+
branches.GET("/:branch_id/products", controllers.GetBranchProducts)
|
| 122 |
+
branches.PUT("/:branch_id/products/:product_id", controllers.ToggleProductAvailability)
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// === PRODUCTS & CATEGORIES ROUTES ===
|
| 127 |
+
products := api.Group("/products")
|
| 128 |
+
{
|
| 129 |
+
products.Use(middleware.GetCurrentUser())
|
| 130 |
+
{
|
| 131 |
+
products.POST("/upload-image", middleware.RequireOwner(), controllers.UploadProductImage)
|
| 132 |
+
products.GET("/", middleware.RequireTenantContext(), controllers.ListProducts)
|
| 133 |
+
products.GET("/:product_id", middleware.RequireTenantContext(), controllers.GetProduct)
|
| 134 |
+
products.POST("/", middleware.RequireOwner(), controllers.CreateProduct)
|
| 135 |
+
products.PUT("/:product_id", middleware.RequireOwner(), controllers.UpdateProduct)
|
| 136 |
+
products.DELETE("/:product_id", middleware.RequireOwner(), controllers.DeleteProduct)
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
categories := api.Group("/categories")
|
| 141 |
+
{
|
| 142 |
+
categories.Use(middleware.GetCurrentUser())
|
| 143 |
+
{
|
| 144 |
+
categories.GET("/", middleware.RequireTenantContext(), controllers.ListCategories)
|
| 145 |
+
categories.GET("/:category_id", middleware.RequireTenantContext(), controllers.GetCategory)
|
| 146 |
+
categories.POST("/", middleware.RequireOwner(), controllers.CreateCategory)
|
| 147 |
+
categories.PUT("/:category_id", middleware.RequireOwner(), controllers.UpdateCategory)
|
| 148 |
+
categories.DELETE("/:category_id", middleware.RequireOwner(), controllers.DeleteCategory)
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
// === ORDERS ROUTES ===
|
| 153 |
+
orders := api.Group("/orders")
|
| 154 |
+
{
|
| 155 |
+
orders.Use(middleware.GetCurrentUser(), middleware.RequireTenantContext())
|
| 156 |
+
{
|
| 157 |
+
orders.POST("/", controllers.CreateOrder)
|
| 158 |
+
orders.GET("/", controllers.ListOrders)
|
| 159 |
+
orders.GET("/:order_id", controllers.GetOrder)
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
// === PAYMENTS ROUTES ===
|
| 164 |
+
payments := api.Group("/payments")
|
| 165 |
+
{
|
| 166 |
+
// Webhook Tripay harus publik agar server Tripay bisa mengirim callbacks
|
| 167 |
+
payments.POST("/webhook", controllers.TripayWebhook)
|
| 168 |
+
|
| 169 |
+
payments.Use(middleware.GetCurrentUser(), middleware.RequireTenantContext())
|
| 170 |
+
{
|
| 171 |
+
payments.POST("/cash", controllers.PayCash)
|
| 172 |
+
payments.POST("/midtrans", controllers.PayTripay) // Keep /midtrans for backwards compatibility with front-end
|
| 173 |
+
payments.POST("/tripay", controllers.PayTripay) // Also expose /tripay natively
|
| 174 |
+
payments.GET("/:order_id/status", controllers.GetPaymentStatus)
|
| 175 |
+
payments.POST("/:order_id/cancel", controllers.CancelMidtransOrder)
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// === REPORTS ROUTES ===
|
| 180 |
+
reports := api.Group("/reports")
|
| 181 |
+
{
|
| 182 |
+
reports.Use(middleware.GetCurrentUser(), middleware.RequireTenantContext())
|
| 183 |
+
{
|
| 184 |
+
reports.GET("/daily", controllers.DailyReport)
|
| 185 |
+
reports.GET("/summary", controllers.SummaryReport)
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// === FEES (SUPER ADMIN ONLY) ROUTES ===
|
| 190 |
+
fees := api.Group("/fees")
|
| 191 |
+
{
|
| 192 |
+
fees.Use(middleware.GetCurrentUser(), middleware.RequireSuperAdmin())
|
| 193 |
+
{
|
| 194 |
+
fees.GET("/report", controllers.GetFeeReport)
|
| 195 |
+
fees.PUT("/companies/:company_id/set-fee", controllers.SetCompanyFee)
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
// 7. Mulai Server (Membaca dinamis port Hugging Face Spaces jika ada)
|
| 201 |
+
serverPort := config.GlobalConfig.Port
|
| 202 |
+
if serverPort == "" {
|
| 203 |
+
serverPort = "8003"
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
log.Printf("WarungPOS Go Server berjalan di port: %s", serverPort)
|
| 207 |
+
if err := r.Run(":" + serverPort); err != nil {
|
| 208 |
+
log.Fatalf("Gagal menjalankan server Gin: %v", err)
|
| 209 |
+
}
|
| 210 |
+
}
|
middleware/auth.go
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package middleware
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"errors"
|
| 5 |
+
"fmt"
|
| 6 |
+
"net/http"
|
| 7 |
+
"strings"
|
| 8 |
+
"time"
|
| 9 |
+
|
| 10 |
+
"service-warungpos-go/config"
|
| 11 |
+
"service-warungpos-go/models"
|
| 12 |
+
|
| 13 |
+
"github.com/gin-gonic/gin"
|
| 14 |
+
"github.com/golang-jwt/jwt/v5"
|
| 15 |
+
"golang.org/x/crypto/bcrypt"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
type JWTClaims struct {
|
| 19 |
+
Sub uint `json:"sub"`
|
| 20 |
+
Role string `json:"role"`
|
| 21 |
+
CompanyID *uint `json:"company_id"`
|
| 22 |
+
jwt.RegisteredClaims
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Security Helpers
|
| 26 |
+
func HashPassword(password string) (string, error) {
|
| 27 |
+
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
| 28 |
+
return string(bytes), err
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
func VerifyPassword(password, hash string) bool {
|
| 32 |
+
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
| 33 |
+
return err == nil
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
func CreateAccessToken(userID uint, role string, companyID *uint) (string, error) {
|
| 37 |
+
expiryHours := config.GlobalConfig.JWTExpiryHours
|
| 38 |
+
if expiryHours == 0 {
|
| 39 |
+
expiryHours = 24
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
claims := JWTClaims{
|
| 43 |
+
Sub: userID,
|
| 44 |
+
Role: role,
|
| 45 |
+
CompanyID: companyID,
|
| 46 |
+
RegisteredClaims: jwt.RegisteredClaims{
|
| 47 |
+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expiryHours) * time.Hour)),
|
| 48 |
+
IssuedAt: jwt.NewNumericDate(time.Now()),
|
| 49 |
+
},
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
| 53 |
+
return token.SignedString([]byte(config.GlobalConfig.JWTSecretKey))
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
func ParseToken(tokenString string) (*JWTClaims, error) {
|
| 57 |
+
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
| 58 |
+
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
| 59 |
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
| 60 |
+
}
|
| 61 |
+
return []byte(config.GlobalConfig.JWTSecretKey), nil
|
| 62 |
+
})
|
| 63 |
+
|
| 64 |
+
if err != nil {
|
| 65 |
+
return nil, err
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
| 69 |
+
return claims, nil
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
return nil, errors.New("invalid token claims")
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Gin Middlewares
|
| 76 |
+
func GetCurrentUser() gin.HandlerFunc {
|
| 77 |
+
return func(c *gin.Context) {
|
| 78 |
+
authHeader := c.GetHeader("Authorization")
|
| 79 |
+
if authHeader == "" {
|
| 80 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Token otorisasi diperlukan"})
|
| 81 |
+
c.Abort()
|
| 82 |
+
return
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
parts := strings.Split(authHeader, " ")
|
| 86 |
+
if len(parts) != 2 || parts[0] != "Bearer" {
|
| 87 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Format token otorisasi salah"})
|
| 88 |
+
c.Abort()
|
| 89 |
+
return
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
tokenString := parts[1]
|
| 93 |
+
claims, err := ParseToken(tokenString)
|
| 94 |
+
if err != nil {
|
| 95 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": fmt.Sprintf("Token error: %v", err)})
|
| 96 |
+
c.Abort()
|
| 97 |
+
return
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
var user models.User
|
| 101 |
+
if err := config.DB.Preload("Company").Preload("Branch").First(&user, claims.Sub).Error; err != nil {
|
| 102 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "User tidak ditemukan"})
|
| 103 |
+
c.Abort()
|
| 104 |
+
return
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if !user.IsActive {
|
| 108 |
+
c.JSON(http.StatusForbidden, gin.H{"detail": "Akun Anda dinonaktifkan. Hubungi Administrator."})
|
| 109 |
+
c.Abort()
|
| 110 |
+
return
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
// Simpan user ke context Gin
|
| 114 |
+
c.Set("user", &user)
|
| 115 |
+
c.Next()
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
func RequireSuperAdmin() gin.HandlerFunc {
|
| 120 |
+
return func(c *gin.Context) {
|
| 121 |
+
userVal, exists := c.Get("user")
|
| 122 |
+
if !exists {
|
| 123 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Hubungan otorisasi gagal"})
|
| 124 |
+
c.Abort()
|
| 125 |
+
return
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
user := userVal.(*models.User)
|
| 129 |
+
if user.Role != "super_admin" {
|
| 130 |
+
c.JSON(http.StatusForbidden, gin.H{"detail": "Akses ditolak: Hanya super_admin yang memiliki izin."})
|
| 131 |
+
c.Abort()
|
| 132 |
+
return
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
c.Next()
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
func RequireOwner() gin.HandlerFunc {
|
| 140 |
+
return func(c *gin.Context) {
|
| 141 |
+
userVal, exists := c.Get("user")
|
| 142 |
+
if !exists {
|
| 143 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Hubungan otorisasi gagal"})
|
| 144 |
+
c.Abort()
|
| 145 |
+
return
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
user := userVal.(*models.User)
|
| 149 |
+
if user.Role != "owner" && user.Role != "super_admin" { // Super admin biasakan lolos untuk CRUD company
|
| 150 |
+
c.JSON(http.StatusForbidden, gin.H{"detail": "Akses ditolak: Hanya owner yang memiliki izin."})
|
| 151 |
+
c.Abort()
|
| 152 |
+
return
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
c.Next()
|
| 156 |
+
}
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
func RequireTenantContext() gin.HandlerFunc {
|
| 160 |
+
return func(c *gin.Context) {
|
| 161 |
+
userVal, exists := c.Get("user")
|
| 162 |
+
if !exists {
|
| 163 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Hubungan otorisasi gagal"})
|
| 164 |
+
c.Abort()
|
| 165 |
+
return
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
user := userVal.(*models.User)
|
| 169 |
+
if user.CompanyID == nil {
|
| 170 |
+
c.JSON(http.StatusForbidden, gin.H{"detail": "Akses ditolak: User tidak terikat ke perusahaan/toko manapun."})
|
| 171 |
+
c.Abort()
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
c.Next()
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
func RequireAuthenticated() gin.HandlerFunc {
|
| 180 |
+
return func(c *gin.Context) {
|
| 181 |
+
_, exists := c.Get("user")
|
| 182 |
+
if !exists {
|
| 183 |
+
c.JSON(http.StatusUnauthorized, gin.H{"detail": "Hubungan otorisasi gagal"})
|
| 184 |
+
c.Abort()
|
| 185 |
+
return
|
| 186 |
+
}
|
| 187 |
+
c.Next()
|
| 188 |
+
}
|
| 189 |
+
}
|
middleware/cors.go
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package middleware
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"github.com/gin-gonic/gin"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
func CORSMiddleware() gin.HandlerFunc {
|
| 8 |
+
return func(c *gin.Context) {
|
| 9 |
+
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
| 10 |
+
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
| 11 |
+
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
| 12 |
+
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
|
| 13 |
+
|
| 14 |
+
if c.Request.Method == "OPTIONS" {
|
| 15 |
+
c.AbortWithStatus(204)
|
| 16 |
+
return
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
c.Next()
|
| 20 |
+
}
|
| 21 |
+
}
|
models/models.go
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package models
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"time"
|
| 5 |
+
)
|
| 6 |
+
|
| 7 |
+
type Company struct {
|
| 8 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 9 |
+
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
| 10 |
+
BankAccountNumber string `gorm:"type:varchar(50)" json:"bank_account_number"`
|
| 11 |
+
BankName string `gorm:"type:varchar(100)" json:"bank_name"`
|
| 12 |
+
Address string `gorm:"type:varchar(255)" json:"address"`
|
| 13 |
+
Email string `gorm:"type:varchar(100)" json:"email"`
|
| 14 |
+
Phone string `gorm:"type:varchar(20)" json:"phone"`
|
| 15 |
+
LogoURL string `gorm:"type:varchar(255)" json:"logo_url"`
|
| 16 |
+
IsActive bool `gorm:"default:true" json:"is_active"`
|
| 17 |
+
FeePercentage float64 `gorm:"type:float;default:0.0;not null" json:"fee_percentage"`
|
| 18 |
+
ApprovedAt *time.Time `json:"approved_at"`
|
| 19 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 20 |
+
Branches []Branch `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
|
| 21 |
+
Users []User `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
|
| 22 |
+
Categories []Category `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
|
| 23 |
+
Products []Product `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
|
| 24 |
+
Orders []Order `gorm:"foreignKey:CompanyID;constraint:OnDelete:CASCADE" json:"-"`
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
type Branch struct {
|
| 28 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 29 |
+
CompanyID uint `gorm:"not null;index" json:"company_id"`
|
| 30 |
+
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
| 31 |
+
Address string `gorm:"type:varchar(255)" json:"address"`
|
| 32 |
+
Phone string `gorm:"type:varchar(20)" json:"phone"`
|
| 33 |
+
IsActive bool `gorm:"default:true" json:"is_active"`
|
| 34 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 35 |
+
Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
|
| 36 |
+
Users []User `gorm:"foreignKey:BranchID;constraint:OnDelete:SET NULL" json:"-"`
|
| 37 |
+
Orders []Order `gorm:"foreignKey:BranchID;constraint:OnDelete:SET NULL" json:"-"`
|
| 38 |
+
BranchProducts []BranchProduct `gorm:"foreignKey:BranchID;constraint:OnDelete:CASCADE" json:"-"`
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
type User struct {
|
| 42 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 43 |
+
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
| 44 |
+
Email string `gorm:"type:varchar(100);uniqueIndex;not null" json:"email"`
|
| 45 |
+
Password string `gorm:"type:varchar(255);not null" json:"-"`
|
| 46 |
+
Phone string `gorm:"type:varchar(20)" json:"phone"`
|
| 47 |
+
Role string `gorm:"type:varchar(20);default:'kasir'" json:"role"` // "super_admin", "owner", "kasir"
|
| 48 |
+
CompanyID *uint `json:"company_id"`
|
| 49 |
+
BranchID *uint `json:"branch_id"`
|
| 50 |
+
IsActive bool `gorm:"default:true" json:"is_active"`
|
| 51 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 52 |
+
Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
|
| 53 |
+
Branch *Branch `gorm:"foreignKey:BranchID" json:"branch,omitempty"`
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
type Category struct {
|
| 57 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 58 |
+
CompanyID uint `gorm:"not null" json:"company_id"`
|
| 59 |
+
Name string `gorm:"type:varchar(100);not null" json:"name"`
|
| 60 |
+
Description string `gorm:"type:varchar(255)" json:"description"`
|
| 61 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 62 |
+
Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
|
| 63 |
+
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
type Product struct {
|
| 67 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 68 |
+
CompanyID uint `gorm:"not null" json:"company_id"`
|
| 69 |
+
Name string `gorm:"type:varchar(200);not null" json:"name"`
|
| 70 |
+
SKU *string `gorm:"type:varchar(50);uniqueIndex" json:"sku"`
|
| 71 |
+
Price int `gorm:"not null" json:"price"`
|
| 72 |
+
Stock int `gorm:"not null;default:0" json:"stock"`
|
| 73 |
+
IsUnlimitedStock bool `gorm:"default:false" json:"is_unlimited_stock"`
|
| 74 |
+
CategoryID *uint `json:"category_id"`
|
| 75 |
+
ImageURL string `gorm:"type:text" json:"image_url"`
|
| 76 |
+
IsActive bool `gorm:"default:true" json:"is_active"`
|
| 77 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 78 |
+
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
| 79 |
+
Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
|
| 80 |
+
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
| 81 |
+
BranchProducts []BranchProduct `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE" json:"-"`
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
type BranchProduct struct {
|
| 85 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 86 |
+
BranchID uint `gorm:"uniqueIndex:uq_branch_product;not null" json:"branch_id"`
|
| 87 |
+
ProductID uint `gorm:"uniqueIndex:uq_branch_product;not null" json:"product_id"`
|
| 88 |
+
IsAvailable bool `gorm:"default:true" json:"is_available"`
|
| 89 |
+
Branch *Branch `gorm:"foreignKey:BranchID" json:"branch,omitempty"`
|
| 90 |
+
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
type Order struct {
|
| 94 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 95 |
+
CompanyID uint `gorm:"not null" json:"company_id"`
|
| 96 |
+
BranchID *uint `json:"branch_id"`
|
| 97 |
+
OrderNumber string `gorm:"type:varchar(50);not null;index" json:"order_number"`
|
| 98 |
+
TotalAmount int `gorm:"not null;default:0" json:"total_amount"`
|
| 99 |
+
PaidAmount int `gorm:"not null;default:0" json:"paid_amount"`
|
| 100 |
+
ChangeAmount int `gorm:"not null;default:0" json:"change_amount"`
|
| 101 |
+
PaymentMethod string `gorm:"type:varchar(20)" json:"payment_method"`
|
| 102 |
+
PaymentStatus string `gorm:"type:varchar(20);default:'pending'" json:"payment_status"` // pending, paid, failed, expired, refunded, cancelled
|
| 103 |
+
MidtransOrderID string `gorm:"type:varchar(100)" json:"midtrans_order_id"`
|
| 104 |
+
MidtransToken string `gorm:"type:varchar(255)" json:"midtrans_token"`
|
| 105 |
+
PlatformFee int `gorm:"not null;default:0" json:"platform_fee"`
|
| 106 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 107 |
+
Company *Company `gorm:"foreignKey:CompanyID" json:"company,omitempty"`
|
| 108 |
+
Branch *Branch `gorm:"foreignKey:BranchID" json:"branch,omitempty"`
|
| 109 |
+
Items []OrderItem `gorm:"foreignKey:OrderID;constraint:OnDelete:CASCADE" json:"items"`
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
type OrderItem struct {
|
| 113 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 114 |
+
OrderID uint `gorm:"not null" json:"order_id"`
|
| 115 |
+
ProductID uint `gorm:"not null" json:"product_id"`
|
| 116 |
+
Quantity int `gorm:"not null;default:1" json:"quantity"`
|
| 117 |
+
UnitPrice int `gorm:"not null" json:"unit_price"`
|
| 118 |
+
Subtotal int `gorm:"not null" json:"subtotal"`
|
| 119 |
+
Product *Product `gorm:"foreignKey:ProductID" json:"product,omitempty"`
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
type OTP struct {
|
| 123 |
+
ID uint `gorm:"primaryKey" json:"id"`
|
| 124 |
+
PhoneNumber string `gorm:"type:varchar(20);not null;index" json:"phone_number"`
|
| 125 |
+
OTPCode string `gorm:"type:varchar(10);not null" json:"otp_code"`
|
| 126 |
+
IsVerified bool `gorm:"default:false" json:"is_verified"`
|
| 127 |
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
| 128 |
+
ExpiresAt time.Time `gorm:"not null" json:"expires_at"`
|
| 129 |
+
}
|
services/cloudinary.go
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package services
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"context"
|
| 6 |
+
"fmt"
|
| 7 |
+
"image"
|
| 8 |
+
"image/jpeg"
|
| 9 |
+
_ "image/png" // Support PNG decoding
|
| 10 |
+
"io"
|
| 11 |
+
"log"
|
| 12 |
+
"mime/multipart"
|
| 13 |
+
"service-warungpos-go/config"
|
| 14 |
+
|
| 15 |
+
"github.com/cloudinary/cloudinary-go/v2"
|
| 16 |
+
"github.com/cloudinary/cloudinary-go/v2/api/uploader"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
// UploadToCloudinary mengompresi gambar dan mengunggahnya ke Cloudinary
|
| 20 |
+
func UploadToCloudinary(fileHeader *multipart.FileHeader) (string, error) {
|
| 21 |
+
// Buka file dari fileHeader
|
| 22 |
+
file, err := fileHeader.Open()
|
| 23 |
+
if err != nil {
|
| 24 |
+
return "", fmt.Errorf("gagal membuka file: %v", err)
|
| 25 |
+
}
|
| 26 |
+
defer file.Close()
|
| 27 |
+
|
| 28 |
+
// Baca seluruh bytes file
|
| 29 |
+
contents, err := io.ReadAll(file)
|
| 30 |
+
if err != nil {
|
| 31 |
+
return "", fmt.Errorf("gagal membaca file: %v", err)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Kompresi gambar ke JPEG dengan quality 75
|
| 35 |
+
compressedBytes, err := compressImage(contents)
|
| 36 |
+
if err != nil {
|
| 37 |
+
log.Printf("[CLOUDINARY] Gagal kompresi, menggunakan file asli: %v", err)
|
| 38 |
+
compressedBytes = contents
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// Inisialisasi Cloudinary dari parameter config
|
| 42 |
+
cld, err := cloudinary.NewFromParams(
|
| 43 |
+
config.GlobalConfig.CloudinaryAPIKey,
|
| 44 |
+
config.GlobalConfig.CloudinaryAPISecret,
|
| 45 |
+
config.GlobalConfig.CloudinaryCloudName,
|
| 46 |
+
)
|
| 47 |
+
if err != nil {
|
| 48 |
+
return "", fmt.Errorf("gagal inisialisasi Cloudinary: %v", err)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
ctx := context.Background()
|
| 52 |
+
|
| 53 |
+
// Gunakan Reader dari compressedBytes untuk upload
|
| 54 |
+
resp, err := cld.Upload.Upload(ctx, bytes.NewReader(compressedBytes), uploader.UploadParams{
|
| 55 |
+
Folder: "warungpos/products",
|
| 56 |
+
})
|
| 57 |
+
if err != nil {
|
| 58 |
+
return "", fmt.Errorf("gagal upload ke Cloudinary: %v", err)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
log.Printf("[CLOUDINARY] Upload sukses! URL: %s", resp.SecureURL)
|
| 62 |
+
return resp.SecureURL, nil
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// compressImage mengompresi gambar ke JPEG (kualitas 75)
|
| 66 |
+
func compressImage(imgBytes []byte) ([]byte, error) {
|
| 67 |
+
// Decode gambar (mendukung JPEG/PNG/WebP jika didaftarkan)
|
| 68 |
+
img, _, err := image.Decode(bytes.NewReader(imgBytes))
|
| 69 |
+
if err != nil {
|
| 70 |
+
return nil, err
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// Encode ulang sebagai JPEG dengan kualitas 75
|
| 74 |
+
var buf bytes.Buffer
|
| 75 |
+
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75})
|
| 76 |
+
if err != nil {
|
| 77 |
+
return nil, err
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
return buf.Bytes(), nil
|
| 81 |
+
}
|
services/tripay.go
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package services
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"crypto/hmac"
|
| 6 |
+
"crypto/sha256"
|
| 7 |
+
"encoding/hex"
|
| 8 |
+
"encoding/json"
|
| 9 |
+
"fmt"
|
| 10 |
+
"io"
|
| 11 |
+
"log"
|
| 12 |
+
"net/http"
|
| 13 |
+
"time"
|
| 14 |
+
|
| 15 |
+
"service-warungpos-go/config"
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
type TripayItem struct {
|
| 19 |
+
SKU string `json:"sku"`
|
| 20 |
+
Name string `json:"name"`
|
| 21 |
+
Price int `json:"price"`
|
| 22 |
+
Quantity int `json:"quantity"`
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
type TripayCreateRequest struct {
|
| 26 |
+
Method string `json:"method"`
|
| 27 |
+
MerchantRef string `json:"merchant_ref"`
|
| 28 |
+
Amount int `json:"amount"`
|
| 29 |
+
CustomerName string `json:"customer_name"`
|
| 30 |
+
CustomerEmail string `json:"customer_email"`
|
| 31 |
+
CustomerPhone string `json:"customer_phone"`
|
| 32 |
+
OrderItems []TripayItem `json:"order_items"`
|
| 33 |
+
Signature string `json:"signature"`
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
type TripayCreateResponse struct {
|
| 37 |
+
Success bool `json:"success"`
|
| 38 |
+
Message string `json:"message"`
|
| 39 |
+
Data struct {
|
| 40 |
+
Reference string `json:"reference"`
|
| 41 |
+
MerchantRef string `json:"merchant_ref"`
|
| 42 |
+
PaymentMethod string `json:"payment_method"`
|
| 43 |
+
PaymentName string `json:"payment_name"`
|
| 44 |
+
Amount int `json:"amount"`
|
| 45 |
+
TotalAmount int `json:"total_amount"`
|
| 46 |
+
CheckoutURL string `json:"checkout_url"`
|
| 47 |
+
QrURL string `json:"qr_url"` // Untuk QRIS
|
| 48 |
+
QrString string `json:"qr_string"` // Untuk QRIS string raw
|
| 49 |
+
PaymentCode string `json:"payment_code"` // Untuk VA / Retail Outlet
|
| 50 |
+
Status string `json:"status"` // UNPAID, PAID, EXPIRED, FAILED
|
| 51 |
+
} `json:"data"`
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
func getTripayBaseURL() string {
|
| 55 |
+
if config.GlobalConfig.TripayIsProduction {
|
| 56 |
+
return "https://tripay.co.id/api"
|
| 57 |
+
}
|
| 58 |
+
return "https://tripay.co.id/api-sandbox"
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// CalculateSignature menghitung signature HMAC-SHA256 untuk request transaksi ke Tripay
|
| 62 |
+
func CalculateSignature(merchantRef string, amount int) string {
|
| 63 |
+
merchantCode := config.GlobalConfig.TripayMerchantCode
|
| 64 |
+
privateKey := config.GlobalConfig.TripayPrivateKey
|
| 65 |
+
|
| 66 |
+
// Format: merchant_code + merchant_ref + amount
|
| 67 |
+
data := merchantCode + merchantRef + fmt.Sprintf("%d", amount)
|
| 68 |
+
|
| 69 |
+
h := hmac.New(sha256.New, []byte(privateKey))
|
| 70 |
+
h.Write([]byte(data))
|
| 71 |
+
return hex.EncodeToString(h.Sum(nil))
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// CreateTripayTransaction membuat tagihan pembayaran nontunai baru di Tripay
|
| 75 |
+
func CreateTripayTransaction(
|
| 76 |
+
orderNumber string,
|
| 77 |
+
amount int,
|
| 78 |
+
paymentMethod string,
|
| 79 |
+
customerName string,
|
| 80 |
+
customerEmail string,
|
| 81 |
+
customerPhone string,
|
| 82 |
+
items []TripayItem,
|
| 83 |
+
) (*TripayCreateResponse, error) {
|
| 84 |
+
// Fallback input default
|
| 85 |
+
if paymentMethod == "" {
|
| 86 |
+
paymentMethod = "QRIS" // Default ke QRIS yang paling populer
|
| 87 |
+
}
|
| 88 |
+
if customerName == "" {
|
| 89 |
+
customerName = "Customer"
|
| 90 |
+
}
|
| 91 |
+
if customerEmail == "" {
|
| 92 |
+
customerEmail = "customer@mail.com"
|
| 93 |
+
}
|
| 94 |
+
if customerPhone == "" {
|
| 95 |
+
customerPhone = "081234567890"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// Hitung signature
|
| 99 |
+
signature := CalculateSignature(orderNumber, amount)
|
| 100 |
+
|
| 101 |
+
// Persiapkan payload request
|
| 102 |
+
payload := TripayCreateRequest{
|
| 103 |
+
Method: paymentMethod,
|
| 104 |
+
MerchantRef: orderNumber,
|
| 105 |
+
Amount: amount,
|
| 106 |
+
CustomerName: customerName,
|
| 107 |
+
CustomerEmail: customerEmail,
|
| 108 |
+
CustomerPhone: customerPhone,
|
| 109 |
+
OrderItems: items,
|
| 110 |
+
Signature: signature,
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
jsonBytes, err := json.Marshal(payload)
|
| 114 |
+
if err != nil {
|
| 115 |
+
return nil, fmt.Errorf("gagal merancang payload Tripay: %v", err)
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
apiURL := getTripayBaseURL() + "/transaction/create"
|
| 119 |
+
log.Printf("[TRIPAY] Mengirim request tagihan ke: %s", apiURL)
|
| 120 |
+
|
| 121 |
+
client := &http.Client{Timeout: 15 * time.Second}
|
| 122 |
+
req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonBytes))
|
| 123 |
+
if err != nil {
|
| 124 |
+
return nil, fmt.Errorf("gagal membuat http request: %v", err)
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
// Atur headers
|
| 128 |
+
req.Header.Set("Content-Type", "application/json")
|
| 129 |
+
req.Header.Set("Authorization", "Bearer "+config.GlobalConfig.TripayAPIKey)
|
| 130 |
+
|
| 131 |
+
resp, err := client.Do(req)
|
| 132 |
+
if err != nil {
|
| 133 |
+
return nil, fmt.Errorf("koneksi HTTP ke Tripay gagal: %v", err)
|
| 134 |
+
}
|
| 135 |
+
defer resp.Body.Close()
|
| 136 |
+
|
| 137 |
+
bodyBytes, err := io.ReadAll(resp.Body)
|
| 138 |
+
if err != nil {
|
| 139 |
+
return nil, fmt.Errorf("gagal membaca response body Tripay: %v", err)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
| 143 |
+
log.Printf("[TRIPAY] Response error: Status=%d, Body=%s", resp.StatusCode, string(bodyBytes))
|
| 144 |
+
return nil, fmt.Errorf("tripay mengembalikan status error %d", resp.StatusCode)
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
var tripayResp TripayCreateResponse
|
| 148 |
+
if err := json.Unmarshal(bodyBytes, &tripayResp); err != nil {
|
| 149 |
+
return nil, fmt.Errorf("gagal parse JSON response Tripay: %v", err)
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
if !tripayResp.Success {
|
| 153 |
+
return nil, fmt.Errorf("tripay gagal memproses: %s", tripayResp.Message)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
log.Printf("[TRIPAY] Sukses membuat transaksi! Ref=%s, URL=%s", tripayResp.Data.Reference, tripayResp.Data.CheckoutURL)
|
| 157 |
+
return &tripayResp, nil
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
// VerifyTripaySignature memvalidasi keaslian callback webhook dari Tripay
|
| 161 |
+
func VerifyTripaySignature(rawJSONBody []byte, receivedSignature string) bool {
|
| 162 |
+
privateKey := config.GlobalConfig.TripayPrivateKey
|
| 163 |
+
|
| 164 |
+
// Tripay Webhook Signature: HMAC-SHA256 dari raw JSON request body menggunakan Private Key
|
| 165 |
+
h := hmac.New(sha256.New, []byte(privateKey))
|
| 166 |
+
h.Write(rawJSONBody)
|
| 167 |
+
expectedSignature := hex.EncodeToString(h.Sum(nil))
|
| 168 |
+
|
| 169 |
+
return hmac.Equal([]byte(receivedSignature), []byte(expectedSignature))
|
| 170 |
+
}
|
services/whatsapp.go
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package services
|
| 2 |
+
|
| 3 |
+
import (
|
| 4 |
+
"bytes"
|
| 5 |
+
"encoding/json"
|
| 6 |
+
"fmt"
|
| 7 |
+
"io"
|
| 8 |
+
"log"
|
| 9 |
+
"net/http"
|
| 10 |
+
"net/url"
|
| 11 |
+
"strings"
|
| 12 |
+
"time"
|
| 13 |
+
|
| 14 |
+
"service-warungpos-go/config"
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
const FonnteAPIURL = "https://api.fonnte.com/send"
|
| 18 |
+
|
| 19 |
+
func formatPhone(phone string) string {
|
| 20 |
+
phone = strings.TrimSpace(phone)
|
| 21 |
+
phone = strings.ReplaceAll(phone, " ", "")
|
| 22 |
+
phone = strings.ReplaceAll(phone, "-", "")
|
| 23 |
+
|
| 24 |
+
if strings.HasPrefix(phone, "0") {
|
| 25 |
+
phone = "62" + phone[1:]
|
| 26 |
+
} else if strings.HasPrefix(phone, "+") {
|
| 27 |
+
phone = phone[1:]
|
| 28 |
+
}
|
| 29 |
+
return phone
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
func fmtRupiah(amount int) string {
|
| 33 |
+
// Format ribuan dengan titik (contoh: 15000 menjadi Rp 15.000)
|
| 34 |
+
str := fmt.Sprintf("%d", amount)
|
| 35 |
+
var result []string
|
| 36 |
+
length := len(str)
|
| 37 |
+
for i := length; i > 0; i -= 3 {
|
| 38 |
+
start := i - 3
|
| 39 |
+
if start < 0 {
|
| 40 |
+
start = 0
|
| 41 |
+
}
|
| 42 |
+
result = append([]string{str[start:i]}, result...)
|
| 43 |
+
}
|
| 44 |
+
return "Rp " + strings.Join(result, ".")
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
func divider(char string, length int) string {
|
| 48 |
+
return strings.Repeat(char, length)
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
type WAItem struct {
|
| 52 |
+
Name string `json:"name"`
|
| 53 |
+
Quantity int `json:"quantity"`
|
| 54 |
+
Price int `json:"price"`
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
func SendPaymentNotification(
|
| 58 |
+
phone string,
|
| 59 |
+
orderNumber string,
|
| 60 |
+
totalAmount int,
|
| 61 |
+
paymentMethod string,
|
| 62 |
+
customerName string,
|
| 63 |
+
items []WAItem,
|
| 64 |
+
changeAmount int,
|
| 65 |
+
storeName string,
|
| 66 |
+
branchName string,
|
| 67 |
+
) bool {
|
| 68 |
+
if config.GlobalConfig.FonnteToken == "" {
|
| 69 |
+
log.Println("[WHATSAPP] Token Fonnte tidak dikonfigurasi, lewati kirim WhatsApp")
|
| 70 |
+
return false
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
if phone == "" {
|
| 74 |
+
log.Println("[WHATSAPP] Nomor telepon customer kosong, lewati kirim WhatsApp")
|
| 75 |
+
return false
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
formattedPhone := formatPhone(phone)
|
| 79 |
+
baseStore := storeName
|
| 80 |
+
if baseStore == "" {
|
| 81 |
+
baseStore = config.GlobalConfig.StoreName
|
| 82 |
+
}
|
| 83 |
+
if baseStore == "" {
|
| 84 |
+
baseStore = "WarungPOS"
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
displayName := baseStore
|
| 88 |
+
if branchName != "" {
|
| 89 |
+
displayName = baseStore + " - " + branchName
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
methodLabel := "Nontunai"
|
| 93 |
+
if paymentMethod == "cash" {
|
| 94 |
+
methodLabel = "Tunai (Cash)"
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Buat isi pesan
|
| 98 |
+
var lines []string
|
| 99 |
+
lines = append(lines, "*"+displayName+"*")
|
| 100 |
+
lines = append(lines, "_Struk Pembayaran_")
|
| 101 |
+
lines = append(lines, divider("-", 30))
|
| 102 |
+
|
| 103 |
+
lines = append(lines, fmt.Sprintf("No. Order : ```%s```", orderNumber))
|
| 104 |
+
lines = append(lines, fmt.Sprintf("Customer : %s", customerName))
|
| 105 |
+
lines = append(lines, fmt.Sprintf("Metode : %s", methodLabel))
|
| 106 |
+
lines = append(lines, divider("-", 30))
|
| 107 |
+
|
| 108 |
+
if len(items) > 0 {
|
| 109 |
+
lines = append(lines, "*Daftar Pesanan*")
|
| 110 |
+
for _, item := range items {
|
| 111 |
+
subtotal := item.Price * item.Quantity
|
| 112 |
+
lines = append(lines, fmt.Sprintf(" %s x%d", item.Name, item.Quantity))
|
| 113 |
+
lines = append(lines, fmt.Sprintf(" %20s", fmtRupiah(subtotal)))
|
| 114 |
+
}
|
| 115 |
+
lines = append(lines, divider("-", 30))
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
lines = append(lines, fmt.Sprintf("*Total : %s*", fmtRupiah(totalAmount)))
|
| 119 |
+
|
| 120 |
+
if paymentMethod == "cash" {
|
| 121 |
+
lines = append(lines, fmt.Sprintf("Dibayar : %s", fmtRupiah(totalAmount+changeAmount)))
|
| 122 |
+
lines = append(lines, fmt.Sprintf("*Kembalian : %s*", fmtRupiah(changeAmount)))
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
lines = append(lines, divider("-", 30))
|
| 126 |
+
lines = append(lines, fmt.Sprintf("_Terima kasih telah berbelanja di %s._", displayName))
|
| 127 |
+
|
| 128 |
+
message := strings.Join(lines, "\n")
|
| 129 |
+
|
| 130 |
+
return postFonnte(formattedPhone, message)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
func SendOtpWa(phone string, otpCode string) bool {
|
| 134 |
+
if config.GlobalConfig.FonnteToken == "" {
|
| 135 |
+
return false
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
formattedPhone := formatPhone(phone)
|
| 139 |
+
storeName := config.GlobalConfig.StoreName
|
| 140 |
+
if storeName == "" {
|
| 141 |
+
storeName = "WarungPOS SaaS"
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
message := fmt.Sprintf("*%s*\n\nKode OTP Registrasi Anda adalah:\n*%s*\n\nKode ini berlaku selama 5 menit. Jangan berikan kode ini kepada siapa pun.", storeName, otpCode)
|
| 145 |
+
|
| 146 |
+
return postFonnte(formattedPhone, message)
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
func SendApprovalWa(phone string, storeName string, email string) bool {
|
| 150 |
+
if config.GlobalConfig.FonnteToken == "" || phone == "" {
|
| 151 |
+
return false
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
formattedPhone := formatPhone(phone)
|
| 155 |
+
message := fmt.Sprintf("✅ *SELAMAT! AKUN ANDA TELAH DISETUJUI*\n\nPerusahaan/Toko *%s* sudah diaktifkan!\nAnda dapat mencoba login ke Dashboard Kasir sekarang menggunakan email:\n_%s_\n\nSukses Selalu,\nTim Pengelola WarungPOS SaaS.", storeName, email)
|
| 156 |
+
|
| 157 |
+
return postFonnte(formattedPhone, message)
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
func postFonnte(target string, message string) bool {
|
| 161 |
+
// Gunakan http client bawaan Go dengan timeout 15 detik
|
| 162 |
+
client := &http.Client{Timeout: 15 * time.Second}
|
| 163 |
+
|
| 164 |
+
// Payload dikirim sebagai form url encoded
|
| 165 |
+
data := url.Values{}
|
| 166 |
+
data.Set("target", target)
|
| 167 |
+
data.Set("message", message)
|
| 168 |
+
data.Set("countryCode", "62")
|
| 169 |
+
|
| 170 |
+
req, err := http.NewRequest("POST", FonnteAPIURL, bytes.NewBufferString(data.Encode()))
|
| 171 |
+
if err != nil {
|
| 172 |
+
log.Printf("[WHATSAPP] Gagal membuat request Fonnte: %v", err)
|
| 173 |
+
return false
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
req.Header.Set("Authorization", config.GlobalConfig.FonnteToken)
|
| 177 |
+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
| 178 |
+
|
| 179 |
+
resp, err := client.Do(req)
|
| 180 |
+
if err != nil {
|
| 181 |
+
log.Printf("[WHATSAPP] Error kirim Fonnte: %v", err)
|
| 182 |
+
return false
|
| 183 |
+
}
|
| 184 |
+
defer resp.Body.Close()
|
| 185 |
+
|
| 186 |
+
bodyBytes, err := io.ReadAll(resp.Body)
|
| 187 |
+
if err != nil {
|
| 188 |
+
log.Printf("[WHATSAPP] Gagal membaca response Fonnte: %v", err)
|
| 189 |
+
return false
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
var responseData map[string]interface{}
|
| 193 |
+
if err := json.Unmarshal(bodyBytes, &responseData); err != nil {
|
| 194 |
+
log.Printf("[WHATSAPP] Gagal parse JSON Fonnte: %v", err)
|
| 195 |
+
return false
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
status, ok := responseData["status"].(bool)
|
| 199 |
+
if ok && status {
|
| 200 |
+
log.Printf("[WHATSAPP] Notification terkirim ke target %s", maskPhone(target))
|
| 201 |
+
return true
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
reason := responseData["reason"]
|
| 205 |
+
log.Printf("[WHATSAPP] Fonnte gagal mengirim: %v", reason)
|
| 206 |
+
return false
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
func maskPhone(phone string) string {
|
| 210 |
+
if len(phone) <= 6 {
|
| 211 |
+
return phone
|
| 212 |
+
}
|
| 213 |
+
return phone[:4] + "****" + phone[len(phone)-2:]
|
| 214 |
+
}
|