Mhamdans17
feat: add max branches and cashiers limit, refine admin UI settings, implement tier-based qris fee
8879e0b | package controllers | |
| import ( | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "log" | |
| "math/rand" | |
| "net/http" | |
| "strconv" | |
| "time" | |
| "service-warungpos-go/config" | |
| "service-warungpos-go/models" | |
| "service-warungpos-go/services" | |
| "github.com/gin-gonic/gin" | |
| "gorm.io/gorm" | |
| ) | |
| // DTOs untuk Order & Payment | |
| type OrderItemCreate struct { | |
| ProductID uint `json:"product_id" binding:"required"` | |
| Quantity int `json:"quantity" binding:"required,gt=0"` | |
| } | |
| type OrderCreateRequest struct { | |
| BranchID *uint `json:"branch_id"` | |
| Items []OrderItemCreate `json:"items" binding:"required,dive"` | |
| } | |
| type OrderItemResponse struct { | |
| ID uint `json:"id"` | |
| ProductID uint `json:"product_id"` | |
| ProductName string `json:"product_name"` | |
| Quantity int `json:"quantity"` | |
| UnitPrice int `json:"unit_price"` | |
| Subtotal int `json:"subtotal"` | |
| } | |
| type OrderResponse struct { | |
| ID uint `json:"id"` | |
| OrderNumber string `json:"order_number"` | |
| BranchID *uint `json:"branch_id"` | |
| BranchName string `json:"branch_name"` | |
| TotalAmount int `json:"total_amount"` | |
| SubtotalAmount int `json:"subtotal_amount"` | |
| DiscountAmount int `json:"discount_amount"` | |
| PointsUsed int `json:"points_used"` | |
| PlatformFee int `json:"platform_fee"` | |
| PaidAmount int `json:"paid_amount"` | |
| ChangeAmount int `json:"change_amount"` | |
| PaymentMethod string `json:"payment_method"` | |
| PaymentStatus string `json:"payment_status"` | |
| MidtransOrderID string `json:"midtrans_order_id"` | |
| MidtransToken string `json:"midtrans_token"` | |
| Items []OrderItemResponse `json:"items"` | |
| CreatedAt time.Time `json:"created_at"` | |
| ServedBy string `json:"served_by"` | |
| CustomerName string `json:"customer_name"` | |
| MemberID *uint `json:"member_id"` | |
| MemberName string `json:"member_name"` | |
| VoucherID *uint `json:"voucher_id"` | |
| } | |
| type CashPaymentRequest struct { | |
| OrderID uint `json:"order_id" binding:"required"` | |
| PaidAmount int `json:"paid_amount" binding:"gte=0"` | |
| CustomerName string `json:"customer_name"` | |
| CustomerPhone string `json:"customer_phone"` | |
| ServedBy string `json:"served_by"` | |
| MemberID *uint `json:"member_id"` | |
| VoucherID *uint `json:"voucher_id"` | |
| } | |
| type MidtransPaymentRequest struct { | |
| OrderID uint `json:"order_id" binding:"required"` | |
| CustomerName string `json:"customer_name"` | |
| CustomerEmail string `json:"customer_email"` | |
| CustomerPhone string `json:"customer_phone"` | |
| ServedBy string `json:"served_by"` | |
| MemberID *uint `json:"member_id"` | |
| VoucherID *uint `json:"voucher_id"` | |
| } | |
| type PaymentStatusResponse struct { | |
| OrderID uint `json:"order_id"` | |
| OrderNumber string `json:"order_number"` | |
| PaymentStatus string `json:"payment_status"` | |
| PaymentMethod string `json:"payment_method"` | |
| TotalAmount int `json:"total_amount"` | |
| PaidAmount int `json:"paid_amount"` | |
| ChangeAmount int `json:"change_amount"` | |
| PointsEarned int `json:"points_earned,omitempty"` | |
| MemberName string `json:"member_name,omitempty"` | |
| } | |
| func generateOrderNumber() string { | |
| now := time.Now() | |
| dateStr := now.Format("20060102") | |
| // Hex unik dari random number | |
| uniqueVal := rand.Intn(65536) | |
| return fmt.Sprintf("WP-%s-%04X", dateStr, uniqueVal) | |
| } | |
| func toOrderResponse(order *models.Order) OrderResponse { | |
| var itemsResp []OrderItemResponse | |
| for _, item := range order.Items { | |
| pName := "Produk Terhapus" | |
| if item.Product != nil { | |
| pName = item.Product.Name | |
| } | |
| itemsResp = append(itemsResp, OrderItemResponse{ | |
| ID: item.ID, | |
| ProductID: item.ProductID, | |
| ProductName: pName, | |
| Quantity: item.Quantity, | |
| UnitPrice: item.UnitPrice, | |
| Subtotal: item.Subtotal, | |
| }) | |
| } | |
| bName := "" | |
| if order.Branch != nil { | |
| bName = order.Branch.Name | |
| } | |
| mName := "" | |
| if order.Member != nil { | |
| mName = order.Member.Name | |
| } | |
| return OrderResponse{ | |
| ID: order.ID, | |
| OrderNumber: order.OrderNumber, | |
| BranchID: order.BranchID, | |
| BranchName: bName, | |
| TotalAmount: order.TotalAmount, | |
| SubtotalAmount: order.SubtotalAmount, | |
| DiscountAmount: order.DiscountAmount, | |
| PointsUsed: order.PointsUsed, | |
| PlatformFee: order.PlatformFee, | |
| PaidAmount: order.PaidAmount, | |
| ChangeAmount: order.ChangeAmount, | |
| PaymentMethod: order.PaymentMethod, | |
| PaymentStatus: order.PaymentStatus, | |
| MidtransOrderID: order.MidtransOrderID, | |
| MidtransToken: order.MidtransToken, | |
| Items: itemsResp, | |
| CreatedAt: order.CreatedAt, | |
| ServedBy: order.ServedBy, | |
| CustomerName: order.CustomerName, | |
| MemberID: order.MemberID, | |
| MemberName: mName, | |
| VoucherID: order.VoucherID, | |
| } | |
| } | |
| // ==================== ORDER CONTROLLER ==================== | |
| func CreateOrder(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| var req OrderCreateRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| // Tentukan branch_id yang berlaku | |
| var effectiveBranchID *uint | |
| if user.Role == "owner" { | |
| effectiveBranchID = req.BranchID | |
| } else { | |
| effectiveBranchID = user.BranchID | |
| } | |
| orderNumber := generateOrderNumber() | |
| tx := config.DB.Begin() | |
| defer func() { | |
| if r := recover(); r != nil { | |
| tx.Rollback() | |
| } | |
| }() | |
| order := models.Order{ | |
| CompanyID: *user.CompanyID, | |
| BranchID: effectiveBranchID, | |
| OrderNumber: orderNumber, | |
| TotalAmount: 0, | |
| PaymentStatus: "pending", | |
| } | |
| if err := tx.Create(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan order"}) | |
| return | |
| } | |
| total := 0 | |
| for _, item := range req.Items { | |
| var product models.Product | |
| if err := tx.Where("id = ? AND company_id = ?", item.ProductID, *user.CompanyID).First(&product).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk dengan ID %d tidak ditemukan", item.ProductID)}) | |
| return | |
| } | |
| if !product.IsActive { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Produk '%s' sudah tidak aktif", product.Name)}) | |
| return | |
| } | |
| // Periksa dan kurangi stok jika stok terbatas (bukan unlimited) | |
| if !product.IsUnlimitedStock { | |
| if product.Stock < item.Quantity { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Stok '%s' tidak cukup (sisa: %d)", product.Name, product.Stock)}) | |
| return | |
| } | |
| product.Stock -= item.Quantity | |
| if err := tx.Save(&product).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memperbarui stok produk"}) | |
| return | |
| } | |
| } | |
| subtotal := product.Price * item.Quantity | |
| orderItem := models.OrderItem{ | |
| OrderID: order.ID, | |
| ProductID: product.ID, | |
| Quantity: item.Quantity, | |
| UnitPrice: product.Price, | |
| Subtotal: subtotal, | |
| } | |
| if err := tx.Create(&orderItem).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyimpan item order"}) | |
| return | |
| } | |
| total += subtotal | |
| } | |
| // Update total amount order | |
| order.SubtotalAmount = total | |
| order.TotalAmount = total | |
| if err := tx.Save(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal meng-update total transaksi"}) | |
| return | |
| } | |
| if err := tx.Commit().Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memproses transaksi"}) | |
| return | |
| } | |
| // Load relationships untuk response | |
| config.DB.Preload("Branch").Preload("Member").Preload("Items.Product").First(&order, order.ID) | |
| c.JSON(http.StatusCreated, toOrderResponse(&order)) | |
| } | |
| func ListOrders(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| skipStr := c.DefaultQuery("skip", "0") | |
| limitStr := c.DefaultQuery("limit", "50") | |
| dateFrom := c.Query("date_from") // YYYY-MM-DD | |
| dateTo := c.Query("date_to") // YYYY-MM-DD | |
| branchFilter := c.Query("branch_id") | |
| skip, _ := strconv.Atoi(skipStr) | |
| limit, _ := strconv.Atoi(limitStr) | |
| query := config.DB.Select("id, company_id, branch_id, order_number, total_amount, platform_fee, paid_amount, change_amount, payment_method, payment_status, midtrans_order_id, midtrans_token, created_at, served_by, customer_name, member_id, voucher_id, discount_amount, points_used"). | |
| Preload("Branch", func(db *gorm.DB) *gorm.DB { | |
| return db.Select("id, name") | |
| }). | |
| Preload("Member", func(db *gorm.DB) *gorm.DB { | |
| return db.Select("id, name") | |
| }). | |
| Preload("Items", func(db *gorm.DB) *gorm.DB { | |
| return db.Select("id, order_id, product_id, quantity, unit_price, subtotal") | |
| }). | |
| Preload("Items.Product", func(db *gorm.DB) *gorm.DB { | |
| return db.Select("id, name") | |
| }). | |
| Where("company_id = ?", *user.CompanyID). | |
| Order("created_at desc") | |
| if dateFrom != "" { | |
| dtFrom, err := time.Parse("2006-01-02", dateFrom) | |
| if err == nil { | |
| // Awal hari (00:00:00) | |
| query = query.Where("created_at >= ?", dtFrom) | |
| } | |
| } | |
| if dateTo != "" { | |
| dtTo, err := time.Parse("2006-01-02", dateTo) | |
| if err == nil { | |
| // Akhir hari (23:59:59) | |
| dtToEnd := dtTo.Add(23*time.Hour + 59*time.Minute + 59*time.Second) | |
| query = query.Where("created_at <= ?", dtToEnd) | |
| } | |
| } | |
| if branchFilter != "" { | |
| if bID, err := strconv.Atoi(branchFilter); err == nil { | |
| query = query.Where("branch_id = ?", bID) | |
| } | |
| } | |
| var orders []models.Order | |
| if err := query.Offset(skip).Limit(limit).Find(&orders).Error; err != nil { | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mengambil histori order"}) | |
| return | |
| } | |
| var resp []OrderResponse | |
| for _, o := range orders { | |
| resp = append(resp, toOrderResponse(&o)) | |
| } | |
| c.JSON(http.StatusOK, resp) | |
| } | |
| func GetOrder(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| orderIDStr := c.Param("order_id") | |
| orderID, err := strconv.Atoi(orderIDStr) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "ID order tidak valid"}) | |
| return | |
| } | |
| var order models.Order | |
| if err := config.DB.Preload("Branch").Preload("Member").Preload("Items.Product").Where("id = ? AND company_id = ?", orderID, *user.CompanyID).First(&order).Error; err != nil { | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"}) | |
| return | |
| } | |
| c.JSON(http.StatusOK, toOrderResponse(&order)) | |
| } | |
| // ==================== PAYMENT CONTROLLER ==================== | |
| func PayCash(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| var req CashPaymentRequest | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| tx := config.DB.Begin() | |
| defer func() { | |
| if r := recover(); r != nil { | |
| tx.Rollback() | |
| } | |
| }() | |
| var order models.Order | |
| if err := tx.Preload("Items.Product").Where("id = ? AND company_id = ?", req.OrderID, *user.CompanyID).First(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"}) | |
| return | |
| } | |
| var company models.Company | |
| if err := tx.First(&company, *user.CompanyID).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Company tidak ditemukan"}) | |
| return | |
| } | |
| // 1. Cek Limit Dompet Mengendap | |
| if company.WalletBalance <= company.WalletLimit { | |
| tx.Rollback() | |
| c.JSON(http.StatusPaymentRequired, gin.H{"detail": "Saldo limit dompet minus. Hubungi Owner untuk melakukan Top-up saldo sistem terlebih dahulu agar bisa menerima pembayaran tunai."}) | |
| return | |
| } | |
| if order.PaymentStatus == "paid" { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"}) | |
| return | |
| } | |
| if err := applyVoucher(tx, &order, req.VoucherID, req.MemberID, company.ID); err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| if req.PaidAmount < order.TotalAmount { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{ | |
| "detail": fmt.Sprintf("Uang kurang! Total: %s, Dibayar: %s, Kurang: %s", | |
| fmtRupiah(order.TotalAmount), | |
| fmtRupiah(req.PaidAmount), | |
| fmtRupiah(order.TotalAmount-req.PaidAmount), | |
| ), | |
| }) | |
| return | |
| } | |
| // Update order data | |
| order.PaymentMethod = "cash" | |
| order.PaymentStatus = "paid" | |
| order.PaidAmount = req.PaidAmount | |
| order.ChangeAmount = req.PaidAmount - order.TotalAmount | |
| if req.ServedBy != "" { | |
| order.ServedBy = req.ServedBy | |
| } | |
| if req.MemberID != nil { | |
| order.MemberID = req.MemberID | |
| } | |
| if req.CustomerName != "" { | |
| order.CustomerName = req.CustomerName | |
| } | |
| if req.CustomerPhone != "" { | |
| order.CustomerPhone = req.CustomerPhone | |
| } | |
| // Hitung fee platform berdasarkan Company | |
| fee := 0 | |
| baseAmountForFee := order.TotalAmount | |
| if company.ChargeFeeBeforeDiscount { | |
| baseAmountForFee = order.SubtotalAmount | |
| } | |
| if company.CashFeeRules != "" && company.CashFeeRules != "[]" { | |
| var rules []map[string]interface{} | |
| if err := json.Unmarshal([]byte(company.CashFeeRules), &rules); err == nil { | |
| for _, r := range rules { | |
| minAmt := int(r["min_amount"].(float64)) | |
| maxAmt := int(r["max_amount"].(float64)) | |
| if baseAmountForFee >= minAmt && baseAmountForFee <= maxAmt { | |
| fee = int(r["fee"].(float64)) | |
| break | |
| } | |
| } | |
| } | |
| } | |
| // Fallback ke fee lama jika tidak ada rule yang cocok/diset | |
| if fee == 0 && company.FeePercentage > 0 { | |
| fee = int(float64(baseAmountForFee) * company.FeePercentage / 100) | |
| } | |
| order.PlatformFee = fee | |
| // 2. Potong saldo dompet jika ada fee | |
| if fee > 0 { | |
| company.WalletBalance -= float64(fee) | |
| if err := tx.Save(&company).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memotong saldo dompet"}) | |
| return | |
| } | |
| walletTx := models.WalletTransaction{ | |
| CompanyID: company.ID, | |
| Amount: -float64(fee), | |
| Type: "cash_fee", | |
| RefID: order.OrderNumber, | |
| Desc: "Potongan fee transaksi tunai", | |
| } | |
| if err := tx.Create(&walletTx).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal mencatat mutasi dompet"}) | |
| return | |
| } | |
| } | |
| if err := tx.Save(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan pembayaran"}) | |
| return | |
| } | |
| awardPoints(tx, &order, &company) | |
| if err := tx.Commit().Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memproses pembayaran"}) | |
| return | |
| } | |
| // Kirim Notifikasi WA (Async) | |
| if req.CustomerPhone != "" { | |
| go func() { | |
| var waItems []services.WAItem | |
| for _, item := range order.Items { | |
| pName := "Item" | |
| if item.Product != nil { | |
| pName = item.Product.Name | |
| } | |
| waItems = append(waItems, services.WAItem{ | |
| Name: pName, | |
| Quantity: item.Quantity, | |
| Price: item.UnitPrice, | |
| }) | |
| } | |
| var branchName string | |
| var branchAddress string | |
| if order.BranchID != nil { | |
| var branch models.Branch | |
| if err := config.DB.First(&branch, *order.BranchID).Error; err == nil { | |
| branchName = branch.Name | |
| branchAddress = branch.Address | |
| } | |
| } | |
| services.SendPaymentNotification( | |
| req.CustomerPhone, | |
| order.OrderNumber, | |
| order.TotalAmount, | |
| "cash", | |
| req.CustomerName, | |
| waItems, | |
| order.ChangeAmount, | |
| company.Name, | |
| branchName, | |
| branchAddress, | |
| order.SubtotalAmount, | |
| order.DiscountAmount, | |
| req.ServedBy, | |
| order.PointsEarned, | |
| ) | |
| }() | |
| } | |
| memberName := "" | |
| if order.Member != nil { | |
| memberName = order.Member.Name | |
| } | |
| c.JSON(http.StatusOK, PaymentStatusResponse{ | |
| OrderID: order.ID, | |
| OrderNumber: order.OrderNumber, | |
| PaymentStatus: order.PaymentStatus, | |
| PaymentMethod: order.PaymentMethod, | |
| TotalAmount: order.TotalAmount, | |
| PaidAmount: order.PaidAmount, | |
| ChangeAmount: order.ChangeAmount, | |
| PointsEarned: order.PointsEarned, | |
| MemberName: memberName, | |
| }) | |
| } | |
| func PayTripay(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| var req MidtransPaymentRequest // Gunakan request DTO yang sama karena field input sama | |
| if err := c.ShouldBindJSON(&req); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| var order models.Order | |
| if err := config.DB.Where("id = ? AND company_id = ?", req.OrderID, *user.CompanyID).First(&order).Error; err != nil { | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"}) | |
| return | |
| } | |
| if order.PaymentStatus == "paid" { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"}) | |
| return | |
| } | |
| var company models.Company | |
| if err := config.DB.First(&company, *user.CompanyID).Error; err != nil { | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Perusahaan tidak ditemukan"}) | |
| return | |
| } | |
| if err := applyVoucher(config.DB, &order, req.VoucherID, req.MemberID, company.ID); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) | |
| return | |
| } | |
| if order.TotalAmount < company.QrisMinTransaction && !company.AllowQrisBelowMin { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Minimal transaksi QRIS adalah Rp%s. Silakan hubungi admin untuk mengizinkan transaksi kecil.", fmtRupiah(company.QrisMinTransaction))}) | |
| return | |
| } | |
| // Load order items untuk dikirim ke Tripay | |
| var orderItems []models.OrderItem | |
| config.DB.Preload("Product").Where("order_id = ?", order.ID).Find(&orderItems) | |
| var tripayItems []services.TripayItem | |
| for _, item := range orderItems { | |
| skuStr := "" | |
| if item.Product != nil && item.Product.SKU != nil { | |
| skuStr = *item.Product.SKU | |
| } | |
| pName := "Product" | |
| if item.Product != nil { | |
| pName = item.Product.Name | |
| } | |
| tripayItems = append(tripayItems, services.TripayItem{ | |
| SKU: skuStr, | |
| Name: pName, | |
| Price: item.UnitPrice, | |
| Quantity: item.Quantity, | |
| }) | |
| } | |
| // Request Tripay Transaction (Default diambil secara dinamis dari database) | |
| tripayRes, err := services.CreateTripayTransaction( | |
| order.OrderNumber, | |
| order.TotalAmount, | |
| "", | |
| req.CustomerName, | |
| req.CustomerEmail, | |
| req.CustomerPhone, | |
| tripayItems, | |
| order.CompanyID, | |
| ) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("Gagal membuat transaksi Tripay: %v", err)}) | |
| return | |
| } | |
| // Simpan Tripay details ke DB | |
| order.MidtransOrderID = tripayRes.Data.Reference // Simpan nomor referensi Tripay di midtrans_order_id demi kecocokan DB | |
| order.MidtransToken = tripayRes.Data.CheckoutURL // Simpan checkout_url di midtrans_token demi kecocokan DB | |
| order.PaymentStatus = "pending" | |
| order.CustomerName = req.CustomerName | |
| order.CustomerPhone = req.CustomerPhone | |
| order.TotalAmount = tripayRes.Data.Amount | |
| order.PlatformFee = tripayRes.Data.TotalFee | |
| if req.ServedBy != "" { | |
| order.ServedBy = req.ServedBy | |
| } | |
| if req.MemberID != nil { | |
| order.MemberID = req.MemberID | |
| } | |
| config.DB.Save(&order) | |
| expTime := tripayRes.Data.ExpiredTime | |
| if expTime == 0 { | |
| expTime = time.Now().Add(5 * time.Minute).Unix() | |
| } | |
| // Kembalikan JSON yang kompatibel dengan format respons frontend agar tidak merusak UI kasir | |
| c.JSON(http.StatusOK, gin.H{ | |
| "order_id": order.ID, | |
| "order_number": order.OrderNumber, | |
| "snap_token": "", // Dikosongkan agar frontend tidak keliru membuka popup Midtrans Snap | |
| "redirect_url": tripayRes.Data.CheckoutURL, | |
| "qr_url": tripayRes.Data.QrURL, | |
| "qr_string": tripayRes.Data.QrString, | |
| "payment_method": tripayRes.Data.PaymentMethod, | |
| "payment_name": tripayRes.Data.PaymentName, | |
| "total_amount": order.TotalAmount, | |
| "tripay_amount": tripayRes.Data.Amount, | |
| "expired_time": expTime, | |
| }) | |
| } | |
| func TripayWebhook(c *gin.Context) { | |
| // Baca raw body untuk verifikasi signature | |
| rawBody, err := io.ReadAll(c.Request.Body) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Gagal membaca request body"}) | |
| return | |
| } | |
| // Parse JSON manual | |
| var notification map[string]interface{} | |
| if err := json.Unmarshal(rawBody, ¬ification); err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Request body harus berupa JSON yang valid"}) | |
| return | |
| } | |
| // Validasi header signature | |
| signature := c.GetHeader("X-Callback-Signature") | |
| if signature == "" { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Header X-Callback-Signature tidak ditemukan"}) | |
| return | |
| } | |
| log.Printf("[WEBHOOK] Tripay payload: %s", string(rawBody)) | |
| // Verifikasi Signature keaslian Tripay | |
| if !services.VerifyTripaySignature(rawBody, signature) { | |
| c.JSON(http.StatusUnauthorized, gin.H{"detail": "Signature verifikasi gagal/tidak valid"}) | |
| return | |
| } | |
| merchantRefVal, ok := notification["merchant_ref"] | |
| if !ok || merchantRefVal == nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Payload webhook tidak valid: merchant_ref wajib ada"}) | |
| return | |
| } | |
| merchantRefStr := merchantRefVal.(string) | |
| tripayStatus, _ := notification["status"].(string) | |
| log.Printf("[WEBHOOK] Tripay: merchant_ref=%s, status=%s", merchantRefStr, tripayStatus) | |
| tx := config.DB.Begin() | |
| defer func() { | |
| if r := recover(); r != nil { | |
| tx.Rollback() | |
| } | |
| }() | |
| // Cek apakah ini Topup atau Order biasa | |
| if len(merchantRefStr) > 6 && merchantRefStr[:6] == "TOPUP-" { | |
| // Ini adalah Topup via Tripay | |
| topupIDStr := merchantRefStr[6:] | |
| var topup models.TopupRequest | |
| if err := tx.First(&topup, topupIDStr).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Request Top-up tidak ditemukan"}) | |
| return | |
| } | |
| if topup.Status != "pending" { | |
| tx.Rollback() | |
| c.JSON(http.StatusOK, gin.H{"success": true, "message": "Sudah diproses sebelumnya"}) | |
| return | |
| } | |
| switch tripayStatus { | |
| case "PAID": | |
| topup.Status = "approved" | |
| tx.Save(&topup) | |
| var company models.Company | |
| tx.First(&company, topup.CompanyID) | |
| company.WalletBalance += topup.Amount | |
| tx.Save(&company) | |
| walletTx := models.WalletTransaction{ | |
| CompanyID: company.ID, | |
| Amount: topup.Amount, | |
| Type: "topup_qris", | |
| RefID: merchantRefStr, | |
| Desc: "Top-up Saldo via QRIS/Tripay", | |
| } | |
| tx.Create(&walletTx) | |
| case "EXPIRED", "FAILED": | |
| topup.Status = "rejected" | |
| tx.Save(&topup) | |
| } | |
| if err := tx.Commit().Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal commit topup"}) | |
| return | |
| } | |
| c.JSON(http.StatusOK, gin.H{"success": true}) | |
| return | |
| } | |
| // === Jika bukan TOPUP, berarti Order biasa === | |
| var order models.Order | |
| if err := tx.Preload("Items.Product").Where("order_number = ?", merchantRefStr).First(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"}) | |
| return | |
| } | |
| oldStatus := order.PaymentStatus | |
| // Map status Tripay: PAID, EXPIRED, FAILED | |
| switch tripayStatus { | |
| case "PAID": | |
| order.PaymentStatus = "paid" | |
| if tripayTotal, ok := notification["total_amount"].(float64); ok && tripayTotal > 0 { | |
| order.TotalAmount = int(tripayTotal) | |
| order.PaidAmount = int(tripayTotal) | |
| } else { | |
| order.PaidAmount = order.TotalAmount | |
| } | |
| case "EXPIRED", "FAILED": | |
| order.PaymentStatus = "failed" | |
| if oldStatus != "failed" { | |
| restoreStock(tx, &order) | |
| } | |
| } | |
| if order.PaymentStatus == "paid" && oldStatus != "paid" { | |
| var company models.Company | |
| if err := tx.First(&company, order.CompanyID).Error; err == nil { | |
| baseAmountForFee := order.TotalAmount | |
| if company.ChargeFeeBeforeDiscount { | |
| baseAmountForFee = order.SubtotalAmount | |
| } | |
| fee := 0 | |
| ruleApplied := false | |
| if company.QrisFeeRules != "" && company.QrisFeeRules != "[]" { | |
| var qrisRules []QrisFeeRule | |
| if err := json.Unmarshal([]byte(company.QrisFeeRules), &qrisRules); err == nil { | |
| for _, rule := range qrisRules { | |
| if baseAmountForFee >= rule.MinAmount && baseAmountForFee <= rule.MaxAmount { | |
| fee = int((float64(baseAmountForFee) * rule.FeePercentage / 100) + float64(rule.FeeFixed)) | |
| if fee < rule.FeeMin { | |
| fee = rule.FeeMin | |
| } | |
| ruleApplied = true | |
| break | |
| } | |
| } | |
| } | |
| } | |
| if !ruleApplied { | |
| fee = int((float64(baseAmountForFee) * company.QrisFeePercentage / 100) + float64(company.QrisFeeFixed)) | |
| if fee < company.QrisFeeMin { | |
| fee = company.QrisFeeMin | |
| } | |
| } | |
| order.PlatformFee = fee | |
| awardPoints(tx, &order, &company) | |
| } | |
| } | |
| if err := tx.Save(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal memproses status webhook"}) | |
| return | |
| } | |
| if err := tx.Commit().Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan commit webhook"}) | |
| return | |
| } | |
| log.Printf("[WEBHOOK] Order %s di-update via Tripay: %s -> %s", order.OrderNumber, oldStatus, order.PaymentStatus) | |
| // Kirim Notifikasi WA struk belanja jika pembayaran berhasil | |
| if order.PaymentStatus == "paid" && oldStatus != "paid" { | |
| go func() { | |
| custPhone := order.CustomerPhone | |
| custName := order.CustomerName | |
| if custName == "" { | |
| custName = "Customer" | |
| } | |
| var waItems []services.WAItem | |
| itemsTotal := 0 | |
| for _, item := range order.Items { | |
| pName := "Item" | |
| if item.Product != nil { | |
| pName = item.Product.Name | |
| } | |
| waItems = append(waItems, services.WAItem{ | |
| Name: pName, | |
| Quantity: item.Quantity, | |
| Price: item.UnitPrice, | |
| }) | |
| itemsTotal += (item.Quantity * item.UnitPrice) | |
| } | |
| if order.TotalAmount > itemsTotal { | |
| waItems = append(waItems, services.WAItem{ | |
| Name: "Biaya Layanan QRIS", | |
| Quantity: 1, | |
| Price: order.TotalAmount - itemsTotal, | |
| }) | |
| } | |
| var company models.Company | |
| config.DB.First(&company, order.CompanyID) | |
| var branchName string | |
| var branchAddress string | |
| if order.BranchID != nil { | |
| var branch models.Branch | |
| if err := config.DB.First(&branch, *order.BranchID).Error; err == nil { | |
| branchName = branch.Name | |
| branchAddress = branch.Address | |
| } | |
| } | |
| if custPhone != "" { | |
| services.SendPaymentNotification( | |
| custPhone, | |
| order.OrderNumber, | |
| order.TotalAmount, | |
| "tripay", | |
| custName, | |
| waItems, | |
| 0, | |
| company.Name, | |
| branchName, | |
| branchAddress, | |
| order.SubtotalAmount, | |
| order.DiscountAmount, | |
| order.ServedBy, | |
| order.PointsEarned, | |
| ) | |
| } | |
| }() | |
| } | |
| c.JSON(http.StatusOK, gin.H{ | |
| "success": true, | |
| "order_number": order.OrderNumber, | |
| }) | |
| } | |
| func GetPaymentStatus(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| orderIDStr := c.Param("order_id") | |
| orderID, err := strconv.Atoi(orderIDStr) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "ID order tidak valid"}) | |
| return | |
| } | |
| var order models.Order | |
| if err := config.DB.Preload("Member").Where("id = ? AND company_id = ?", orderID, *user.CompanyID).First(&order).Error; err != nil { | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"}) | |
| return | |
| } | |
| memberName := "" | |
| if order.Member != nil { | |
| memberName = order.Member.Name | |
| } | |
| c.JSON(http.StatusOK, PaymentStatusResponse{ | |
| OrderID: order.ID, | |
| OrderNumber: order.OrderNumber, | |
| PaymentStatus: order.PaymentStatus, | |
| PaymentMethod: order.PaymentMethod, | |
| TotalAmount: order.TotalAmount, | |
| PaidAmount: order.PaidAmount, | |
| ChangeAmount: order.ChangeAmount, | |
| PointsEarned: order.PointsEarned, | |
| MemberName: memberName, | |
| }) | |
| } | |
| func CancelMidtransOrder(c *gin.Context) { | |
| userVal, _ := c.Get("user") | |
| user := userVal.(*models.User) | |
| orderIDStr := c.Param("order_id") | |
| orderID, err := strconv.Atoi(orderIDStr) | |
| if err != nil { | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "ID order tidak valid"}) | |
| return | |
| } | |
| tx := config.DB.Begin() | |
| defer func() { | |
| if r := recover(); r != nil { | |
| tx.Rollback() | |
| } | |
| }() | |
| var order models.Order | |
| if err := tx.Preload("Items.Product").Where("id = ? AND company_id = ?", orderID, *user.CompanyID).First(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusNotFound, gin.H{"detail": "Order tidak ditemukan"}) | |
| return | |
| } | |
| if order.PaymentStatus == "paid" { | |
| tx.Rollback() | |
| c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"}) | |
| return | |
| } | |
| if order.PaymentStatus == "cancelled" { | |
| tx.Rollback() | |
| c.JSON(http.StatusOK, gin.H{ | |
| "status": "cancelled", | |
| "order_id": order.ID, | |
| "order_number": order.OrderNumber, | |
| "payment_status": order.PaymentStatus, | |
| }) | |
| return | |
| } | |
| oldStatus := order.PaymentStatus | |
| order.PaymentStatus = "cancelled" | |
| if oldStatus != "cancelled" { | |
| restoreStock(tx, &order) | |
| } | |
| if err := tx.Save(&order).Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal membatalkan order"}) | |
| return | |
| } | |
| if err := tx.Commit().Error; err != nil { | |
| tx.Rollback() | |
| c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan proses pembatalan"}) | |
| return | |
| } | |
| c.JSON(http.StatusOK, gin.H{ | |
| "status": "cancelled", | |
| "order_id": order.ID, | |
| "order_number": order.OrderNumber, | |
| "payment_status": order.PaymentStatus, | |
| }) | |
| } | |
| // Helpers | |
| func restoreStock(tx *gorm.DB, order *models.Order) { | |
| // GORM raw query atau update | |
| for _, item := range order.Items { | |
| var product models.Product | |
| if err := tx.First(&product, item.ProductID).Error; err == nil { | |
| if !product.IsUnlimitedStock { | |
| product.Stock += item.Quantity | |
| tx.Save(&product) | |
| log.Printf("[RESTORE] Stok '%s' dikembalikan +%d (sekarang: %d)", product.Name, item.Quantity, product.Stock) | |
| } | |
| } | |
| } | |
| } | |
| func awardPoints(tx *gorm.DB, order *models.Order, company *models.Company) { | |
| // Skip earning points if voucher is used | |
| if order.MemberID == nil || order.VoucherID != nil { | |
| return | |
| } | |
| branchName := "Pusat" | |
| if order.BranchID != nil { | |
| var branch models.Branch | |
| if err := tx.First(&branch, *order.BranchID).Error; err == nil { | |
| if !branch.IsPointEnabled { | |
| return // Cabang ini menonaktifkan fitur poin | |
| } | |
| branchName = branch.Name | |
| } | |
| } | |
| var rules []models.PointRule | |
| tx.Where("company_id = ?", company.ID).Order("min_amount desc").Find(&rules) | |
| pointsToAdd := 0 | |
| var matchedRule *models.PointRule | |
| for _, rule := range rules { | |
| if order.TotalAmount >= rule.MinAmount { | |
| pointsToAdd = rule.PointReward | |
| matchedRule = &rule | |
| break | |
| } | |
| } | |
| if pointsToAdd > 0 && matchedRule != nil { | |
| var member models.Member | |
| if err := tx.First(&member, *order.MemberID).Error; err == nil { | |
| member.Points += pointsToAdd | |
| tx.Save(&member) | |
| order.PointsEarned = pointsToAdd | |
| tx.Save(order) | |
| order.Member = &member | |
| var itemNames string | |
| for i, item := range order.Items { | |
| pName := "Item" | |
| if item.Product != nil { | |
| pName = item.Product.Name | |
| } | |
| if i > 0 { | |
| itemNames += ", " | |
| } | |
| itemNames += fmt.Sprintf("%dx %s", item.Quantity, pName) | |
| } | |
| if len(itemNames) > 45 { | |
| itemNames = itemNames[:42] + "..." | |
| } | |
| history := models.PointHistory{ | |
| CompanyID: company.ID, | |
| MemberID: member.ID, | |
| PointsDelta: pointsToAdd, | |
| Reason: "Beli " + itemNames + " (Cabang " + branchName + ")", | |
| } | |
| tx.Create(&history) | |
| log.Printf("[POINTS] Member %s (+%d poin dari transaksi %d berdasarkan rule min %d)", member.Name, pointsToAdd, order.TotalAmount, matchedRule.MinAmount) | |
| } | |
| } | |
| } | |
| func applyVoucher(tx *gorm.DB, order *models.Order, voucherID *uint, memberID *uint, companyID uint) error { | |
| if voucherID == nil { | |
| return nil | |
| } | |
| if memberID == nil { | |
| return errors.New("Voucher hanya dapat digunakan oleh Member") | |
| } | |
| var voucher models.Voucher | |
| if err := tx.Where("id = ? AND company_id = ? AND is_active = true", *voucherID, companyID).First(&voucher).Error; err != nil { | |
| return errors.New("Voucher tidak ditemukan atau tidak aktif") | |
| } | |
| if voucher.BranchID != nil && order.BranchID != nil && *voucher.BranchID != *order.BranchID { | |
| return errors.New("Voucher ini tidak berlaku untuk cabang ini") | |
| } | |
| if order.TotalAmount < voucher.MinPurchase { | |
| return errors.New("Total belanja belum memenuhi syarat minimal voucher") | |
| } | |
| var member models.Member | |
| if err := tx.Where("id = ? AND company_id = ?", *memberID, companyID).First(&member).Error; err != nil { | |
| return errors.New("Member tidak ditemukan") | |
| } | |
| if member.Points < voucher.PointCost { | |
| return errors.New("Poin member tidak mencukupi untuk menggunakan voucher ini") | |
| } | |
| // Kurangi poin member | |
| if voucher.PointCost > 0 { | |
| member.Points -= voucher.PointCost | |
| if err := tx.Save(&member).Error; err != nil { | |
| return errors.New("Gagal memotong poin member") | |
| } | |
| // Catat history | |
| history := models.PointHistory{ | |
| CompanyID: companyID, | |
| MemberID: member.ID, | |
| PointsDelta: -voucher.PointCost, | |
| Reason: "Tukar poin untuk voucher " + voucher.Name, | |
| } | |
| if err := tx.Create(&history).Error; err != nil { | |
| return errors.New("Gagal mencatat histori poin") | |
| } | |
| } | |
| // Terapkan diskon | |
| discount := voucher.DiscountValue | |
| if discount > order.TotalAmount { | |
| discount = order.TotalAmount | |
| } | |
| order.TotalAmount -= discount | |
| order.DiscountAmount = discount | |
| order.VoucherID = voucherID | |
| order.PointsUsed = voucher.PointCost | |
| return nil | |
| } | |