Dev / controllers /order_controller.go
Mhamdans17
feat: support dynamic tenant Tripay credentials and proxy via database
6fd3b19
Raw
History Blame
21.3 kB
package controllers
import (
"encoding/json"
"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"`
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"`
}
type CashPaymentRequest struct {
OrderID uint `json:"order_id" binding:"required"`
PaidAmount int `json:"paid_amount" binding:"required,gt=0"`
CustomerName string `json:"customer_name"`
CustomerPhone string `json:"customer_phone"`
}
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"`
}
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"`
}
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
}
return OrderResponse{
ID: order.ID,
OrderNumber: order.OrderNumber,
BranchID: order.BranchID,
BranchName: bName,
TotalAmount: order.TotalAmount,
PaidAmount: order.PaidAmount,
ChangeAmount: order.ChangeAmount,
PaymentMethod: order.PaymentMethod,
PaymentStatus: order.PaymentStatus,
MidtransOrderID: order.MidtransOrderID,
MidtransToken: order.MidtransToken,
Items: itemsResp,
CreatedAt: order.CreatedAt,
}
}
// ==================== 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.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("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, paid_amount, change_amount, payment_method, payment_status, midtrans_order_id, midtrans_token, created_at").
Preload("Branch", 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("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
}
if order.PaymentStatus == "paid" {
tx.Rollback()
c.JSON(http.StatusBadRequest, gin.H{"detail": "Order sudah dibayar"})
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
// Hitung fee platform berdasarkan Company
var company models.Company
if err := tx.First(&company, *user.CompanyID).Error; err == nil {
order.PlatformFee = int(float64(order.TotalAmount) * company.FeePercentage / 100)
}
if err := tx.Save(&order).Error; err != nil {
tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"detail": "Gagal menyelesaikan pembayaran"})
return
}
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
if order.BranchID != nil {
var branch models.Branch
if err := config.DB.First(&branch, *order.BranchID).Error; err == nil {
branchName = branch.Name
}
}
services.SendPaymentNotification(
req.CustomerPhone,
order.OrderNumber,
order.TotalAmount,
"cash",
req.CustomerName,
waItems,
order.ChangeAmount,
company.Name,
branchName,
)
}()
}
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,
})
}
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
}
// 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 ke QRIS yang paling populer)
tripayRes, err := services.CreateTripayTransaction(
order.OrderNumber,
order.TotalAmount,
"QRIS",
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.PaymentMethod = "tripay"
config.DB.Save(&order)
// 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": tripayRes.Data.Reference,
"redirect_url": tripayRes.Data.CheckoutURL,
"total_amount": order.TotalAmount,
})
}
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()
}
}()
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"
order.PaidAmount = order.TotalAmount
case "EXPIRED", "FAILED":
order.PaymentStatus = "failed"
if oldStatus != "failed" {
restoreStock(tx, &order)
}
}
// Jika sukses terbayar, hitung platform fee
if order.PaymentStatus == "paid" && oldStatus != "paid" {
var company models.Company
if err := tx.First(&company, order.CompanyID).Error; err == nil {
order.PlatformFee = int(float64(order.TotalAmount) * company.FeePercentage / 100)
}
}
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, _ := notification["customer_phone"].(string)
custName, _ := notification["customer_name"].(string)
if custName == "" {
custName = "Customer"
}
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 company models.Company
config.DB.First(&company, order.CompanyID)
var branchName string
if order.BranchID != nil {
var branch models.Branch
if err := config.DB.First(&branch, *order.BranchID).Error; err == nil {
branchName = branch.Name
}
}
if custPhone != "" {
services.SendPaymentNotification(
custPhone,
order.OrderNumber,
order.TotalAmount,
"tripay",
custName,
waItems,
0,
company.Name,
branchName,
)
}
}()
}
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.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, PaymentStatusResponse{
OrderID: order.ID,
OrderNumber: order.OrderNumber,
PaymentStatus: order.PaymentStatus,
PaymentMethod: order.PaymentMethod,
TotalAmount: order.TotalAmount,
PaidAmount: order.PaidAmount,
ChangeAmount: order.ChangeAmount,
})
}
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)
}
}
}
}