Dev / controllers /order_controller.go
Mhamdans17
feat: add max branches and cashiers limit, refine admin UI settings, implement tier-based qris fee
8879e0b
Raw
History Blame Contribute Delete
33.3 kB
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, &notification); 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
}