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 }