From 57ccd40b8415a3a12b9031bc25b072bb3a083804 Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Wed, 25 Jun 2025 14:47:53 +0200 Subject: [PATCH 01/12] feat: add payment status to orders and update related logic - Introduced PaymentStatus to the Order entity and updated the OrderDTO and OrderSummaryDTO to include payment status. - Modified CreateCompleteCheckoutResponse to check for payment status when determining if action is required. - Updated order repository to handle payment status during order creation and updates. - Adjusted webhook handler to update payment status based on events from payment providers. - Created migration scripts to add payment_status column to the orders table and populate it based on existing order statuses. - Updated frontend types to reflect changes in order and payment status. - Enhanced unit tests to cover new payment status logic and ensure correctness. --- cmd/seed/main.go | 32 +- docs/order_api_examples.md | 8 +- .../application/usecase/checkout_usecase.go | 12 +- internal/application/usecase/order_usecase.go | 96 ++- .../usecase/product_usecase_test.go | 5 +- internal/domain/entity/checkout.go | 1 + internal/domain/entity/order.go | 110 ++- internal/domain/entity/order_test.go | 673 ++++++++++++++++++ internal/dto/checkout.go | 2 +- internal/dto/order.go | 51 +- internal/dto/order_test.go | 63 +- .../repository/postgres/order_repository.go | 54 +- .../interfaces/api/handler/webhook_handler.go | 47 +- ...0031_add_payment_status_to_orders.down.sql | 3 + ...000031_add_payment_status_to_orders.up.sql | 17 + web/types/api.ts | 21 +- 16 files changed, 1026 insertions(+), 169 deletions(-) create mode 100644 internal/domain/entity/order_test.go create mode 100644 migrations/000031_add_payment_status_to_orders.down.sql create mode 100644 migrations/000031_add_payment_status_to_orders.up.sql diff --git a/cmd/seed/main.go b/cmd/seed/main.go index ee7f704..f1cafa3 100644 --- a/cmd/seed/main.go +++ b/cmd/seed/main.go @@ -786,7 +786,7 @@ func seedOrders(db *sql.DB) error { } // Order statuses - statuses := []string{"pending", "paid", "shipped", "delivered", "cancelled"} + statuses := []string{"pending", "paid", "shipped", "completed", "cancelled"} // Payment providers paymentProviders := []string{"stripe", "paypal", "mock"} @@ -820,24 +820,24 @@ func seedOrders(db *sql.DB) error { createdAt := now.Add(time.Duration(-i*24) * time.Hour) // Each order created a day apart updatedAt := createdAt - // Set completed_at for delivered orders + // Set completed_at for completed orders var completedAt *time.Time - if status == "delivered" { + if status == "completed" { completedTime := updatedAt.Add(3 * 24 * time.Hour) // 3 days after creation completedAt = &completedTime } - // Set payment details for paid, shipped, or delivered orders + // Set payment details for paid, shipped, or completed orders var paymentID string var paymentProvider string var trackingCode string - if status == "paid" || status == "shipped" || status == "delivered" { + if status == "paid" || status == "shipped" || status == "completed" { paymentID = fmt.Sprintf("payment_%d_%s", i, time.Now().Format("20060102")) paymentProvider = paymentProviders[i%len(paymentProviders)] } - if status == "shipped" || status == "delivered" { + if status == "shipped" || status == "completed" { trackingCode = fmt.Sprintf("TRACK%d%s", i, time.Now().Format("20060102")) } @@ -852,17 +852,31 @@ func seedOrders(db *sql.DB) error { // Insert order var orderID int + // Set payment status based on order status + var paymentStatus string + switch status { + case "pending": + paymentStatus = "pending" + case "paid", "shipped", "completed": + paymentStatus = "captured" + case "cancelled": + paymentStatus = "cancelled" + default: + paymentStatus = "pending" + } + err = tx.QueryRow(` INSERT INTO orders ( - user_id, total_amount, status, shipping_address, billing_address, + user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, order_number ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id `, userID, 0, // Total amount will be updated after adding items status, + paymentStatus, shippingAddrJSON, billingAddrJSON, paymentID, @@ -1610,7 +1624,7 @@ func seedPaymentTransactions(db *sql.DB) error { SELECT id, payment_id, payment_provider, total_amount, order_number FROM orders WHERE payment_provider IS NOT NULL - AND status IN ('paid', 'shipped', 'delivered') + AND status IN ('paid', 'shipped', 'completed') `) if err != nil { return err diff --git a/docs/order_api_examples.md b/docs/order_api_examples.md index 1ad6485..3b1c1b7 100644 --- a/docs/order_api_examples.md +++ b/docs/order_api_examples.md @@ -57,7 +57,7 @@ Example response: "phone_number": "+1987654321" }, "payment_method": "wallet", - "payment_status": "paid", + "payment_status": "captured", "shipping_method": "express", "shipping_cost": 14.99, "tax_amount": 0, @@ -103,7 +103,7 @@ Example response: "total_amount": 2514.97, "currency": "USD", "payment_method": "wallet", - "payment_status": "paid", + "payment_status": "captured", "shipping_method": "express", "shipping_cost": 14.99, "tax_amount": 0, @@ -156,7 +156,7 @@ Example response: "total_amount": 2514.97, "currency": "USD", "payment_method": "wallet", - "payment_status": "paid", + "payment_status": "captured", "shipping_method": "express", "shipping_cost": 14.99, "tax_amount": 0, @@ -243,7 +243,7 @@ Example response: "phone_number": "+1987654321" }, "payment_method": "wallet", - "payment_status": "paid", + "payment_status": "captured", "shipping_method": "express", "shipping_cost": 14.99, "tax_amount": 0, diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index 9dbb976..43dd2c9 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -81,7 +81,7 @@ func (uc *CheckoutUseCase) ProcessPayment(order *entity.Order, input ProcessPaym // Check if order is already paid if order.Status == entity.OrderStatusPaid || order.Status == entity.OrderStatusShipped || - order.Status == entity.OrderStatusDelivered { + order.Status == entity.OrderStatusCompleted { return nil, errors.New("order is already paid") } @@ -124,9 +124,8 @@ func (uc *CheckoutUseCase) ProcessPayment(order *entity.Order, input ProcessPaym if err := order.SetActionURL(paymentResult.ActionURL); err != nil { return nil, err } - if err := order.UpdateStatus(entity.OrderStatusPendingAction); err != nil { - return nil, err - } + // Payment requires action - keep order status as pending + // Payment status remains pending until action is completed // Update order in repository if err := uc.orderRepo.Update(order); err != nil { @@ -185,7 +184,7 @@ func (uc *CheckoutUseCase) ProcessPayment(order *entity.Order, input ProcessPaym return nil, errors.New(paymentResult.Message) } - // Update order with payment ID, provider, and status + // Update order with payment ID, provider, and payment status if err := order.SetPaymentID(paymentResult.TransactionID); err != nil { return nil, err } @@ -195,7 +194,8 @@ func (uc *CheckoutUseCase) ProcessPayment(order *entity.Order, input ProcessPaym if err := order.SetPaymentMethod(string(order.PaymentMethod)); err != nil { return nil, err } - if err := order.UpdateStatus(entity.OrderStatusPaid); err != nil { + // Update payment status to authorized, which will also update order status to paid + if err := order.UpdatePaymentStatus(entity.PaymentStatusAuthorized); err != nil { return nil, err } diff --git a/internal/application/usecase/order_usecase.go b/internal/application/usecase/order_usecase.go index ac7268b..f7d3a79 100644 --- a/internal/application/usecase/order_usecase.go +++ b/internal/application/usecase/order_usecase.go @@ -120,9 +120,9 @@ func (uc *OrderUseCase) ListOrdersByStatus(status entity.OrderStatus, offset, li } func (uc *OrderUseCase) FailOrder(order *entity.Order) error { - // Update the order status to failed - if err := order.UpdateStatus(entity.OrderStatusFailed); err != nil { - return fmt.Errorf("failed to update order status: %w", err) + // Update the payment status to failed, which will also update order status to cancelled + if err := order.UpdatePaymentStatus(entity.PaymentStatusFailed); err != nil { + return fmt.Errorf("failed to update payment status: %w", err) } // Save the updated order in the repository @@ -141,13 +141,18 @@ func (uc *OrderUseCase) CapturePayment(transactionID string, amount int64) error return errors.New("order not found for payment ID") } - // Check if the order is already captured - if order.Status == entity.OrderStatusCaptured { - return errors.New("payment already captured") + // Check if the payment is already captured + if order.PaymentStatus == entity.PaymentStatusCaptured { + return errors.New("payment already captured for this order") } - // Check if the order is in a state that allows capture - if order.Status != entity.OrderStatusPaid { - return errors.New("payment capture not allowed in current order status") + + // Check if the payment is in authorized state and order is shipped (new rule) + if order.PaymentStatus != entity.PaymentStatusAuthorized { + return errors.New("payment must be authorized before capture") + } + + if order.Status != entity.OrderStatusShipped { + return errors.New("order must be shipped before payment can be captured") } // Check if the amount is valid @@ -186,8 +191,9 @@ func (uc *OrderUseCase) CapturePayment(transactionID string, amount int64) error return fmt.Errorf("failed to capture payment: %v", err) } - if err := order.UpdateStatus(entity.OrderStatusCaptured); err != nil { - return fmt.Errorf("failed to update order status: %v", err) + // Update payment status to captured, which will also update order status to completed + if err := order.UpdatePaymentStatus(entity.PaymentStatusCaptured); err != nil { + return fmt.Errorf("failed to update payment status: %v", err) } // Save the updated order in repository @@ -235,14 +241,16 @@ func (uc *OrderUseCase) CancelPayment(transactionID string) error { return errors.New("order not found for payment ID") } - // Check if the order is already canceled - if order.Status == entity.OrderStatusCancelled { + // Check if the payment is already cancelled + if order.PaymentStatus == entity.PaymentStatusCancelled { return errors.New("payment already canceled") } - // Check if the order is in a state that allows cancellation - if order.Status != entity.OrderStatusPendingAction { - return errors.New("payment cancellation not allowed in current order status") + + // Check if the payment is in authorized state (can only cancel authorized payments that aren't captured) + if order.PaymentStatus != entity.PaymentStatusAuthorized { + return errors.New("payment cancellation only allowed for authorized payments") } + // Check if the transaction ID is valid if transactionID == "" { return errors.New("transaction ID is required") @@ -272,9 +280,9 @@ func (uc *OrderUseCase) CancelPayment(transactionID string) error { return fmt.Errorf("failed to cancel payment: %v", err) } - // Update the order status to cancelled after successful payment cancellation - if err := order.UpdateStatus(entity.OrderStatusCancelled); err != nil { - return fmt.Errorf("failed to update order status: %v", err) + // Update payment status to cancelled, which will also update order status to cancelled + if err := order.UpdatePaymentStatus(entity.PaymentStatusCancelled); err != nil { + return fmt.Errorf("failed to update payment status: %v", err) } // Save the updated order in the repository @@ -293,7 +301,8 @@ func (uc *OrderUseCase) CancelPayment(transactionID string) error { string(providerType), ) if err == nil { - txn.AddMetadata("previous_status", string(entity.OrderStatusPendingAction)) + txn.AddMetadata("previous_order_status", string(order.Status)) + txn.AddMetadata("previous_payment_status", string(entity.PaymentStatusAuthorized)) if err := uc.paymentTxnRepo.Create(txn); err != nil { log.Printf("Failed to save cancel transaction: %v\n", err) @@ -311,14 +320,16 @@ func (uc *OrderUseCase) RefundPayment(transactionID string, amount int64) error return errors.New("order not found for payment ID") } - // Check if the order is already refunded - if order.Status == entity.OrderStatusRefunded { + // Check if the payment is already refunded + if order.PaymentStatus == entity.PaymentStatusRefunded { return errors.New("payment already refunded") } - // Check if the order is in a state that allows refund - if order.Status != entity.OrderStatusPaid && order.Status != entity.OrderStatusCaptured { - return errors.New("payment refund not allowed in current order status") + + // Check if the payment is in a state that allows refund (authorized or captured) + if order.PaymentStatus != entity.PaymentStatusAuthorized && order.PaymentStatus != entity.PaymentStatusCaptured { + return errors.New("payment refund only allowed for authorized or captured payments") } + // Check if the amount is valid if amount <= 0 { return errors.New("refund amount must be greater than zero") @@ -368,10 +379,10 @@ func (uc *OrderUseCase) RefundPayment(transactionID string, amount int64) error isFullRefund = true } - // Only update the order status to refunded if it's a full refund + // Only update the payment status to refunded if it's a full refund if isFullRefund { - if err := order.UpdateStatus(entity.OrderStatusRefunded); err != nil { - return fmt.Errorf("failed to update order status: %v", err) + if err := order.UpdatePaymentStatus(entity.PaymentStatusRefunded); err != nil { + return fmt.Errorf("failed to update payment status: %v", err) } // Save the updated order in the repository @@ -392,7 +403,7 @@ func (uc *OrderUseCase) RefundPayment(transactionID string, amount int64) error ) if err == nil { txn.AddMetadata("full_refund", fmt.Sprintf("%t", isFullRefund)) - txn.AddMetadata("previous_status", string(order.Status)) + txn.AddMetadata("previous_payment_status", string(order.PaymentStatus)) // Record total refunded amount including this transaction totalRefunded := totalRefundedSoFar + amount @@ -462,3 +473,30 @@ func (uc *OrderUseCase) RecordPaymentTransaction(transaction *entity.PaymentTran // Create transaction record return uc.paymentTxnRepo.Create(transaction) } + +// UpdatePaymentStatusInput contains the data needed to update payment status +type UpdatePaymentStatusInput struct { + OrderID uint + PaymentStatus entity.PaymentStatus +} + +// UpdatePaymentStatus updates the payment status of an order +func (uc *OrderUseCase) UpdatePaymentStatus(input UpdatePaymentStatusInput) (*entity.Order, error) { + // Get order + order, err := uc.orderRepo.GetByID(input.OrderID) + if err != nil { + return nil, fmt.Errorf("order not found: %w", err) + } + + // Update payment status + if err := order.UpdatePaymentStatus(input.PaymentStatus); err != nil { + return nil, fmt.Errorf("failed to update payment status: %w", err) + } + + // Update order in repository + if err := uc.orderRepo.Update(order); err != nil { + return nil, fmt.Errorf("failed to save order: %w", err) + } + + return order, nil +} diff --git a/internal/application/usecase/product_usecase_test.go b/internal/application/usecase/product_usecase_test.go index 65e7de4..cb2d0ff 100644 --- a/internal/application/usecase/product_usecase_test.go +++ b/internal/application/usecase/product_usecase_test.go @@ -952,8 +952,9 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { Subtotal: 19998, }, }, - TotalAmount: 19998, - Status: entity.OrderStatusPaid, + TotalAmount: 19998, + Status: entity.OrderStatusPaid, + PaymentStatus: entity.PaymentStatusCaptured, } orderRepo.Create(order) diff --git a/internal/domain/entity/checkout.go b/internal/domain/entity/checkout.go index 34c9a0e..4105606 100644 --- a/internal/domain/entity/checkout.go +++ b/internal/domain/entity/checkout.go @@ -411,6 +411,7 @@ func (c *Checkout) ToOrder() *Order { DiscountAmount: c.DiscountAmount, FinalAmount: c.FinalAmount, Status: OrderStatusPending, + PaymentStatus: PaymentStatusPending, // Initialize payment status ShippingAddr: c.ShippingAddr, BillingAddr: c.BillingAddr, CustomerDetails: &c.CustomerDetails, diff --git a/internal/domain/entity/order.go b/internal/domain/entity/order.go index 1bb2fd9..1484c3d 100644 --- a/internal/domain/entity/order.go +++ b/internal/domain/entity/order.go @@ -11,15 +11,23 @@ import ( type OrderStatus string const ( - OrderStatusPending OrderStatus = "pending" - OrderStatusPendingAction OrderStatus = "pending_action" // Requires user action (e.g., redirect to payment provider) - OrderStatusPaid OrderStatus = "paid" - OrderStatusCaptured OrderStatus = "captured" // Payment captured - OrderStatusShipped OrderStatus = "shipped" - OrderStatusDelivered OrderStatus = "delivered" - OrderStatusCancelled OrderStatus = "cancelled" - OrderStatusRefunded OrderStatus = "refunded" - OrderStatusFailed OrderStatus = "failed" + OrderStatusPending OrderStatus = "pending" + OrderStatusPaid OrderStatus = "paid" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusCancelled OrderStatus = "cancelled" + OrderStatusCompleted OrderStatus = "completed" // Set automatically when payment is captured +) + +// PaymentStatus represents the status of a payment +type PaymentStatus string + +const ( + PaymentStatusPending PaymentStatus = "pending" + PaymentStatusAuthorized PaymentStatus = "authorized" + PaymentStatusCaptured PaymentStatus = "captured" + PaymentStatusRefunded PaymentStatus = "refunded" + PaymentStatusCancelled PaymentStatus = "cancelled" + PaymentStatusFailed PaymentStatus = "failed" ) // Order represents an order entity @@ -31,6 +39,7 @@ type Order struct { Items []OrderItem TotalAmount int64 // stored in cents Status OrderStatus + PaymentStatus PaymentStatus // New field for payment status ShippingAddr Address BillingAddr Address PaymentID string @@ -131,6 +140,7 @@ func NewOrder(userID uint, items []OrderItem, currency string, shippingAddr, bil DiscountAmount: 0, FinalAmount: totalAmount, // Initially same as total amount Status: OrderStatusPending, + PaymentStatus: PaymentStatusPending, // Initialize payment status ShippingAddr: shippingAddr, BillingAddr: billingAddr, CreatedAt: now, @@ -175,6 +185,7 @@ func NewGuestOrder(items []OrderItem, shippingAddr, billingAddr Address, custome DiscountAmount: 0, FinalAmount: totalAmount, // Initially same as total amount Status: OrderStatusPending, + PaymentStatus: PaymentStatusPending, // Initialize payment status ShippingAddr: shippingAddr, BillingAddr: billingAddr, CreatedAt: now, @@ -195,8 +206,8 @@ func (o *Order) UpdateStatus(status OrderStatus) error { o.Status = status o.UpdatedAt = time.Now() - // If the status is delivered or cancelled, set the completed_at timestamp - if status == OrderStatusDelivered || status == OrderStatusCancelled || status == OrderStatusRefunded { + // If the status is cancelled or completed, set the completed_at timestamp + if status == OrderStatusCancelled || status == OrderStatusCompleted { now := time.Now() o.CompletedAt = &now } @@ -207,14 +218,11 @@ func (o *Order) UpdateStatus(status OrderStatus) error { // isValidStatusTransition checks if a status transition is valid func isValidStatusTransition(from, to OrderStatus) bool { validTransitions := map[OrderStatus][]OrderStatus{ - OrderStatusPending: {OrderStatusPendingAction, OrderStatusPaid, OrderStatusCancelled}, - OrderStatusPendingAction: {OrderStatusPaid, OrderStatusCancelled}, - OrderStatusPaid: {OrderStatusShipped, OrderStatusCancelled, OrderStatusRefunded, OrderStatusCaptured}, - OrderStatusCaptured: {OrderStatusShipped, OrderStatusCancelled, OrderStatusRefunded}, - OrderStatusShipped: {OrderStatusDelivered, OrderStatusCancelled}, - OrderStatusDelivered: {OrderStatusRefunded}, - OrderStatusCancelled: {OrderStatusRefunded}, - OrderStatusRefunded: {}, + OrderStatusPending: {OrderStatusPaid, OrderStatusCancelled}, + OrderStatusPaid: {OrderStatusShipped, OrderStatusCancelled}, + OrderStatusShipped: {OrderStatusCompleted, OrderStatusCancelled}, + OrderStatusCancelled: {}, + OrderStatusCompleted: {}, } return slices.Contains(validTransitions[from], to) @@ -348,12 +356,68 @@ func (o *Order) CalculateTotalWeight() float64 { return totalWeight } -// IsCaptured returns true if the order is captured +// IsCaptured returns true if the payment is captured func (o *Order) IsCaptured() bool { - return o.Status == OrderStatusCaptured + return o.PaymentStatus == PaymentStatusCaptured } -// IsRefunded returns true if the order is refunded +// IsRefunded returns true if the payment is refunded func (o *Order) IsRefunded() bool { - return o.Status == OrderStatusRefunded + return o.PaymentStatus == PaymentStatusRefunded +} + +// UpdatePaymentStatus updates the payment status and handles order status transitions +func (o *Order) UpdatePaymentStatus(status PaymentStatus) error { + if !isValidPaymentStatusTransition(o.PaymentStatus, status) { + return errors.New("invalid payment status transition: " + string(o.PaymentStatus) + " -> " + string(status)) + } + + o.PaymentStatus = status + o.UpdatedAt = time.Now() + + // Handle automatic order status transitions based on payment status + switch status { + case PaymentStatusAuthorized: + // When payment is authorized, order becomes "paid" + if o.Status == OrderStatusPending { + o.Status = OrderStatusPaid + } + case PaymentStatusFailed: + // When payment fails, order is cancelled + if o.Status == OrderStatusPending { + o.Status = OrderStatusCancelled + now := time.Now() + o.CompletedAt = &now + } + case PaymentStatusCaptured: + // When payment is captured and order is shipped, order is completed + if o.Status == OrderStatusShipped { + o.Status = OrderStatusCompleted + now := time.Now() + o.CompletedAt = &now + } + case PaymentStatusCancelled: + // When payment is cancelled, order is cancelled + if o.Status == OrderStatusPending || o.Status == OrderStatusPaid { + o.Status = OrderStatusCancelled + now := time.Now() + o.CompletedAt = &now + } + } + + return nil +} + +// isValidPaymentStatusTransition checks if a payment status transition is valid +func isValidPaymentStatusTransition(from, to PaymentStatus) bool { + validTransitions := map[PaymentStatus][]PaymentStatus{ + PaymentStatusPending: {PaymentStatusAuthorized, PaymentStatusFailed}, + PaymentStatusAuthorized: {PaymentStatusCaptured, PaymentStatusRefunded, PaymentStatusCancelled}, + PaymentStatusCaptured: {PaymentStatusRefunded}, + PaymentStatusRefunded: {}, + PaymentStatusCancelled: {}, + PaymentStatusFailed: {}, + } + + return slices.Contains(validTransitions[from], to) } diff --git a/internal/domain/entity/order_test.go b/internal/domain/entity/order_test.go new file mode 100644 index 0000000..24beaf0 --- /dev/null +++ b/internal/domain/entity/order_test.go @@ -0,0 +1,673 @@ +package entity + +import ( + "testing" + "time" +) + +func TestOrderConstants(t *testing.T) { + // Test OrderStatus constants + if OrderStatusPending != "pending" { + t.Errorf("Expected OrderStatusPending to be 'pending', got %s", OrderStatusPending) + } + if OrderStatusPaid != "paid" { + t.Errorf("Expected OrderStatusPaid to be 'paid', got %s", OrderStatusPaid) + } + if OrderStatusShipped != "shipped" { + t.Errorf("Expected OrderStatusShipped to be 'shipped', got %s", OrderStatusShipped) + } + if OrderStatusCancelled != "cancelled" { + t.Errorf("Expected OrderStatusCancelled to be 'cancelled', got %s", OrderStatusCancelled) + } + if OrderStatusCompleted != "completed" { + t.Errorf("Expected OrderStatusCompleted to be 'completed', got %s", OrderStatusCompleted) + } + + // Test PaymentStatus constants + if PaymentStatusPending != "pending" { + t.Errorf("Expected PaymentStatusPending to be 'pending', got %s", PaymentStatusPending) + } + if PaymentStatusAuthorized != "authorized" { + t.Errorf("Expected PaymentStatusAuthorized to be 'authorized', got %s", PaymentStatusAuthorized) + } + if PaymentStatusCaptured != "captured" { + t.Errorf("Expected PaymentStatusCaptured to be 'captured', got %s", PaymentStatusCaptured) + } + if PaymentStatusRefunded != "refunded" { + t.Errorf("Expected PaymentStatusRefunded to be 'refunded', got %s", PaymentStatusRefunded) + } + if PaymentStatusCancelled != "cancelled" { + t.Errorf("Expected PaymentStatusCancelled to be 'cancelled', got %s", PaymentStatusCancelled) + } + if PaymentStatusFailed != "failed" { + t.Errorf("Expected PaymentStatusFailed to be 'failed', got %s", PaymentStatusFailed) + } +} + +func TestNewOrder(t *testing.T) { + // Test valid order creation + items := []OrderItem{ + { + ProductID: 1, + Quantity: 2, + Price: 1000, // $10.00 + Weight: 0.5, + }, + { + ProductID: 2, + Quantity: 1, + Price: 2000, // $20.00 + Weight: 1.0, + }, + } + + shippingAddr := Address{ + Street: "123 Main St", + City: "New York", + State: "NY", + PostalCode: "10001", + Country: "USA", + } + + billingAddr := Address{ + Street: "456 Oak Ave", + City: "Los Angeles", + State: "CA", + PostalCode: "90210", + Country: "USA", + } + + customerDetails := CustomerDetails{ + Email: "test@example.com", + Phone: "+1234567890", + FullName: "John Doe", + } + + order, err := NewOrder(1, items, "USD", shippingAddr, billingAddr, customerDetails) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify order properties + if order.UserID != 1 { + t.Errorf("Expected UserID 1, got %d", order.UserID) + } + if order.Currency != "USD" { + t.Errorf("Expected Currency 'USD', got %s", order.Currency) + } + if order.TotalAmount != 4000 { // (2*1000) + (1*2000) = 4000 + t.Errorf("Expected TotalAmount 4000, got %d", order.TotalAmount) + } + if order.FinalAmount != 4000 { + t.Errorf("Expected FinalAmount 4000, got %d", order.FinalAmount) + } + if order.TotalWeight != 2.0 { // (2*0.5) + (1*1.0) = 2.0 + t.Errorf("Expected TotalWeight 2.0, got %f", order.TotalWeight) + } + if order.Status != OrderStatusPending { + t.Errorf("Expected Status %s, got %s", OrderStatusPending, order.Status) + } + if order.PaymentStatus != PaymentStatusPending { + t.Errorf("Expected PaymentStatus %s, got %s", PaymentStatusPending, order.PaymentStatus) + } + if order.IsGuestOrder { + t.Errorf("Expected IsGuestOrder false, got true") + } + if order.CustomerDetails.Email != "test@example.com" { + t.Errorf("Expected customer email 'test@example.com', got %s", order.CustomerDetails.Email) + } + if len(order.Items) != 2 { + t.Errorf("Expected 2 items, got %d", len(order.Items)) + } + + // Verify order number format + expectedPrefix := "ORD-" + time.Now().Format("20060102") + if !contains(order.OrderNumber, expectedPrefix) { + t.Errorf("Expected order number to contain %s, got %s", expectedPrefix, order.OrderNumber) + } +} + +func TestNewOrderValidation(t *testing.T) { + items := []OrderItem{ + {ProductID: 1, Quantity: 1, Price: 1000, Weight: 0.5}, + } + addr := Address{Street: "123 Main St", City: "NYC", State: "NY", PostalCode: "10001", Country: "USA"} + customer := CustomerDetails{Email: "test@example.com", Phone: "+1234567890", FullName: "John Doe"} + + // Test zero user ID + _, err := NewOrder(0, items, "USD", addr, addr, customer) + if err == nil { + t.Error("Expected error for zero user ID") + } + + // Test empty items + _, err = NewOrder(1, []OrderItem{}, "USD", addr, addr, customer) + if err == nil { + t.Error("Expected error for empty items") + } + + // Test empty currency + _, err = NewOrder(1, items, "", addr, addr, customer) + if err == nil { + t.Error("Expected error for empty currency") + } + + // Test zero quantity + invalidItems := []OrderItem{ + {ProductID: 1, Quantity: 0, Price: 1000, Weight: 0.5}, + } + _, err = NewOrder(1, invalidItems, "USD", addr, addr, customer) + if err == nil { + t.Error("Expected error for zero quantity") + } + + // Test zero price + invalidItems = []OrderItem{ + {ProductID: 1, Quantity: 1, Price: 0, Weight: 0.5}, + } + _, err = NewOrder(1, invalidItems, "USD", addr, addr, customer) + if err == nil { + t.Error("Expected error for zero price") + } +} + +func TestNewGuestOrder(t *testing.T) { + items := []OrderItem{ + {ProductID: 1, Quantity: 1, Price: 1500, Weight: 0.8}, + } + + shippingAddr := Address{ + Street: "789 Guest St", + City: "Miami", + State: "FL", + PostalCode: "33101", + Country: "USA", + } + + billingAddr := Address{ + Street: "789 Guest St", + City: "Miami", + State: "FL", + PostalCode: "33101", + Country: "USA", + } + + customerDetails := CustomerDetails{ + Email: "guest@example.com", + Phone: "+1987654321", + FullName: "Guest User", + } + + order, err := NewGuestOrder(items, shippingAddr, billingAddr, customerDetails) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify guest order properties + if order.UserID != 0 { + t.Errorf("Expected UserID 0 for guest order, got %d", order.UserID) + } + if !order.IsGuestOrder { + t.Errorf("Expected IsGuestOrder true, got false") + } + if order.TotalAmount != 1500 { + t.Errorf("Expected TotalAmount 1500, got %d", order.TotalAmount) + } + if order.Status != OrderStatusPending { + t.Errorf("Expected Status %s, got %s", OrderStatusPending, order.Status) + } + if order.PaymentStatus != PaymentStatusPending { + t.Errorf("Expected PaymentStatus %s, got %s", PaymentStatusPending, order.PaymentStatus) + } + + // Verify order number format for guest orders + expectedPrefix := "GS-" + time.Now().Format("20060102") + if !contains(order.OrderNumber, expectedPrefix) { + t.Errorf("Expected guest order number to contain %s, got %s", expectedPrefix, order.OrderNumber) + } +} + +func TestUpdateStatus(t *testing.T) { + order := createTestOrder(t) + + // Test valid transitions + testCases := []struct { + name string + fromStatus OrderStatus + toStatus OrderStatus + shouldErr bool + }{ + {"Pending to Paid", OrderStatusPending, OrderStatusPaid, false}, + {"Pending to Cancelled", OrderStatusPending, OrderStatusCancelled, false}, + {"Paid to Shipped", OrderStatusPaid, OrderStatusShipped, false}, + {"Paid to Cancelled", OrderStatusPaid, OrderStatusCancelled, false}, + {"Shipped to Completed", OrderStatusShipped, OrderStatusCompleted, false}, + {"Shipped to Cancelled", OrderStatusShipped, OrderStatusCancelled, false}, + // Invalid transitions + {"Pending to Shipped", OrderStatusPending, OrderStatusShipped, true}, + {"Pending to Completed", OrderStatusPending, OrderStatusCompleted, true}, + {"Paid to Completed", OrderStatusPaid, OrderStatusCompleted, true}, + {"Cancelled to Any", OrderStatusCancelled, OrderStatusPaid, true}, + {"Completed to Any", OrderStatusCompleted, OrderStatusPaid, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Reset order status + order.Status = tc.fromStatus + order.CompletedAt = nil + + err := order.UpdateStatus(tc.toStatus) + + if tc.shouldErr && err == nil { + t.Errorf("Expected error for transition %s -> %s", tc.fromStatus, tc.toStatus) + } + if !tc.shouldErr && err != nil { + t.Errorf("Unexpected error for transition %s -> %s: %v", tc.fromStatus, tc.toStatus, err) + } + + if !tc.shouldErr { + if order.Status != tc.toStatus { + t.Errorf("Expected status %s, got %s", tc.toStatus, order.Status) + } + + // Check if completed_at is set for terminal states + if tc.toStatus == OrderStatusCancelled || tc.toStatus == OrderStatusCompleted { + if order.CompletedAt == nil { + t.Errorf("Expected CompletedAt to be set for status %s", tc.toStatus) + } + } + } + }) + } +} + +func TestUpdatePaymentStatus(t *testing.T) { + testCases := []struct { + name string + fromPaymentStatus PaymentStatus + toPaymentStatus PaymentStatus + initialOrderStatus OrderStatus + expectedOrderStatus OrderStatus + shouldErr bool + shouldSetCompleted bool + }{ + { + name: "Pending to Authorized", + fromPaymentStatus: PaymentStatusPending, + toPaymentStatus: PaymentStatusAuthorized, + initialOrderStatus: OrderStatusPending, + expectedOrderStatus: OrderStatusPaid, + shouldErr: false, + }, + { + name: "Pending to Failed", + fromPaymentStatus: PaymentStatusPending, + toPaymentStatus: PaymentStatusFailed, + initialOrderStatus: OrderStatusPending, + expectedOrderStatus: OrderStatusCancelled, + shouldErr: false, + shouldSetCompleted: true, + }, + { + name: "Authorized to Captured (Shipped Order)", + fromPaymentStatus: PaymentStatusAuthorized, + toPaymentStatus: PaymentStatusCaptured, + initialOrderStatus: OrderStatusShipped, + expectedOrderStatus: OrderStatusCompleted, + shouldErr: false, + shouldSetCompleted: true, + }, + { + name: "Authorized to Cancelled", + fromPaymentStatus: PaymentStatusAuthorized, + toPaymentStatus: PaymentStatusCancelled, + initialOrderStatus: OrderStatusPaid, + expectedOrderStatus: OrderStatusCancelled, + shouldErr: false, + shouldSetCompleted: true, + }, + { + name: "Captured to Refunded", + fromPaymentStatus: PaymentStatusCaptured, + toPaymentStatus: PaymentStatusRefunded, + initialOrderStatus: OrderStatusCompleted, + expectedOrderStatus: OrderStatusCompleted, // Order status doesn't change on refund + shouldErr: false, + }, + // Invalid transitions + { + name: "Pending to Captured (invalid)", + fromPaymentStatus: PaymentStatusPending, + toPaymentStatus: PaymentStatusCaptured, + shouldErr: true, + }, + { + name: "Failed to any (invalid)", + fromPaymentStatus: PaymentStatusFailed, + toPaymentStatus: PaymentStatusAuthorized, + shouldErr: true, + }, + { + name: "Refunded to any (invalid)", + fromPaymentStatus: PaymentStatusRefunded, + toPaymentStatus: PaymentStatusCaptured, + shouldErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + order := createTestOrder(t) + order.PaymentStatus = tc.fromPaymentStatus + order.Status = tc.initialOrderStatus + order.CompletedAt = nil + + err := order.UpdatePaymentStatus(tc.toPaymentStatus) + + if tc.shouldErr && err == nil { + t.Errorf("Expected error for payment transition %s -> %s", tc.fromPaymentStatus, tc.toPaymentStatus) + } + if !tc.shouldErr && err != nil { + t.Errorf("Unexpected error for payment transition %s -> %s: %v", tc.fromPaymentStatus, tc.toPaymentStatus, err) + } + + if !tc.shouldErr { + if order.PaymentStatus != tc.toPaymentStatus { + t.Errorf("Expected payment status %s, got %s", tc.toPaymentStatus, order.PaymentStatus) + } + + if tc.expectedOrderStatus != "" && order.Status != tc.expectedOrderStatus { + t.Errorf("Expected order status %s, got %s", tc.expectedOrderStatus, order.Status) + } + + if tc.shouldSetCompleted && order.CompletedAt == nil { + t.Errorf("Expected CompletedAt to be set") + } + } + }) + } +} + +func TestOrderSetters(t *testing.T) { + order := createTestOrder(t) + + // Test SetPaymentID + err := order.SetPaymentID("payment_12345") + if err != nil { + t.Errorf("Unexpected error setting payment ID: %v", err) + } + if order.PaymentID != "payment_12345" { + t.Errorf("Expected payment ID 'payment_12345', got %s", order.PaymentID) + } + + // Test SetPaymentID with empty value + err = order.SetPaymentID("") + if err == nil { + t.Error("Expected error for empty payment ID") + } + + // Test SetPaymentProvider + err = order.SetPaymentProvider("stripe") + if err != nil { + t.Errorf("Unexpected error setting payment provider: %v", err) + } + if order.PaymentProvider != "stripe" { + t.Errorf("Expected payment provider 'stripe', got %s", order.PaymentProvider) + } + + // Test SetPaymentProvider with empty value + err = order.SetPaymentProvider("") + if err == nil { + t.Error("Expected error for empty payment provider") + } + + // Test SetPaymentMethod + err = order.SetPaymentMethod("card") + if err != nil { + t.Errorf("Unexpected error setting payment method: %v", err) + } + if order.PaymentMethod != "card" { + t.Errorf("Expected payment method 'card', got %s", order.PaymentMethod) + } + + // Test SetTrackingCode + err = order.SetTrackingCode("TRACK123456") + if err != nil { + t.Errorf("Unexpected error setting tracking code: %v", err) + } + if order.TrackingCode != "TRACK123456" { + t.Errorf("Expected tracking code 'TRACK123456', got %s", order.TrackingCode) + } + + // Test SetActionURL + err = order.SetActionURL("https://payment.example.com/checkout") + if err != nil { + t.Errorf("Unexpected error setting action URL: %v", err) + } + if order.ActionURL != "https://payment.example.com/checkout" { + t.Errorf("Expected action URL 'https://payment.example.com/checkout', got %s", order.ActionURL) + } +} + +func TestSetOrderNumber(t *testing.T) { + order := createTestOrder(t) + orderID := uint(12345) + + order.SetOrderNumber(orderID) + + expectedOrderNumber := "ORD-" + order.CreatedAt.Format("20060102") + "-012345" + if order.OrderNumber != expectedOrderNumber { + t.Errorf("Expected order number %s, got %s", expectedOrderNumber, order.OrderNumber) + } +} + +func TestSetShippingMethod(t *testing.T) { + order := createTestOrder(t) + originalFinalAmount := order.FinalAmount + + shippingOption := &ShippingOption{ + ShippingMethodID: 1, + Name: "Express Shipping", + Cost: 500, // $5.00 + EstimatedDeliveryDays: 2, + } + + err := order.SetShippingMethod(shippingOption) + if err != nil { + t.Errorf("Unexpected error setting shipping method: %v", err) + } + + if order.ShippingMethodID != 1 { + t.Errorf("Expected shipping method ID 1, got %d", order.ShippingMethodID) + } + if order.ShippingCost != 500 { + t.Errorf("Expected shipping cost 500, got %d", order.ShippingCost) + } + if order.FinalAmount != originalFinalAmount+500 { + t.Errorf("Expected final amount %d, got %d", originalFinalAmount+500, order.FinalAmount) + } + if order.ShippingOption == nil || order.ShippingOption.Name != "Express Shipping" { + t.Errorf("Expected shipping option to be set correctly") + } + + // Test with nil shipping option + err = order.SetShippingMethod(nil) + if err == nil { + t.Error("Expected error for nil shipping option") + } +} + +func TestCalculateTotalWeight(t *testing.T) { + order := createTestOrder(t) + + // Modify items for testing + order.Items = []OrderItem{ + {ProductID: 1, Quantity: 2, Price: 1000, Weight: 0.5}, // 2 * 0.5 = 1.0 + {ProductID: 2, Quantity: 3, Price: 1500, Weight: 1.2}, // 3 * 1.2 = 3.6 + } + + totalWeight := order.CalculateTotalWeight() + expectedWeight := 4.6 // 1.0 + 3.6 + + if totalWeight != expectedWeight { + t.Errorf("Expected total weight %.2f, got %.2f", expectedWeight, totalWeight) + } + if order.TotalWeight != expectedWeight { + t.Errorf("Expected order total weight %.2f, got %.2f", expectedWeight, order.TotalWeight) + } +} + +func TestIsCaptured(t *testing.T) { + order := createTestOrder(t) + + // Test when not captured + order.PaymentStatus = PaymentStatusPending + if order.IsCaptured() { + t.Error("Expected IsCaptured to be false for pending payment") + } + + // Test when captured + order.PaymentStatus = PaymentStatusCaptured + if !order.IsCaptured() { + t.Error("Expected IsCaptured to be true for captured payment") + } +} + +func TestIsRefunded(t *testing.T) { + order := createTestOrder(t) + + // Test when not refunded + order.PaymentStatus = PaymentStatusCaptured + if order.IsRefunded() { + t.Error("Expected IsRefunded to be false for captured payment") + } + + // Test when refunded + order.PaymentStatus = PaymentStatusRefunded + if !order.IsRefunded() { + t.Error("Expected IsRefunded to be true for refunded payment") + } +} + +func TestApplyDiscount(t *testing.T) { + order := createTestOrder(t) + order.TotalAmount = 10000 // $100.00 + order.FinalAmount = 10000 + order.ShippingCost = 500 // $5.00 + + // Create a test discount + discount := &Discount{ + ID: 1, + Code: "SAVE10", + Type: DiscountTypeBasket, + Method: DiscountMethodPercentage, + Value: 10.0, // 10% off + Active: true, + StartDate: time.Now().Add(-24 * time.Hour), + EndDate: time.Now().Add(24 * time.Hour), + UsageLimit: 100, + CurrentUsage: 5, + } + + err := order.ApplyDiscount(discount) + if err != nil { + t.Errorf("Unexpected error applying discount: %v", err) + } + + expectedDiscountAmount := int64(1000) // 10% of $100.00 + if order.DiscountAmount != expectedDiscountAmount { + t.Errorf("Expected discount amount %d, got %d", expectedDiscountAmount, order.DiscountAmount) + } + + expectedFinalAmount := order.TotalAmount + order.ShippingCost - expectedDiscountAmount + if order.FinalAmount != expectedFinalAmount { + t.Errorf("Expected final amount %d, got %d", expectedFinalAmount, order.FinalAmount) + } + + if order.AppliedDiscount == nil { + t.Error("Expected applied discount to be set") + } else { + if order.AppliedDiscount.DiscountID != discount.ID { + t.Errorf("Expected applied discount ID %d, got %d", discount.ID, order.AppliedDiscount.DiscountID) + } + if order.AppliedDiscount.DiscountCode != discount.Code { + t.Errorf("Expected applied discount code %s, got %s", discount.Code, order.AppliedDiscount.DiscountCode) + } + } + + // Test applying nil discount + err = order.ApplyDiscount(nil) + if err == nil { + t.Error("Expected error for nil discount") + } +} + +func TestRemoveDiscount(t *testing.T) { + order := createTestOrder(t) + order.TotalAmount = 10000 + order.ShippingCost = 500 + order.DiscountAmount = 1000 + order.FinalAmount = 9500 // 10000 + 500 - 1000 + order.AppliedDiscount = &AppliedDiscount{ + DiscountID: 1, + DiscountCode: "SAVE10", + DiscountAmount: 1000, + } + + order.RemoveDiscount() + + if order.DiscountAmount != 0 { + t.Errorf("Expected discount amount 0, got %d", order.DiscountAmount) + } + if order.FinalAmount != 10500 { // 10000 + 500 + t.Errorf("Expected final amount 10500, got %d", order.FinalAmount) + } + if order.AppliedDiscount != nil { + t.Error("Expected applied discount to be nil") + } +} + +// Helper functions + +func createTestOrder(t *testing.T) *Order { + items := []OrderItem{ + {ProductID: 1, Quantity: 1, Price: 1000, Weight: 0.5}, + } + + addr := Address{ + Street: "123 Test St", + City: "Test City", + State: "TS", + PostalCode: "12345", + Country: "USA", + } + + customer := CustomerDetails{ + Email: "test@example.com", + Phone: "+1234567890", + FullName: "Test User", + } + + order, err := NewOrder(1, items, "USD", addr, addr, customer) + if err != nil { + t.Fatalf("Failed to create test order: %v", err) + } + + return order +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && s[:len(substr)] == substr || + len(s) > len(substr) && s[len(s)-len(substr):] == substr || + len(s) > len(substr) && findSubstring(s, substr) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/dto/checkout.go b/internal/dto/checkout.go index c158af1..f06ba73 100644 --- a/internal/dto/checkout.go +++ b/internal/dto/checkout.go @@ -171,7 +171,7 @@ func CreateCheckoutResponse(checkout *entity.Checkout) ResponseDTO[CheckoutDTO] func CreateCompleteCheckoutResponse(order *entity.Order) ResponseDTO[CheckoutCompleteResponse] { response := CheckoutCompleteResponse{ Order: ToOrderSummaryDTO(order), - ActionRequired: order.Status == entity.OrderStatusPendingAction, + ActionRequired: order.Status == entity.OrderStatusPending && order.PaymentStatus == entity.PaymentStatusPending && order.ActionURL != "", ActionURL: order.ActionURL, } return SuccessResponse(response) diff --git a/internal/dto/order.go b/internal/dto/order.go index c36cba6..fa2fc71 100644 --- a/internal/dto/order.go +++ b/internal/dto/order.go @@ -15,6 +15,7 @@ type OrderDTO struct { OrderNumber string `json:"order_number"` Items []OrderItemDTO `json:"items"` Status OrderStatus `json:"status"` + PaymentStatus PaymentStatus `json:"payment_status"` TotalAmount float64 `json:"total_amount"` // Subtotal (items only) ShippingCost float64 `json:"shipping_cost"` // Shipping cost FinalAmount float64 `json:"final_amount"` // Total including shipping and discounts @@ -31,17 +32,18 @@ type OrderDTO struct { } type OrderSummaryDTO struct { - ID uint `json:"id"` - OrderNumber string `json:"order_number"` - UserID uint `json:"user_id"` - Status OrderStatus `json:"status"` - TotalAmount float64 `json:"total_amount"` // Subtotal (items only) - ShippingCost float64 `json:"shipping_cost"` // Shipping cost - FinalAmount float64 `json:"final_amount"` // Total including shipping and discounts - OrderLinesAmount int `json:"order_lines_amount"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + OrderNumber string `json:"order_number"` + UserID uint `json:"user_id"` + Status OrderStatus `json:"status"` + PaymentStatus PaymentStatus `json:"payment_status"` + TotalAmount float64 `json:"total_amount"` // Subtotal (items only) + ShippingCost float64 `json:"shipping_cost"` // Shipping cost + FinalAmount float64 `json:"final_amount"` // Total including shipping and discounts + OrderLinesAmount int `json:"order_lines_amount"` + Currency string `json:"currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PaymentDetails struct { @@ -117,14 +119,23 @@ type ProcessPaymentRequest struct { type OrderStatus string const ( - OrderStatusPending OrderStatus = "pending" - OrderStatusPendingAction OrderStatus = "pending_action" // Requires user action (e.g., redirect to payment provider) - OrderStatusPaid OrderStatus = "paid" - OrderStatusCaptured OrderStatus = "captured" // Payment captured - OrderStatusShipped OrderStatus = "shipped" - OrderStatusDelivered OrderStatus = "delivered" - OrderStatusCancelled OrderStatus = "cancelled" - OrderStatusRefunded OrderStatus = "refunded" + OrderStatusPending OrderStatus = "pending" + OrderStatusPaid OrderStatus = "paid" + OrderStatusShipped OrderStatus = "shipped" + OrderStatusCancelled OrderStatus = "cancelled" + OrderStatusCompleted OrderStatus = "completed" +) + +// PaymentStatus represents the status of a payment +type PaymentStatus string + +const ( + PaymentStatusPending PaymentStatus = "pending" + PaymentStatusAuthorized PaymentStatus = "authorized" + PaymentStatusCaptured PaymentStatus = "captured" + PaymentStatusRefunded PaymentStatus = "refunded" + PaymentStatusCancelled PaymentStatus = "cancelled" + PaymentStatusFailed PaymentStatus = "failed" ) // PaymentMethod represents the payment method used for an order @@ -176,6 +187,7 @@ func ToOrderSummaryDTO(order *entity.Order) OrderSummaryDTO { OrderNumber: order.OrderNumber, UserID: order.UserID, Status: OrderStatus(order.Status), + PaymentStatus: PaymentStatus(order.PaymentStatus), TotalAmount: money.FromCents(order.TotalAmount), ShippingCost: money.FromCents(order.ShippingCost), FinalAmount: money.FromCents(order.FinalAmount), @@ -264,6 +276,7 @@ func toOrderDTO(order *entity.Order) OrderDTO { OrderNumber: order.OrderNumber, UserID: order.UserID, Status: OrderStatus(order.Status), + PaymentStatus: PaymentStatus(order.PaymentStatus), TotalAmount: money.FromCents(order.TotalAmount), ShippingCost: money.FromCents(order.ShippingCost), FinalAmount: money.FromCents(order.FinalAmount), diff --git a/internal/dto/order_test.go b/internal/dto/order_test.go index 43d059f..9b918e0 100644 --- a/internal/dto/order_test.go +++ b/internal/dto/order_test.go @@ -78,6 +78,7 @@ func TestOrderDTO(t *testing.T) { OrderNumber: "ORD-001", Items: items, Status: OrderStatusPaid, + PaymentStatus: PaymentStatusCaptured, TotalAmount: 69.97, FinalAmount: 59.97, Currency: "USD", @@ -352,7 +353,7 @@ func TestOrderSearchRequest(t *testing.T) { request := OrderSearchRequest{ UserID: 1, Status: OrderStatusPaid, - PaymentStatus: "completed", + PaymentStatus: string(PaymentStatusCaptured), StartDate: &startDate, EndDate: &endDate, PaginationDTO: PaginationDTO{ @@ -368,8 +369,8 @@ func TestOrderSearchRequest(t *testing.T) { if request.Status != OrderStatusPaid { t.Errorf("Expected Status %s, got %s", OrderStatusPaid, request.Status) } - if request.PaymentStatus != "completed" { - t.Errorf("Expected PaymentStatus 'completed', got %s", request.PaymentStatus) + if request.PaymentStatus != string(PaymentStatusCaptured) { + t.Errorf("Expected PaymentStatus '%s', got %s", PaymentStatusCaptured, request.PaymentStatus) } if request.StartDate == nil { t.Error("Expected StartDate not nil") @@ -416,26 +417,38 @@ func TestOrderStatusConstants(t *testing.T) { if OrderStatusPending != "pending" { t.Errorf("Expected OrderStatusPending 'pending', got %s", OrderStatusPending) } - if OrderStatusPendingAction != "pending_action" { - t.Errorf("Expected OrderStatusPendingAction 'pending_action', got %s", OrderStatusPendingAction) - } if OrderStatusPaid != "paid" { t.Errorf("Expected OrderStatusPaid 'paid', got %s", OrderStatusPaid) } - if OrderStatusCaptured != "captured" { - t.Errorf("Expected OrderStatusCaptured 'captured', got %s", OrderStatusCaptured) - } if OrderStatusShipped != "shipped" { t.Errorf("Expected OrderStatusShipped 'shipped', got %s", OrderStatusShipped) } - if OrderStatusDelivered != "delivered" { - t.Errorf("Expected OrderStatusDelivered 'delivered', got %s", OrderStatusDelivered) - } if OrderStatusCancelled != "cancelled" { t.Errorf("Expected OrderStatusCancelled 'cancelled', got %s", OrderStatusCancelled) } - if OrderStatusRefunded != "refunded" { - t.Errorf("Expected OrderStatusRefunded 'refunded', got %s", OrderStatusRefunded) + if OrderStatusCompleted != "completed" { + t.Errorf("Expected OrderStatusCompleted 'completed', got %s", OrderStatusCompleted) + } +} + +func TestPaymentStatusConstants(t *testing.T) { + if PaymentStatusPending != "pending" { + t.Errorf("Expected PaymentStatusPending 'pending', got %s", PaymentStatusPending) + } + if PaymentStatusAuthorized != "authorized" { + t.Errorf("Expected PaymentStatusAuthorized 'authorized', got %s", PaymentStatusAuthorized) + } + if PaymentStatusCaptured != "captured" { + t.Errorf("Expected PaymentStatusCaptured 'captured', got %s", PaymentStatusCaptured) + } + if PaymentStatusRefunded != "refunded" { + t.Errorf("Expected PaymentStatusRefunded 'refunded', got %s", PaymentStatusRefunded) + } + if PaymentStatusCancelled != "cancelled" { + t.Errorf("Expected PaymentStatusCancelled 'cancelled', got %s", PaymentStatusCancelled) + } + if PaymentStatusFailed != "failed" { + t.Errorf("Expected PaymentStatusFailed 'failed', got %s", PaymentStatusFailed) } } @@ -460,18 +473,20 @@ func TestPaymentProviderConstants(t *testing.T) { func TestOrderListResponse(t *testing.T) { orders := []OrderSummaryDTO{ { - ID: 1, - OrderNumber: "ORD-001", - Status: OrderStatusPaid, - TotalAmount: 99.99, - Currency: "USD", + ID: 1, + OrderNumber: "ORD-001", + Status: OrderStatusPaid, + PaymentStatus: PaymentStatusCaptured, + TotalAmount: 99.99, + Currency: "USD", }, { - ID: 2, - OrderNumber: "ORD-002", - Status: OrderStatusShipped, - TotalAmount: 149.99, - Currency: "EUR", + ID: 2, + OrderNumber: "ORD-002", + Status: OrderStatusShipped, + PaymentStatus: PaymentStatusCaptured, + TotalAmount: 149.99, + Currency: "EUR", }, } diff --git a/internal/infrastructure/repository/postgres/order_repository.go b/internal/infrastructure/repository/postgres/order_repository.go index c0552da..3e36d3b 100644 --- a/internal/infrastructure/repository/postgres/order_repository.go +++ b/internal/infrastructure/repository/postgres/order_repository.go @@ -55,12 +55,12 @@ func (r *OrderRepository) Create(order *entity.Order) error { // Add guest order fields query = ` INSERT INTO orders ( - user_id, total_amount, status, shipping_address, billing_address, + user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, final_amount, customer_email, customer_phone, customer_full_name, is_guest_order, shipping_method_id, shipping_cost, total_weight, currency ) - VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING id ` @@ -68,6 +68,7 @@ func (r *OrderRepository) Create(order *entity.Order) error { query, order.TotalAmount, order.Status, + order.PaymentStatus, shippingAddrJSON, billingAddrJSON, order.PaymentID, @@ -90,12 +91,12 @@ func (r *OrderRepository) Create(order *entity.Order) error { // Regular user order query = ` INSERT INTO orders ( - user_id, total_amount, status, shipping_address, billing_address, + user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, final_amount, customer_email, customer_phone, customer_full_name, shipping_method_id, shipping_cost, total_weight, currency ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) RETURNING id ` @@ -104,6 +105,7 @@ func (r *OrderRepository) Create(order *entity.Order) error { order.UserID, order.TotalAmount, order.Status, + order.PaymentStatus, shippingAddrJSON, billingAddrJSON, order.PaymentID, @@ -169,7 +171,7 @@ func (r *OrderRepository) Create(order *entity.Order) error { func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { // Get order query := ` - SELECT id, order_number, user_id, total_amount, status, shipping_address, billing_address, + SELECT id, order_number, user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, discount_amount, discount_id, discount_code, final_amount, action_url, customer_email, customer_phone, customer_full_name, is_guest_order, shipping_method_id, shipping_cost, @@ -200,6 +202,7 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { &userID, &order.TotalAmount, &order.Status, + &order.PaymentStatus, &shippingAddrJSON, &billingAddrJSON, &order.PaymentID, @@ -362,20 +365,20 @@ func (r *OrderRepository) Update(order *entity.Order) error { // Update order query := ` UPDATE orders - SET status = $1, shipping_address = $2, billing_address = $3, - payment_id = $4, payment_provider = $5, tracking_code = $6, updated_at = $7, completed_at = $8, order_number = $9, - final_amount = $10, - discount_id = $11, - discount_amount = $12, - discount_code = $13, - action_url = $14, - shipping_method_id = $15, - shipping_cost = $16, - total_weight = $17, - customer_email = $18, - customer_phone = $19, - customer_full_name = $20 - WHERE id = $21 + SET status = $1, payment_status = $2, shipping_address = $3, billing_address = $4, + payment_id = $5, payment_provider = $6, tracking_code = $7, updated_at = $8, completed_at = $9, order_number = $10, + final_amount = $11, + discount_id = $12, + discount_amount = $13, + discount_code = $14, + action_url = $15, + shipping_method_id = $16, + shipping_cost = $17, + total_weight = $18, + customer_email = $19, + customer_phone = $20, + customer_full_name = $21 + WHERE id = $22 ` var discountID sql.NullInt64 @@ -393,6 +396,7 @@ func (r *OrderRepository) Update(order *entity.Order) error { _, err = r.db.Exec( query, order.Status, + order.PaymentStatus, shippingAddrJSON, billingAddrJSON, order.PaymentID, @@ -421,7 +425,7 @@ func (r *OrderRepository) Update(order *entity.Order) error { // GetByUser retrieves orders for a user func (r *OrderRepository) GetByUser(userID uint, offset, limit int) ([]*entity.Order, error) { query := ` - SELECT id, order_number, user_id, total_amount, status, shipping_address, billing_address, + SELECT id, order_number, user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, customer_email, customer_phone, customer_full_name, is_guest_order, currency FROM orders @@ -453,6 +457,7 @@ func (r *OrderRepository) GetByUser(userID uint, offset, limit int) ([]*entity.O &userIDNull, &order.TotalAmount, &order.Status, + &order.PaymentStatus, &shippingAddrJSON, &billingAddrJSON, &order.PaymentID, @@ -552,7 +557,7 @@ func (r *OrderRepository) GetByUser(userID uint, offset, limit int) ([]*entity.O // ListByStatus retrieves orders by status func (r *OrderRepository) ListByStatus(status entity.OrderStatus, offset, limit int) ([]*entity.Order, error) { query := ` - SELECT id, order_number, user_id, total_amount, status, created_at, updated_at, completed_at, + SELECT id, order_number, user_id, total_amount, status, payment_status, created_at, updated_at, completed_at, customer_email, customer_full_name, is_guest_order, currency FROM orders WHERE status = $1 @@ -581,6 +586,7 @@ func (r *OrderRepository) ListByStatus(status entity.OrderStatus, offset, limit &userIDNull, &order.TotalAmount, &order.Status, + &order.PaymentStatus, &order.CreatedAt, &order.UpdatedAt, &completedAt, @@ -674,7 +680,7 @@ func (r *OrderRepository) GetByPaymentID(paymentID string) (*entity.Order, error // Get order by payment_id query := ` - SELECT id, order_number, user_id, total_amount, status, shipping_address, billing_address, + SELECT id, order_number, user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, discount_amount, discount_id, discount_code, final_amount, action_url, customer_email, customer_phone, customer_full_name, is_guest_order, shipping_method_id, shipping_cost, @@ -705,6 +711,7 @@ func (r *OrderRepository) GetByPaymentID(paymentID string) (*entity.Order, error &userID, &order.TotalAmount, &order.Status, + &order.PaymentStatus, &shippingAddrJSON, &billingAddrJSON, &order.PaymentID, @@ -856,7 +863,7 @@ func (r *OrderRepository) GetByPaymentID(paymentID string) (*entity.Order, error // ListAll lists all orders func (r *OrderRepository) ListAll(offset, limit int) ([]*entity.Order, error) { query := ` - SELECT id, order_number, user_id, total_amount, status, + SELECT id, order_number, user_id, total_amount, status, payment_status, payment_provider, created_at, updated_at, completed_at, final_amount, customer_email, customer_full_name, is_guest_order, currency FROM orders @@ -884,6 +891,7 @@ func (r *OrderRepository) ListAll(offset, limit int) ([]*entity.Order, error) { &userID, &order.TotalAmount, &order.Status, + &order.PaymentStatus, &order.PaymentProvider, &order.CreatedAt, &order.UpdatedAt, diff --git a/internal/interfaces/api/handler/webhook_handler.go b/internal/interfaces/api/handler/webhook_handler.go index 47069ed..caf5651 100644 --- a/internal/interfaces/api/handler/webhook_handler.go +++ b/internal/interfaces/api/handler/webhook_handler.go @@ -184,15 +184,15 @@ func (h *WebhookHandler) HandleMobilePayAuthorized(event *models.WebhookEvent) e return err } - // Update the order status to paid - input := usecase.UpdateOrderStatusInput{ - OrderID: orderID, - Status: entity.OrderStatusPaid, + // Update payment status to authorized + input := usecase.UpdatePaymentStatusInput{ + OrderID: orderID, + PaymentStatus: entity.PaymentStatusAuthorized, } - order, err := h.orderUseCase.UpdateOrderStatus(input) + order, err := h.orderUseCase.UpdatePaymentStatus(input) if err != nil { - h.logger.Error("Failed to update order status for MobilePay payment: %v", err) + h.logger.Error("Failed to update payment status for MobilePay payment: %v", err) return err } @@ -224,14 +224,15 @@ func (h *WebhookHandler) HandleMobilePayCaptured(event *models.WebhookEvent) err h.logger.Info("MobilePay payment captured for order %d", orderID) - input := usecase.UpdateOrderStatusInput{ - OrderID: orderID, - Status: entity.OrderStatusCaptured, + // Update payment status to captured + input := usecase.UpdatePaymentStatusInput{ + OrderID: orderID, + PaymentStatus: entity.PaymentStatusCaptured, } - order, err := h.orderUseCase.UpdateOrderStatus(input) + order, err := h.orderUseCase.UpdatePaymentStatus(input) if err != nil { - h.logger.Error("Failed to update order status for MobilePay payment: %v", err) + h.logger.Error("Failed to update payment status for MobilePay payment: %v", err) return err } @@ -283,15 +284,15 @@ func (h *WebhookHandler) HandleMobilePayRefunded(event *models.WebhookEvent) err return err } - // Update order status to refunded - input := usecase.UpdateOrderStatusInput{ - OrderID: orderID, - Status: entity.OrderStatusRefunded, + // Update payment status to refunded + input := usecase.UpdatePaymentStatusInput{ + OrderID: orderID, + PaymentStatus: entity.PaymentStatusRefunded, } - order, err2 := h.orderUseCase.UpdateOrderStatus(input) + order, err2 := h.orderUseCase.UpdatePaymentStatus(input) if err2 != nil { - h.logger.Error("Failed to mark order as refunded for MobilePay payment: %v", err2) + h.logger.Error("Failed to update payment status to refunded for MobilePay payment: %v", err2) return err2 } @@ -793,16 +794,16 @@ func (h *WebhookHandler) handleRefund(event stripe.Event) { } } - // If the charge was fully refunded, update the order status + // If the charge was fully refunded, update the payment status if charge.Refunded { - input := usecase.UpdateOrderStatusInput{ - OrderID: order.ID, - Status: entity.OrderStatusRefunded, + input := usecase.UpdatePaymentStatusInput{ + OrderID: order.ID, + PaymentStatus: entity.PaymentStatusRefunded, } - _, err = h.orderUseCase.UpdateOrderStatus(input) + _, err = h.orderUseCase.UpdatePaymentStatus(input) if err != nil { - h.logger.Error("Failed to update order status to refunded: %v", err) + h.logger.Error("Failed to update payment status to refunded: %v", err) return } } diff --git a/migrations/000031_add_payment_status_to_orders.down.sql b/migrations/000031_add_payment_status_to_orders.down.sql new file mode 100644 index 0000000..5f1e7d5 --- /dev/null +++ b/migrations/000031_add_payment_status_to_orders.down.sql @@ -0,0 +1,3 @@ +-- Remove payment_status column from orders table +DROP INDEX IF EXISTS idx_orders_payment_status; +ALTER TABLE orders DROP COLUMN IF EXISTS payment_status; diff --git a/migrations/000031_add_payment_status_to_orders.up.sql b/migrations/000031_add_payment_status_to_orders.up.sql new file mode 100644 index 0000000..ac70615 --- /dev/null +++ b/migrations/000031_add_payment_status_to_orders.up.sql @@ -0,0 +1,17 @@ +-- Add payment_status column to orders table +ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_status VARCHAR(20) NOT NULL DEFAULT 'pending'; + +-- Create index for payment_status +CREATE INDEX IF NOT EXISTS idx_orders_payment_status ON orders(payment_status); + +-- Update existing orders to have proper payment_status based on their current status +-- Orders with status 'paid', 'shipped', 'completed' should have payment_status 'captured' +-- Orders with status 'cancelled' should have payment_status 'cancelled' +-- Orders with status 'pending' should have payment_status 'pending' +UPDATE orders +SET payment_status = + CASE + WHEN status IN ('paid', 'shipped', 'completed') THEN 'captured' + WHEN status = 'cancelled' THEN 'cancelled' + ELSE 'pending' + END; diff --git a/web/types/api.ts b/web/types/api.ts index afea77b..70630af 100644 --- a/web/types/api.ts +++ b/web/types/api.ts @@ -214,8 +214,8 @@ export interface ResponseDTO { export interface ListResponseDTO { success: boolean; message?: string; - data?: T[]; - pagination?: PaginationDTO; + data: T[]; + pagination: PaginationDTO; error?: string; } /** @@ -423,6 +423,7 @@ export interface OrderDTO { order_number: string; items: OrderItemDTO[]; status: OrderStatus; + payment_status: PaymentStatus; total_amount: number /* float64 */; // Subtotal (items only) shipping_cost: number /* float64 */; // Shipping cost final_amount: number /* float64 */; // Total including shipping and discounts @@ -442,6 +443,7 @@ export interface OrderSummaryDTO { order_number: string; user_id: number /* uint */; status: OrderStatus; + payment_status: PaymentStatus; total_amount: number /* float64 */; // Subtotal (items only) shipping_cost: number /* float64 */; // Shipping cost final_amount: number /* float64 */; // Total including shipping and discounts @@ -529,13 +531,20 @@ export interface ProcessPaymentRequest { */ export type OrderStatus = string; export const OrderStatusPending: OrderStatus = "pending"; -export const OrderStatusPendingAction: OrderStatus = "pending_action"; // Requires user action (e.g., redirect to payment provider) export const OrderStatusPaid: OrderStatus = "paid"; -export const OrderStatusCaptured: OrderStatus = "captured"; // Payment captured export const OrderStatusShipped: OrderStatus = "shipped"; -export const OrderStatusDelivered: OrderStatus = "delivered"; export const OrderStatusCancelled: OrderStatus = "cancelled"; -export const OrderStatusRefunded: OrderStatus = "refunded"; +export const OrderStatusCompleted: OrderStatus = "completed"; +/** + * PaymentStatus represents the status of a payment + */ +export type PaymentStatus = string; +export const PaymentStatusPending: PaymentStatus = "pending"; +export const PaymentStatusAuthorized: PaymentStatus = "authorized"; +export const PaymentStatusCaptured: PaymentStatus = "captured"; +export const PaymentStatusRefunded: PaymentStatus = "refunded"; +export const PaymentStatusCancelled: PaymentStatus = "cancelled"; +export const PaymentStatusFailed: PaymentStatus = "failed"; /** * PaymentMethod represents the payment method used for an order */ From 29a0115117bbc643b100e27294f605e70e3b51ab Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 01:12:46 +0200 Subject: [PATCH 02/12] feat: Enhance order processing with stock management and health check - Implemented stock reservation logic during payment authorization in CheckoutUseCase. - Added stock restoration for cancelled or failed payments in OrderUseCase. - Introduced health check endpoint to monitor service status and database connectivity. - Updated OrderItem structure to include ProductVariantID for better inventory management. - Modified CORS middleware to allow dynamic configuration of allowed origins. - Created migrations to add product_variant_id to order_items table for improved order tracking. - Added comprehensive unit tests for stock management logic in OrderUseCase. - Refactored OrderDTO and OrderSummaryDTO to include checkout_id and customer details. --- .env.example | 14 +- Makefile | 20 +- config/config.go | 3 +- .../application/usecase/checkout_usecase.go | 79 +++++++ internal/application/usecase/order_usecase.go | 144 ++++++++++-- .../application/usecase/order_usecase_test.go | 215 ++++++++++++++++++ internal/domain/entity/checkout.go | 15 +- internal/domain/entity/order.go | 16 +- internal/dto/order.go | 68 +++--- internal/dto/order_test.go | 32 --- .../container/handler_provider.go | 16 ++ .../container/usecase_provider.go | 1 + .../repository/postgres/order_repository.go | 41 +++- .../interfaces/api/handler/health_handler.go | 88 +++++++ .../interfaces/api/handler/webhook_handler.go | 1 + .../api/middleware/cors_middleware.go | 10 + internal/interfaces/api/server.go | 6 + ...product_variant_id_to_order_items.down.sql | 3 + ...d_product_variant_id_to_order_items.up.sql | 5 + web/types/api.ts | 14 +- 20 files changed, 667 insertions(+), 124 deletions(-) create mode 100644 internal/application/usecase/order_usecase_test.go create mode 100644 internal/interfaces/api/handler/health_handler.go create mode 100644 migrations/000032_add_product_variant_id_to_order_items.down.sql create mode 100644 migrations/000032_add_product_variant_id_to_order_items.up.sql diff --git a/.env.example b/.env.example index 76717fa..90f6d65 100644 --- a/.env.example +++ b/.env.example @@ -4,12 +4,6 @@ DB_USER= DB_PASSWORD= DB_NAME=commercify -TEST_DB_HOST=localhost -TEST_DB_PORT=5432 -TEST_DB_USER=postgres -TEST_DB_PASSWORD=postgres -TEST_DB_NAME=commercify_test - AUTH_JWT_SECRET=your_jwt_secret EMAIL_ENABLED=true @@ -27,11 +21,6 @@ STRIPE_PUBLIC_KEY=pk_test_your_key STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret STRIPE_PAYMENT_DESCRIPTION=Commercify Store Purchase -PAYPAL_ENABLED=true -PAYPAL_CLIENT_ID=your_client_id -PAYPAL_CLIENT_SECRET=your_client_secret -PAYPAL_SANDBOX=true - MOBILEPAY_ENABLED=false MOBILEPAY_TEST_MODE=true MOBILEPAY_MERCHANT_SERIAL_NUMBER=your_merchant_serial_number @@ -42,4 +31,5 @@ MOBILEPAY_WEBHOOK_URL=https://your-site.com/api/webhooks/mobilepay MOBILEPAY_PAYMENT_DESCRIPTION=Commercify Store Purchase MOBILEPAY_MARKET=NOK -RETURN_URL=https://your-site.com/payment/complete \ No newline at end of file +RETURN_URL=https://your-site.com/payment/complete +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 \ No newline at end of file diff --git a/Makefile b/Makefile index 79dab7d..daf0a64 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help db-start db-stop db-restart db-logs db-clean migrate-up migrate-down seed-data build run test clean +.PHONY: help db-start db-stop db-restart db-logs db-clean migrate-up migrate-down seed-data build run test clean docker-build docker-build-tag docker-push docker-build-push # Default target help: ## Show this help message @@ -56,6 +56,24 @@ stop-docker: ## Stop the entire application stack logs: ## Show application logs docker compose logs -f api +# Docker image commands +docker-build: ## Build Docker image + docker build -t commercifygo:latest . + +docker-build-tag: ## Build Docker image with specific tag (use TAG=version) + @if [ -z "$(TAG)" ]; then echo "Error: TAG is required. Use: make docker-build-tag TAG=v1.0.0"; exit 1; fi + docker build -t commercifygo:$(TAG) -t commercifygo:latest . + +docker-push: ## Push Docker image to registry (use REGISTRY and TAG) + @if [ -z "$(REGISTRY)" ]; then echo "Error: REGISTRY is required. Use: make docker-push REGISTRY=your-registry.com"; exit 1; fi + @if [ -z "$(TAG)" ]; then echo "Error: TAG is required. Use: make docker-push REGISTRY=your-registry.com TAG=v1.0.0"; exit 1; fi + docker tag commercifygo:$(TAG) $(REGISTRY)/commercifygo:$(TAG) + docker tag commercifygo:latest $(REGISTRY)/commercifygo:latest + docker push $(REGISTRY)/commercifygo:$(TAG) + docker push $(REGISTRY)/commercifygo:latest + +docker-build-push: docker-build-tag docker-push ## Build and push Docker image (use REGISTRY and TAG) + # Development commands test: ## Run tests go test ./... diff --git a/config/config.go b/config/config.go index b4eb545..7063ac4 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" ) // Config holds all configuration for the application @@ -222,7 +223,7 @@ func LoadConfig() (*Config, error) { IsTestMode: mobilePayTestMode, }, CORS: CORSConfig{ - AllowedOrigins: []string{"*"}, + AllowedOrigins: strings.Split(getEnv("CORS_ALLOWED_ORIGINS", "*"), ","), AllowAllOrigins: true, }, DefaultCurrency: getEnv("DEFAULT_CURRENCY", "USD"), diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index 43dd2c9..bb3a880 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -204,6 +204,25 @@ func (uc *CheckoutUseCase) ProcessPayment(order *entity.Order, input ProcessPaym return nil, err } + // Decrease stock when payment is authorized to reserve items + if err := uc.decreaseStockForOrder(order); err != nil { + // If stock update fails, we should consider the payment failed + // Try to update the order status to indicate the failure + log.Printf("Failed to decrease stock for order %d: %v", order.ID, err) + + // Update payment status to failed since we can't fulfill the order + if updateErr := order.UpdatePaymentStatus(entity.PaymentStatusFailed); updateErr != nil { + log.Printf("Failed to update payment status to failed: %v", updateErr) + } else { + // Save the updated order status + if saveErr := uc.orderRepo.Update(order); saveErr != nil { + log.Printf("Failed to save failed payment status: %v", saveErr) + } + } + + return nil, fmt.Errorf("unable to reserve stock for order: %w", err) + } + // Record the successful authorization transaction txn, err := entity.NewPaymentTransaction( order.ID, @@ -983,3 +1002,63 @@ func (uc *CheckoutUseCase) getPriceInCurrency(variant *entity.ProductVariant, ta convertedPrice := fromCurrency.ConvertAmount(variant.Price, toCurrency) return convertedPrice, nil } + +// decreaseStockForOrder decreases stock for all items in an order when payment is authorized +func (uc *CheckoutUseCase) decreaseStockForOrder(order *entity.Order) error { + for _, item := range order.Items { + // Skip items without variant ID (shouldn't happen, but safety check) + if item.ProductVariantID == 0 { + continue + } + + // Get the variant + variant, err := uc.productVariantRepo.GetByID(item.ProductVariantID) + if err != nil { + return fmt.Errorf("failed to get variant %d: %w", item.ProductVariantID, err) + } + + // Check if there's enough stock + if variant.Stock < item.Quantity { + return fmt.Errorf("insufficient stock for product %s (SKU: %s): available %d, required %d", + item.ProductName, item.SKU, variant.Stock, item.Quantity) + } + + // Update stock + if err := variant.UpdateStock(variant.Stock - item.Quantity); err != nil { + return fmt.Errorf("failed to update stock for variant %d: %w", item.ProductVariantID, err) + } + + // Save the updated variant + if err := uc.productVariantRepo.Update(variant); err != nil { + return fmt.Errorf("failed to save variant %d: %w", item.ProductVariantID, err) + } + } + return nil +} + +// increaseStockForOrder increases stock for all items in an order (for cancellations/failures) +func (uc *CheckoutUseCase) increaseStockForOrder(order *entity.Order) error { + for _, item := range order.Items { + // Skip items without variant ID (shouldn't happen, but safety check) + if item.ProductVariantID == 0 { + continue + } + + // Get the variant + variant, err := uc.productVariantRepo.GetByID(item.ProductVariantID) + if err != nil { + return fmt.Errorf("failed to get variant %d: %w", item.ProductVariantID, err) + } + + // Update stock + if err := variant.UpdateStock(variant.Stock + item.Quantity); err != nil { + return fmt.Errorf("failed to update stock for variant %d: %w", item.ProductVariantID, err) + } + + // Save the updated variant + if err := uc.productVariantRepo.Update(variant); err != nil { + return fmt.Errorf("failed to save variant %d: %w", item.ProductVariantID, err) + } + } + return nil +} diff --git a/internal/application/usecase/order_usecase.go b/internal/application/usecase/order_usecase.go index f7d3a79..8f6dd9a 100644 --- a/internal/application/usecase/order_usecase.go +++ b/internal/application/usecase/order_usecase.go @@ -14,19 +14,21 @@ import ( // OrderUseCase implements order-related use cases type OrderUseCase struct { - orderRepo repository.OrderRepository - productRepo repository.ProductRepository - userRepo repository.UserRepository - paymentSvc service.PaymentService - emailSvc service.EmailService - paymentTxnRepo repository.PaymentTransactionRepository - currencyRepo repository.CurrencyRepository + orderRepo repository.OrderRepository + productRepo repository.ProductRepository + productVariantRepo repository.ProductVariantRepository + userRepo repository.UserRepository + paymentSvc service.PaymentService + emailSvc service.EmailService + paymentTxnRepo repository.PaymentTransactionRepository + currencyRepo repository.CurrencyRepository } // NewOrderUseCase creates a new OrderUseCase func NewOrderUseCase( orderRepo repository.OrderRepository, productRepo repository.ProductRepository, + productVariantRepo repository.ProductVariantRepository, userRepo repository.UserRepository, paymentSvc service.PaymentService, emailSvc service.EmailService, @@ -34,13 +36,14 @@ func NewOrderUseCase( currencyRepo repository.CurrencyRepository, ) *OrderUseCase { return &OrderUseCase{ - orderRepo: orderRepo, - productRepo: productRepo, - userRepo: userRepo, - paymentSvc: paymentSvc, - emailSvc: emailSvc, - paymentTxnRepo: paymentTxnRepo, - currencyRepo: currencyRepo, + orderRepo: orderRepo, + productRepo: productRepo, + productVariantRepo: productVariantRepo, + userRepo: userRepo, + paymentSvc: paymentSvc, + emailSvc: emailSvc, + paymentTxnRepo: paymentTxnRepo, + currencyRepo: currencyRepo, } } @@ -192,15 +195,17 @@ func (uc *OrderUseCase) CapturePayment(transactionID string, amount int64) error } // Update payment status to captured, which will also update order status to completed - if err := order.UpdatePaymentStatus(entity.PaymentStatusCaptured); err != nil { - return fmt.Errorf("failed to update payment status: %v", err) - } + // if err := order.UpdatePaymentStatus(entity.PaymentStatusCaptured); err != nil { + // return fmt.Errorf("failed to update payment status: %v", err) + // } // Save the updated order in repository if err := uc.orderRepo.Update(order); err != nil { return fmt.Errorf("failed to save order status: %v", err) } + // Stock was already decreased when payment was authorized, no need to decrease again + // Record successful capture transaction // Track if this is a full or partial capture isFullCapture := amount >= order.FinalAmount @@ -478,6 +483,7 @@ func (uc *OrderUseCase) RecordPaymentTransaction(transaction *entity.PaymentTran type UpdatePaymentStatusInput struct { OrderID uint PaymentStatus entity.PaymentStatus + TransactionID string // Optional, for logging purposes } // UpdatePaymentStatus updates the payment status of an order @@ -488,6 +494,9 @@ func (uc *OrderUseCase) UpdatePaymentStatus(input UpdatePaymentStatusInput) (*en return nil, fmt.Errorf("order not found: %w", err) } + // Store the previous payment status to determine if stock updates are needed + previousPaymentStatus := order.PaymentStatus + // Update payment status if err := order.UpdatePaymentStatus(input.PaymentStatus); err != nil { return nil, fmt.Errorf("failed to update payment status: %w", err) @@ -498,5 +507,106 @@ func (uc *OrderUseCase) UpdatePaymentStatus(input UpdatePaymentStatusInput) (*en return nil, fmt.Errorf("failed to save order: %w", err) } + // Handle stock updates based on payment status transitions + if err := uc.handleStockUpdatesForPaymentStatusChange(order, previousPaymentStatus, input.PaymentStatus); err != nil { + // Log the error but don't fail the status update since the payment status change was successful + log.Printf("Warning: Failed to update stock for order %d: %v", order.ID, err) + } + return order, nil } + +// handleStockUpdatesForPaymentStatusChange handles stock updates when payment status changes +func (uc *OrderUseCase) handleStockUpdatesForPaymentStatusChange(order *entity.Order, previousStatus, newStatus entity.PaymentStatus) error { + // Only handle stock changes for specific transitions + switch { + case previousStatus != entity.PaymentStatusAuthorized && newStatus == entity.PaymentStatusAuthorized: + // Payment was just authorized - decrease stock to reserve items + return uc.decreaseStock(order) + + case previousStatus == entity.PaymentStatusAuthorized && newStatus == entity.PaymentStatusCancelled: + // Payment was authorized but now cancelled - restore stock + return uc.increaseStock(order) + + case previousStatus == entity.PaymentStatusAuthorized && newStatus == entity.PaymentStatusFailed: + // Payment was authorized but now failed - restore stock + return uc.increaseStock(order) + + case previousStatus == entity.PaymentStatusCaptured && newStatus == entity.PaymentStatusRefunded: + // Payment was captured but now refunded - restore stock + return uc.increaseStock(order) + + case previousStatus != entity.PaymentStatusCancelled && newStatus == entity.PaymentStatusCancelled && previousStatus != entity.PaymentStatusAuthorized: + // Payment was cancelled without being authorized first - no stock change needed + return nil + + case previousStatus != entity.PaymentStatusFailed && newStatus == entity.PaymentStatusFailed && previousStatus != entity.PaymentStatusAuthorized: + // Payment failed without being authorized first - no stock change needed + return nil + + default: + // No stock change needed for other transitions (e.g., authorized -> captured) + return nil + } +} + +// decreaseStock decreases stock for all items in an order +func (uc *OrderUseCase) decreaseStock(order *entity.Order) error { + for _, item := range order.Items { + // Skip items without variant ID (shouldn't happen, but safety check) + if item.ProductVariantID == 0 { + continue + } + + // Get the variant + variant, err := uc.productVariantRepo.GetByID(item.ProductVariantID) + if err != nil { + return fmt.Errorf("failed to get variant %d: %w", item.ProductVariantID, err) + } + + // Check if there's enough stock + if variant.Stock < item.Quantity { + return fmt.Errorf("insufficient stock for product %s (SKU: %s): available %d, required %d", + item.ProductName, item.SKU, variant.Stock, item.Quantity) + } + + // Update stock + changeAmount := -item.Quantity // Negative because we're decreasing + if err := variant.UpdateStock(changeAmount); err != nil { + return fmt.Errorf("failed to update stock for variant %d: %w", item.ProductVariantID, err) + } + + // Save the updated variant + if err := uc.productVariantRepo.Update(variant); err != nil { + return fmt.Errorf("failed to save variant %d: %w", item.ProductVariantID, err) + } + } + return nil +} + +// increaseStock increases stock for all items in an order (for cancellations/refunds) +func (uc *OrderUseCase) increaseStock(order *entity.Order) error { + for _, item := range order.Items { + // Skip items without variant ID (shouldn't happen, but safety check) + if item.ProductVariantID == 0 { + continue + } + + // Get the variant + variant, err := uc.productVariantRepo.GetByID(item.ProductVariantID) + if err != nil { + return fmt.Errorf("failed to get variant %d: %w", item.ProductVariantID, err) + } + + // Update stock + if err := variant.UpdateStock(item.Quantity); err != nil { // Positive quantity to increase stock + return fmt.Errorf("failed to update stock for variant %d: %w", item.ProductVariantID, err) + } + + // Save the updated variant + if err := uc.productVariantRepo.Update(variant); err != nil { + return fmt.Errorf("failed to save variant %d: %w", item.ProductVariantID, err) + } + } + return nil +} diff --git a/internal/application/usecase/order_usecase_test.go b/internal/application/usecase/order_usecase_test.go new file mode 100644 index 0000000..e26b612 --- /dev/null +++ b/internal/application/usecase/order_usecase_test.go @@ -0,0 +1,215 @@ +package usecase + +import ( + "testing" + + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/service" + "github.com/zenfulcode/commercify/testutil/mock" +) + +// Simple mock services for testing stock management +type mockPaymentService struct{} + +func (m *mockPaymentService) GetAvailableProviders() []service.PaymentProvider { return nil } +func (m *mockPaymentService) GetAvailableProvidersForCurrency(currency string) []service.PaymentProvider { + return nil +} +func (m *mockPaymentService) ProcessPayment(request service.PaymentRequest) (*service.PaymentResult, error) { + return nil, nil +} +func (m *mockPaymentService) VerifyPayment(transactionID string, provider service.PaymentProviderType) (bool, error) { + return false, nil +} +func (m *mockPaymentService) CapturePayment(transactionID, currency string, amount int64, provider service.PaymentProviderType) (*service.PaymentResult, error) { + return nil, nil +} +func (m *mockPaymentService) RefundPayment(transactionID, currency string, amount int64, provider service.PaymentProviderType) (*service.PaymentResult, error) { + return nil, nil +} +func (m *mockPaymentService) CancelPayment(transactionID string, provider service.PaymentProviderType) (*service.PaymentResult, error) { + return nil, nil +} +func (m *mockPaymentService) ForceApprovePayment(transactionID, phoneNumber string, provider service.PaymentProviderType) error { + return nil +} + +type mockEmailService struct{} + +func (m *mockEmailService) SendEmail(data service.EmailData) error { return nil } +func (m *mockEmailService) SendOrderConfirmation(order *entity.Order, user *entity.User) error { + return nil +} +func (m *mockEmailService) SendOrderNotification(order *entity.Order, user *entity.User) error { + return nil +} + +func TestOrderUseCase_HandleStockUpdatesForPaymentStatusChange(t *testing.T) { + tests := []struct { + name string + previousStatus entity.PaymentStatus + newStatus entity.PaymentStatus + initialStock int + orderQuantity int + expectedStock int + expectError bool + errorMessage string + }{ + { + name: "Stock decreased when payment authorized", + previousStatus: entity.PaymentStatusPending, + newStatus: entity.PaymentStatusAuthorized, + initialStock: 10, + orderQuantity: 2, + expectedStock: 8, + expectError: false, + }, + { + name: "Stock increased when authorized payment cancelled", + previousStatus: entity.PaymentStatusAuthorized, + newStatus: entity.PaymentStatusCancelled, + initialStock: 8, + orderQuantity: 2, + expectedStock: 10, + expectError: false, + }, + { + name: "Stock increased when authorized payment failed", + previousStatus: entity.PaymentStatusAuthorized, + newStatus: entity.PaymentStatusFailed, + initialStock: 8, + orderQuantity: 2, + expectedStock: 10, + expectError: false, + }, + { + name: "Stock increased when captured payment refunded", + previousStatus: entity.PaymentStatusCaptured, + newStatus: entity.PaymentStatusRefunded, + initialStock: 8, + orderQuantity: 2, + expectedStock: 10, + expectError: false, + }, + { + name: "No stock change for authorized to captured", + previousStatus: entity.PaymentStatusAuthorized, + newStatus: entity.PaymentStatusCaptured, + initialStock: 8, + orderQuantity: 2, + expectedStock: 8, + expectError: false, + }, + { + name: "No stock change for pending to cancelled", + previousStatus: entity.PaymentStatusPending, + newStatus: entity.PaymentStatusCancelled, + initialStock: 10, + orderQuantity: 2, + expectedStock: 10, + expectError: false, + }, + { + name: "No stock change for pending to failed", + previousStatus: entity.PaymentStatusPending, + newStatus: entity.PaymentStatusFailed, + initialStock: 10, + orderQuantity: 2, + expectedStock: 10, + expectError: false, + }, + { + name: "Error when insufficient stock on authorization", + previousStatus: entity.PaymentStatusPending, + newStatus: entity.PaymentStatusAuthorized, + initialStock: 1, + orderQuantity: 2, + expectedStock: 1, + expectError: true, + errorMessage: "insufficient stock", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup test repositories + orderRepo := mock.NewMockOrderRepository(false) + productRepo := mock.NewMockProductRepository() + productVariantRepo := mock.NewMockProductVariantRepository() + userRepo := mock.NewMockUserRepository() + paymentTxnRepo := mock.NewMockPaymentTransactionRepository() + currencyRepo := mock.NewMockCurrencyRepository() + + // Setup payment service and email service mocks + paymentSvc := &mockPaymentService{} + emailSvc := &mockEmailService{} + + // Create the use case + uc := NewOrderUseCase( + orderRepo, + productRepo, + productVariantRepo, + userRepo, + paymentSvc, + emailSvc, + paymentTxnRepo, + currencyRepo, + ) + + // Create a simple test order with pre-configured variant + variant := &entity.ProductVariant{ + ProductID: 1, + SKU: "TEST-SKU", + Stock: tt.initialStock, + } + + // Create the variant in the repository + if err := productVariantRepo.Create(variant); err != nil { + t.Fatalf("Failed to create variant: %v", err) + } + + order := &entity.Order{ + ID: 1, + Items: []entity.OrderItem{ + { + ProductVariantID: variant.ID, // Use the ID assigned by the mock repository + Quantity: tt.orderQuantity, + ProductName: "Test Product", + SKU: "TEST-SKU", + }, + }, + PaymentStatus: tt.previousStatus, + } + + // Test the stock update logic + stockErr := uc.handleStockUpdatesForPaymentStatusChange(order, tt.previousStatus, tt.newStatus) + + // Check error expectation + if tt.expectError { + if stockErr == nil { + t.Errorf("Expected error but got none") + } else if tt.errorMessage != "" && !contains(stockErr.Error(), tt.errorMessage) { + t.Errorf("Expected error to contain '%s', got: %v", tt.errorMessage, stockErr) + } + } else if stockErr != nil { + t.Errorf("Unexpected error: %v", stockErr) + } + + // Check stock level + updatedVariant, err := productVariantRepo.GetByID(variant.ID) + if err != nil { + t.Fatalf("Failed to get updated variant: %v", err) + } + + if updatedVariant.Stock != tt.expectedStock { + t.Errorf("Expected stock to be %d, got %d", tt.expectedStock, updatedVariant.Stock) + } + }) + } +} + +func contains(str, substr string) bool { + return len(str) >= len(substr) && (str == substr || + len(str) > len(substr) && (str[:len(substr)] == substr || + str[len(str)-len(substr):] == substr)) +} diff --git a/internal/domain/entity/checkout.go b/internal/domain/entity/checkout.go index 4105606..3328c4d 100644 --- a/internal/domain/entity/checkout.go +++ b/internal/domain/entity/checkout.go @@ -437,13 +437,14 @@ func convertCheckoutItemsToOrderItems(checkoutItems []CheckoutItem) []OrderItem orderItems := make([]OrderItem, len(checkoutItems)) for i, item := range checkoutItems { orderItems[i] = OrderItem{ - ProductID: item.ProductID, - Quantity: item.Quantity, - Price: item.Price, - Subtotal: item.Price * int64(item.Quantity), - Weight: item.Weight, - ProductName: item.ProductName, - SKU: item.SKU, + ProductID: item.ProductID, + ProductVariantID: item.ProductVariantID, + Quantity: item.Quantity, + Price: item.Price, + Subtotal: item.Price * int64(item.Quantity), + Weight: item.Weight, + ProductName: item.ProductName, + SKU: item.SKU, } } return orderItems diff --git a/internal/domain/entity/order.go b/internal/domain/entity/order.go index 1484c3d..8809be8 100644 --- a/internal/domain/entity/order.go +++ b/internal/domain/entity/order.go @@ -70,16 +70,18 @@ type Order struct { // OrderItem represents an item in an order type OrderItem struct { - ID uint `json:"id"` - OrderID uint `json:"order_id"` - ProductID uint `json:"product_id"` - Quantity int `json:"quantity"` - Price int64 `json:"price"` // stored in cents - Subtotal int64 `json:"subtotal"` // stored in cents - Weight float64 `json:"weight"` // Weight per item + ID uint `json:"id"` + OrderID uint `json:"order_id"` + ProductID uint `json:"product_id"` + ProductVariantID uint `json:"product_variant_id,omitempty"` + Quantity int `json:"quantity"` + Price int64 `json:"price"` // stored in cents + Subtotal int64 `json:"subtotal"` // stored in cents + Weight float64 `json:"weight"` // Weight per item ProductName string `json:"product_name"` SKU string `json:"sku"` + ImageURL string `json:"image_url,omitempty"` } // Address represents a shipping or billing address diff --git a/internal/dto/order.go b/internal/dto/order.go index fa2fc71..0cce890 100644 --- a/internal/dto/order.go +++ b/internal/dto/order.go @@ -5,7 +5,6 @@ import ( "github.com/zenfulcode/commercify/internal/domain/entity" "github.com/zenfulcode/commercify/internal/domain/money" - "github.com/zenfulcode/commercify/internal/domain/service" ) // OrderDTO represents an order in the system @@ -26,24 +25,26 @@ type OrderDTO struct { ShippingDetails ShippingOptionDTO `json:"shipping_details"` DiscountDetails AppliedDiscountDTO `json:"discount_details"` Customer CustomerDetailsDTO `json:"customer"` - CheckoutID string `json:"checkout_id,omitempty"` + CheckoutID string `json:"checkout_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type OrderSummaryDTO struct { - ID uint `json:"id"` - OrderNumber string `json:"order_number"` - UserID uint `json:"user_id"` - Status OrderStatus `json:"status"` - PaymentStatus PaymentStatus `json:"payment_status"` - TotalAmount float64 `json:"total_amount"` // Subtotal (items only) - ShippingCost float64 `json:"shipping_cost"` // Shipping cost - FinalAmount float64 `json:"final_amount"` // Total including shipping and discounts - OrderLinesAmount int `json:"order_lines_amount"` - Currency string `json:"currency"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `json:"id"` + OrderNumber string `json:"order_number"` + CheckoutID string `json:"checkout_id"` + UserID uint `json:"user_id"` + Customer CustomerDetailsDTO `json:"customer"` + Status OrderStatus `json:"status"` + PaymentStatus PaymentStatus `json:"payment_status"` + TotalAmount float64 `json:"total_amount"` // Subtotal (items only) + ShippingCost float64 `json:"shipping_cost"` // Shipping cost + FinalAmount float64 `json:"final_amount"` // Total including shipping and discounts + OrderLinesAmount int `json:"order_lines_amount"` + Currency string `json:"currency"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type PaymentDetails struct { @@ -67,6 +68,7 @@ type OrderItemDTO struct { Quantity int `json:"quantity"` UnitPrice float64 `json:"unit_price"` TotalPrice float64 `json:"total_price"` + ImageURL string `json:"image_url,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -107,14 +109,6 @@ type OrderSearchRequest struct { PaginationDTO `json:"pagination"` } -// ProcessPaymentRequest represents the data needed to process a payment -type ProcessPaymentRequest struct { - PaymentMethod PaymentMethod `json:"payment_method"` - PaymentProvider PaymentProvider `json:"payment_provider"` - CardDetails *service.CardDetails `json:"card_details,omitempty"` - PhoneNumber string `json:"phone_number,omitempty"` -} - // OrderStatus represents the status of an order type OrderStatus string @@ -185,6 +179,7 @@ func ToOrderSummaryDTO(order *entity.Order) OrderSummaryDTO { return OrderSummaryDTO{ ID: order.ID, OrderNumber: order.OrderNumber, + CheckoutID: order.CheckoutSessionID, UserID: order.UserID, Status: OrderStatus(order.Status), PaymentStatus: PaymentStatus(order.PaymentStatus), @@ -193,8 +188,13 @@ func ToOrderSummaryDTO(order *entity.Order) OrderSummaryDTO { FinalAmount: money.FromCents(order.FinalAmount), OrderLinesAmount: len(order.Items), Currency: order.Currency, - CreatedAt: order.CreatedAt, - UpdatedAt: order.UpdatedAt, + Customer: CustomerDetailsDTO{ + Email: order.CustomerDetails.Email, + Phone: order.CustomerDetails.Phone, + FullName: order.CustomerDetails.FullName, + }, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, } } @@ -205,14 +205,18 @@ func toOrderDTO(order *entity.Order) OrderDTO { items = make([]OrderItemDTO, len(order.Items)) for i, item := range order.Items { items[i] = OrderItemDTO{ - ID: item.ID, - OrderID: order.ID, - ProductID: item.ProductID, - Quantity: item.Quantity, - UnitPrice: money.FromCents(item.Price), - TotalPrice: money.FromCents(item.Subtotal), - CreatedAt: order.CreatedAt, - UpdatedAt: order.UpdatedAt, + ID: item.ID, + OrderID: order.ID, + ProductID: item.ProductID, + Quantity: item.Quantity, + UnitPrice: money.FromCents(item.Price), + TotalPrice: money.FromCents(item.Subtotal), + ImageURL: item.ImageURL, + SKU: item.SKU, + ProductName: item.ProductName, + VariantID: item.ProductVariantID, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, } } } diff --git a/internal/dto/order_test.go b/internal/dto/order_test.go index 9b918e0..c8ed810 100644 --- a/internal/dto/order_test.go +++ b/internal/dto/order_test.go @@ -3,8 +3,6 @@ package dto import ( "testing" "time" - - "github.com/zenfulcode/commercify/internal/domain/service" ) func TestOrderDTO(t *testing.T) { @@ -383,36 +381,6 @@ func TestOrderSearchRequest(t *testing.T) { } } -func TestProcessPaymentRequest(t *testing.T) { - cardDetails := &service.CardDetails{ - CardNumber: "4111111111111111", - ExpiryMonth: 12, - ExpiryYear: 2025, - CVV: "123", - CardholderName: "John Doe", - } - - request := ProcessPaymentRequest{ - PaymentMethod: PaymentMethodCard, - PaymentProvider: PaymentProviderStripe, - CardDetails: cardDetails, - PhoneNumber: "+1-555-123-4567", - } - - if request.PaymentMethod != PaymentMethodCard { - t.Errorf("Expected PaymentMethod %s, got %s", PaymentMethodCard, request.PaymentMethod) - } - if request.PaymentProvider != PaymentProviderStripe { - t.Errorf("Expected PaymentProvider %s, got %s", PaymentProviderStripe, request.PaymentProvider) - } - if request.CardDetails.CardNumber != "4111111111111111" { - t.Errorf("Expected CardDetails.CardNumber '4111111111111111', got %s", request.CardDetails.CardNumber) - } - if request.PhoneNumber != "+1-555-123-4567" { - t.Errorf("Expected PhoneNumber '+1-555-123-4567', got %s", request.PhoneNumber) - } -} - func TestOrderStatusConstants(t *testing.T) { if OrderStatusPending != "pending" { t.Errorf("Expected OrderStatusPending 'pending', got %s", OrderStatusPending) diff --git a/internal/infrastructure/container/handler_provider.go b/internal/infrastructure/container/handler_provider.go index 2e77ffc..78f9706 100644 --- a/internal/infrastructure/container/handler_provider.go +++ b/internal/infrastructure/container/handler_provider.go @@ -18,6 +18,7 @@ type HandlerProvider interface { DiscountHandler() *handler.DiscountHandler ShippingHandler() *handler.ShippingHandler CurrencyHandler() *handler.CurrencyHandler + HealthHandler() *handler.HealthHandler } // handlerProvider is the concrete implementation of HandlerProvider @@ -35,6 +36,7 @@ type handlerProvider struct { discountHandler *handler.DiscountHandler shippingHandler *handler.ShippingHandler currencyHandler *handler.CurrencyHandler + healthHandler *handler.HealthHandler } // NewHandlerProvider creates a new handler provider @@ -190,3 +192,17 @@ func (p *handlerProvider) CurrencyHandler() *handler.CurrencyHandler { } return p.currencyHandler } + +// HealthHandler returns the health handler +func (p *handlerProvider) HealthHandler() *handler.HealthHandler { + p.mu.Lock() + defer p.mu.Unlock() + + if p.healthHandler == nil { + p.healthHandler = handler.NewHealthHandler( + p.container.DB(), + p.container.Logger(), + ) + } + return p.healthHandler +} diff --git a/internal/infrastructure/container/usecase_provider.go b/internal/infrastructure/container/usecase_provider.go index c0c002f..accbfd5 100644 --- a/internal/infrastructure/container/usecase_provider.go +++ b/internal/infrastructure/container/usecase_provider.go @@ -127,6 +127,7 @@ func (p *useCaseProvider) OrderUseCase() *usecase.OrderUseCase { p.orderUseCase = usecase.NewOrderUseCase( p.container.Repositories().OrderRepository(), p.container.Repositories().ProductRepository(), + p.container.Repositories().ProductVariantRepository(), p.container.Repositories().UserRepository(), p.container.Services().PaymentService(), p.container.Services().EmailService(), diff --git a/internal/infrastructure/repository/postgres/order_repository.go b/internal/infrastructure/repository/postgres/order_repository.go index 3e36d3b..5f6a2b5 100644 --- a/internal/infrastructure/repository/postgres/order_repository.go +++ b/internal/infrastructure/repository/postgres/order_repository.go @@ -146,17 +146,21 @@ func (r *OrderRepository) Create(order *entity.Order) error { for i := range order.Items { order.Items[i].OrderID = order.ID query := ` - INSERT INTO order_items (order_id, product_id, quantity, price, subtotal, created_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO order_items (order_id, product_id, product_variant_id, quantity, price, subtotal, weight, product_name, sku, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id ` err = tx.QueryRow( query, order.Items[i].OrderID, order.Items[i].ProductID, + order.Items[i].ProductVariantID, order.Items[i].Quantity, order.Items[i].Price, order.Items[i].Subtotal, + order.Items[i].Weight, + order.Items[i].ProductName, + order.Items[i].SKU, order.CreatedAt, ).Scan(&order.Items[i].ID) if err != nil { @@ -307,7 +311,7 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { // Get order items query = ` - SELECT oi.id, oi.order_id, oi.product_id, oi.quantity, oi.price, oi.subtotal, + SELECT oi.id, oi.order_id, oi.product_id, oi.product_variant_id, oi.quantity, oi.price, oi.subtotal, oi.weight, p.name as product_name, p.product_number as sku FROM order_items oi LEFT JOIN products p ON p.id = oi.product_id @@ -324,19 +328,25 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { for rows.Next() { item := entity.OrderItem{} var productName, sku sql.NullString + var productVariantID sql.NullInt64 err := rows.Scan( &item.ID, &item.OrderID, &item.ProductID, + &productVariantID, &item.Quantity, &item.Price, &item.Subtotal, + &item.Weight, &productName, &sku, ) if err != nil { return nil, err } + if productVariantID.Valid { + item.ProductVariantID = uint(productVariantID.Int64) + } if productName.Valid { item.ProductName = productName.String } @@ -519,7 +529,7 @@ func (r *OrderRepository) GetByUser(userID uint, offset, limit int) ([]*entity.O // Get order items itemsQuery := ` - SELECT id, order_id, product_id, quantity, price, subtotal + SELECT id, order_id, product_id, product_variant_id, quantity, price, subtotal, weight, product_name, sku FROM order_items WHERE order_id = $1 ` @@ -532,18 +542,33 @@ func (r *OrderRepository) GetByUser(userID uint, offset, limit int) ([]*entity.O order.Items = []entity.OrderItem{} for itemRows.Next() { item := entity.OrderItem{} + var productVariantID sql.NullInt64 + var productName, sku sql.NullString err := itemRows.Scan( &item.ID, &item.OrderID, &item.ProductID, + &productVariantID, &item.Quantity, &item.Price, &item.Subtotal, + &item.Weight, + &productName, + &sku, ) if err != nil { itemRows.Close() return nil, err } + if productVariantID.Valid { + item.ProductVariantID = uint(productVariantID.Int64) + } + if productName.Valid { + item.ProductName = productName.String + } + if sku.Valid { + item.SKU = sku.String + } order.Items = append(order.Items, item) } itemRows.Close() @@ -818,7 +843,7 @@ func (r *OrderRepository) GetByPaymentID(paymentID string) (*entity.Order, error // Get order items query = ` - SELECT oi.id, oi.order_id, oi.product_id, oi.quantity, oi.price, oi.subtotal, + SELECT oi.id, oi.order_id, oi.product_id, oi.product_variant_id, oi.quantity, oi.price, oi.subtotal, oi.weight, p.name as product_name, p.product_number as sku FROM order_items oi LEFT JOIN products p ON p.id = oi.product_id @@ -835,19 +860,25 @@ func (r *OrderRepository) GetByPaymentID(paymentID string) (*entity.Order, error for rows.Next() { item := entity.OrderItem{} var productName, sku sql.NullString + var productVariantID sql.NullInt64 err := rows.Scan( &item.ID, &item.OrderID, &item.ProductID, + &productVariantID, &item.Quantity, &item.Price, &item.Subtotal, + &item.Weight, &productName, &sku, ) if err != nil { return nil, err } + if productVariantID.Valid { + item.ProductVariantID = uint(productVariantID.Int64) + } if productName.Valid { item.ProductName = productName.String } diff --git a/internal/interfaces/api/handler/health_handler.go b/internal/interfaces/api/handler/health_handler.go new file mode 100644 index 0000000..540fcd0 --- /dev/null +++ b/internal/interfaces/api/handler/health_handler.go @@ -0,0 +1,88 @@ +package handler + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "time" + + "github.com/zenfulcode/commercify/internal/dto" + "github.com/zenfulcode/commercify/internal/infrastructure/logger" +) + +// HealthHandler handles health check requests +type HealthHandler struct { + db *sql.DB + logger logger.Logger +} + +// NewHealthHandler creates a new HealthHandler +func NewHealthHandler(db *sql.DB, logger logger.Logger) *HealthHandler { + return &HealthHandler{ + db: db, + logger: logger, + } +} + +// HealthStatus represents the health status of the service +type HealthStatus struct { + Status string `json:"status"` + Timestamp time.Time `json:"timestamp"` + Version string `json:"version,omitempty"` + Services map[string]string `json:"services"` + Uptime string `json:"uptime,omitempty"` +} + +var startTime = time.Now() + +// Health performs a health check and returns the service status +func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { + h.logger.Info("Health check requested") + + status := "healthy" + httpStatus := http.StatusOK + services := make(map[string]string) + + // Check database connectivity + if err := h.checkDatabase(); err != nil { + h.logger.Error("Database health check failed: %v", err) + services["database"] = "unhealthy" + status = "unhealthy" + httpStatus = http.StatusServiceUnavailable + } else { + services["database"] = "healthy" + } + + // Calculate uptime + uptime := time.Since(startTime).String() + + healthStatus := HealthStatus{ + Status: status, + Timestamp: time.Now(), + Version: "1.0.0", // You can make this configurable + Services: services, + Uptime: uptime, + } + + response := dto.ResponseDTO[HealthStatus]{ + Success: status == "healthy", + Data: healthStatus, + } + + if status != "healthy" { + response.Error = "One or more services are unhealthy" + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpStatus) + json.NewEncoder(w).Encode(response) +} + +// checkDatabase verifies database connectivity +func (h *HealthHandler) checkDatabase() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return h.db.PingContext(ctx) +} diff --git a/internal/interfaces/api/handler/webhook_handler.go b/internal/interfaces/api/handler/webhook_handler.go index caf5651..42b21d0 100644 --- a/internal/interfaces/api/handler/webhook_handler.go +++ b/internal/interfaces/api/handler/webhook_handler.go @@ -188,6 +188,7 @@ func (h *WebhookHandler) HandleMobilePayAuthorized(event *models.WebhookEvent) e input := usecase.UpdatePaymentStatusInput{ OrderID: orderID, PaymentStatus: entity.PaymentStatusAuthorized, + TransactionID: event.Reference, } order, err := h.orderUseCase.UpdatePaymentStatus(input) diff --git a/internal/interfaces/api/middleware/cors_middleware.go b/internal/interfaces/api/middleware/cors_middleware.go index b751fa9..3a74d02 100644 --- a/internal/interfaces/api/middleware/cors_middleware.go +++ b/internal/interfaces/api/middleware/cors_middleware.go @@ -1,6 +1,7 @@ package middleware import ( + "fmt" "net/http" "slices" @@ -26,9 +27,18 @@ func (m *CorsMiddleware) ApplyCors(next http.Handler) http.Handler { // Get allowed origins from config or use default allowedOrigins := m.getAllowedOrigins() + if len(allowedOrigins) == 0 { + // If no origins are configured, allow all origins + allowedOrigins = []string{"*"} + } + + fmt.Println("Allowed Origins:", allowedOrigins) + // Get origin from request origin := r.Header.Get("Origin") + fmt.Println("Request Origin:", origin) + // Check if the origin is allowed if m.isAllowedOrigin(origin, allowedOrigins) { w.Header().Set("Access-Control-Allow-Origin", origin) diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 8b12022..7bab9f2 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -80,12 +80,18 @@ func (s *Server) setupRoutes() { discountHandler := s.container.Handlers().DiscountHandler() shippingHandler := s.container.Handlers().ShippingHandler() currencyHandler := s.container.Handlers().CurrencyHandler() + healthHandler := s.container.Handlers().HealthHandler() // Extract middleware from container authMiddleware := s.container.Middlewares().AuthMiddleware() + corsMiddleware := s.container.Middlewares().CorsMiddleware() + + // Health check routes (no prefix, for load balancers and monitoring) + s.router.HandleFunc("/health", healthHandler.Health).Methods(http.MethodGet) // Register routes api := s.router.PathPrefix("/api").Subrouter() + api.Use(corsMiddleware.ApplyCors) // Public routes api.HandleFunc("/auth/register", userHandler.Register).Methods(http.MethodPost) diff --git a/migrations/000032_add_product_variant_id_to_order_items.down.sql b/migrations/000032_add_product_variant_id_to_order_items.down.sql new file mode 100644 index 0000000..4171636 --- /dev/null +++ b/migrations/000032_add_product_variant_id_to_order_items.down.sql @@ -0,0 +1,3 @@ +-- Remove product_variant_id column from order_items table +DROP INDEX IF EXISTS idx_order_items_variant; +ALTER TABLE order_items DROP COLUMN IF EXISTS product_variant_id; diff --git a/migrations/000032_add_product_variant_id_to_order_items.up.sql b/migrations/000032_add_product_variant_id_to_order_items.up.sql new file mode 100644 index 0000000..28b8e7a --- /dev/null +++ b/migrations/000032_add_product_variant_id_to_order_items.up.sql @@ -0,0 +1,5 @@ +-- Add product_variant_id column to order_items table +ALTER TABLE order_items ADD COLUMN product_variant_id INTEGER REFERENCES product_variants(id); + +-- Create index for the new column +CREATE INDEX idx_order_items_variant ON order_items(product_variant_id); diff --git a/web/types/api.ts b/web/types/api.ts index 70630af..1556606 100644 --- a/web/types/api.ts +++ b/web/types/api.ts @@ -434,14 +434,16 @@ export interface OrderDTO { shipping_details: ShippingOptionDTO; discount_details: AppliedDiscountDTO; customer: CustomerDetailsDTO; - checkout_id?: string; + checkout_id: string; created_at: string; updated_at: string; } export interface OrderSummaryDTO { id: number /* uint */; order_number: string; + checkout_id: string; user_id: number /* uint */; + customer: CustomerDetailsDTO; status: OrderStatus; payment_status: PaymentStatus; total_amount: number /* float64 */; // Subtotal (items only) @@ -474,6 +476,7 @@ export interface OrderItemDTO { quantity: number /* int */; unit_price: number /* float64 */; total_price: number /* float64 */; + image_url?: string; created_at: string; updated_at: string; } @@ -517,15 +520,6 @@ export interface OrderSearchRequest { end_date?: string; pagination: PaginationDTO; } -/** - * ProcessPaymentRequest represents the data needed to process a payment - */ -export interface ProcessPaymentRequest { - payment_method: PaymentMethod; - payment_provider: PaymentProvider; - card_details?: any /* service.CardDetails */; - phone_number?: string; -} /** * OrderStatus represents the status of an order */ From c55034d59d818eaff3984a7d0473fe189f69456c Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 01:50:19 +0200 Subject: [PATCH 03/12] feat: Add missing fields to order_items and update related logic in seeding and checkout --- Makefile | 8 +-- cmd/seed/main.go | 53 ++++++++++++++----- internal/domain/entity/checkout.go | 15 +++--- ...033_add_missing_order_item_fields.down.sql | 5 ++ ...00033_add_missing_order_item_fields.up.sql | 7 +++ 5 files changed, 64 insertions(+), 24 deletions(-) create mode 100644 migrations/000033_add_missing_order_item_fields.down.sql create mode 100644 migrations/000033_add_missing_order_item_fields.up.sql diff --git a/Makefile b/Makefile index daf0a64..f43ea04 100644 --- a/Makefile +++ b/Makefile @@ -58,17 +58,17 @@ logs: ## Show application logs # Docker image commands docker-build: ## Build Docker image - docker build -t commercifygo:latest . + docker build -t ghcr.io/zenfulcode/commercifygo:latest . docker-build-tag: ## Build Docker image with specific tag (use TAG=version) @if [ -z "$(TAG)" ]; then echo "Error: TAG is required. Use: make docker-build-tag TAG=v1.0.0"; exit 1; fi - docker build -t commercifygo:$(TAG) -t commercifygo:latest . + docker build -t ghcr.io/zenfulcode/commercifygo:$(TAG) -t ghcr.io/zenfulcode/commercifygo:latest . docker-push: ## Push Docker image to registry (use REGISTRY and TAG) @if [ -z "$(REGISTRY)" ]; then echo "Error: REGISTRY is required. Use: make docker-push REGISTRY=your-registry.com"; exit 1; fi @if [ -z "$(TAG)" ]; then echo "Error: TAG is required. Use: make docker-push REGISTRY=your-registry.com TAG=v1.0.0"; exit 1; fi - docker tag commercifygo:$(TAG) $(REGISTRY)/commercifygo:$(TAG) - docker tag commercifygo:latest $(REGISTRY)/commercifygo:latest +# docker tag $(REGISTRY)commercifygo:$(TAG) $(REGISTRY)/commercifygo:$(TAG) +# docker tag $(REGISTRY)commercifygo:latest $(REGISTRY)/commercifygo:latest docker push $(REGISTRY)/commercifygo:$(TAG) docker push $(REGISTRY)/commercifygo:latest diff --git a/cmd/seed/main.go b/cmd/seed/main.go index f1cafa3..aefb440 100644 --- a/cmd/seed/main.go +++ b/cmd/seed/main.go @@ -110,6 +110,13 @@ func main() { fmt.Println("Shipping rates seeded successfully") } + if *allFlag || *ordersFlag { + if err := seedOrders(db); err != nil { + log.Fatalf("Failed to seed orders: %v", err) + } + fmt.Println("Orders seeded successfully") + } + // if *allFlag || *paymentTransactionsFlag { // if err := seedPaymentTransactions(db); err != nil { // log.Fatalf("Failed to seed payment transactions: %v", err) @@ -135,8 +142,15 @@ func clearData(db *sql.DB) error { // Clear tables in reverse order of dependencies tables := []string{ + "checkout_items", + "checkouts", "order_items", "orders", + "shipping_rates", + "shipping_zones", + "shipping_methods", + "discounts", + "product_variants", "products", "categories", "users", @@ -728,22 +742,31 @@ func seedOrders(db *sql.DB) error { return fmt.Errorf("no users found to create orders for") } - // Get product data - productRows, err := db.Query("SELECT id, price FROM products") + // Get product data with their default variants + productRows, err := db.Query(` + SELECT p.id, p.name, pv.id as variant_id, pv.price, pv.sku, pv.stock + FROM products p + JOIN product_variants pv ON p.id = pv.product_id + WHERE pv.is_default = true + `) if err != nil { return err } defer productRows.Close() type productInfo struct { - id int - price float64 + id int + name string + variantID int + price int64 // Price is stored as int64 (cents) + sku string + stock int } var products []productInfo for productRows.Next() { var p productInfo - if err := productRows.Scan(&p.id, &p.price); err != nil { + if err := productRows.Scan(&p.id, &p.name, &p.variantID, &p.price, &p.sku, &p.stock); err != nil { return err } products = append(products, p) @@ -909,22 +932,26 @@ func seedOrders(db *sql.DB) error { // Random quantity between 1 and 3 quantity := (j % 3) + 1 - // Calculate subtotal - subtotal := float64(quantity) * product.price - totalAmount += subtotal + // Calculate subtotal (price is already in cents) + subtotal := int64(quantity) * product.price + totalAmount += float64(subtotal) // Insert order item _, err = tx.Exec(` INSERT INTO order_items ( - order_id, product_id, quantity, price, subtotal, created_at + order_id, product_id, product_variant_id, quantity, price, subtotal, weight, product_name, sku, created_at ) - VALUES ($1, $2, $3, $4, $5, $6) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, orderID, product.id, + product.variantID, quantity, - int64(product.price), - int64(subtotal), + product.price, + subtotal, + 0.5, // Default weight for seeded items + product.name, + product.sku, createdAt, ) @@ -934,7 +961,7 @@ func seedOrders(db *sql.DB) error { } } - // Update order with total amount + // Update order with total amount (totalAmount is already in cents) _, err = tx.Exec(` UPDATE orders SET total_amount = $1 diff --git a/internal/domain/entity/checkout.go b/internal/domain/entity/checkout.go index 3328c4d..538bf79 100644 --- a/internal/domain/entity/checkout.go +++ b/internal/domain/entity/checkout.go @@ -387,13 +387,14 @@ func (c *Checkout) ToOrder() *Order { items := make([]OrderItem, len(c.Items)) for i, item := range c.Items { items[i] = OrderItem{ - ProductID: item.ProductID, - Quantity: item.Quantity, - Price: item.Price, - Subtotal: item.Price * int64(item.Quantity), - Weight: item.Weight, - ProductName: item.ProductName, - SKU: item.SKU, + ProductID: item.ProductID, + ProductVariantID: item.ProductVariantID, + Quantity: item.Quantity, + Price: item.Price, + Subtotal: item.Price * int64(item.Quantity), + Weight: item.Weight, + ProductName: item.ProductName, + SKU: item.SKU, } } diff --git a/migrations/000033_add_missing_order_item_fields.down.sql b/migrations/000033_add_missing_order_item_fields.down.sql new file mode 100644 index 0000000..67c4531 --- /dev/null +++ b/migrations/000033_add_missing_order_item_fields.down.sql @@ -0,0 +1,5 @@ +-- Remove missing fields from order_items table +DROP INDEX IF EXISTS idx_order_items_sku; +ALTER TABLE order_items DROP COLUMN IF EXISTS sku; +ALTER TABLE order_items DROP COLUMN IF EXISTS product_name; +ALTER TABLE order_items DROP COLUMN IF EXISTS weight; diff --git a/migrations/000033_add_missing_order_item_fields.up.sql b/migrations/000033_add_missing_order_item_fields.up.sql new file mode 100644 index 0000000..a9287a3 --- /dev/null +++ b/migrations/000033_add_missing_order_item_fields.up.sql @@ -0,0 +1,7 @@ +-- Add missing fields to order_items table +ALTER TABLE order_items ADD COLUMN IF NOT EXISTS weight DECIMAL(10, 3) DEFAULT 0; +ALTER TABLE order_items ADD COLUMN IF NOT EXISTS product_name VARCHAR(255) DEFAULT ''; +ALTER TABLE order_items ADD COLUMN IF NOT EXISTS sku VARCHAR(100) DEFAULT ''; + +-- Create indexes for the new columns +CREATE INDEX IF NOT EXISTS idx_order_items_sku ON order_items(sku); From e9e0c61914a9ed33ab159fd04487c76b156190de Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 16:56:31 +0200 Subject: [PATCH 04/12] feat: Implement email notifications for payment status changes and remove unused stock increase function --- .../application/usecase/checkout_usecase.go | 27 -------- internal/application/usecase/order_usecase.go | 61 +++++++++++++++++++ 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index bb3a880..877704d 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -1035,30 +1035,3 @@ func (uc *CheckoutUseCase) decreaseStockForOrder(order *entity.Order) error { } return nil } - -// increaseStockForOrder increases stock for all items in an order (for cancellations/failures) -func (uc *CheckoutUseCase) increaseStockForOrder(order *entity.Order) error { - for _, item := range order.Items { - // Skip items without variant ID (shouldn't happen, but safety check) - if item.ProductVariantID == 0 { - continue - } - - // Get the variant - variant, err := uc.productVariantRepo.GetByID(item.ProductVariantID) - if err != nil { - return fmt.Errorf("failed to get variant %d: %w", item.ProductVariantID, err) - } - - // Update stock - if err := variant.UpdateStock(variant.Stock + item.Quantity); err != nil { - return fmt.Errorf("failed to update stock for variant %d: %w", item.ProductVariantID, err) - } - - // Save the updated variant - if err := uc.productVariantRepo.Update(variant); err != nil { - return fmt.Errorf("failed to save variant %d: %w", item.ProductVariantID, err) - } - } - return nil -} diff --git a/internal/application/usecase/order_usecase.go b/internal/application/usecase/order_usecase.go index 8f6dd9a..d03e0a9 100644 --- a/internal/application/usecase/order_usecase.go +++ b/internal/application/usecase/order_usecase.go @@ -513,6 +513,12 @@ func (uc *OrderUseCase) UpdatePaymentStatus(input UpdatePaymentStatusInput) (*en log.Printf("Warning: Failed to update stock for order %d: %v", order.ID, err) } + // Send emails for payment status changes + if err := uc.handleEmailsForPaymentStatusChange(order, previousPaymentStatus, input.PaymentStatus); err != nil { + // Log the error but don't fail the status update since the payment status change was successful + log.Printf("Warning: Failed to send emails for order %d: %v", order.ID, err) + } + return order, nil } @@ -610,3 +616,58 @@ func (uc *OrderUseCase) increaseStock(order *entity.Order) error { } return nil } + +// handleEmailsForPaymentStatusChange sends appropriate emails when payment status changes +func (uc *OrderUseCase) handleEmailsForPaymentStatusChange(order *entity.Order, previousStatus, newStatus entity.PaymentStatus) error { + // Only send emails when payment status changes to authorized or paid + shouldSendEmails := false + + switch { + case previousStatus != entity.PaymentStatusAuthorized && newStatus == entity.PaymentStatusAuthorized: + // Payment was just authorized - send order confirmation and notification emails + shouldSendEmails = true + case previousStatus != entity.PaymentStatusCaptured && newStatus == entity.PaymentStatusCaptured: + // Payment was just captured/paid - send order confirmation and notification emails + shouldSendEmails = true + default: + // No emails needed for other transitions + return nil + } + + if !shouldSendEmails { + return nil + } + + // Create user object for email sending + var user *entity.User + if order.IsGuestOrder || order.UserID == 0 { + // Guest order - create a temporary user object with customer details + if order.CustomerDetails == nil { + return fmt.Errorf("guest order missing customer details") + } + user = &entity.User{ + Email: order.CustomerDetails.Email, + FirstName: order.CustomerDetails.FullName, // Use FullName as FirstName for guest orders + } + } else { + // Registered user - get from repository + var err error + user, err = uc.userRepo.GetByID(order.UserID) + if err != nil { + return fmt.Errorf("failed to get user %d: %w", order.UserID, err) + } + } + + // Send order confirmation email to customer + if err := uc.emailSvc.SendOrderConfirmation(order, user); err != nil { + return fmt.Errorf("failed to send order confirmation email: %w", err) + } + + // Send order notification email to admin + if err := uc.emailSvc.SendOrderNotification(order, user); err != nil { + return fmt.Errorf("failed to send order notification email: %w", err) + } + + log.Printf("Sent order confirmation and notification emails for order %d (status: %s)", order.ID, newStatus) + return nil +} From e5cbc30f4c47bfc8b05c695814cc10095d82c49b Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 17:21:31 +0200 Subject: [PATCH 05/12] feat: Add OrderNumber to PaymentRequest and update return URLs in payment services --- internal/application/usecase/checkout_usecase.go | 1 + internal/domain/service/payment_service.go | 1 + internal/infrastructure/payment/mobilepay_payment_service.go | 2 +- internal/infrastructure/payment/stripe_payment_service.go | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index 877704d..8f28a1f 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -101,6 +101,7 @@ func (uc *CheckoutUseCase) ProcessPayment(order *entity.Order, input ProcessPaym // Process payment paymentResult, err := uc.paymentSvc.ProcessPayment(service.PaymentRequest{ OrderID: order.ID, + OrderNumber: order.OrderNumber, Amount: order.FinalAmount, // Use final amount (after discounts) Currency: order.Currency, PaymentMethod: input.PaymentMethod, diff --git a/internal/domain/service/payment_service.go b/internal/domain/service/payment_service.go index 6e62f73..fc154fc 100644 --- a/internal/domain/service/payment_service.go +++ b/internal/domain/service/payment_service.go @@ -31,6 +31,7 @@ type PaymentProvider struct { // PaymentRequest represents a request to process a payment type PaymentRequest struct { OrderID uint + OrderNumber string Amount int64 Currency string PaymentMethod PaymentMethod diff --git a/internal/infrastructure/payment/mobilepay_payment_service.go b/internal/infrastructure/payment/mobilepay_payment_service.go index ecd39a1..0f85fb4 100644 --- a/internal/infrastructure/payment/mobilepay_payment_service.go +++ b/internal/infrastructure/payment/mobilepay_payment_service.go @@ -107,7 +107,7 @@ func (s *MobilePayPaymentService) ProcessPayment(request service.PaymentRequest) Type: "WALLET", }, Reference: reference, - ReturnURL: s.config.ReturnURL + "?reference=" + reference, + ReturnURL: s.config.ReturnURL + "?order=" + request.OrderNumber, UserFlow: models.UserFlowWebRedirect, PaymentDescription: s.config.PaymentDescription, } diff --git a/internal/infrastructure/payment/stripe_payment_service.go b/internal/infrastructure/payment/stripe_payment_service.go index fa5f97b..614932d 100644 --- a/internal/infrastructure/payment/stripe_payment_service.go +++ b/internal/infrastructure/payment/stripe_payment_service.go @@ -185,7 +185,7 @@ func (s *StripePaymentService) ProcessPayment(request service.PaymentRequest) (* "method": paymentMethodType, }, }, - ReturnURL: stripe.String(s.config.ReturnURL), + ReturnURL: stripe.String(s.config.ReturnURL + "?order=" + request.OrderNumber), } // Create a customer if email is provided From a22aa30795a300204df0805ba36816a70ac5e2eb Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 17:21:47 +0200 Subject: [PATCH 06/12] feat: Refactor payment verification logic to use switch case for better readability --- internal/infrastructure/payment/stripe_payment_service.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/infrastructure/payment/stripe_payment_service.go b/internal/infrastructure/payment/stripe_payment_service.go index 614932d..dd59962 100644 --- a/internal/infrastructure/payment/stripe_payment_service.go +++ b/internal/infrastructure/payment/stripe_payment_service.go @@ -272,9 +272,10 @@ func (s *StripePaymentService) VerifyPayment(transactionID string, provider serv } // Check if the payment intent was successful - if paymentIntent.Status == stripe.PaymentIntentStatusSucceeded { + switch paymentIntent.Status { + case stripe.PaymentIntentStatusSucceeded: return true, nil - } else if paymentIntent.Status == stripe.PaymentIntentStatusRequiresCapture { + case stripe.PaymentIntentStatusRequiresCapture: // Payment is authorized but requires capture return true, nil } From abde90ba801f797b20fada87bcee09f5937ef86a Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 19:09:14 +0200 Subject: [PATCH 07/12] feat: Add EmailTestHandler and related email testing functionality; enhance email logging and templates --- .../container/handler_provider.go | 39 +++-- .../email/smtp_email_service.go | 23 ++- .../api/handler/email_test_handler.go | 165 ++++++++++++++++++ .../interfaces/api/handler/health_handler.go | 2 +- internal/interfaces/api/server.go | 4 + templates/emails/order_confirmation.html | 31 +++- templates/emails/order_notification.html | 31 +++- 7 files changed, 269 insertions(+), 26 deletions(-) create mode 100644 internal/interfaces/api/handler/email_test_handler.go diff --git a/internal/infrastructure/container/handler_provider.go b/internal/infrastructure/container/handler_provider.go index 78f9706..166b013 100644 --- a/internal/infrastructure/container/handler_provider.go +++ b/internal/infrastructure/container/handler_provider.go @@ -19,6 +19,7 @@ type HandlerProvider interface { ShippingHandler() *handler.ShippingHandler CurrencyHandler() *handler.CurrencyHandler HealthHandler() *handler.HealthHandler + EmailTestHandler() *handler.EmailTestHandler } // handlerProvider is the concrete implementation of HandlerProvider @@ -26,17 +27,18 @@ type handlerProvider struct { container Container mu sync.Mutex - userHandler *handler.UserHandler - productHandler *handler.ProductHandler - categoryHandler *handler.CategoryHandler - checkoutHandler *handler.CheckoutHandler - orderHandler *handler.OrderHandler - paymentHandler *handler.PaymentHandler - webhookHandler *handler.WebhookHandler - discountHandler *handler.DiscountHandler - shippingHandler *handler.ShippingHandler - currencyHandler *handler.CurrencyHandler - healthHandler *handler.HealthHandler + userHandler *handler.UserHandler + productHandler *handler.ProductHandler + categoryHandler *handler.CategoryHandler + checkoutHandler *handler.CheckoutHandler + orderHandler *handler.OrderHandler + paymentHandler *handler.PaymentHandler + webhookHandler *handler.WebhookHandler + discountHandler *handler.DiscountHandler + shippingHandler *handler.ShippingHandler + currencyHandler *handler.CurrencyHandler + healthHandler *handler.HealthHandler + emailTestHandler *handler.EmailTestHandler } // NewHandlerProvider creates a new handler provider @@ -206,3 +208,18 @@ func (p *handlerProvider) HealthHandler() *handler.HealthHandler { } return p.healthHandler } + +// EmailTestHandler returns the email test handler +func (p *handlerProvider) EmailTestHandler() *handler.EmailTestHandler { + p.mu.Lock() + defer p.mu.Unlock() + + if p.emailTestHandler == nil { + p.emailTestHandler = handler.NewEmailTestHandler( + p.container.Services().EmailService(), + p.container.Logger(), + p.container.Config().Email, + ) + } + return p.emailTestHandler +} diff --git a/internal/infrastructure/email/smtp_email_service.go b/internal/infrastructure/email/smtp_email_service.go index e50b5b5..f1d51ec 100644 --- a/internal/infrastructure/email/smtp_email_service.go +++ b/internal/infrastructure/email/smtp_email_service.go @@ -29,6 +29,8 @@ func NewSMTPEmailService(config config.EmailConfig, logger logger.Logger) *SMTPE // SendEmail sends an email with the given data func (s *SMTPEmailService) SendEmail(data service.EmailData) error { + s.logger.Info("Attempting to send email to: %s, Subject: %s, Enabled: %t", data.To, data.Subject, s.config.Enabled) + // If email service is disabled, log and return if !s.config.Enabled { s.logger.Info("Email service is disabled. Would have sent email to: %s, Subject: %s", data.To, data.Subject) @@ -43,8 +45,10 @@ func (s *SMTPEmailService) SendEmail(data service.EmailData) error { // Use template if provided body, err = s.renderTemplate(data.Template, data.Data) if err != nil { + s.logger.Error("Failed to render email template %s: %v", data.Template, err) return err } + s.logger.Info("Email template rendered successfully") } else { // Use provided body body = data.Body @@ -74,6 +78,7 @@ func (s *SMTPEmailService) SendEmail(data service.EmailData) error { "%s", s.config.FromName, s.config.FromEmail, data.To, data.Subject, contentType, body)) // Send email + s.logger.Info("Attempting to send email via SMTP to %s:%d", s.config.SMTPHost, s.config.SMTPPort) err = smtp.SendMail( fmt.Sprintf("%s:%d", s.config.SMTPHost, s.config.SMTPPort), auth, @@ -83,7 +88,7 @@ func (s *SMTPEmailService) SendEmail(data service.EmailData) error { ) if err != nil { - s.logger.Error("Failed to send email: %v", err) + s.logger.Error("Failed to send email to %s: %v", data.To, err) return err } @@ -93,6 +98,8 @@ func (s *SMTPEmailService) SendEmail(data service.EmailData) error { // SendOrderConfirmation sends an order confirmation email to the customer func (s *SMTPEmailService) SendOrderConfirmation(order *entity.Order, user *entity.User) error { + s.logger.Info("Sending order confirmation email for Order ID: %d to User: %s", order.ID, user.Email) + // Prepare data for the template data := map[string]interface{}{ "Order": order, @@ -113,6 +120,8 @@ func (s *SMTPEmailService) SendOrderConfirmation(order *entity.Order, user *enti // SendOrderNotification sends an order notification email to the admin func (s *SMTPEmailService) SendOrderNotification(order *entity.Order, user *entity.User) error { + s.logger.Info("Sending order notification email for Order ID: %d to Admin: %s", order.ID, s.config.AdminEmail) + // Prepare data for the template data := map[string]interface{}{ "Order": order, @@ -135,8 +144,18 @@ func (s *SMTPEmailService) renderTemplate(templateName string, data map[string]i // Get template path templatePath := filepath.Join("templates", "emails", templateName) + // Create template with helper functions + tmpl := template.New(templateName).Funcs(template.FuncMap{ + "centsToDollars": func(cents int64) float64 { + return float64(cents) / 100.0 + }, + "formatPrice": func(cents int64) string { + return fmt.Sprintf("%.2f", float64(cents)/100.0) + }, + }) + // Parse template - tmpl, err := template.ParseFiles(templatePath) + tmpl, err := tmpl.ParseFiles(templatePath) if err != nil { return "", err } diff --git a/internal/interfaces/api/handler/email_test_handler.go b/internal/interfaces/api/handler/email_test_handler.go new file mode 100644 index 0000000..a5cfea9 --- /dev/null +++ b/internal/interfaces/api/handler/email_test_handler.go @@ -0,0 +1,165 @@ +package handler + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/zenfulcode/commercify/config" + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/service" + "github.com/zenfulcode/commercify/internal/infrastructure/logger" +) + +// EmailTestHandler handles email testing endpoints +type EmailTestHandler struct { + emailSvc service.EmailService + logger logger.Logger + config config.EmailConfig +} + +// NewEmailTestHandler creates a new EmailTestHandler +func NewEmailTestHandler(emailSvc service.EmailService, logger logger.Logger, emailConfig config.EmailConfig) *EmailTestHandler { + return &EmailTestHandler{ + emailSvc: emailSvc, + logger: logger, + config: emailConfig, + } +} + +// TestEmail sends test order confirmation and notification emails +func (h *EmailTestHandler) TestEmail(w http.ResponseWriter, r *http.Request) { + h.logger.Info("Test email endpoint called") + + // Create a mock user (but we'll send emails to admin address) + mockUser := &entity.User{ + ID: 1, + Email: "customer@example.com", // This is just for the mock data + FirstName: "John", + LastName: "Doe", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Create a mock order + mockOrder := &entity.Order{ + ID: 12345, + OrderNumber: "ORD-12345", + UserID: mockUser.ID, + Status: entity.OrderStatusCompleted, + PaymentStatus: entity.PaymentStatusCaptured, + TotalAmount: 9950, // $99.50 in cents (subtotal before shipping/discounts) + ShippingCost: 850, // $8.50 shipping cost + DiscountAmount: 1500, // $15.00 discount + FinalAmount: 8300, // $83.00 final amount (99.50 + 8.50 - 15.00) + Currency: "USD", + ShippingAddr: entity.Address{ + Street: "123 Test Street", + City: "Test City", + State: "Test State", + PostalCode: "12345", + Country: "US", + }, + BillingAddr: entity.Address{ + Street: "123 Test Street", + City: "Test City", + State: "Test State", + PostalCode: "12345", + Country: "US", + }, + CustomerDetails: &entity.CustomerDetails{ + Email: mockUser.Email, + Phone: "+1234567890", + FullName: mockUser.FirstName + " " + mockUser.LastName, + }, + IsGuestOrder: false, + PaymentProvider: "stripe", + PaymentMethod: "card", + AppliedDiscount: &entity.AppliedDiscount{ + DiscountID: 1, + DiscountCode: "SUMMER25", + DiscountAmount: 1500, // $15.00 discount + }, + Items: []entity.OrderItem{ + { + ID: 1, + ProductID: 1, + Quantity: 2, + Price: 2500, // $25.00 in cents + Subtotal: 5000, // $50.00 in cents + ProductName: "Test Product 1", + SKU: "TEST-001", + }, + { + ID: 2, + ProductID: 2, + Quantity: 1, + Price: 4950, // $49.50 in cents + Subtotal: 4950, // $49.50 in cents + ProductName: "Test Product 2", + SKU: "TEST-002", + }, + }, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + var errors []string + + // Override email addresses to send both emails to admin for testing + adminUser := &entity.User{ + ID: mockUser.ID, + Email: h.config.AdminEmail, // Send to admin email + FirstName: mockUser.FirstName, + LastName: mockUser.LastName, + CreatedAt: mockUser.CreatedAt, + UpdatedAt: mockUser.UpdatedAt, + } + + // Also update the order's customer details to use admin email for testing + testOrder := *mockOrder + testOrder.CustomerDetails = &entity.CustomerDetails{ + Email: h.config.AdminEmail, // Send to admin email + Phone: mockOrder.CustomerDetails.Phone, + FullName: mockOrder.CustomerDetails.FullName, + } + + // Send order confirmation email to admin (instead of customer) + h.logger.Info("Sending test order confirmation email to admin: %s", h.config.AdminEmail) + if err := h.emailSvc.SendOrderConfirmation(&testOrder, adminUser); err != nil { + h.logger.Error("Failed to send order confirmation email: %v", err) + errors = append(errors, "Order confirmation: "+err.Error()) + } else { + h.logger.Info("Order confirmation email sent successfully") + } + + // Send order notification email to admin + h.logger.Info("Sending test order notification email to admin: %s", h.config.AdminEmail) + if err := h.emailSvc.SendOrderNotification(&testOrder, adminUser); err != nil { + h.logger.Error("Failed to send order notification email: %v", err) + errors = append(errors, "Order notification: "+err.Error()) + } else { + h.logger.Info("Order notification email sent successfully") + } + + w.Header().Set("Content-Type", "application/json") + + if len(errors) > 0 { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "errors": errors, + }) + return + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Both order confirmation and notification emails sent successfully", + "details": map[string]string{ + "customer_email": mockUser.Email, + "order_id": "12345", + }, + }) +} diff --git a/internal/interfaces/api/handler/health_handler.go b/internal/interfaces/api/handler/health_handler.go index 540fcd0..0616165 100644 --- a/internal/interfaces/api/handler/health_handler.go +++ b/internal/interfaces/api/handler/health_handler.go @@ -60,7 +60,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { healthStatus := HealthStatus{ Status: status, Timestamp: time.Now(), - Version: "1.0.0", // You can make this configurable + Version: "1.0.4", // TODO: Make this configurable Services: services, Uptime: uptime, } diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 7bab9f2..073a16b 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -81,6 +81,7 @@ func (s *Server) setupRoutes() { shippingHandler := s.container.Handlers().ShippingHandler() currencyHandler := s.container.Handlers().CurrencyHandler() healthHandler := s.container.Handlers().HealthHandler() + emailTestHandler := s.container.Handlers().EmailTestHandler() // Extract middleware from container authMiddleware := s.container.Middlewares().AuthMiddleware() @@ -170,6 +171,9 @@ func (s *Server) setupRoutes() { admin.HandleFunc("/currencies", currencyHandler.DeleteCurrency).Methods(http.MethodDelete) admin.HandleFunc("/currencies/default", currencyHandler.SetDefaultCurrency).Methods(http.MethodPut) + // Admin email test route + admin.HandleFunc("/test/email", emailTestHandler.TestEmail).Methods(http.MethodPost) + // Admin category routes admin.HandleFunc("/categories", categoryHandler.CreateCategory).Methods(http.MethodPost) admin.HandleFunc("/categories/{id:[0-9]+}", categoryHandler.UpdateCategory).Methods(http.MethodPut) diff --git a/templates/emails/order_confirmation.html b/templates/emails/order_confirmation.html index 540bac5..c50585c 100644 --- a/templates/emails/order_confirmation.html +++ b/templates/emails/order_confirmation.html @@ -66,8 +66,7 @@

Order Confirmation

Order Number: #{{.Order.ID}}

- Order Date: {{.Order.CreatedAt.Format "January 2, - 2006"}} + Order Date: {{.Order.CreatedAt.Format "January 2, 2006"}}

Order Status: {{.Order.Status}}

@@ -88,15 +87,35 @@

Order Summary

Product #{{.ProductID}} {{.Quantity}} - ${{printf "%.2f" .Price}} - ${{printf "%.2f" .Subtotal}} + ${{formatPrice .Price}} + ${{formatPrice .Subtotal}} {{end}} -
-

Total: ${{printf "%.2f" .Order.TotalAmount}}

+
+

Subtotal: ${{formatPrice .Order.TotalAmount}}

+ + {{if gt .Order.ShippingCost 0}} +

Shipping: ${{formatPrice .Order.ShippingCost}}

+ {{else}} +

Shipping: Free

+ {{end}} + + {{if gt .Order.DiscountAmount 0}} +

Discount: -${{formatPrice .Order.DiscountAmount}} + {{if .Order.AppliedDiscount}} + {{if .Order.AppliedDiscount.DiscountCode}} + (Code: {{.Order.AppliedDiscount.DiscountCode}}) + {{end}} + {{end}} +

+ {{end}} + +
+

Total: ${{formatPrice .Order.FinalAmount}}

+

Shipping Address

diff --git a/templates/emails/order_notification.html b/templates/emails/order_notification.html index 1a8622f..2aeafa4 100644 --- a/templates/emails/order_notification.html +++ b/templates/emails/order_notification.html @@ -72,8 +72,7 @@

Customer Information

Name: {{.User.FirstName}} {{.User.LastName}}

Email: {{.User.Email}}

- Order Date: {{.Order.CreatedAt.Format "January 2, 2006 - at 3:04 PM"}} + Order Date: {{.Order.CreatedAt.Format "January 2, 2006 at 3:04 PM"}}

@@ -93,15 +92,35 @@

Order Details

{{.ProductID}} {{.Quantity}} - ${{printf "%.2f" .Price}} - ${{printf "%.2f" .Subtotal}} + ${{formatPrice .Price}} + ${{formatPrice .Subtotal}} {{end}} -
-

Total: ${{printf "%.2f" .Order.TotalAmount}}

+
+

Subtotal: ${{formatPrice .Order.TotalAmount}}

+ + {{if gt .Order.ShippingCost 0}} +

Shipping: ${{formatPrice .Order.ShippingCost}}

+ {{else}} +

Shipping: Free

+ {{end}} + + {{if gt .Order.DiscountAmount 0}} +

Discount Applied: -${{formatPrice .Order.DiscountAmount}} + {{if .Order.AppliedDiscount}} + {{if .Order.AppliedDiscount.DiscountCode}} + (Code: {{.Order.AppliedDiscount.DiscountCode}}) + {{end}} + {{end}} +

+ {{end}} + +
+

Final Total: ${{formatPrice .Order.FinalAmount}}

+

Shipping Address

From 945ee2b29edb1102f1189cbe0e365268868a9456 Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sat, 28 Jun 2025 19:13:19 +0200 Subject: [PATCH 08/12] feat: Add 'dev' tag to Docker image build and push commands --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f43ea04..a7fa681 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ docker-build: ## Build Docker image docker-build-tag: ## Build Docker image with specific tag (use TAG=version) @if [ -z "$(TAG)" ]; then echo "Error: TAG is required. Use: make docker-build-tag TAG=v1.0.0"; exit 1; fi - docker build -t ghcr.io/zenfulcode/commercifygo:$(TAG) -t ghcr.io/zenfulcode/commercifygo:latest . + docker build -t ghcr.io/zenfulcode/commercifygo:$(TAG) -t ghcr.io/zenfulcode/commercifygo:latest -t ghcr.io/zenfulcode/commercifygo:dev . docker-push: ## Push Docker image to registry (use REGISTRY and TAG) @if [ -z "$(REGISTRY)" ]; then echo "Error: REGISTRY is required. Use: make docker-push REGISTRY=your-registry.com"; exit 1; fi @@ -71,6 +71,7 @@ docker-push: ## Push Docker image to registry (use REGISTRY and TAG) # docker tag $(REGISTRY)commercifygo:latest $(REGISTRY)/commercifygo:latest docker push $(REGISTRY)/commercifygo:$(TAG) docker push $(REGISTRY)/commercifygo:latest + docker push $(REGISTRY)/commercifygo:dev docker-build-push: docker-build-tag docker-push ## Build and push Docker image (use REGISTRY and TAG) From 902f6550205fc0c5a30e82429eabb34e20e41daf Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sun, 29 Jun 2025 00:28:02 +0200 Subject: [PATCH 09/12] feat: Add checkout session ID support to orders and enhance order retrieval logic --- Makefile | 3 + .../domain/repository/order_repository.go | 1 + .../repository/postgres/order_repository.go | 212 +++++++++++++++++- .../api/handler/email_test_handler.go | 21 +- .../interfaces/api/handler/health_handler.go | 2 +- .../interfaces/api/handler/order_handler.go | 65 ++++-- .../api/middleware/auth_middleware.go | 41 ++++ internal/interfaces/api/server.go | 8 +- ...add_checkout_session_id_to_orders.down.sql | 3 + ...4_add_checkout_session_id_to_orders.up.sql | 14 ++ testutil/mock/order_repository.go | 17 ++ 11 files changed, 349 insertions(+), 38 deletions(-) create mode 100644 migrations/000034_add_checkout_session_id_to_orders.down.sql create mode 100644 migrations/000034_add_checkout_session_id_to_orders.up.sql diff --git a/Makefile b/Makefile index a7fa681..5a8e4e7 100644 --- a/Makefile +++ b/Makefile @@ -75,6 +75,9 @@ docker-push: ## Push Docker image to registry (use REGISTRY and TAG) docker-build-push: docker-build-tag docker-push ## Build and push Docker image (use REGISTRY and TAG) +docker-dev-build: ## Build Docker image for development + docker build -t ghcr.io/zenfulcode/commercifygo:dev . + # Development commands test: ## Run tests go test ./... diff --git a/internal/domain/repository/order_repository.go b/internal/domain/repository/order_repository.go index c5a5b9a..de21f89 100644 --- a/internal/domain/repository/order_repository.go +++ b/internal/domain/repository/order_repository.go @@ -6,6 +6,7 @@ import "github.com/zenfulcode/commercify/internal/domain/entity" type OrderRepository interface { Create(order *entity.Order) error GetByID(orderID uint) (*entity.Order, error) + GetByCheckoutSessionID(checkoutSessionID string) (*entity.Order, error) Update(order *entity.Order) error GetByUser(userID uint, offset, limit int) ([]*entity.Order, error) ListByStatus(status entity.OrderStatus, offset, limit int) ([]*entity.Order, error) diff --git a/internal/infrastructure/repository/postgres/order_repository.go b/internal/infrastructure/repository/postgres/order_repository.go index 5f6a2b5..2ba0f8a 100644 --- a/internal/infrastructure/repository/postgres/order_repository.go +++ b/internal/infrastructure/repository/postgres/order_repository.go @@ -58,9 +58,9 @@ func (r *OrderRepository) Create(order *entity.Order) error { user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, final_amount, customer_email, customer_phone, customer_full_name, is_guest_order, shipping_method_id, shipping_cost, - total_weight, currency + total_weight, currency, checkout_session_id ) - VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id ` @@ -86,6 +86,7 @@ func (r *OrderRepository) Create(order *entity.Order) error { order.ShippingCost, order.TotalWeight, order.Currency, + order.CheckoutSessionID, ).Scan(&order.ID) } else { // Regular user order @@ -94,9 +95,9 @@ func (r *OrderRepository) Create(order *entity.Order) error { user_id, total_amount, status, payment_status, shipping_address, billing_address, payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, final_amount, customer_email, customer_phone, customer_full_name, shipping_method_id, shipping_cost, total_weight, - currency + currency, checkout_session_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) RETURNING id ` @@ -122,6 +123,7 @@ func (r *OrderRepository) Create(order *entity.Order) error { order.ShippingCost, order.TotalWeight, order.Currency, + order.CheckoutSessionID, ).Scan(&order.ID) } @@ -179,7 +181,7 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, discount_amount, discount_id, discount_code, final_amount, action_url, customer_email, customer_phone, customer_full_name, is_guest_order, shipping_method_id, shipping_cost, - total_weight, currency + total_weight, currency, checkout_session_id FROM orders WHERE id = $1 ` @@ -199,6 +201,7 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { var discountID sql.NullInt64 var discountCode sql.NullString + var checkoutSessionID sql.NullString err := r.db.QueryRow(query, orderID).Scan( &order.ID, @@ -228,6 +231,7 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { &shippingCost, &totalWeight, &order.Currency, + &checkoutSessionID, ) if err == sql.ErrNoRows { @@ -309,6 +313,11 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { order.TotalWeight = totalWeight.Float64 } + // Set checkout session ID if valid + if checkoutSessionID.Valid { + order.CheckoutSessionID = checkoutSessionID.String + } + // Get order items query = ` SELECT oi.id, oi.order_id, oi.product_id, oi.product_variant_id, oi.quantity, oi.price, oi.subtotal, oi.weight, @@ -359,6 +368,199 @@ func (r *OrderRepository) GetByID(orderID uint) (*entity.Order, error) { return order, nil } +// GetByCheckoutSessionID retrieves an order by checkout session ID +func (r *OrderRepository) GetByCheckoutSessionID(checkoutSessionID string) (*entity.Order, error) { + // Get order + query := ` + SELECT id, order_number, user_id, total_amount, status, payment_status, shipping_address, billing_address, + payment_id, payment_provider, tracking_code, created_at, updated_at, completed_at, + discount_amount, discount_id, discount_code, final_amount, action_url, + customer_email, customer_phone, customer_full_name, is_guest_order, shipping_method_id, shipping_cost, + total_weight, currency, checkout_session_id + FROM orders + WHERE checkout_session_id = $1 + ` + + order := &entity.Order{} + var shippingAddrJSON, billingAddrJSON []byte + var completedAt sql.NullTime + var paymentProvider sql.NullString + var orderNumber sql.NullString + var actionURL sql.NullString + var userID sql.NullInt64 // Use NullInt64 to handle NULL user_id + var customerEmail, customerPhone, customerFullName sql.NullString + var isGuestOrder sql.NullBool + var shippingMethodID sql.NullInt64 + var shippingCost sql.NullInt64 + var totalWeight sql.NullFloat64 + var discountID sql.NullInt64 + var discountCode sql.NullString + var checkoutSessionIDResult sql.NullString + + err := r.db.QueryRow(query, checkoutSessionID).Scan( + &order.ID, + &orderNumber, + &userID, + &order.TotalAmount, + &order.Status, + &order.PaymentStatus, + &shippingAddrJSON, + &billingAddrJSON, + &order.PaymentID, + &paymentProvider, + &order.TrackingCode, + &order.CreatedAt, + &order.UpdatedAt, + &completedAt, + &order.DiscountAmount, + &discountID, + &discountCode, + &order.FinalAmount, + &actionURL, + &customerEmail, + &customerPhone, + &customerFullName, + &isGuestOrder, + &shippingMethodID, + &shippingCost, + &totalWeight, + &order.Currency, + &checkoutSessionIDResult, + ) + + if err == sql.ErrNoRows { + return nil, errors.New("order not found") + } + + if err != nil { + return nil, err + } + + // Handle user_id properly + if userID.Valid { + order.UserID = uint(userID.Int64) + } else { + order.UserID = 0 // Use 0 to represent NULL in our application + } + + // Handle guest order fields + if isGuestOrder.Valid && isGuestOrder.Bool { + order.IsGuestOrder = true + order.CustomerDetails = &entity.CustomerDetails{} + if customerEmail.Valid { + order.CustomerDetails.Email = customerEmail.String + } + if customerPhone.Valid { + order.CustomerDetails.Phone = customerPhone.String + } + if customerFullName.Valid { + order.CustomerDetails.FullName = customerFullName.String + } + } + + // Set order number if valid + if orderNumber.Valid { + order.OrderNumber = orderNumber.String + } + + // Set payment provider if valid + if paymentProvider.Valid { + order.PaymentProvider = paymentProvider.String + } + + // Set action URL if valid + if actionURL.Valid { + order.ActionURL = actionURL.String + } + + // Set checkout session ID if valid + if checkoutSessionIDResult.Valid { + order.CheckoutSessionID = checkoutSessionIDResult.String + } + + // Unmarshal addresses + if err := json.Unmarshal(shippingAddrJSON, &order.ShippingAddr); err != nil { + return nil, err + } + + if err := json.Unmarshal(billingAddrJSON, &order.BillingAddr); err != nil { + return nil, err + } + + // Set completed at if valid + if completedAt.Valid { + order.CompletedAt = &completedAt.Time + } + + // Set shipping method ID if valid + if shippingMethodID.Valid { + order.ShippingMethodID = uint(shippingMethodID.Int64) + } + + // Set shipping cost if valid + if shippingCost.Valid { + order.ShippingCost = shippingCost.Int64 + } + + // Set total weight if valid + if totalWeight.Valid { + order.TotalWeight = totalWeight.Float64 + } + + // Get order items + query = ` + SELECT oi.id, oi.order_id, oi.product_id, oi.product_variant_id, oi.quantity, oi.price, oi.subtotal, oi.weight, + p.name as product_name, p.product_number as sku + FROM order_items oi + LEFT JOIN products p ON p.id = oi.product_id + WHERE oi.order_id = $1 + ` + + rows, err := r.db.Query(query, order.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + order.Items = []entity.OrderItem{} + for rows.Next() { + item := entity.OrderItem{} + var productName, sku sql.NullString + var productVariantID sql.NullInt64 + err := rows.Scan( + &item.ID, + &item.OrderID, + &item.ProductID, + &productVariantID, + &item.Quantity, + &item.Price, + &item.Subtotal, + &item.Weight, + &productName, + &sku, + ) + if err != nil { + return nil, err + } + + if productVariantID.Valid { + item.ProductVariantID = uint(productVariantID.Int64) + } + + if productName.Valid { + item.ProductName = productName.String + } + + if sku.Valid { + item.SKU = sku.String + } + + order.Items = append(order.Items, item) + } + + return order, nil +} + // Update updates an order func (r *OrderRepository) Update(order *entity.Order) error { // Marshal addresses to JSON diff --git a/internal/interfaces/api/handler/email_test_handler.go b/internal/interfaces/api/handler/email_test_handler.go index a5cfea9..d06cfa2 100644 --- a/internal/interfaces/api/handler/email_test_handler.go +++ b/internal/interfaces/api/handler/email_test_handler.go @@ -43,16 +43,17 @@ func (h *EmailTestHandler) TestEmail(w http.ResponseWriter, r *http.Request) { // Create a mock order mockOrder := &entity.Order{ - ID: 12345, - OrderNumber: "ORD-12345", - UserID: mockUser.ID, - Status: entity.OrderStatusCompleted, - PaymentStatus: entity.PaymentStatusCaptured, - TotalAmount: 9950, // $99.50 in cents (subtotal before shipping/discounts) - ShippingCost: 850, // $8.50 shipping cost - DiscountAmount: 1500, // $15.00 discount - FinalAmount: 8300, // $83.00 final amount (99.50 + 8.50 - 15.00) - Currency: "USD", + ID: 12345, + OrderNumber: "ORD-12345", + UserID: mockUser.ID, + Status: entity.OrderStatusCompleted, + PaymentStatus: entity.PaymentStatusCaptured, + TotalAmount: 9950, // $99.50 in cents (subtotal before shipping/discounts) + ShippingCost: 850, // $8.50 shipping cost + DiscountAmount: 1500, // $15.00 discount + FinalAmount: 8300, // $83.00 final amount (99.50 + 8.50 - 15.00) + Currency: "USD", + CheckoutSessionID: "test-checkout-session-12345", // Add checkout session ID for testing ShippingAddr: entity.Address{ Street: "123 Test Street", City: "Test City", diff --git a/internal/interfaces/api/handler/health_handler.go b/internal/interfaces/api/handler/health_handler.go index 0616165..6a3f14b 100644 --- a/internal/interfaces/api/handler/health_handler.go +++ b/internal/interfaces/api/handler/health_handler.go @@ -60,7 +60,7 @@ func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { healthStatus := HealthStatus{ Status: status, Timestamp: time.Now(), - Version: "1.0.4", // TODO: Make this configurable + Version: "1.0.6", // TODO: Make this configurable Services: services, Uptime: uptime, } diff --git a/internal/interfaces/api/handler/order_handler.go b/internal/interfaces/api/handler/order_handler.go index c212dba..669b887 100644 --- a/internal/interfaces/api/handler/order_handler.go +++ b/internal/interfaces/api/handler/order_handler.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/mux" "github.com/zenfulcode/commercify/internal/application/usecase" + "github.com/zenfulcode/commercify/internal/domain/common" "github.com/zenfulcode/commercify/internal/domain/entity" "github.com/zenfulcode/commercify/internal/dto" "github.com/zenfulcode/commercify/internal/infrastructure/logger" @@ -29,16 +30,12 @@ func NewOrderHandler(orderUseCase *usecase.OrderUseCase, logger logger.Logger) * // GetOrder handles getting an order by ID func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { - // Get user ID from context - userID, ok := r.Context().Value(middleware.UserIDKey).(uint) - if !ok { - h.logger.Error("Unauthorized access attempt") - response := dto.ErrorResponse("Unauthorized") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(response) - return - } + h.logger.Info("GetOrder called") + + // Get user ID from context (optional for checkout session access) + userID, isAuthenticated := r.Context().Value(middleware.UserIDKey).(uint) + + h.logger.Debug("GetOrder called with userID: %d, isAuthenticated: %t", userID, isAuthenticated) // Get order ID from URL vars := mux.Vars(r) @@ -49,6 +46,8 @@ func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { return } + h.logger.Debug("Fetching order with ID: %d", id) + // Get order order, err := h.orderUseCase.GetOrderByID(uint(id)) if err != nil { @@ -60,19 +59,45 @@ func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { return } - // Check if the user is authorized to view this order - if order.UserID != userID { - role, ok := r.Context().Value(middleware.RoleKey).(string) - if !ok || role != string(entity.RoleAdmin) { - h.logger.Error("Unauthorized access to order %d by user %d", order.ID, userID) - response := dto.ErrorResponse("You are not authorized to view this order") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - json.NewEncoder(w).Encode(response) - return + h.logger.Debug("Order %d fetched successfully: CheckoutSessionID=%s", order.ID, order.CheckoutSessionID) + + // Check authorization: user owns the order, admin, or checkout session matches + authorized := false + + // Check if authenticated user owns the order or is admin + if isAuthenticated { + if order.UserID == userID { + authorized = true + } else { + // Check if user is admin + role, ok := r.Context().Value(middleware.RoleKey).(string) + if ok && role == string(entity.RoleAdmin) { + authorized = true + } + } + } + + // If not authorized by user auth, check checkout session cookie + if !authorized { + cookie, err := r.Cookie(common.CheckoutSessionCookie) + + h.logger.Debug("Checking checkout session cookie for order %d: %v", order.ID, err) + + if err == nil && cookie.Value != "" && cookie.Value == order.CheckoutSessionID { + authorized = true + h.logger.Info("Order %d accessed via checkout session: %s", order.ID, cookie.Value) } } + if !authorized { + h.logger.Error("Unauthorized access to order %d", order.ID) + response := dto.ErrorResponse("You are not authorized to view this order") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(response) + return + } + orderDTO := dto.OrderDetailResponse(order) // Return order diff --git a/internal/interfaces/api/middleware/auth_middleware.go b/internal/interfaces/api/middleware/auth_middleware.go index b873e29..de63493 100644 --- a/internal/interfaces/api/middleware/auth_middleware.go +++ b/internal/interfaces/api/middleware/auth_middleware.go @@ -83,3 +83,44 @@ func AdminOnly(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// OptionalAuthenticate attempts to authenticate a request but allows it to proceed even if authentication fails +func (m *AuthMiddleware) OptionalAuthenticate(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Get token from Authorization header + authHeader := r.Header.Get("Authorization") + + // If no auth header, proceed without authentication + if authHeader == "" { + next.ServeHTTP(w, r) + return + } + + // Check if the header has the Bearer prefix + if !strings.HasPrefix(authHeader, "Bearer ") { + // Invalid format, but proceed without authentication + next.ServeHTTP(w, r) + return + } + + // Extract token + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + + // Validate token + claims, err := m.jwtService.ValidateToken(tokenString) + if err != nil { + // Invalid token, but proceed without authentication + m.logger.Debug("Optional authentication failed: %v", err) + next.ServeHTTP(w, r) + return + } + + // Add user info to request context if authentication succeeded + ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID) + ctx = context.WithValue(ctx, emailKey, claims.Email) + ctx = context.WithValue(ctx, RoleKey, claims.Role) + + // Call the next handler with the updated context + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 073a16b..3c9a017 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -139,6 +139,11 @@ func (s *Server) setupRoutes() { s.setupMobilePayWebhooks(api, webhookHandler) s.setupStripeWebhooks(api, webhookHandler) + // Routes with optional authentication (accessible via auth or checkout session) + optionalAuth := api.PathPrefix("").Subrouter() + optionalAuth.Use(authMiddleware.OptionalAuthenticate) + optionalAuth.HandleFunc("/orders/{orderId:[0-9]+}", orderHandler.GetOrder).Methods(http.MethodGet) + // Protected routes protected := api.PathPrefix("").Subrouter() protected.Use(authMiddleware.Authenticate) @@ -148,8 +153,7 @@ func (s *Server) setupRoutes() { protected.HandleFunc("/users/me", userHandler.UpdateProfile).Methods(http.MethodPut) protected.HandleFunc("/users/me/password", userHandler.ChangePassword).Methods(http.MethodPut) - // Order routes - protected.HandleFunc("/orders/{orderId:[0-9]+}", orderHandler.GetOrder).Methods(http.MethodGet) + // Order routes (authenticated users only) protected.HandleFunc("/orders", orderHandler.ListOrders).Methods(http.MethodGet) // Admin routes diff --git a/migrations/000034_add_checkout_session_id_to_orders.down.sql b/migrations/000034_add_checkout_session_id_to_orders.down.sql new file mode 100644 index 0000000..a89e4b3 --- /dev/null +++ b/migrations/000034_add_checkout_session_id_to_orders.down.sql @@ -0,0 +1,3 @@ +-- Remove checkout_session_id column from orders table +DROP INDEX IF EXISTS idx_orders_checkout_session; +ALTER TABLE orders DROP COLUMN IF EXISTS checkout_session_id; diff --git a/migrations/000034_add_checkout_session_id_to_orders.up.sql b/migrations/000034_add_checkout_session_id_to_orders.up.sql new file mode 100644 index 0000000..583ca4d --- /dev/null +++ b/migrations/000034_add_checkout_session_id_to_orders.up.sql @@ -0,0 +1,14 @@ +-- Add checkout_session_id column to orders table +ALTER TABLE orders ADD COLUMN checkout_session_id VARCHAR(255); + +-- Create index on checkout_session_id for faster lookups +CREATE INDEX idx_orders_checkout_session ON orders(checkout_session_id); + +-- Populate existing orders with checkout session IDs from the checkouts table +-- This links orders to their corresponding checkout sessions +UPDATE orders +SET checkout_session_id = c.session_id +FROM checkouts c +WHERE orders.id = c.converted_order_id + AND c.session_id IS NOT NULL + AND c.session_id != ''; diff --git a/testutil/mock/order_repository.go b/testutil/mock/order_repository.go index 3d1bfcc..e749834 100644 --- a/testutil/mock/order_repository.go +++ b/testutil/mock/order_repository.go @@ -25,6 +25,23 @@ func NewMockOrderRepository( } } +// GetByCheckoutSessionID implements repository.OrderRepository. +func (r *OrderRepository) GetByCheckoutSessionID(checkoutSessionID string) (*entity.Order, error) { + if checkoutSessionID == "" { + return nil, errors.New("checkout session ID cannot be empty") + } + + for _, order := range r.orders { + if order.CheckoutSessionID == checkoutSessionID { + // Return a clone to prevent unintended modifications + clone := *order + return &clone, nil + } + } + + return nil, errors.New("order not found for checkout session ID") +} + // ListAll implements repository.OrderRepository. func (r *OrderRepository) ListAll(offset int, limit int) ([]*entity.Order, error) { orders := make([]*entity.Order, 0, len(r.orders)) From 56780beba63f0aeed80feadd6b83c470b60d19fd Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sun, 29 Jun 2025 00:30:04 +0200 Subject: [PATCH 10/12] feat: Remove excessive debug logging from GetOrder method for cleaner output --- internal/interfaces/api/handler/order_handler.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/internal/interfaces/api/handler/order_handler.go b/internal/interfaces/api/handler/order_handler.go index 669b887..c095ca9 100644 --- a/internal/interfaces/api/handler/order_handler.go +++ b/internal/interfaces/api/handler/order_handler.go @@ -30,13 +30,8 @@ func NewOrderHandler(orderUseCase *usecase.OrderUseCase, logger logger.Logger) * // GetOrder handles getting an order by ID func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { - h.logger.Info("GetOrder called") - // Get user ID from context (optional for checkout session access) userID, isAuthenticated := r.Context().Value(middleware.UserIDKey).(uint) - - h.logger.Debug("GetOrder called with userID: %d, isAuthenticated: %t", userID, isAuthenticated) - // Get order ID from URL vars := mux.Vars(r) id, err := strconv.ParseUint(vars["orderId"], 10, 32) @@ -46,8 +41,6 @@ func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { return } - h.logger.Debug("Fetching order with ID: %d", id) - // Get order order, err := h.orderUseCase.GetOrderByID(uint(id)) if err != nil { @@ -59,8 +52,6 @@ func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { return } - h.logger.Debug("Order %d fetched successfully: CheckoutSessionID=%s", order.ID, order.CheckoutSessionID) - // Check authorization: user owns the order, admin, or checkout session matches authorized := false @@ -80,9 +71,6 @@ func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { // If not authorized by user auth, check checkout session cookie if !authorized { cookie, err := r.Cookie(common.CheckoutSessionCookie) - - h.logger.Debug("Checking checkout session cookie for order %d: %v", order.ID, err) - if err == nil && cookie.Value != "" && cookie.Value == order.CheckoutSessionID { authorized = true h.logger.Info("Order %d accessed via checkout session: %s", order.ID, cookie.Value) From bfd1631fa2c997cc8506c3e334c9d15d35fa8bb0 Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sun, 29 Jun 2025 14:59:12 +0200 Subject: [PATCH 11/12] feat: Implement checkout expiration process and related commands --- Makefile | 5 ++ cmd/api/main.go | 33 +++++++++++++ cmd/expire-checkouts/main.go | 49 +++++++++++++++++++ .../application/usecase/checkout_usecase.go | 8 +-- internal/interfaces/api/server.go | 5 ++ 5 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 cmd/expire-checkouts/main.go diff --git a/Makefile b/Makefile index 5a8e4e7..387bff7 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ build: ## Build the application go build -o bin/api ./cmd/api go build -o bin/migrate ./cmd/migrate go build -o bin/seed ./cmd/seed + go build -o bin/expire-checkouts ./cmd/expire-checkouts run: db-start ## Run the application locally with database @echo "Starting database and waiting for it to be ready..." @@ -105,3 +106,7 @@ vet: ## Run go vet mod-tidy: ## Tidy Go modules go mod tidy + +# Maintenance commands +expire-checkouts: ## Expire old checkouts manually + go run ./cmd/expire-checkouts diff --git a/cmd/api/main.go b/cmd/api/main.go index 3d4c0e5..7834b4b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -48,6 +48,9 @@ func main() { // Initialize API server server := api.NewServer(cfg, db, logger) + // Start background checkout expiry process + go startCheckoutExpiryProcess(server, logger) + // Start server in a goroutine go func() { logger.Info("Starting server on port %s", cfg.Server.Port) @@ -73,3 +76,33 @@ func main() { logger.Info("Server exited properly") } + +// startCheckoutExpiryProcess runs a background process to expire old checkouts +func startCheckoutExpiryProcess(server *api.Server, logger logger.Logger) { + // Run every 15 minutes + ticker := time.NewTicker(15 * time.Minute) + defer ticker.Stop() + + // Run immediately on startup + expireCheckouts(server, logger) + + for range ticker.C { + expireCheckouts(server, logger) + } +} + +// expireCheckouts expires old checkouts +func expireCheckouts(server *api.Server, logger logger.Logger) { + checkoutUseCase := server.GetContainer().UseCases().CheckoutUseCase() + if checkoutUseCase == nil { + logger.Error("CheckoutUseCase not available") + return + } + + amountExpired, err := checkoutUseCase.ExpireOldCheckouts() + if err != nil { + logger.Error("Failed to expire old checkouts: %v", err) + } else { + logger.Info("Expired %d old checkouts", amountExpired) + } +} diff --git a/cmd/expire-checkouts/main.go b/cmd/expire-checkouts/main.go new file mode 100644 index 0000000..5f88457 --- /dev/null +++ b/cmd/expire-checkouts/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "log" + + "github.com/joho/godotenv" + "github.com/zenfulcode/commercify/config" + "github.com/zenfulcode/commercify/internal/infrastructure/container" + "github.com/zenfulcode/commercify/internal/infrastructure/database" + "github.com/zenfulcode/commercify/internal/infrastructure/logger" +) + +func main() { + // Load environment variables + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using environment variables") + } + + // Initialize logger + logger := logger.NewLogger() + logger.Info("Starting checkout expiry cleanup tool") + + // Load configuration + cfg, err := config.LoadConfig() + if err != nil { + logger.Fatal("Failed to load configuration: %v", err) + } + + // Connect to database + db, err := database.NewPostgresConnection(cfg.Database) + if err != nil { + logger.Fatal("Failed to connect to database: %v", err) + } + defer db.Close() + + // Initialize dependency container + diContainer := container.NewContainer(cfg, db, logger) + + // Get checkout use case + checkoutUseCase := diContainer.UseCases().CheckoutUseCase() + + // Expire old checkouts + amountExpired, err := checkoutUseCase.ExpireOldCheckouts() + if err != nil { + logger.Fatal("Failed to expire old checkouts: %v", err) + } + + logger.Info("Expired %d old checkouts", amountExpired) +} diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index 8f28a1f..5e15f1d 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -492,13 +492,15 @@ func (uc *CheckoutUseCase) RemoveDiscountCode(checkout *entity.Checkout) (*entit } // ExpireOldCheckouts marks expired checkouts as expired -func (uc *CheckoutUseCase) ExpireOldCheckouts() error { +func (uc *CheckoutUseCase) ExpireOldCheckouts() (int, error) { // Get expired checkouts expiredCheckouts, err := uc.checkoutRepo.GetExpiredCheckouts() if err != nil { - return err + return 0, err } + amountExpired := len(expiredCheckouts) + // Mark each as expired for _, checkout := range expiredCheckouts { checkout.MarkAsExpired() @@ -509,7 +511,7 @@ func (uc *CheckoutUseCase) ExpireOldCheckouts() error { } } - return nil + return amountExpired, nil } // CreateOrderFromCheckout creates an order from a checkout diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 3c9a017..ec7fdd3 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -237,6 +237,11 @@ func (s *Server) setupRoutes() { admin.HandleFunc("/variants/{variantId:[0-9]+}/prices/{currency}", productHandler.RemoveVariantPrice).Methods(http.MethodDelete) } +// GetContainer returns the dependency injection container +func (s *Server) GetContainer() container.Container { + return s.container +} + // setupStripeWebhooks configures Stripe webhooks func (s *Server) setupStripeWebhooks(api *mux.Router, webhookHandler *handler.WebhookHandler) { if !s.config.Stripe.Enabled { From 1d3edd55c29ddbea3bc1243f443508c4e10a2165 Mon Sep 17 00:00:00 2001 From: gkhaavik Date: Sun, 29 Jun 2025 15:51:05 +0200 Subject: [PATCH 12/12] feat: Enhance checkout expiration logic with detailed logging and new seeding options --- cmd/api/main.go | 6 +- cmd/expire-checkouts/main.go | 8 +- cmd/seed/main.go | 412 +++++++++++++++++- .../application/usecase/checkout_usecase.go | 65 ++- internal/domain/entity/checkout.go | 65 +++ .../domain/repository/checkout_repository.go | 6 + .../postgres/checkout_repository.go | 133 ++++++ testutil/mock/checkout_repository.go | 32 ++ 8 files changed, 713 insertions(+), 14 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 7834b4b..a367614 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -99,10 +99,12 @@ func expireCheckouts(server *api.Server, logger logger.Logger) { return } - amountExpired, err := checkoutUseCase.ExpireOldCheckouts() + result, err := checkoutUseCase.ExpireOldCheckouts() if err != nil { logger.Error("Failed to expire old checkouts: %v", err) } else { - logger.Info("Expired %d old checkouts", amountExpired) + logger.Info("Checkout cleanup completed: %d abandoned, %d deleted, %d expired (total: %d)", + result.AbandonedCount, result.DeletedCount, result.ExpiredCount, + result.AbandonedCount+result.DeletedCount+result.ExpiredCount) } } diff --git a/cmd/expire-checkouts/main.go b/cmd/expire-checkouts/main.go index 5f88457..3e7a14e 100644 --- a/cmd/expire-checkouts/main.go +++ b/cmd/expire-checkouts/main.go @@ -40,10 +40,14 @@ func main() { checkoutUseCase := diContainer.UseCases().CheckoutUseCase() // Expire old checkouts - amountExpired, err := checkoutUseCase.ExpireOldCheckouts() + result, err := checkoutUseCase.ExpireOldCheckouts() if err != nil { logger.Fatal("Failed to expire old checkouts: %v", err) } - logger.Info("Expired %d old checkouts", amountExpired) + logger.Info("Checkout cleanup completed:") + logger.Info("- Abandoned checkouts: %d", result.AbandonedCount) + logger.Info("- Deleted checkouts: %d", result.DeletedCount) + logger.Info("- Expired checkouts: %d", result.ExpiredCount) + logger.Info("Total processed: %d", result.AbandonedCount+result.DeletedCount+result.ExpiredCount) } diff --git a/cmd/seed/main.go b/cmd/seed/main.go index aefb440..eb8ffc8 100644 --- a/cmd/seed/main.go +++ b/cmd/seed/main.go @@ -26,6 +26,7 @@ func main() { productVariantsFlag := flag.Bool("product-variants", false, "Seed product variants data") discountsFlag := flag.Bool("discounts", false, "Seed discounts data") ordersFlag := flag.Bool("orders", false, "Seed orders data") + checkoutsFlag := flag.Bool("checkouts", false, "Seed checkouts data") paymentTransactionsFlag := flag.Bool("payment-transactions", false, "Seed payment transactions data") shippingFlag := flag.Bool("shipping", false, "Seed shipping data (methods, zones, rates)") clearFlag := flag.Bool("clear", false, "Clear all data before seeding") @@ -117,6 +118,13 @@ func main() { fmt.Println("Orders seeded successfully") } + if *allFlag || *checkoutsFlag { + if err := seedCheckouts(db); err != nil { + log.Fatalf("Failed to seed checkouts: %v", err) + } + fmt.Println("Checkouts seeded successfully") + } + // if *allFlag || *paymentTransactionsFlag { // if err := seedPaymentTransactions(db); err != nil { // log.Fatalf("Failed to seed payment transactions: %v", err) @@ -125,7 +133,7 @@ func main() { // } if !*allFlag && !*usersFlag && !*categoriesFlag && !*productsFlag && !*productVariantsFlag && - !*ordersFlag && !*clearFlag && !*discountsFlag && + !*ordersFlag && !*checkoutsFlag && !*clearFlag && !*discountsFlag && !*paymentTransactionsFlag && !*shippingFlag { fmt.Println("No action specified") fmt.Println("\nUsage:") @@ -1750,3 +1758,405 @@ func seedPaymentTransactions(db *sql.DB) error { fmt.Printf("Seeded %d payment transactions\n", len(orders)) return nil } + +// seedCheckouts seeds checkout data for testing expiry and cleanup logic +func seedCheckouts(db *sql.DB) error { + // Get user IDs + userRows, err := db.Query("SELECT id FROM users LIMIT 3") + if err != nil { + return err + } + defer userRows.Close() + + var userIDs []int + for userRows.Next() { + var id int + if err := userRows.Scan(&id); err != nil { + return err + } + userIDs = append(userIDs, id) + } + + if len(userIDs) == 0 { + // Create at least one guest checkout if no users exist + userIDs = []int{} // Empty slice, we'll use guest checkouts only + } + + // Get product data with their default variants + productRows, err := db.Query(` + SELECT p.id, p.name, pv.id as variant_id, pv.price, pv.sku + FROM products p + JOIN product_variants pv ON p.id = pv.product_id + WHERE pv.is_default = true + LIMIT 5 + `) + if err != nil { + return err + } + defer productRows.Close() + + type productInfo struct { + id int + name string + variantID int + price int64 + sku string + } + + var products []productInfo + for productRows.Next() { + var p productInfo + if err := productRows.Scan(&p.id, &p.name, &p.variantID, &p.price, &p.sku); err != nil { + return err + } + products = append(products, p) + } + + if len(products) == 0 { + return fmt.Errorf("no products found to create checkouts with") + } + + now := time.Now() + + // Sample addresses + addresses := []map[string]string{ + { + "street": "123 Main St", + "city": "New York", + "state": "NY", + "postal_code": "10001", + "country": "USA", + }, + { + "street": "456 Oak Ave", + "city": "Los Angeles", + "state": "CA", + "postal_code": "90001", + "country": "USA", + }, + { + "street": "789 Pine Rd", + "city": "Chicago", + "state": "IL", + "postal_code": "60601", + "country": "USA", + }, + } + + // Sample customer details + customerDetails := []map[string]string{ + { + "email": "john.doe@example.com", + "phone": "+1-555-0101", + "full_name": "John Doe", + }, + { + "email": "jane.smith@example.com", + "phone": "+1-555-0102", + "full_name": "Jane Smith", + }, + { + "email": "bob.wilson@example.com", + "phone": "+1-555-0103", + "full_name": "Bob Wilson", + }, + } + + // Create different types of checkouts for testing expiry logic + checkouts := []struct { + description string + userID *int + sessionID string + status string + hasCustomerDetails bool + hasShippingAddress bool + lastActivityAt time.Time + createdAt time.Time + expiresAt time.Time + addItems bool + }{ + { + description: "Active checkout with customer info - should be abandoned (16 min old)", + userID: func() *int { + if len(userIDs) > 0 { + return &userIDs[0] + } else { + return nil + } + }(), + sessionID: func() string { + if len(userIDs) > 0 { + return "" + } else { + return "user_session_1" + } + }(), + status: "active", + hasCustomerDetails: true, + hasShippingAddress: true, + lastActivityAt: now.Add(-16 * time.Minute), + createdAt: now.Add(-20 * time.Minute), + expiresAt: now.Add(4 * time.Hour), + addItems: true, + }, + { + description: "Active checkout with customer info - still active (10 min old)", + userID: func() *int { + if len(userIDs) > 1 { + return &userIDs[1] + } else if len(userIDs) > 0 { + return &userIDs[0] + } else { + return nil + } + }(), + sessionID: func() string { + if len(userIDs) > 1 { + return "" + } else { + return "user_session_2" + } + }(), + status: "active", + hasCustomerDetails: true, + hasShippingAddress: false, + lastActivityAt: now.Add(-10 * time.Minute), + createdAt: now.Add(-15 * time.Minute), + expiresAt: now.Add(9 * time.Hour), + addItems: true, + }, + { + description: "Empty guest checkout - should be deleted (25 hours old)", + userID: nil, + sessionID: "guest_session_old", + status: "active", + hasCustomerDetails: false, + hasShippingAddress: false, + lastActivityAt: now.Add(-25 * time.Hour), + createdAt: now.Add(-25 * time.Hour), + expiresAt: now.Add(-1 * time.Hour), + addItems: false, + }, + { + description: "Empty guest checkout - still active (20 hours old)", + userID: nil, + sessionID: "guest_session_recent", + status: "active", + hasCustomerDetails: false, + hasShippingAddress: false, + lastActivityAt: now.Add(-20 * time.Hour), + createdAt: now.Add(-20 * time.Hour), + expiresAt: now.Add(4 * time.Hour), + addItems: false, + }, + { + description: "Abandoned checkout - should be deleted (8 days old)", + userID: func() *int { + if len(userIDs) > 2 { + return &userIDs[2] + } else if len(userIDs) > 0 { + return &userIDs[0] + } else { + return nil + } + }(), + sessionID: func() string { + if len(userIDs) > 2 { + return "" + } else { + return "user_session_3" + } + }(), + status: "abandoned", + hasCustomerDetails: true, + hasShippingAddress: true, + lastActivityAt: now.Add(-8 * 24 * time.Hour), + createdAt: now.Add(-8 * 24 * time.Hour), + expiresAt: now.Add(-4 * 24 * time.Hour), + addItems: true, + }, + { + description: "Abandoned checkout - still recoverable (5 days old)", + userID: func() *int { + if len(userIDs) > 0 { + return &userIDs[0] + } else { + return nil + } + }(), + sessionID: func() string { + if len(userIDs) > 0 { + return "" + } else { + return "user_session_4" + } + }(), + status: "abandoned", + hasCustomerDetails: true, + hasShippingAddress: false, + lastActivityAt: now.Add(-5 * 24 * time.Hour), + createdAt: now.Add(-5 * 24 * time.Hour), + expiresAt: now.Add(-1 * 24 * time.Hour), + addItems: true, + }, + { + description: "Expired checkout - should be deleted", + userID: nil, + sessionID: "expired_session", + status: "expired", + hasCustomerDetails: false, + hasShippingAddress: false, + lastActivityAt: now.Add(-2 * 24 * time.Hour), + createdAt: now.Add(-2 * 24 * time.Hour), + expiresAt: now.Add(-1 * 24 * time.Hour), + addItems: false, + }, + { + description: "Guest checkout with shipping info - should be abandoned (20 min old)", + userID: nil, + sessionID: "guest_with_shipping", + status: "active", + hasCustomerDetails: false, + hasShippingAddress: true, + lastActivityAt: now.Add(-20 * time.Minute), + createdAt: now.Add(-25 * time.Minute), + expiresAt: now.Add(23 * time.Hour), + addItems: true, + }, + } + + // Insert checkouts + for i, checkout := range checkouts { + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction for checkout %d: %w", i, err) + } + + // Prepare addresses and customer details + var shippingAddrJSON, billingAddrJSON, customerDetailsJSON []byte + + if checkout.hasShippingAddress { + addr := addresses[i%len(addresses)] + shippingAddrJSON, _ = json.Marshal(addr) + billingAddrJSON = shippingAddrJSON // Use same address for billing + } else { + shippingAddrJSON, _ = json.Marshal(map[string]string{}) + billingAddrJSON, _ = json.Marshal(map[string]string{}) + } + + if checkout.hasCustomerDetails { + details := customerDetails[i%len(customerDetails)] + customerDetailsJSON, _ = json.Marshal(details) + } else { + customerDetailsJSON, _ = json.Marshal(map[string]string{}) + } + + // Insert checkout + var checkoutID uint + var userID sql.NullInt64 + if checkout.userID != nil { + userID.Int64 = int64(*checkout.userID) + userID.Valid = true + } + + var sessionID sql.NullString + if checkout.sessionID != "" { + sessionID.String = checkout.sessionID + sessionID.Valid = true + } + + err = tx.QueryRow(` + INSERT INTO checkouts ( + user_id, session_id, status, shipping_address, billing_address, + customer_details, currency, total_amount, shipping_cost, discount_amount, + final_amount, created_at, updated_at, last_activity_at, expires_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id + `, + userID, + sessionID, + checkout.status, + shippingAddrJSON, + billingAddrJSON, + customerDetailsJSON, + "USD", + 0, // Will be updated after adding items + 0, + 0, + 0, + checkout.createdAt, + checkout.createdAt, + checkout.lastActivityAt, + checkout.expiresAt, + ).Scan(&checkoutID) + + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to insert checkout %d: %w", i, err) + } + + // Add checkout items if specified + if checkout.addItems { + totalAmount := int64(0) + numItems := (i % 3) + 1 // 1-3 items per checkout + + for j := 0; j < numItems; j++ { + product := products[j%len(products)] + quantity := (j % 2) + 1 // 1-2 quantity per item + + itemTotal := int64(quantity) * product.price + totalAmount += itemTotal + + _, err = tx.Exec(` + INSERT INTO checkout_items ( + checkout_id, product_id, product_variant_id, quantity, price, + weight, product_name, sku, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, + checkoutID, + product.id, + product.variantID, + quantity, + product.price, + 0.5, // Default weight + product.name, + product.sku, + checkout.createdAt, + checkout.createdAt, + ) + + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to insert checkout item %d for checkout %d: %w", j, i, err) + } + } + + // Update checkout with total amount + _, err = tx.Exec(` + UPDATE checkouts + SET total_amount = $1, final_amount = $2 + WHERE id = $3 + `, + totalAmount, + totalAmount, + checkoutID, + ) + + if err != nil { + tx.Rollback() + return fmt.Errorf("failed to update checkout total for checkout %d: %w", i, err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit transaction for checkout %d: %w", i, err) + } + + fmt.Printf("Created checkout: %s (ID: %d)\n", checkout.description, checkoutID) + } + + fmt.Printf("Seeded %d checkouts for testing expiry logic\n", len(checkouts)) + return nil +} diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index 5e15f1d..9c1760c 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -491,27 +491,74 @@ func (uc *CheckoutUseCase) RemoveDiscountCode(checkout *entity.Checkout) (*entit return checkout, nil } -// ExpireOldCheckouts marks expired checkouts as expired -func (uc *CheckoutUseCase) ExpireOldCheckouts() (int, error) { - // Get expired checkouts - expiredCheckouts, err := uc.checkoutRepo.GetExpiredCheckouts() +// CheckoutCleanupResult represents the results of checkout cleanup operations +type CheckoutCleanupResult struct { + AbandonedCount int `json:"abandoned_count"` + DeletedCount int `json:"deleted_count"` + ExpiredCount int `json:"expired_count"` +} + +// ExpireOldCheckouts performs comprehensive checkout cleanup operations +func (uc *CheckoutUseCase) ExpireOldCheckouts() (*CheckoutCleanupResult, error) { + result := &CheckoutCleanupResult{} + + // 1. Mark checkouts with customer/shipping info as abandoned after 15 minutes + checkoutsToAbandon, err := uc.checkoutRepo.GetCheckoutsToAbandon() if err != nil { - return 0, err + return result, fmt.Errorf("failed to get checkouts to abandon: %w", err) + } + + for _, checkout := range checkoutsToAbandon { + checkout.MarkAsAbandoned() + err = uc.checkoutRepo.Update(checkout) + if err != nil { + log.Printf("Failed to mark checkout %d as abandoned: %v", checkout.ID, err) + continue + } + result.AbandonedCount++ } - amountExpired := len(expiredCheckouts) + // 2. Delete checkouts that should be deleted (empty > 24h or abandoned > 7 days) + checkoutsToDelete, err := uc.checkoutRepo.GetCheckoutsToDelete() + if err != nil { + return result, fmt.Errorf("failed to get checkouts to delete: %w", err) + } + + for _, checkout := range checkoutsToDelete { + err = uc.checkoutRepo.Delete(checkout.ID) + if err != nil { + log.Printf("Failed to delete checkout %d: %v", checkout.ID, err) + continue + } + result.DeletedCount++ + } + + // 3. Mark remaining expired checkouts as expired (legacy support) + expiredCheckouts, err := uc.checkoutRepo.GetExpiredCheckouts() + if err != nil { + return result, fmt.Errorf("failed to get expired checkouts: %w", err) + } - // Mark each as expired for _, checkout := range expiredCheckouts { checkout.MarkAsExpired() err = uc.checkoutRepo.Update(checkout) if err != nil { - // Continue despite errors + log.Printf("Failed to mark checkout %d as expired: %v", checkout.ID, err) continue } + result.ExpiredCount++ } - return amountExpired, nil + return result, nil +} + +// ExpireOldCheckoutsLegacy returns only the total count for backward compatibility +func (uc *CheckoutUseCase) ExpireOldCheckoutsLegacy() (int, error) { + result, err := uc.ExpireOldCheckouts() + if err != nil { + return 0, err + } + return result.AbandonedCount + result.DeletedCount + result.ExpiredCount, nil } // CreateOrderFromCheckout creates an order from a checkout diff --git a/internal/domain/entity/checkout.go b/internal/domain/entity/checkout.go index 538bf79..702ef39 100644 --- a/internal/domain/entity/checkout.go +++ b/internal/domain/entity/checkout.go @@ -363,6 +363,71 @@ func (c *Checkout) TotalItems() int { return total } +// HasCustomerInfo returns true if the checkout has customer information +func (c *Checkout) HasCustomerInfo() bool { + return c.CustomerDetails.Email != "" || + c.CustomerDetails.Phone != "" || + c.CustomerDetails.FullName != "" +} + +// HasShippingInfo returns true if the checkout has shipping address information +func (c *Checkout) HasShippingInfo() bool { + return c.ShippingAddr.Street != "" || + c.ShippingAddr.City != "" || + c.ShippingAddr.State != "" || + c.ShippingAddr.PostalCode != "" || + c.ShippingAddr.Country != "" +} + +// HasCustomerOrShippingInfo returns true if the checkout has either customer or shipping information +func (c *Checkout) HasCustomerOrShippingInfo() bool { + return c.HasCustomerInfo() || c.HasShippingInfo() +} + +// IsEmpty returns true if the checkout has no items and no customer/shipping information +func (c *Checkout) IsEmpty() bool { + return len(c.Items) == 0 && !c.HasCustomerOrShippingInfo() +} + +// ShouldBeAbandoned returns true if the checkout should be marked as abandoned +// (has customer/shipping info and hasn't been active for 15 minutes) +func (c *Checkout) ShouldBeAbandoned() bool { + if c.Status != CheckoutStatusActive { + return false + } + + if !c.HasCustomerOrShippingInfo() { + return false + } + + abandonThreshold := time.Now().Add(-15 * time.Minute) + return c.LastActivityAt.Before(abandonThreshold) +} + +// ShouldBeDeleted returns true if the checkout should be deleted +func (c *Checkout) ShouldBeDeleted() bool { + now := time.Now() + + // Delete empty checkouts after 24 hours + if !c.HasCustomerOrShippingInfo() { + deleteThreshold := now.Add(-24 * time.Hour) + return c.LastActivityAt.Before(deleteThreshold) + } + + // Delete abandoned checkouts after 7 days in abandoned state + if c.Status == CheckoutStatusAbandoned { + deleteThreshold := now.Add(-7 * 24 * time.Hour) + return c.UpdatedAt.Before(deleteThreshold) + } + + // Delete all expired checkouts + if c.Status == CheckoutStatusExpired { + return true + } + + return false +} + // recalculateTotals recalculates the total amount, weight, and final amount func (c *Checkout) recalculateTotals() { // Calculate total amount and weight diff --git a/internal/domain/repository/checkout_repository.go b/internal/domain/repository/checkout_repository.go index b17824d..d582016 100644 --- a/internal/domain/repository/checkout_repository.go +++ b/internal/domain/repository/checkout_repository.go @@ -28,6 +28,12 @@ type CheckoutRepository interface { // GetExpiredCheckouts retrieves all checkouts that have expired GetExpiredCheckouts() ([]*entity.Checkout, error) + // GetCheckoutsToAbandon retrieves active checkouts with customer/shipping info that should be marked as abandoned + GetCheckoutsToAbandon() ([]*entity.Checkout, error) + + // GetCheckoutsToDelete retrieves checkouts that should be deleted (empty checkouts > 24h or abandoned > 7 days) + GetCheckoutsToDelete() ([]*entity.Checkout, error) + // GetCheckoutsByStatus retrieves checkouts by status GetCheckoutsByStatus(status entity.CheckoutStatus, offset, limit int) ([]*entity.Checkout, error) diff --git a/internal/infrastructure/repository/postgres/checkout_repository.go b/internal/infrastructure/repository/postgres/checkout_repository.go index b72026c..3a3aca3 100644 --- a/internal/infrastructure/repository/postgres/checkout_repository.go +++ b/internal/infrastructure/repository/postgres/checkout_repository.go @@ -1010,3 +1010,136 @@ func (r *CheckoutRepository) scanCheckoutItem(rows *sql.Rows) (*entity.CheckoutI return &item, nil } + +// GetCheckoutsToAbandon retrieves active checkouts with customer/shipping info that should be marked as abandoned +func (r *CheckoutRepository) GetCheckoutsToAbandon() ([]*entity.Checkout, error) { + // Find active checkouts with customer or shipping info that haven't been active for 15 minutes + abandonThreshold := time.Now().Add(-15 * time.Minute) + + query := ` + SELECT + id, user_id, session_id, status, shipping_address, + billing_address, shipping_method_id, payment_provider, + total_amount, shipping_cost, total_weight, customer_details, + currency, discount_code, discount_amount, final_amount, + applied_discount, created_at, updated_at, last_activity_at, + expires_at, completed_at, converted_order_id + FROM checkouts + WHERE status = $1 + AND last_activity_at < $2 + AND ( + (customer_details->>'email' != '' AND customer_details->>'email' IS NOT NULL) + OR (customer_details->>'phone' != '' AND customer_details->>'phone' IS NOT NULL) + OR (customer_details->>'full_name' != '' AND customer_details->>'full_name' IS NOT NULL) + OR (shipping_address->>'street' != '' AND shipping_address->>'street' IS NOT NULL) + OR (shipping_address->>'city' != '' AND shipping_address->>'city' IS NOT NULL) + OR (shipping_address->>'state' != '' AND shipping_address->>'state' IS NOT NULL) + OR (shipping_address->>'postal_code' != '' AND shipping_address->>'postal_code' IS NOT NULL) + OR (shipping_address->>'country' != '' AND shipping_address->>'country' IS NOT NULL) + )` + + rows, err := r.db.Query(query, entity.CheckoutStatusActive, abandonThreshold) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanCheckoutsWithItems(rows) +} + +// GetCheckoutsToDelete retrieves checkouts that should be deleted +func (r *CheckoutRepository) GetCheckoutsToDelete() ([]*entity.Checkout, error) { + now := time.Now() + emptyDeleteThreshold := now.Add(-24 * time.Hour) + abandonedDeleteThreshold := now.Add(-7 * 24 * time.Hour) + + query := ` + SELECT + id, user_id, session_id, status, shipping_address, + billing_address, shipping_method_id, payment_provider, + total_amount, shipping_cost, total_weight, customer_details, + currency, discount_code, discount_amount, final_amount, + applied_discount, created_at, updated_at, last_activity_at, + expires_at, completed_at, converted_order_id + FROM checkouts + WHERE + ( + -- Delete empty checkouts after 24 hours + ( + status = $1 + AND last_activity_at < $2 + AND (customer_details->>'email' = '' OR customer_details->>'email' IS NULL) + AND (customer_details->>'phone' = '' OR customer_details->>'phone' IS NULL) + AND (customer_details->>'full_name' = '' OR customer_details->>'full_name' IS NULL) + AND (shipping_address->>'street' = '' OR shipping_address->>'street' IS NULL) + AND (shipping_address->>'city' = '' OR shipping_address->>'city' IS NULL) + AND (shipping_address->>'state' = '' OR shipping_address->>'state' IS NULL) + AND (shipping_address->>'postal_code' = '' OR shipping_address->>'postal_code' IS NULL) + AND (shipping_address->>'country' = '' OR shipping_address->>'country' IS NULL) + ) + OR + -- Delete abandoned checkouts after 7 days + ( + status = $3 + AND updated_at < $4 + ) + OR + -- Delete all expired checkouts + ( + status = $5 + ) + )` + + rows, err := r.db.Query(query, + entity.CheckoutStatusActive, emptyDeleteThreshold, + entity.CheckoutStatusAbandoned, abandonedDeleteThreshold, + entity.CheckoutStatusExpired) + if err != nil { + return nil, err + } + defer rows.Close() + + return r.scanCheckoutsWithItems(rows) +} + +// scanCheckoutsWithItems is a helper method to scan checkouts and their items +func (r *CheckoutRepository) scanCheckoutsWithItems(rows *sql.Rows) ([]*entity.Checkout, error) { + checkouts := []*entity.Checkout{} + for rows.Next() { + checkout, err := r.scanCheckout(rows) + if err != nil { + return nil, err + } + + // Get checkout items + itemsQuery := ` + SELECT + id, checkout_id, product_id, product_variant_id, quantity, + price, weight, product_name, variant_name, sku, + created_at, updated_at + FROM checkout_items + WHERE checkout_id = $1 + ORDER BY id ASC` + + itemRows, err := r.db.Query(itemsQuery, checkout.ID) + if err != nil { + return nil, err + } + + items := []entity.CheckoutItem{} + for itemRows.Next() { + item, err := r.scanCheckoutItem(itemRows) + if err != nil { + itemRows.Close() + return nil, err + } + items = append(items, *item) + } + itemRows.Close() + + checkout.Items = items + checkouts = append(checkouts, checkout) + } + + return checkouts, nil +} diff --git a/testutil/mock/checkout_repository.go b/testutil/mock/checkout_repository.go index 63b0ac5..db02a9d 100644 --- a/testutil/mock/checkout_repository.go +++ b/testutil/mock/checkout_repository.go @@ -28,6 +28,38 @@ func NewMockCheckoutRepository() repository.CheckoutRepository { } } +// GetCheckoutsToAbandon implements repository.CheckoutRepository. +func (r *MockCheckoutRepository) GetCheckoutsToAbandon() ([]*entity.Checkout, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + + var checkoutsToAbandon []*entity.Checkout + + for _, checkout := range r.checkouts { + if checkout.ShouldBeAbandoned() { + checkoutsToAbandon = append(checkoutsToAbandon, checkout) + } + } + + return checkoutsToAbandon, nil +} + +// GetCheckoutsToDelete implements repository.CheckoutRepository. +func (r *MockCheckoutRepository) GetCheckoutsToDelete() ([]*entity.Checkout, error) { + r.mutex.Lock() + defer r.mutex.Unlock() + + var checkoutsToDelete []*entity.Checkout + + for _, checkout := range r.checkouts { + if checkout.ShouldBeDeleted() { + checkoutsToDelete = append(checkoutsToDelete, checkout) + } + } + + return checkoutsToDelete, nil +} + // Create adds a checkout to the repository func (r *MockCheckoutRepository) Create(checkout *entity.Checkout) error { r.mutex.Lock()