Mhamdans17 commited on
Commit
edcf070
·
0 Parent(s):

feat: migrate warungpos backend to Go with Tripay

Browse files
.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, &notification); 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
+ }