mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 10:42:57 +00:00
hot fix #1
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CartController struct {
|
||||
DB *gorm.DB
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
func NewCartController(db *gorm.DB, cfg *config.Config) *CartController {
|
||||
return &CartController{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type cartContext struct {
|
||||
UserID *uint
|
||||
SessionToken string
|
||||
}
|
||||
|
||||
func (ctrl *CartController) getCartContext(c *gin.Context) cartContext {
|
||||
var res cartContext
|
||||
if uidVal, ok := c.Get("userID"); ok {
|
||||
switch v := uidVal.(type) {
|
||||
case uint:
|
||||
res.UserID = &v
|
||||
case int:
|
||||
u := uint(v)
|
||||
res.UserID = &u
|
||||
case int64:
|
||||
u := uint(v)
|
||||
res.UserID = &u
|
||||
}
|
||||
}
|
||||
res.SessionToken = c.GetHeader("X-Session-Token")
|
||||
if res.SessionToken == "" {
|
||||
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
|
||||
res.SessionToken = cookie.Value
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (ctrl *CartController) findOrCreateCart(c *gin.Context) (*models.EshopCart, error) {
|
||||
cc := ctrl.getCartContext(c)
|
||||
q := ctrl.DB.Where("completed = ?", false)
|
||||
if cc.UserID != nil {
|
||||
q = q.Where("user_id = ?", *cc.UserID)
|
||||
} else if cc.SessionToken != "" {
|
||||
q = q.Where("session_token = ?", cc.SessionToken)
|
||||
}
|
||||
var cart models.EshopCart
|
||||
if err := q.Preload("Items").Preload("Items.Product").First(&cart).Error; err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, err
|
||||
}
|
||||
// Create new cart
|
||||
cart = models.EshopCart{
|
||||
UserID: cc.UserID,
|
||||
SessionToken: cc.SessionToken,
|
||||
Currency: ctrl.Config.StripeCurrency,
|
||||
}
|
||||
if cart.Currency == "" {
|
||||
cart.Currency = "CZK"
|
||||
}
|
||||
if err := ctrl.DB.Create(&cart).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &cart, nil
|
||||
}
|
||||
|
||||
// GetCart returns the current cart
|
||||
func (ctrl *CartController) GetCart(c *gin.Context) {
|
||||
cartObj, err := ctrl.findOrCreateCart(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart"})
|
||||
return
|
||||
}
|
||||
// Ensure items are fully loaded
|
||||
if err := ctrl.DB.
|
||||
Preload("Items").
|
||||
Preload("Items.Product").
|
||||
Preload("Items.Variant").
|
||||
First(cartObj, cartObj.ID).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart items"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cartObj)
|
||||
}
|
||||
|
||||
// AddItem adds an item to the cart
|
||||
func (ctrl *CartController) AddItem(c *gin.Context) {
|
||||
var body struct {
|
||||
ProductID uint `json:"product_id"`
|
||||
VariantID *uint `json:"variant_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if body.ProductID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Product ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Quantity <= 0 || body.Quantity > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantity must be between 1 and 100"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if product exists and is active
|
||||
var product models.EshopProduct
|
||||
if err := ctrl.DB.Where("id = ? AND active = ?", body.ProductID, true).First(&product).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Product not found or not available"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load product"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate variant if provided
|
||||
if body.VariantID != nil {
|
||||
var variant models.EshopProductVariant
|
||||
if err := ctrl.DB.Where("id = ? AND product_id = ?", *body.VariantID, body.ProductID).First(&variant).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Product variant not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load variant"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check stock (negative values mean unlimited)
|
||||
if variant.StockQty >= 0 && variant.StockQty < body.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock for this variant"})
|
||||
return
|
||||
}
|
||||
}
|
||||
cartObj, err := ctrl.findOrCreateCart(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart"})
|
||||
return
|
||||
}
|
||||
// Upsert cart item
|
||||
var item models.EshopCartItem
|
||||
q := ctrl.DB.Where("cart_id = ? AND product_id = ?", cartObj.ID, body.ProductID)
|
||||
if body.VariantID != nil {
|
||||
q = q.Where("variant_id = ?", *body.VariantID)
|
||||
}
|
||||
if err := q.First(&item).Error; err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
|
||||
return
|
||||
}
|
||||
item = models.EshopCartItem{
|
||||
CartID: cartObj.ID,
|
||||
ProductID: body.ProductID,
|
||||
VariantID: body.VariantID,
|
||||
Quantity: body.Quantity,
|
||||
UnitPriceCents: product.PriceCents,
|
||||
Currency: product.Currency,
|
||||
}
|
||||
if item.Currency == "" {
|
||||
item.Currency = cartObj.Currency
|
||||
}
|
||||
if err := ctrl.DB.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add item"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Update quantity
|
||||
item.Quantity += body.Quantity
|
||||
if item.Quantity <= 0 {
|
||||
if err := ctrl.DB.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := ctrl.DB.Save(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// UpdateItem updates quantity of a cart item
|
||||
func (ctrl *CartController) UpdateItem(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var body struct {
|
||||
Quantity int `json:"quantity"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Quantity < 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"})
|
||||
return
|
||||
}
|
||||
var item models.EshopCartItem
|
||||
if err := ctrl.DB.First(&item, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cart item not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load cart item"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check ownership (simplified) - in real app check if item belongs to user's cart
|
||||
// Here we assume if they know the ID, they can edit it (or rely on middleware/session match in findOrCreateCart flow if we enforced it strictly)
|
||||
// Ideally we should check if item.CartID belongs to current session/user.
|
||||
// For MVP let's leave it as is, or add a check:
|
||||
cc := ctrl.getCartContext(c)
|
||||
var cart models.EshopCart
|
||||
if err := ctrl.DB.First(&cart, item.CartID).Error; err == nil {
|
||||
if cc.UserID != nil && (cart.UserID == nil || *cart.UserID != *cc.UserID) {
|
||||
// user mismatch
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
if cc.UserID == nil && cc.SessionToken != "" && cart.SessionToken != cc.SessionToken {
|
||||
// token mismatch
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if body.Quantity == 0 {
|
||||
if err := ctrl.DB.Delete(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
item.Quantity = body.Quantity
|
||||
if err := ctrl.DB.Save(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update cart"})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// RemoveItem removes an item from the cart
|
||||
func (ctrl *CartController) RemoveItem(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := ctrl.DB.Delete(&models.EshopCartItem{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove item"})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services/eshop"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PaymentProvider represents a minimal interface for payment services used by checkout.
|
||||
// It is implemented by RevolutService, StripeService and can be satisfied by fakes in tests.
|
||||
type PaymentProvider interface {
|
||||
CreatePayment(order *models.EshopOrder) (*eshop.PaymentResult, error)
|
||||
}
|
||||
|
||||
type CheckoutController struct {
|
||||
DB *gorm.DB
|
||||
RevolutService PaymentProvider
|
||||
StripeService *eshop.StripeService
|
||||
Config *config.Config
|
||||
}
|
||||
|
||||
func NewCheckoutController(db *gorm.DB, cfg *config.Config) *CheckoutController {
|
||||
return &CheckoutController{
|
||||
DB: db,
|
||||
RevolutService: eshop.NewRevolutService(cfg),
|
||||
StripeService: eshop.NewStripeService(cfg),
|
||||
Config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
type CheckoutRequest struct {
|
||||
FirstName string `json:"first_name" binding:"required"`
|
||||
LastName string `json:"last_name" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Phone string `json:"phone"`
|
||||
BillingAddress json.RawMessage `json:"billing_address"`
|
||||
ShippingAddress json.RawMessage `json:"shipping_address"`
|
||||
ShippingMethod string `json:"shipping_method" binding:"required"`
|
||||
// For Packeta, we might receive packet_point_id in shipping_address or separately
|
||||
}
|
||||
|
||||
func (ctrl *CheckoutController) Checkout(c *gin.Context) {
|
||||
var req CheckoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid email address"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate phone format (basic Czech phone validation)
|
||||
if req.Phone != "" {
|
||||
// Remove spaces, dashes, parentheses
|
||||
phone := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(req.Phone, " ", ""), "-", ""), "(", "")
|
||||
phone = strings.ReplaceAll(phone, ")", "")
|
||||
|
||||
// Check if it starts with +420 or is 9 digits (Czech format)
|
||||
if !strings.HasPrefix(phone, "+420") && len(phone) != 9 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid phone number format"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Validate shipping method
|
||||
validShippingMethods := []string{"packeta", "courier"}
|
||||
isValidShipping := false
|
||||
for _, method := range validShippingMethods {
|
||||
if req.ShippingMethod == method {
|
||||
isValidShipping = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValidShipping {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid shipping method"})
|
||||
return
|
||||
}
|
||||
|
||||
// For Packeta, validate that shipping address contains point ID
|
||||
if req.ShippingMethod == "packeta" {
|
||||
var pointData struct {
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(req.ShippingAddress, &pointData); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Packeta point data"})
|
||||
return
|
||||
}
|
||||
if pointData.ID == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Packeta point ID is required"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Get Cart
|
||||
// Helper to find cart (duplicated from main.go - should be refactored to service)
|
||||
// For now, let's just assume we can get it via user or session
|
||||
userIDVal, _ := c.Get("userID")
|
||||
var userID *uint
|
||||
if u, ok := userIDVal.(uint); ok {
|
||||
userID = &u
|
||||
}
|
||||
sessionToken := c.GetHeader("X-Session-Token")
|
||||
if sessionToken == "" {
|
||||
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
|
||||
sessionToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
var cart models.EshopCart
|
||||
q := ctrl.DB.Preload("Items").Preload("Items.Product").Preload("Items.Variant").Where("completed = ?", false)
|
||||
if userID != nil {
|
||||
q = q.Where("user_id = ?", *userID)
|
||||
} else if sessionToken != "" {
|
||||
q = q.Where("session_token = ?", sessionToken)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No session identified"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := q.First(&cart).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Cart not found or expired"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve cart"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(cart.Items) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Cart is empty"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate cart items and check inventory
|
||||
for _, item := range cart.Items {
|
||||
if item.Quantity <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid item quantity"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if product still exists and is active
|
||||
var product models.EshopProduct
|
||||
if err := ctrl.DB.Where("id = ? AND active = ?", item.ProductID, true).First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Product no longer available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check variant if exists
|
||||
if item.VariantID != nil {
|
||||
var variant models.EshopProductVariant
|
||||
if err := ctrl.DB.Where("id = ? AND product_id = ?", item.VariantID, item.ProductID).First(&variant).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Product variant no longer available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check stock for variant (negative values mean unlimited)
|
||||
if variant.StockQty >= 0 && variant.StockQty < item.Quantity {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Insufficient stock for product variant"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verify price hasn't changed
|
||||
if item.UnitPriceCents != product.PriceCents {
|
||||
// For MVP, we'll allow it but log it
|
||||
logger.Warn("Price mismatch for product %d: cart %d vs current %d", item.ProductID, item.UnitPriceCents, product.PriceCents)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Calculate totals
|
||||
var itemsTotal int64
|
||||
for _, item := range cart.Items {
|
||||
itemsTotal += item.UnitPriceCents * int64(item.Quantity)
|
||||
}
|
||||
|
||||
// Shipping price - simplistic logic for MVP
|
||||
var shippingPrice int64 = 0
|
||||
if req.ShippingMethod == "packeta" {
|
||||
shippingPrice = 7900 // 79 CZK
|
||||
} else if req.ShippingMethod == "courier" {
|
||||
shippingPrice = 9900 // 99 CZK
|
||||
}
|
||||
|
||||
totalAmount := itemsTotal + shippingPrice
|
||||
|
||||
// 3. Create Order
|
||||
// Generate order number (e.g. 202510001)
|
||||
orderNumber := fmt.Sprintf("%s%d", time.Now().Format("200601"), time.Now().Unix()%100000) // simplified
|
||||
|
||||
order := models.EshopOrder{
|
||||
OrderNumber: orderNumber,
|
||||
UserID: userID,
|
||||
SessionToken: sessionToken,
|
||||
Email: req.Email,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
BillingAddressJSON: string(req.BillingAddress),
|
||||
ShippingAddressJSON: string(req.ShippingAddress),
|
||||
Status: "awaiting_payment",
|
||||
TotalAmountCents: totalAmount,
|
||||
Currency: cart.Currency,
|
||||
ShippingMethod: req.ShippingMethod,
|
||||
ShippingPriceCents: shippingPrice,
|
||||
}
|
||||
|
||||
tx := ctrl.DB.Begin()
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create order: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
|
||||
return
|
||||
}
|
||||
|
||||
// Create items
|
||||
for _, cartItem := range cart.Items {
|
||||
orderItem := models.EshopOrderItem{
|
||||
OrderID: order.ID,
|
||||
ProductID: cartItem.ProductID,
|
||||
VariantID: cartItem.VariantID,
|
||||
Name: cartItem.Product.Name,
|
||||
Quantity: cartItem.Quantity,
|
||||
UnitPriceCents: cartItem.UnitPriceCents,
|
||||
Currency: cartItem.Currency,
|
||||
VATRate: cartItem.Product.VATRate,
|
||||
}
|
||||
// Add variant name if exists
|
||||
if cartItem.Variant != nil {
|
||||
orderItem.Name += " (" + cartItem.Variant.Name + ")"
|
||||
orderItem.SKU = cartItem.Variant.SKU
|
||||
}
|
||||
if err := tx.Create(&orderItem).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create order item: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order items"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Choose payment provider (Stripe preferred, then Revolut)
|
||||
provider := ""
|
||||
if ctrl.Config.StripeEnabled {
|
||||
provider = "stripe"
|
||||
} else if ctrl.Config.RevolutEnabled {
|
||||
provider = "revolut"
|
||||
}
|
||||
|
||||
var result *eshop.PaymentResult
|
||||
var providerErr error
|
||||
|
||||
switch provider {
|
||||
case "stripe":
|
||||
result, providerErr = ctrl.StripeService.CreatePayment(&order)
|
||||
case "revolut":
|
||||
result, providerErr = ctrl.RevolutService.CreatePayment(&order)
|
||||
}
|
||||
|
||||
// Handle different payment provider responses
|
||||
if provider != "" && providerErr == nil && result != nil {
|
||||
payment := models.EshopPayment{
|
||||
OrderID: order.ID,
|
||||
Provider: provider,
|
||||
ProviderPaymentID: result.ProviderPaymentID,
|
||||
Status: "pending",
|
||||
AmountCents: order.TotalAmountCents,
|
||||
Currency: order.Currency,
|
||||
RawPayloadJSON: result.RawPayloadJSON,
|
||||
}
|
||||
if err := tx.Create(&payment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create payment record: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save payment info"})
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
// Different response format based on provider
|
||||
response := gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_number": order.OrderNumber,
|
||||
"payment_provider": provider,
|
||||
}
|
||||
|
||||
if provider == "stripe" {
|
||||
// For Stripe, return the client secret for frontend confirmation
|
||||
var paymentData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result.RawPayloadJSON), &paymentData); err == nil {
|
||||
if clientSecret, ok := paymentData["client_secret"].(string); ok {
|
||||
response["client_secret"] = clientSecret
|
||||
}
|
||||
}
|
||||
} else if strings.TrimSpace(result.RedirectURL) != "" {
|
||||
// For redirect-based providers (Revolut)
|
||||
response["payment_redirect_url"] = result.RedirectURL
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
// If provider was selected but failed or is not implemented yet, log the error
|
||||
if provider != "" && providerErr != nil {
|
||||
logger.Error("Payment provider error (%s): %v", provider, providerErr)
|
||||
}
|
||||
|
||||
// 5. No online payment provider available – fall back to manual email instructions
|
||||
supportEmail := ctrl.getSupportEmail()
|
||||
manualMeta := map[string]string{
|
||||
"reason": "no_online_provider",
|
||||
"contact_email": supportEmail,
|
||||
}
|
||||
if providerErr != nil {
|
||||
manualMeta["provider_error"] = providerErr.Error()
|
||||
}
|
||||
metaJSON, _ := json.Marshal(manualMeta)
|
||||
|
||||
manualPayment := models.EshopPayment{
|
||||
OrderID: order.ID,
|
||||
Provider: "manual_email",
|
||||
Status: "pending",
|
||||
AmountCents: order.TotalAmountCents,
|
||||
Currency: order.Currency,
|
||||
RawPayloadJSON: string(metaJSON),
|
||||
}
|
||||
if err := tx.Create(&manualPayment).Error; err != nil {
|
||||
tx.Rollback()
|
||||
logger.Error("Failed to create manual payment record: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save payment info"})
|
||||
return
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"order_id": order.ID,
|
||||
"order_number": order.OrderNumber,
|
||||
"manual_payment": true,
|
||||
"contact_email": supportEmail,
|
||||
})
|
||||
}
|
||||
|
||||
// getSupportEmail returns the best support email for manual orders
|
||||
func (ctrl *CheckoutController) getSupportEmail() string {
|
||||
email := strings.TrimSpace(ctrl.Config.ContactEmail)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(ctrl.Config.AdminEmail)
|
||||
}
|
||||
|
||||
var settings models.EshopSettings
|
||||
if err := ctrl.DB.First(&settings).Error; err == nil {
|
||||
if se := strings.TrimSpace(settings.SupportEmail); se != "" {
|
||||
email = se
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
// Safe default to avoid returning empty email
|
||||
email = "info@example.com"
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
// GetOrder returns a single order by ID, ensuring the user/session owns it
|
||||
func (ctrl *CheckoutController) GetOrder(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
userIDVal, _ := c.Get("userID")
|
||||
var userID *uint
|
||||
if u, ok := userIDVal.(uint); ok {
|
||||
userID = &u
|
||||
}
|
||||
sessionToken := c.GetHeader("X-Session-Token")
|
||||
if sessionToken == "" {
|
||||
if cookie, err := c.Request.Cookie("eshop_session_token"); err == nil {
|
||||
sessionToken = cookie.Value
|
||||
}
|
||||
}
|
||||
|
||||
var order models.EshopOrder
|
||||
q := ctrl.DB.Preload("Items").Preload("Payments").Preload("Labels").Where("id = ?", id)
|
||||
|
||||
// Security check: only allow owner to view
|
||||
if userID != nil {
|
||||
q = q.Where("user_id = ?", *userID)
|
||||
} else if sessionToken != "" {
|
||||
q = q.Where("session_token = ?", sessionToken)
|
||||
} else {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := q.First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, order)
|
||||
}
|
||||
|
||||
// RevolutWebhook handles asynchronous notifications from Revolut about payment state changes.
|
||||
// It expects a JSON payload containing order status updates.
|
||||
func (ctrl *CheckoutController) RevolutWebhook(c *gin.Context) {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.Error("[revolut-webhook] failed to read body: %v", err)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
signature := strings.TrimSpace(c.GetHeader("Revolut-Pay-Payload-Signature"))
|
||||
revolutService := eshop.NewRevolutService(ctrl.Config)
|
||||
valid, err := revolutService.VerifyWebhook(body, signature)
|
||||
if err != nil {
|
||||
logger.Error("[revolut-webhook] signature verification failed: %v", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !valid {
|
||||
logger.Error("[revolut-webhook] invalid signature")
|
||||
c.Status(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse webhook payload
|
||||
webhook, err := revolutService.ParseWebhook(body)
|
||||
if err != nil {
|
||||
logger.Error("[revolut-webhook] failed to parse JSON: %v", err)
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Find payment and related order
|
||||
tx := ctrl.DB.Begin()
|
||||
var payment models.EshopPayment
|
||||
if err := tx.Preload("Order").Where("provider = ? AND provider_payment_id = ?", "revolut", webhook.Order.ID).First(&payment).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] payment not found for id=%s: %v", webhook.Order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
order := payment.Order
|
||||
if order.ID == 0 {
|
||||
logger.Error("[revolut-webhook] loaded payment without order for id=%s", webhook.Order.ID)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Decide new statuses based on Revolut order status
|
||||
newPaymentStatus := ""
|
||||
newOrderStatus := ""
|
||||
|
||||
switch webhook.Order.Status {
|
||||
case "COMPLETED":
|
||||
newPaymentStatus = "paid"
|
||||
newOrderStatus = "paid"
|
||||
case "CANCELLED":
|
||||
newPaymentStatus = "cancelled"
|
||||
newOrderStatus = "cancelled"
|
||||
case "FAILED":
|
||||
newPaymentStatus = "failed"
|
||||
newOrderStatus = "cancelled"
|
||||
default:
|
||||
// For other states we just store the raw payload and return OK
|
||||
logger.Info("[revolut-webhook] unhandled status %s for payment %s", webhook.Order.Status, webhook.Order.ID)
|
||||
if err := tx.Model(&payment).Updates(map[string]interface{}{
|
||||
"raw_payload_json": string(body),
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to store raw payload for payment %s: %v", webhook.Order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
tx.Commit()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Update payment status
|
||||
updates := map[string]interface{}{
|
||||
"status": newPaymentStatus,
|
||||
"raw_payload_json": string(body),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if err := tx.Model(&payment).Updates(updates).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to update payment %s: %v", webhook.Order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Update order status if needed
|
||||
if order.Status != newOrderStatus {
|
||||
if err := tx.Model(&models.EshopOrder{}).
|
||||
Where("id = ?", order.ID).
|
||||
Update("status", newOrderStatus).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to update order %d status: %v", order.ID, err)
|
||||
tx.Rollback()
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark cart as completed if order is paid
|
||||
if newOrderStatus == "paid" {
|
||||
cartQ := tx.Model(&models.EshopCart{}).Where("completed = ?", false)
|
||||
if order.UserID != nil {
|
||||
cartQ = cartQ.Where("user_id = ?", order.UserID)
|
||||
} else {
|
||||
cartQ = cartQ.Where("session_token = ?", order.SessionToken)
|
||||
}
|
||||
if err := cartQ.Update("completed", true).Error; err != nil {
|
||||
logger.Error("[revolut-webhook] failed to mark cart as completed for order %d: %v", order.ID, err)
|
||||
// Non-fatal, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
logger.Info("[revolut-webhook] processed payment %s with status %s for order %d", webhook.Order.ID, webhook.Order.Status, order.ID)
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// StripeWebhook handles Stripe webhook events
|
||||
func (ctrl *CheckoutController) StripeWebhook(c *gin.Context) {
|
||||
if ctrl.Config.StripeWebhookSecret == "" {
|
||||
logger.Error("[stripe-webhook] webhook secret not configured")
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Webhook not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.Error("[stripe-webhook] failed to read webhook body: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
|
||||
signature := c.GetHeader("Stripe-Signature")
|
||||
if signature == "" {
|
||||
logger.Error("[stripe-webhook] no signature provided")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No signature"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the event
|
||||
var event map[string]interface{}
|
||||
if err := json.Unmarshal(body, &event); err != nil {
|
||||
logger.Error("[stripe-webhook] failed to parse event: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
eventType, ok := event["type"].(string)
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] no event type")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No event type"})
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[stripe-webhook] received event: %s", eventType)
|
||||
|
||||
switch eventType {
|
||||
case "payment_intent.succeeded":
|
||||
ctrl.handleStripePaymentSucceeded(event, c)
|
||||
case "payment_intent.payment_failed":
|
||||
ctrl.handleStripePaymentFailed(event, c)
|
||||
default:
|
||||
logger.Info("[stripe-webhook] unhandled event type: %s", eventType)
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (ctrl *CheckoutController) handleStripePaymentSucceeded(event map[string]interface{}, c *gin.Context) {
|
||||
paymentIntent, ok := event["data"].(map[string]interface{})["object"].(map[string]interface{})
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] invalid payment intent object")
|
||||
return
|
||||
}
|
||||
|
||||
paymentIntentID, ok := paymentIntent["id"].(string)
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] no payment intent ID")
|
||||
return
|
||||
}
|
||||
|
||||
metadata, _ := paymentIntent["metadata"].(map[string]interface{})
|
||||
orderIDStr, _ := metadata["order_id"].(string)
|
||||
|
||||
if orderIDStr == "" {
|
||||
logger.Error("[stripe-webhook] no order ID in payment intent metadata")
|
||||
return
|
||||
}
|
||||
|
||||
tx := ctrl.DB.Begin()
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
// Find the payment record
|
||||
var payment models.EshopPayment
|
||||
if err := tx.Where("provider_payment_id = ? AND provider = ?", paymentIntentID, "stripe").First(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] payment record not found: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Update payment status
|
||||
payment.Status = "succeeded"
|
||||
if err := tx.Save(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to update payment status: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Update order status
|
||||
if err := tx.Model(&models.EshopOrder{}).Where("id = ?", payment.OrderID).Update("status", "paid").Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to update order status: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Get order for cart completion
|
||||
var order models.EshopOrder
|
||||
if err := tx.First(&order, payment.OrderID).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to get order: %v", err)
|
||||
tx.Rollback()
|
||||
return
|
||||
}
|
||||
|
||||
// Mark cart as completed
|
||||
cartQ := tx.Model(&models.EshopCart{}).Where("completed = ?", false)
|
||||
if order.UserID != nil {
|
||||
cartQ = cartQ.Where("user_id = ?", *order.UserID)
|
||||
} else if strings.TrimSpace(order.SessionToken) != "" {
|
||||
cartQ = cartQ.Where("session_token = ?", order.SessionToken)
|
||||
}
|
||||
if err := cartQ.Update("completed", true).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to mark cart as completed: %v", err)
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
logger.Info("[stripe-webhook] payment succeeded for order %d, payment intent %s", payment.OrderID, paymentIntentID)
|
||||
}
|
||||
|
||||
func (ctrl *CheckoutController) handleStripePaymentFailed(event map[string]interface{}, c *gin.Context) {
|
||||
paymentIntent, ok := event["data"].(map[string]interface{})["object"].(map[string]interface{})
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] invalid payment intent object")
|
||||
return
|
||||
}
|
||||
|
||||
paymentIntentID, ok := paymentIntent["id"].(string)
|
||||
if !ok {
|
||||
logger.Error("[stripe-webhook] no payment intent ID")
|
||||
return
|
||||
}
|
||||
|
||||
// Find and update payment record
|
||||
var payment models.EshopPayment
|
||||
if err := ctrl.DB.Where("provider_payment_id = ? AND provider = ?", paymentIntentID, "stripe").First(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] payment record not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
payment.Status = "failed"
|
||||
if err := ctrl.DB.Save(&payment).Error; err != nil {
|
||||
logger.Error("[stripe-webhook] failed to update payment status: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("[stripe-webhook] payment failed for order %d, payment intent %s", payment.OrderID, paymentIntentID)
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
eshoppkg "fotbal-club/internal/services/eshop"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// fakePaymentProvider is a simple test double for PaymentProvider used to
|
||||
// simulate payment providers without performing real HTTP calls.
|
||||
type fakePaymentProvider struct {
|
||||
called bool
|
||||
result *eshoppkg.PaymentResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakePaymentProvider) CreatePayment(order *models.EshopOrder) (*eshoppkg.PaymentResult, error) {
|
||||
f.called = true
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
// TestCheckoutController_ManualPaymentFallback_CreatesOrderAndPayment verifies
|
||||
// that when no online payment providers are enabled, Checkout creates an order
|
||||
// and a manual_email payment and returns the expected JSON response.
|
||||
func TestCheckoutController_ManualPaymentFallback_CreatesOrderAndPayment(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// In-memory SQLite DB for isolated test
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory db: %v", err)
|
||||
}
|
||||
|
||||
// Migrate the minimal set of tables needed for checkout
|
||||
if err := db.AutoMigrate(
|
||||
&models.EshopProduct{},
|
||||
&models.EshopCart{},
|
||||
&models.EshopCartItem{},
|
||||
&models.EshopOrder{},
|
||||
&models.EshopOrderItem{},
|
||||
&models.EshopPayment{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate schemas: %v", err)
|
||||
}
|
||||
|
||||
// Seed one active product
|
||||
product := models.EshopProduct{
|
||||
Slug: "test-product",
|
||||
Name: "Test Product",
|
||||
PriceCents: 10000,
|
||||
Currency: "CZK",
|
||||
VATRate: 21,
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatalf("failed to seed product: %v", err)
|
||||
}
|
||||
|
||||
// Seed cart with one item for a specific session token
|
||||
const sessionToken = "test-session-token"
|
||||
cart := models.EshopCart{
|
||||
SessionToken: sessionToken,
|
||||
Currency: "CZK",
|
||||
Completed: false,
|
||||
}
|
||||
if err := db.Create(&cart).Error; err != nil {
|
||||
t.Fatalf("failed to seed cart: %v", err)
|
||||
}
|
||||
|
||||
cartItem := models.EshopCartItem{
|
||||
CartID: cart.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 2,
|
||||
UnitPriceCents: product.PriceCents,
|
||||
Currency: product.Currency,
|
||||
}
|
||||
if err := db.Create(&cartItem).Error; err != nil {
|
||||
t.Fatalf("failed to seed cart item: %v", err)
|
||||
}
|
||||
|
||||
// Config with no online payment providers -> forces manual_email fallback
|
||||
cfg := &config.Config{
|
||||
ContactEmail: "eshop-support@example.com",
|
||||
RevolutEnabled: false,
|
||||
}
|
||||
|
||||
ctrl := &CheckoutController{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
// Build checkout request payload
|
||||
body := map[string]interface{}{
|
||||
"first_name": "Jan",
|
||||
"last_name": "Novák",
|
||||
"email": "jan.novak@example.com",
|
||||
"phone": "+420123456789",
|
||||
"billing_address": map[string]string{
|
||||
"street": "Testovací 123",
|
||||
"city": "Praha",
|
||||
"zip": "11000",
|
||||
"country": "CZ",
|
||||
},
|
||||
"shipping_address": map[string]string{
|
||||
"packet_point_id": "PACKETA123",
|
||||
},
|
||||
"shipping_method": "packeta",
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal request body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/checkout", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Execute handler
|
||||
ctrl.Checkout(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Parse JSON response
|
||||
var resp struct {
|
||||
OrderID uint `json:"order_id"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
ManualPayment bool `json:"manual_payment"`
|
||||
ContactEmail string `json:"contact_email"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v (body: %s)", err, w.Body.String())
|
||||
}
|
||||
|
||||
if resp.OrderID == 0 {
|
||||
t.Fatalf("expected non-zero order_id in response")
|
||||
}
|
||||
if !resp.ManualPayment {
|
||||
t.Errorf("expected manual_payment=true in response")
|
||||
}
|
||||
if resp.ContactEmail != cfg.ContactEmail {
|
||||
t.Errorf("expected contact_email %q, got %q", cfg.ContactEmail, resp.ContactEmail)
|
||||
}
|
||||
|
||||
// Load order with related items and payments
|
||||
var order models.EshopOrder
|
||||
if err := db.Preload("Items").Preload("Payments").First(&order, resp.OrderID).Error; err != nil {
|
||||
t.Fatalf("failed to load created order: %v", err)
|
||||
}
|
||||
|
||||
// itemsTotal = 2 * 10000, shippingPrice = 7900 for Packeta -> 27900
|
||||
var expectedItemsTotal int64 = int64(2 * 10000)
|
||||
var expectedShipping int64 = 7900
|
||||
var expectedTotal int64 = expectedItemsTotal + expectedShipping
|
||||
|
||||
if order.TotalAmountCents != expectedTotal {
|
||||
t.Errorf("unexpected order total: got %d, want %d", order.TotalAmountCents, expectedTotal)
|
||||
}
|
||||
if order.ShippingPriceCents != expectedShipping {
|
||||
t.Errorf("unexpected shipping price: got %d, want %d", order.ShippingPriceCents, expectedShipping)
|
||||
}
|
||||
if order.Status != "awaiting_payment" {
|
||||
t.Errorf("unexpected order status: got %q, want %q", order.Status, "awaiting_payment")
|
||||
}
|
||||
|
||||
if len(order.Items) != 1 {
|
||||
t.Fatalf("expected 1 order item, got %d", len(order.Items))
|
||||
}
|
||||
|
||||
if len(order.Payments) != 1 {
|
||||
t.Fatalf("expected 1 payment, got %d", len(order.Payments))
|
||||
}
|
||||
payment := order.Payments[0]
|
||||
if payment.Provider != "manual_email" {
|
||||
t.Errorf("expected payment provider manual_email, got %q", payment.Provider)
|
||||
}
|
||||
if payment.AmountCents != order.TotalAmountCents {
|
||||
t.Errorf("unexpected payment amount: got %d, want %d", payment.AmountCents, order.TotalAmountCents)
|
||||
}
|
||||
if payment.Status != "pending" {
|
||||
t.Errorf("unexpected payment status: got %q, want %q", payment.Status, "pending")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckoutController_RevolutWebhook_UpdatesPaymentAndOrderStatuses
|
||||
// verifies that a Revolut webhook with COMPLETED status correctly updates
|
||||
// payment status to "paid", order status to "paid", and marks the cart as completed.
|
||||
func TestCheckoutController_RevolutWebhook_Paid_UpdatesOrderAndCart(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// In-memory SQLite DB for isolated test
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory db: %v", err)
|
||||
}
|
||||
|
||||
// Migrate minimal tables needed for webhook logic
|
||||
if err := db.AutoMigrate(
|
||||
&models.EshopCart{},
|
||||
&models.EshopCartItem{},
|
||||
&models.EshopOrder{},
|
||||
&models.EshopPayment{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate schemas: %v", err)
|
||||
}
|
||||
|
||||
const (
|
||||
paymentID = "123456"
|
||||
sessionToken = "webhook-session"
|
||||
)
|
||||
|
||||
// Seed cart for the given session token
|
||||
cart := models.EshopCart{
|
||||
SessionToken: sessionToken,
|
||||
Currency: "CZK",
|
||||
Completed: false,
|
||||
}
|
||||
if err := db.Create(&cart).Error; err != nil {
|
||||
t.Fatalf("failed to seed cart: %v", err)
|
||||
}
|
||||
|
||||
// Seed order linked to the same session token
|
||||
order := models.EshopOrder{
|
||||
SessionToken: sessionToken,
|
||||
Status: "awaiting_payment",
|
||||
Currency: "CZK",
|
||||
TotalAmountCents: 15000,
|
||||
ShippingMethod: "packeta",
|
||||
ShippingPriceCents: 7900,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
t.Fatalf("failed to seed order: %v", err)
|
||||
}
|
||||
|
||||
// Seed pending Revolut payment for the order
|
||||
payment := models.EshopPayment{
|
||||
OrderID: order.ID,
|
||||
Provider: "revolut",
|
||||
ProviderPaymentID: "123456", // Must match webhook order ID
|
||||
Status: "pending",
|
||||
AmountCents: order.TotalAmountCents,
|
||||
Currency: order.Currency,
|
||||
}
|
||||
if err := db.Create(&payment).Error; err != nil {
|
||||
t.Fatalf("failed to seed payment: %v", err)
|
||||
}
|
||||
|
||||
ctrl := &CheckoutController{
|
||||
DB: db,
|
||||
Config: &config.Config{},
|
||||
}
|
||||
|
||||
// Build Revolut webhook payload with COMPLETED status
|
||||
body := map[string]interface{}{
|
||||
"type": "ORDER_COMPLETED",
|
||||
"order_id": "123456",
|
||||
"order": map[string]interface{}{
|
||||
"id": "123456",
|
||||
"amount": 10000,
|
||||
"currency": "CZK",
|
||||
"status": "COMPLETED",
|
||||
"merchant_order_id": "TEST-001",
|
||||
"created_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
}
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal webhook body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create webhook request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Execute webhook handler
|
||||
ctrl.RevolutWebhook(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Reload payment, order and cart
|
||||
var updatedPayment models.EshopPayment
|
||||
if err := db.First(&updatedPayment, payment.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated payment: %v", err)
|
||||
}
|
||||
|
||||
if updatedPayment.Status != "paid" {
|
||||
t.Errorf("unexpected payment status: got %q, want %q", updatedPayment.Status, "paid")
|
||||
}
|
||||
|
||||
var updatedOrder models.EshopOrder
|
||||
if err := db.First(&updatedOrder, order.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated order: %v", err)
|
||||
}
|
||||
if updatedOrder.Status != "paid" {
|
||||
t.Errorf("unexpected order status: got %q, want %q", updatedOrder.Status, "paid")
|
||||
}
|
||||
|
||||
var updatedCart models.EshopCart
|
||||
if err := db.First(&updatedCart, cart.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated cart: %v", err)
|
||||
}
|
||||
if !updatedCart.Completed {
|
||||
t.Errorf("expected cart to be marked completed, but it was not")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckoutController_FullPaidFlow_Revolut_E2E covers the full flow
|
||||
// checkout → Revolut payment creation (via fake provider) → Revolut webhook
|
||||
// updating payment/order status and marking the cart as completed.
|
||||
func TestCheckoutController_FullPaidFlow_Revolut_E2E(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// In-memory SQLite DB for isolated test
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory db: %v", err)
|
||||
}
|
||||
|
||||
// Migrate tables needed for checkout + payments
|
||||
if err := db.AutoMigrate(
|
||||
&models.EshopProduct{},
|
||||
&models.EshopCart{},
|
||||
&models.EshopCartItem{},
|
||||
&models.EshopOrder{},
|
||||
&models.EshopOrderItem{},
|
||||
&models.EshopPayment{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate schemas: %v", err)
|
||||
}
|
||||
|
||||
// Seed one active product
|
||||
product := models.EshopProduct{
|
||||
Slug: "e2e-product",
|
||||
Name: "E2E Product",
|
||||
PriceCents: 15000,
|
||||
Currency: "CZK",
|
||||
VATRate: 21,
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatalf("failed to seed product: %v", err)
|
||||
}
|
||||
|
||||
// Seed cart with one item for a specific session token
|
||||
const sessionToken = "e2e-session-token"
|
||||
cart := models.EshopCart{
|
||||
SessionToken: sessionToken,
|
||||
Currency: "CZK",
|
||||
Completed: false,
|
||||
}
|
||||
if err := db.Create(&cart).Error; err != nil {
|
||||
t.Fatalf("failed to seed cart: %v", err)
|
||||
}
|
||||
|
||||
cartItem := models.EshopCartItem{
|
||||
CartID: cart.ID,
|
||||
ProductID: product.ID,
|
||||
Quantity: 1,
|
||||
UnitPriceCents: product.PriceCents,
|
||||
Currency: product.Currency,
|
||||
}
|
||||
if err := db.Create(&cartItem).Error; err != nil {
|
||||
t.Fatalf("failed to seed cart item: %v", err)
|
||||
}
|
||||
|
||||
// Config with Revolut enabled so that Checkout chooses Revolut provider
|
||||
cfg := &config.Config{
|
||||
ContactEmail: "eshop-support@example.com",
|
||||
RevolutEnabled: true,
|
||||
}
|
||||
|
||||
const (
|
||||
providerPaymentID = "555666"
|
||||
redirectURL = "https://gw.gopay.test/pay/555666"
|
||||
)
|
||||
|
||||
fakeProv := &fakePaymentProvider{
|
||||
result: &eshoppkg.PaymentResult{
|
||||
RedirectURL: redirectURL,
|
||||
ProviderPaymentID: providerPaymentID,
|
||||
RawPayloadJSON: `{"id":555666,"gw_url":"` + redirectURL + `"}`,
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := &CheckoutController{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
body := map[string]interface{}{
|
||||
"first_name": "Petr",
|
||||
"last_name": "Svoboda",
|
||||
"email": "petr.svoboda@example.com",
|
||||
"phone": "+420777000111",
|
||||
"billing_address": map[string]string{
|
||||
"street": "E2E 1",
|
||||
"city": "Praha",
|
||||
"zip": "11000",
|
||||
"country": "CZ",
|
||||
},
|
||||
"shipping_address": map[string]string{
|
||||
"packet_point_id": "PACKETA-E2E",
|
||||
},
|
||||
"shipping_method": "packeta",
|
||||
}
|
||||
|
||||
checkoutPayload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal checkout body: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/checkout", bytes.NewReader(checkoutPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create checkout request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
ctrl.Checkout(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("checkout expected status 200, got %d, body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if !fakeProv.called {
|
||||
t.Fatalf("expected fake payment provider to be called during checkout")
|
||||
}
|
||||
|
||||
var checkoutResp struct {
|
||||
OrderID uint `json:"order_id"`
|
||||
OrderNumber string `json:"order_number"`
|
||||
PaymentProvider string `json:"payment_provider"`
|
||||
PaymentRedirectURL string `json:"payment_redirect_url"`
|
||||
ManualPayment bool `json:"manual_payment"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &checkoutResp); err != nil {
|
||||
t.Fatalf("failed to unmarshal checkout response: %v (body: %s)", err, w.Body.String())
|
||||
}
|
||||
|
||||
if checkoutResp.OrderID == 0 {
|
||||
t.Fatalf("expected non-zero order_id in checkout response")
|
||||
}
|
||||
if checkoutResp.PaymentProvider != "revolut" {
|
||||
t.Errorf("expected payment_provider=revolut, got %q", checkoutResp.PaymentProvider)
|
||||
}
|
||||
if checkoutResp.PaymentRedirectURL != redirectURL {
|
||||
t.Errorf("unexpected payment_redirect_url: got %q, want %q", checkoutResp.PaymentRedirectURL, redirectURL)
|
||||
}
|
||||
if checkoutResp.ManualPayment {
|
||||
t.Errorf("did not expect manual_payment=true for Revolut flow")
|
||||
}
|
||||
|
||||
// Verify DB state after checkout but before webhook
|
||||
var order models.EshopOrder
|
||||
if err := db.Preload("Payments").First(&order, checkoutResp.OrderID).Error; err != nil {
|
||||
t.Fatalf("failed to load order after checkout: %v", err)
|
||||
}
|
||||
if order.Status != "awaiting_payment" {
|
||||
t.Errorf("unexpected order status after checkout: got %q, want %q", order.Status, "awaiting_payment")
|
||||
}
|
||||
if len(order.Payments) != 1 {
|
||||
t.Fatalf("expected 1 payment after checkout, got %d", len(order.Payments))
|
||||
}
|
||||
p := order.Payments[0]
|
||||
if p.Provider != "revolut" {
|
||||
t.Errorf("expected payment provider revolut, got %q", p.Provider)
|
||||
}
|
||||
if p.ProviderPaymentID != providerPaymentID {
|
||||
t.Errorf("unexpected provider payment id: got %q, want %q", p.ProviderPaymentID, providerPaymentID)
|
||||
}
|
||||
if p.Status != "pending" {
|
||||
t.Errorf("unexpected payment status after checkout: got %q, want %q", p.Status, "pending")
|
||||
}
|
||||
|
||||
var cartBefore models.EshopCart
|
||||
if err := db.First(&cartBefore, cart.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load cart after checkout: %v", err)
|
||||
}
|
||||
if cartBefore.Completed {
|
||||
t.Errorf("expected cart to be not completed before webhook")
|
||||
}
|
||||
|
||||
// ---- Phase 2: Revolut webhook marks payment/order as paid and cart as completed ----
|
||||
webhookBody := map[string]interface{}{
|
||||
"type": "ORDER_COMPLETED",
|
||||
"order_id": "555666",
|
||||
"order": map[string]interface{}{
|
||||
"id": "555666",
|
||||
"amount": 10000,
|
||||
"currency": "CZK",
|
||||
"status": "COMPLETED",
|
||||
"merchant_order_id": "TEST-002",
|
||||
"created_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
}
|
||||
webhookPayload, err := json.Marshal(webhookBody)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal webhook body: %v", err)
|
||||
}
|
||||
|
||||
webhookReq, err := http.NewRequest(http.MethodPost, "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(webhookPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create webhook request: %v", err)
|
||||
}
|
||||
webhookReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
webhookW := httptest.NewRecorder()
|
||||
webhookCtx, _ := gin.CreateTestContext(webhookW)
|
||||
webhookCtx.Request = webhookReq
|
||||
|
||||
ctrl.RevolutWebhook(webhookCtx)
|
||||
|
||||
if webhookW.Code != http.StatusOK {
|
||||
t.Fatalf("webhook expected status 200, got %d, body: %s", webhookW.Code, webhookW.Body.String())
|
||||
}
|
||||
|
||||
// Reload payment, order and cart after webhook
|
||||
var updatedPayment models.EshopPayment
|
||||
if err := db.First(&updatedPayment, p.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated payment after webhook: %v", err)
|
||||
}
|
||||
if updatedPayment.Status != "paid" {
|
||||
t.Errorf("unexpected payment status after webhook: got %q, want %q", updatedPayment.Status, "paid")
|
||||
}
|
||||
|
||||
var updatedOrder models.EshopOrder
|
||||
if err := db.First(&updatedOrder, order.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated order after webhook: %v", err)
|
||||
}
|
||||
if updatedOrder.Status != "paid" {
|
||||
t.Errorf("unexpected order status after webhook: got %q, want %q", updatedOrder.Status, "paid")
|
||||
}
|
||||
|
||||
var updatedCart models.EshopCart
|
||||
if err := db.First(&updatedCart, cart.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated cart after webhook: %v", err)
|
||||
}
|
||||
if !updatedCart.Completed {
|
||||
t.Errorf("expected cart to be completed after webhook, but it was not")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
eshoppkg "fotbal-club/internal/services/eshop"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestEshopE2E_FullPurchaseFlow tests the complete flow:
|
||||
// 1. Add item to cart (POST /cart/items)
|
||||
// TestCheckoutController_FullPaidFlow_Revolut_E2E covers the full flow
|
||||
// checkout → Revolut payment creation (via fake provider) → Revolut webhook
|
||||
// updating payment/order status and marking the cart as completed.
|
||||
func TestEshopE2E_FullPurchaseFlow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// 1. Setup DB
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory db: %v", err)
|
||||
}
|
||||
// Migrate all necessary tables
|
||||
if err := db.AutoMigrate(
|
||||
&models.EshopProduct{},
|
||||
&models.EshopProductVariant{},
|
||||
&models.EshopCart{},
|
||||
&models.EshopCartItem{},
|
||||
&models.EshopOrder{},
|
||||
&models.EshopOrderItem{},
|
||||
&models.EshopPayment{},
|
||||
&models.EshopShippingLabel{}, // if needed for checkout
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate schemas: %v", err)
|
||||
}
|
||||
|
||||
// 2. Seed Product
|
||||
product := models.EshopProduct{
|
||||
Slug: "jersey-home",
|
||||
Name: "Home Jersey",
|
||||
PriceCents: 150000, // 1500.00 CZK
|
||||
Currency: "CZK",
|
||||
VATRate: 21,
|
||||
Active: true,
|
||||
}
|
||||
if err := db.Create(&product).Error; err != nil {
|
||||
t.Fatalf("failed to seed product: %v", err)
|
||||
}
|
||||
|
||||
// 3. Setup Controllers & Router
|
||||
cfg := &config.Config{
|
||||
RevolutEnabled: true, // Enable Revolut for this test
|
||||
}
|
||||
|
||||
fakeProv := &fakePaymentProvider{
|
||||
result: &eshoppkg.PaymentResult{
|
||||
RedirectURL: "https://revolut.test/pay/123",
|
||||
ProviderPaymentID: "123456789",
|
||||
RawPayloadJSON: `{"id":123456789}`,
|
||||
},
|
||||
}
|
||||
|
||||
checkoutCtrl := &CheckoutController{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
RevolutService: fakeProv,
|
||||
}
|
||||
|
||||
cartCtrl := &CartController{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
// Middleware to simulate session token
|
||||
r.Use(func(c *gin.Context) {
|
||||
// In real app, this is done by JWTOptional/Session middleware
|
||||
// We'll just read header and set context
|
||||
token := c.GetHeader("X-Session-Token")
|
||||
if token != "" {
|
||||
c.Set("session_token", token)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
eshopGroup := r.Group("/api/v1/eshop")
|
||||
{
|
||||
eshopGroup.POST("/cart/items", cartCtrl.AddItem)
|
||||
eshopGroup.POST("/checkout", checkoutCtrl.Checkout)
|
||||
eshopGroup.POST("/payments/revolut/webhook", checkoutCtrl.RevolutWebhook)
|
||||
}
|
||||
|
||||
const sessionToken = "user-session-123"
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 1: Add Item to Cart
|
||||
// -------------------------------------------------------------------------
|
||||
addItemBody := map[string]interface{}{
|
||||
"product_id": product.ID,
|
||||
"quantity": 1,
|
||||
}
|
||||
payload, _ := json.Marshal(addItemBody)
|
||||
req1, _ := http.NewRequest("POST", "/api/v1/eshop/cart/items", bytes.NewReader(payload))
|
||||
req1.Header.Set("Content-Type", "application/json")
|
||||
req1.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w1, req1)
|
||||
|
||||
if w1.Code != http.StatusNoContent {
|
||||
t.Fatalf("AddItem failed: %d %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
// Verify cart exists in DB
|
||||
var cart models.EshopCart
|
||||
if err := db.Where("session_token = ?", sessionToken).First(&cart).Error; err != nil {
|
||||
t.Fatalf("failed to find cart: %v", err)
|
||||
}
|
||||
if cart.Completed {
|
||||
t.Fatal("new cart should not be completed")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 2: Checkout
|
||||
// -------------------------------------------------------------------------
|
||||
checkoutBody := map[string]interface{}{
|
||||
"first_name": "Test",
|
||||
"last_name": "User",
|
||||
"email": "test@example.com",
|
||||
"phone": "+420777123456",
|
||||
"billing_address": map[string]string{
|
||||
"street": "Test St 1",
|
||||
"city": "Prague",
|
||||
"zip": "10000",
|
||||
},
|
||||
"shipping_method": "personal_pickup", // simple method
|
||||
}
|
||||
payload2, _ := json.Marshal(checkoutBody)
|
||||
req2, _ := http.NewRequest("POST", "/api/v1/eshop/checkout", bytes.NewReader(payload2))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("Checkout failed: %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var checkoutResp struct {
|
||||
OrderID uint `json:"order_id"`
|
||||
Provider string `json:"payment_provider"`
|
||||
}
|
||||
json.Unmarshal(w2.Body.Bytes(), &checkoutResp)
|
||||
|
||||
if checkoutResp.OrderID == 0 {
|
||||
t.Fatal("expected order_id")
|
||||
}
|
||||
if checkoutResp.Provider != "revolut" {
|
||||
t.Fatalf("expected revolut provider, got %s", checkoutResp.Provider)
|
||||
}
|
||||
|
||||
// DEBUG: Print all payments
|
||||
var payments []models.EshopPayment
|
||||
db.Find(&payments)
|
||||
for _, p := range payments {
|
||||
t.Logf("Payment in DB: ID=%d, OrderID=%d, Provider=%s, ProviderPaymentID='%s'", p.ID, p.OrderID, p.Provider, p.ProviderPaymentID)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 3: Payment Webhook (Success)
|
||||
// -------------------------------------------------------------------------
|
||||
webhookBody := map[string]interface{}{
|
||||
"type": "ORDER_COMPLETED",
|
||||
"order_id": "123456789", // matches the fake provider ID
|
||||
"order": map[string]interface{}{
|
||||
"id": "123456789",
|
||||
"amount": 15000,
|
||||
"currency": "CZK",
|
||||
"status": "COMPLETED",
|
||||
"merchant_order_id": "TEST-001",
|
||||
"created_at": "2024-01-01T12:00:00Z",
|
||||
},
|
||||
"timestamp": "2024-01-01T12:00:00Z",
|
||||
}
|
||||
payload3, _ := json.Marshal(webhookBody)
|
||||
req3, _ := http.NewRequest("POST", "/api/v1/eshop/payments/revolut/webhook", bytes.NewReader(payload3))
|
||||
req3.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w3 := httptest.NewRecorder()
|
||||
r.ServeHTTP(w3, req3)
|
||||
|
||||
if w3.Code != http.StatusOK {
|
||||
t.Fatalf("Webhook failed: %d %s", w3.Code, w3.Body.String())
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 4: Verification
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Check Order Status
|
||||
var order models.EshopOrder
|
||||
if err := db.First(&order, checkoutResp.OrderID).Error; err != nil {
|
||||
t.Fatalf("failed to load order: %v", err)
|
||||
}
|
||||
if order.Status != "paid" {
|
||||
t.Errorf("expected order status 'paid', got '%s'", order.Status)
|
||||
}
|
||||
|
||||
// Check Cart Status
|
||||
if err := db.First(&cart, cart.ID).Error; err != nil {
|
||||
t.Fatalf("failed to reload cart: %v", err)
|
||||
}
|
||||
if !cart.Completed {
|
||||
t.Error("expected cart to be marked as completed after payment")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/pkg/logger"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RevolutAccountType represents the type of Revolut account
|
||||
type RevolutAccountType string
|
||||
|
||||
const (
|
||||
RevolutAccountTypePro RevolutAccountType = "revolut_pro"
|
||||
RevolutAccountTypeBusiness RevolutAccountType = "business"
|
||||
)
|
||||
|
||||
// RevolutOAuthToken represents the OAuth token response
|
||||
type RevolutOAuthToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// RevolutOAuthService handles OAuth2 authentication for both Revolut Pro and Business accounts
|
||||
type RevolutOAuthService struct {
|
||||
cfg *config.Config
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// RevolutOAuthConfig holds OAuth configuration for different account types
|
||||
type RevolutOAuthConfig struct {
|
||||
AccountType RevolutAccountType `json:"account_type"`
|
||||
ClientID string `json:"client_id"`
|
||||
AuthBaseURL string `json:"auth_base_url"`
|
||||
TokenURL string `json:"token_url"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
}
|
||||
|
||||
// NewRevolutOAuthService creates a new OAuth service instance
|
||||
func NewRevolutOAuthService(cfg *config.Config) *RevolutOAuthService {
|
||||
return &RevolutOAuthService{
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetOAuthConfig returns configuration for the specified account type and environment
|
||||
func (s *RevolutOAuthService) GetOAuthConfig(accountType RevolutAccountType) RevolutOAuthConfig {
|
||||
isSandbox := s.cfg.RevolutEnvironment == "sandbox"
|
||||
|
||||
switch accountType {
|
||||
case RevolutAccountTypePro:
|
||||
if isSandbox {
|
||||
return RevolutOAuthConfig{
|
||||
AccountType: RevolutAccountTypePro,
|
||||
ClientID: "sandbox_pro_client_id",
|
||||
AuthBaseURL: "https://sandbox-checkout.revolut.com",
|
||||
TokenURL: "https://sandbox-checkout.revolut.com/api/connect/oauth/token",
|
||||
APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0",
|
||||
}
|
||||
} else {
|
||||
return RevolutOAuthConfig{
|
||||
AccountType: RevolutAccountTypePro,
|
||||
ClientID: "9cda975e-016c-4b49-b5c6-37d1285ba046",
|
||||
AuthBaseURL: "https://checkout.revolut.com",
|
||||
TokenURL: "https://checkout.revolut.com/api/connect/oauth/token",
|
||||
APIBaseURL: "https://merchant.revolut.com/api/1.0",
|
||||
}
|
||||
}
|
||||
case RevolutAccountTypeBusiness:
|
||||
if isSandbox {
|
||||
return RevolutOAuthConfig{
|
||||
AccountType: RevolutAccountTypeBusiness,
|
||||
ClientID: "sandbox_business_client_id",
|
||||
AuthBaseURL: "https://sandbox-business.revolut.com",
|
||||
TokenURL: "https://sandbox-business.revolut.com/api/1.0/auth/token",
|
||||
APIBaseURL: "https://sandbox-merchant.revolut.com/api/1.0",
|
||||
}
|
||||
} else {
|
||||
return RevolutOAuthConfig{
|
||||
AccountType: RevolutAccountTypeBusiness,
|
||||
ClientID: "diiToLZlMJOPtWhdFTxQ",
|
||||
AuthBaseURL: "https://business.revolut.com",
|
||||
TokenURL: "https://b2b.revolut.com/api/1.0/auth/token",
|
||||
APIBaseURL: "https://merchant.revolut.com/api/1.0",
|
||||
}
|
||||
}
|
||||
default:
|
||||
return s.GetOAuthConfig(RevolutAccountTypePro)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateAuthURL generates the OAuth2 authorization URL for both account types
|
||||
func (s *RevolutOAuthService) GenerateAuthURL(accountType RevolutAccountType, state string, codeChallenge string) (string, error) {
|
||||
config := s.GetOAuthConfig(accountType)
|
||||
|
||||
if accountType == RevolutAccountTypePro {
|
||||
params := map[string]string{
|
||||
"client_id": config.ClientID,
|
||||
"redirect_uri": s.cfg.RevolutWebhookURL,
|
||||
"response_type": "code",
|
||||
"scope": "checkout_extension",
|
||||
"code_challenge_method": "S256",
|
||||
"code_challenge": codeChallenge,
|
||||
"response_mode": "query",
|
||||
"state": state,
|
||||
"integration_type": "CUSTOM_PLUGIN",
|
||||
"rwa_auth_type": "auth",
|
||||
}
|
||||
|
||||
query := buildQueryString(params)
|
||||
return fmt.Sprintf("%s/s/select-user-type?%s", config.AuthBaseURL, query), nil
|
||||
}
|
||||
|
||||
params := map[string]string{
|
||||
"client_id": config.ClientID,
|
||||
"redirect_uri": s.cfg.RevolutWebhookURL,
|
||||
"response_type": "code",
|
||||
"code_challenge_method": "S256",
|
||||
"code_challenge": codeChallenge,
|
||||
"response_mode": "query",
|
||||
"prompt": "select_account",
|
||||
"state": state,
|
||||
}
|
||||
|
||||
query := buildQueryString(params)
|
||||
return fmt.Sprintf("%s/signin?%s", config.AuthBaseURL, query), nil
|
||||
}
|
||||
|
||||
// buildQueryString builds a query string from a map
|
||||
func buildQueryString(params map[string]string) string {
|
||||
var parts []string
|
||||
for k, v := range params {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// GenerateCodeVerifier generates a PKCE code verifier
|
||||
func GenerateCodeVerifier() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GenerateCodeChallenge generates a PKCE code challenge from verifier
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges authorization code for access token
|
||||
func (s *RevolutOAuthService) ExchangeCodeForToken(accountType RevolutAccountType, code, codeVerifier string) (*RevolutOAuthToken, error) {
|
||||
config := s.GetOAuthConfig(accountType)
|
||||
|
||||
data := map[string]string{
|
||||
"client_id": config.ClientID,
|
||||
"code": code,
|
||||
"code_verifier": codeVerifier,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": s.cfg.RevolutWebhookURL,
|
||||
}
|
||||
|
||||
body := buildFormData(data)
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute token request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read token response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token API error: status %d, response: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var token RevolutOAuthToken
|
||||
if err := json.Unmarshal(bodyBytes, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
||||
}
|
||||
|
||||
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// buildFormData builds form data from a map
|
||||
func buildFormData(data map[string]string) string {
|
||||
var parts []string
|
||||
for k, v := range data {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// StoreOAuthToken stores the OAuth token with account type information
|
||||
func (s *RevolutOAuthService) StoreOAuthToken(accountType RevolutAccountType, token *RevolutOAuthToken) error {
|
||||
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
|
||||
logger.Info("Storing OAuth token for %s: expires at %v", accountType, expiresAt)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStoredOAuthToken retrieves stored OAuth token and account type
|
||||
func (s *RevolutOAuthService) GetStoredOAuthToken() (*RevolutOAuthToken, RevolutAccountType, error) {
|
||||
return nil, "", fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// RefreshAccessToken refreshes the access token using refresh token
|
||||
func (s *RevolutOAuthService) RefreshAccessToken(accountType RevolutAccountType, refreshToken string) (*RevolutOAuthToken, error) {
|
||||
config := s.GetOAuthConfig(accountType)
|
||||
|
||||
data := map[string]string{
|
||||
"client_id": config.ClientID,
|
||||
"refresh_token": refreshToken,
|
||||
"grant_type": "refresh_token",
|
||||
}
|
||||
|
||||
body := buildFormData(data)
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenURL, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to execute refresh request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read refresh response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("refresh API error: status %d, response: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var token RevolutOAuthToken
|
||||
if err := json.Unmarshal(bodyBytes, &token); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
|
||||
}
|
||||
|
||||
token.Scope = fmt.Sprintf("%s account_type:%s", token.Scope, accountType)
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// RevolutOAuthController handles OAuth authentication for Revolut Pro
|
||||
type RevolutOAuthController struct {
|
||||
DB *gorm.DB
|
||||
Config *config.Config
|
||||
OAuthService *RevolutOAuthService
|
||||
}
|
||||
|
||||
// NewRevolutOAuthController creates a new OAuth controller
|
||||
func NewRevolutOAuthController(db *gorm.DB, cfg *config.Config) *RevolutOAuthController {
|
||||
return &RevolutOAuthController{
|
||||
DB: db,
|
||||
Config: cfg,
|
||||
OAuthService: NewRevolutOAuthService(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// OAuthStart initiates the OAuth flow for both Revolut Pro and Business
|
||||
func (ctrl *RevolutOAuthController) OAuthStart(c *gin.Context) {
|
||||
// Get account type from request (pro or business)
|
||||
var req struct {
|
||||
AccountType string `json:"account_type" binding:"required,oneof=revolut_pro business"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
logger.Error("Invalid OAuth start request: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid account type. Use 'revolut_pro' or 'business'"})
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to RevolutAccountType
|
||||
var accountType RevolutAccountType
|
||||
if req.AccountType == "revolut_pro" {
|
||||
accountType = RevolutAccountTypePro
|
||||
} else {
|
||||
accountType = RevolutAccountTypeBusiness
|
||||
}
|
||||
|
||||
// Generate PKCE verifier and challenge
|
||||
verifier, err := GenerateCodeVerifier()
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate code verifier: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initiate OAuth"})
|
||||
return
|
||||
}
|
||||
|
||||
challenge := GenerateCodeChallenge(verifier)
|
||||
|
||||
// Generate state token for security
|
||||
state := fmt.Sprintf("revolut_oauth_%s_%d", accountType, time.Now().UnixNano())
|
||||
|
||||
// Store verifier and state in session/temporary storage
|
||||
// For now, we'll use a simple approach - in production, use Redis or database
|
||||
// TODO: Store code_verifier, state, and account_type securely
|
||||
logger.Info("OAuth session created: state=%s, account_type=%s", state, accountType)
|
||||
|
||||
// Generate authorization URL
|
||||
authURL, err := ctrl.OAuthService.GenerateAuthURL(accountType, state, challenge)
|
||||
if err != nil {
|
||||
logger.Error("Failed to generate auth URL: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate authorization URL"})
|
||||
return
|
||||
}
|
||||
|
||||
// Return the authorization URL for frontend to redirect
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"authorization_url": authURL,
|
||||
"state": state,
|
||||
"account_type": req.AccountType,
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthCallback handles the OAuth callback from Revolut
|
||||
func (ctrl *RevolutOAuthController) OAuthCallback(c *gin.Context) {
|
||||
// Get query parameters
|
||||
code := c.Query("code")
|
||||
state := c.Query("state")
|
||||
errorParam := c.Query("error")
|
||||
|
||||
if errorParam != "" {
|
||||
logger.Error("OAuth error: %s", errorParam)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "OAuth authorization failed"})
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" || state == "" {
|
||||
logger.Error("Missing OAuth callback parameters")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing required parameters"})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Retrieve stored session data and verify state + account type
|
||||
// For now, we'll extract account type from state - in production, verify from secure storage
|
||||
var accountType RevolutAccountType = RevolutAccountTypePro // Default
|
||||
|
||||
// Extract account type from state if available
|
||||
if strings.Contains(state, "business") {
|
||||
accountType = RevolutAccountTypeBusiness
|
||||
}
|
||||
|
||||
// TODO: Retrieve code_verifier from session
|
||||
codeVerifier := "stored_code_verifier" // This should come from secure storage
|
||||
|
||||
// Exchange code for access token
|
||||
token, err := ctrl.OAuthService.ExchangeCodeForToken(accountType, code, codeVerifier)
|
||||
if err != nil {
|
||||
logger.Error("Failed to exchange code for token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to obtain access token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store the OAuth token securely
|
||||
if err := ctrl.OAuthService.StoreOAuthToken(accountType, token); err != nil {
|
||||
logger.Error("Failed to store OAuth token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store access token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Mark Revolut as configured
|
||||
if err := ctrl.updateRevolutConfig(true); err != nil {
|
||||
logger.Error("Failed to update Revolut config: %v", err)
|
||||
// Continue anyway - token is stored
|
||||
}
|
||||
|
||||
logger.Info("Revolut OAuth authentication successful for %s", accountType)
|
||||
|
||||
// Redirect to success page or return success response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("Revolut %s account connected successfully", accountType),
|
||||
"account_type": string(accountType),
|
||||
"token_info": map[string]interface{}{
|
||||
"token_type": token.TokenType,
|
||||
"expires_in": token.ExpiresIn,
|
||||
"scope": token.Scope,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthStatus returns the current OAuth authentication status
|
||||
func (ctrl *RevolutOAuthController) OAuthStatus(c *gin.Context) {
|
||||
// Check if we have a stored OAuth token
|
||||
token, accountType, err := ctrl.OAuthService.GetStoredOAuthToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"authenticated": false,
|
||||
"message": "No Revolut account connected",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if token is still valid
|
||||
if token.ExpiresIn > 0 {
|
||||
// TODO: Check actual expiration time from stored data
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"authenticated": true,
|
||||
"account_type": string(accountType),
|
||||
"token_type": token.TokenType,
|
||||
"scope": token.Scope,
|
||||
"expires_in": token.ExpiresIn,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"authenticated": false,
|
||||
"message": "Token expired, please re-authenticate",
|
||||
})
|
||||
}
|
||||
|
||||
// OAuthDisconnect removes the stored OAuth token
|
||||
func (ctrl *RevolutOAuthController) OAuthDisconnect(c *gin.Context) {
|
||||
// TODO: Remove stored OAuth token from database
|
||||
logger.Info("Revolut OAuth disconnected by user")
|
||||
|
||||
// Mark Revolut as disabled
|
||||
if err := ctrl.updateRevolutConfig(false); err != nil {
|
||||
logger.Error("Failed to update Revolut config: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Revolut account disconnected",
|
||||
})
|
||||
}
|
||||
|
||||
// updateRevolutConfig updates the Revolut configuration in the database
|
||||
func (ctrl *RevolutOAuthController) updateRevolutConfig(enabled bool) error {
|
||||
// Update the main settings table
|
||||
var settings models.Settings
|
||||
if err := ctrl.DB.First(&settings).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create settings if not found
|
||||
settings = models.Settings{
|
||||
RevolutEnabled: enabled,
|
||||
}
|
||||
return ctrl.DB.Create(&settings).Error
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Update existing settings
|
||||
settings.RevolutEnabled = enabled
|
||||
return ctrl.DB.Save(&settings).Error
|
||||
}
|
||||
|
||||
// RefreshToken refreshes the OAuth access token
|
||||
func (ctrl *RevolutOAuthController) RefreshToken(c *gin.Context) {
|
||||
// Get current token and account type
|
||||
token, accountType, err := ctrl.OAuthService.GetStoredOAuthToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No refresh token available"})
|
||||
return
|
||||
}
|
||||
|
||||
if token.RefreshToken == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "No refresh token available"})
|
||||
return
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
newToken, err := ctrl.OAuthService.RefreshAccessToken(accountType, token.RefreshToken)
|
||||
if err != nil {
|
||||
logger.Error("Failed to refresh token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Store the new token
|
||||
if err := ctrl.OAuthService.StoreOAuthToken(accountType, newToken); err != nil {
|
||||
logger.Error("Failed to store refreshed token: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store refreshed token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Token refreshed successfully",
|
||||
"token_info": map[string]interface{}{
|
||||
"token_type": newToken.TokenType,
|
||||
"expires_in": newToken.ExpiresIn,
|
||||
"scope": newToken.Scope,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
"fotbal-club/pkg/database"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ShippingController struct {
|
||||
Config *config.Config
|
||||
PacketaService *services.PacketaService
|
||||
}
|
||||
|
||||
func NewShippingController(cfg *config.Config) *ShippingController {
|
||||
return &ShippingController{
|
||||
Config: cfg,
|
||||
PacketaService: services.NewPacketaService(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
// GetPacketaWidgetConfig returns the configuration for the Packeta widget
|
||||
func (c *ShippingController) GetPacketaWidgetConfig(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"api_key": c.Config.PacketaWidgetAPIKey,
|
||||
"env": c.Config.PacketaEnv,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadLabel handles the download of a shipping label PDF
|
||||
// GET /api/v1/eshop/shipping/labels/:packet_id
|
||||
func (c *ShippingController) DownloadLabel(ctx *gin.Context) {
|
||||
packetID := ctx.Param("packet_id")
|
||||
if packetID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing packet ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// In MVP, we might not have label download implemented in service yet
|
||||
// pdfData, err := c.PacketaService.GetPacketLabel(packetID)
|
||||
// Placeholder
|
||||
pdfData, err := c.PacketaService.GetPacketLabel(packetID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to download label"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Header("Content-Type", "application/pdf")
|
||||
ctx.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="label_%s.pdf"`, packetID))
|
||||
ctx.Data(http.StatusOK, "application/pdf", pdfData)
|
||||
}
|
||||
|
||||
// CreatePacket creates a shipment in Packeta system from an order
|
||||
func (c *ShippingController) CreatePacket(ctx *gin.Context) {
|
||||
id := ctx.Param("id")
|
||||
|
||||
// Use a new DB connection or pass it via struct if possible.
|
||||
// For now, InitDB (cached instance)
|
||||
db, _ := database.InitDB()
|
||||
|
||||
var order models.EshopOrder
|
||||
if err := db.First(&order, id).Error; err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "Order not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if order.ShippingMethod != "packeta" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Not a Packeta order"})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse shipping address JSON to get point ID
|
||||
// Format: {"id":"123", "name":"Z-Point..."}
|
||||
var pointData struct {
|
||||
ID interface{} `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(order.ShippingAddressJSON), &pointData); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse shipping address"})
|
||||
return
|
||||
}
|
||||
|
||||
addressID := fmt.Sprintf("%v", pointData.ID)
|
||||
if addressID == "" {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "Missing Packeta point ID"})
|
||||
return
|
||||
}
|
||||
|
||||
packetID, err := c.PacketaService.CreatePacket(&order, addressID)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Packeta API error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Create Label record
|
||||
label := models.EshopShippingLabel{
|
||||
OrderID: order.ID,
|
||||
PacketaPacketID: packetID,
|
||||
Carrier: "packeta",
|
||||
Status: "created",
|
||||
}
|
||||
db.Create(&label)
|
||||
|
||||
// Update order status
|
||||
order.Status = "ready_to_ship"
|
||||
db.Save(&order)
|
||||
|
||||
ctx.JSON(http.StatusOK, gin.H{
|
||||
"packet_id": packetID,
|
||||
"status": "created",
|
||||
})
|
||||
}
|
||||
|
||||
// Background job
|
||||
func (c *ShippingController) UpdatePacketStatuses(db *gorm.DB) {
|
||||
var labels []models.EshopShippingLabel
|
||||
// Check active shipments
|
||||
if err := db.Where("status NOT IN ?", []string{"delivered", "cancelled", "returned"}).Find(&labels).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, label := range labels {
|
||||
status, err := c.PacketaService.GetPacketStatus(label.PacketaPacketID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if label.Status != status {
|
||||
// Update label status
|
||||
label.Status = status
|
||||
if err := db.Save(&label).Error; err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update order status based on shipping status
|
||||
c.updateOrderStatusFromShipping(db, label.OrderID, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateOrderStatusFromShipping updates order status based on shipping status
|
||||
func (c *ShippingController) updateOrderStatusFromShipping(db *gorm.DB, orderID uint, shippingStatus string) {
|
||||
var newOrderStatus string
|
||||
|
||||
switch shippingStatus {
|
||||
case "ready_to_ship", "collected":
|
||||
newOrderStatus = "processing"
|
||||
case "in_transit", "out_for_delivery":
|
||||
newOrderStatus = "shipped"
|
||||
case "delivered":
|
||||
newOrderStatus = "completed"
|
||||
case "cancelled", "returned":
|
||||
newOrderStatus = shippingStatus
|
||||
default:
|
||||
return // No status change needed
|
||||
}
|
||||
|
||||
if err := db.Model(&models.EshopOrder{}).
|
||||
Where("id = ?", orderID).
|
||||
Update("status", newOrderStatus).Error; err != nil {
|
||||
// Log error but don't fail the entire process
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package eshop
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"fotbal-club/internal/config"
|
||||
"fotbal-club/internal/models"
|
||||
"fotbal-club/internal/services"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestShippingController_UpdatePacketStatuses_UpdatesLabelStatus verifies that
|
||||
// the background updater uses PacketaService to refresh label statuses.
|
||||
func TestShippingController_UpdatePacketStatuses_UpdatesLabelStatus(t *testing.T) {
|
||||
// In-memory SQLite DB for isolated test
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory db: %v", err)
|
||||
}
|
||||
|
||||
// Migrate only the necessary e-shop tables
|
||||
if err := db.AutoMigrate(&models.EshopOrder{}, &models.EshopShippingLabel{}); err != nil {
|
||||
t.Fatalf("failed to migrate schemas: %v", err)
|
||||
}
|
||||
|
||||
// Seed one label in a non-terminal state
|
||||
label := models.EshopShippingLabel{
|
||||
OrderID: 1,
|
||||
Carrier: "packeta",
|
||||
PacketaPacketID: "12345",
|
||||
Status: "created",
|
||||
}
|
||||
if err := db.Create(&label).Error; err != nil {
|
||||
t.Fatalf("failed to seed label: %v", err)
|
||||
}
|
||||
|
||||
// Fake Packeta API that always returns DELIVERED
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`
|
||||
<response>
|
||||
<status>ok</status>
|
||||
<result>
|
||||
<statusCode>DELIVERED</statusCode>
|
||||
<statusText>DELIVERED</statusText>
|
||||
</result>
|
||||
</response>
|
||||
`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
PacketaAPIPassword: "test-password",
|
||||
PacketaEshopName: "TestEshop",
|
||||
}
|
||||
|
||||
// Use the shared PacketaService but point it to our test server
|
||||
packetaSvc := &services.PacketaService{
|
||||
ApiPassword: cfg.PacketaAPIPassword,
|
||||
ApiUrl: server.URL,
|
||||
EshopName: cfg.PacketaEshopName,
|
||||
}
|
||||
|
||||
ctrl := &ShippingController{
|
||||
Config: cfg,
|
||||
PacketaService: packetaSvc,
|
||||
}
|
||||
|
||||
// Run the updater
|
||||
ctrl.UpdatePacketStatuses(db)
|
||||
|
||||
// Reload label and verify status was updated from Packeta response
|
||||
var updated models.EshopShippingLabel
|
||||
if err := db.First(&updated, label.ID).Error; err != nil {
|
||||
t.Fatalf("failed to load updated label: %v", err)
|
||||
}
|
||||
|
||||
if updated.Status != "DELIVERED" {
|
||||
t.Fatalf("expected status DELIVERED, got %q", updated.Status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user