get('cart', []); if (empty($cart)) { return redirect()->route('cart')->with('error', 'Your cart is empty!'); } $items = []; $total = 0; foreach ($cart as $itemKey => $cartItem) { // Handle both old and new cart formats if (is_array($cartItem)) { $productId = $cartItem['product_id'] ?? null; $type = $cartItem['type'] ?? 'wallpaper'; $printStockId = $cartItem['print_stock_id'] ?? null; $isSample = $cartItem['is_sample'] ?? false; } else { $productId = $itemKey; $type = 'wallpaper'; $printStockId = null; $isSample = false; } if ($productId) { $product = Product::find($productId); if ($product) { $quantity = is_array($cartItem) ? ($cartItem['quantity'] ?? 1) : $cartItem; // For samples, use fixed sample cost if ($isSample) { $subtotal = ShippingService::getSampleCost() * $quantity; } else { $stockCost = 0; $stock = null; // Get print stock if available if ($printStockId) { $stock = $product->printStocks()->find($printStockId); if ($stock) { $stockCost = ($type === 'wallpaper') ? $stock->cost_per_meter : $stock->cost_per_m2; } } // Calculate price based on stock cost only (no base design cost) $basePrice = $stockCost; if ($type === 'wallpaper') { $length = is_array($cartItem) ? ($cartItem['length'] ?? 0) : 0; $subtotal = $basePrice * $length * $quantity; } elseif ($type === 'mural') { $width = is_array($cartItem) ? ($cartItem['width'] ?? 0) : 0; $height = is_array($cartItem) ? ($cartItem['height'] ?? 0) : 0; $m2 = $width * $height; $subtotal = $basePrice * $m2 * $quantity; } else { $subtotal = $basePrice * $quantity; } $stock = null; } $total += $subtotal; $items[] = [ 'key' => $itemKey, 'product' => $product, 'stock' => isset($stock) ? $stock : null, 'quantity' => $quantity, 'type' => $type, 'is_sample' => $isSample, 'length' => is_array($cartItem) ? ($cartItem['length'] ?? null) : null, 'width' => is_array($cartItem) ? ($cartItem['width'] ?? null) : null, 'height' => is_array($cartItem) ? ($cartItem['height'] ?? null) : null, 'subtotal' => $subtotal ]; } } } $shippingFee = ShippingService::calculateShippingFee($total); $shippingLabel = ShippingService::getShippingLabel($total); $grandTotal = $total + $shippingFee; return view('checkout', [ 'items' => $items, 'total' => $total, 'shippingFee' => $shippingFee, 'shippingLabel' => $shippingLabel, 'grandTotal' => $grandTotal, 'itemCount' => count($cart) ]); } public function process(Request $request) { $request->validate([ 'customer_name' => 'required|string|max:255', 'customer_email' => 'required|email', 'customer_phone' => 'required|string|max:20', 'shipping_address' => 'required|string|max:500', 'notes' => 'nullable|string|max:500' ]); $cart = session()->get('cart', []); if (empty($cart)) { return redirect()->route('cart')->with('error', 'Your cart is empty!'); } // Calculate total and validate stock $total = 0; $orderItems = []; foreach ($cart as $itemKey => $cartItem) { // Handle both old and new cart formats if (is_array($cartItem)) { $productId = $cartItem['product_id'] ?? null; $quantity = $cartItem['quantity'] ?? 1; $type = $cartItem['type'] ?? 'wallpaper'; $printStockId = $cartItem['print_stock_id'] ?? null; $isSample = $cartItem['is_sample'] ?? false; } else { $productId = $itemKey; $quantity = $cartItem; $type = 'wallpaper'; $printStockId = null; $isSample = false; } $product = Product::find($productId); if (!$product) { return redirect()->route('cart')->with('error', 'Product not found!'); } if ($product->stock < $quantity) { return redirect()->route('cart')->with('error', "Insufficient stock for {$product->name}"); } // For samples, use fixed sample cost if ($isSample) { $subtotal = ShippingService::getSampleCost() * $quantity; $stockCost = ShippingService::getSampleCost(); } else { $stockCost = 0; $stock = null; // Get print stock if available if ($printStockId) { $stock = $product->printStocks()->find($printStockId); if ($stock) { $stockCost = ($type === 'wallpaper') ? $stock->cost_per_meter : $stock->cost_per_m2; } } // Calculate price based on stock cost only (no base design cost) $basePrice = $stockCost; if ($type === 'wallpaper') { $length = is_array($cartItem) ? ($cartItem['length'] ?? 0) : 0; $subtotal = $basePrice * $length * $quantity; } elseif ($type === 'mural') { $width = is_array($cartItem) ? ($cartItem['width'] ?? 0) : 0; $height = is_array($cartItem) ? ($cartItem['height'] ?? 0) : 0; $m2 = $width * $height; $subtotal = $basePrice * $m2 * $quantity; } else { $subtotal = $basePrice * $quantity; } } $total += $subtotal; $orderItems[$itemKey] = [ 'product_id' => $productId, 'quantity' => $quantity, 'price' => $subtotal / $quantity, 'print_stock_id' => $printStockId, 'type' => $type, 'is_sample' => $isSample, 'length' => is_array($cartItem) ? ($cartItem['length'] ?? null) : null, 'width' => is_array($cartItem) ? ($cartItem['width'] ?? null) : null, 'height' => is_array($cartItem) ? ($cartItem['height'] ?? null) : null, 'subtotal' => $subtotal ]; } // Calculate shipping $shippingFee = ShippingService::calculateShippingFee($total); // Create order $orderNumber = 'ORD-' . date('Ymd') . '-' . strtoupper(uniqid()); $orderData = [ 'user_id' => auth()->check() ? auth()->id() : null, 'order_number' => $orderNumber, 'total' => $total, 'shipping_fee' => $shippingFee, 'status' => 'pending', 'payment_method' => 'yoco', 'payment_status' => 'pending', 'customer_name' => $request->input('customer_name'), 'customer_email' => $request->input('customer_email'), 'customer_phone' => $request->input('customer_phone'), 'shipping_address' => $request->input('shipping_address'), ]; if ($request->filled('notes')) { $orderData['notes'] = $request->input('notes'); } $order = Order::create($orderData); // Create order items foreach ($orderItems as $itemKey => $data) { $product = Product::find($data['product_id']); OrderItem::create([ 'order_id' => $order->uuid, 'product_id' => $data['product_id'], 'quantity' => $data['quantity'], 'price' => $data['price'], 'print_stock_id' => $data['print_stock_id'], 'type' => $data['type'], 'is_sample' => $data['is_sample'], 'length' => $data['length'], 'width' => $data['width'], 'height' => $data['height'] ]); } // Store order UUID in session for payment session(['pending_order_uuid' => $order->uuid]); // Redirect to Yoco payment return redirect()->route('yoco-payment', ['order' => $order->uuid]); } public function success(Order $order) { // Authorization: only allow viewing own orders or admin if (auth()->check() && auth()->user()->id !== $order->user_id && !auth()->user()->is_admin) { abort(403, 'Unauthorized access to this order.'); } // For guest orders, verify via session if (!auth()->check() && session('pending_order_uuid') !== $order->uuid) { abort(403, 'Unauthorized access to this order.'); } return view('order-success', ['order' => $order]); } public function history() { $orders = Order::orderBy('created_at', 'desc')->get(); return view('order-history', ['orders' => $orders]); } public function yocoPayment(Order $order) { // Verify this is a pending payment if ($order->payment_status !== 'pending') { abort(400, 'This order has already been paid.'); } // Create Yoco checkout $secretKey = config('services.yoco.secret_key'); $mode = config('services.yoco.mode'); // Check if API key is configured if (empty($secretKey) || $secretKey === 'sk_test_your_key_here') { return redirect()->route('checkout')->with('error', 'Payment gateway not configured. Please contact support.'); } $baseUrl = $mode === 'live' ? 'https://payments.yoco.com/api/checkouts' : 'https://payments.yoco.com/api/checkouts'; $checkoutData = [ 'amount' => (int)(($order->total + $order->shipping_fee) * 100), // Amount in cents, including shipping 'currency' => 'ZAR', 'successUrl' => route('yoco-success', ['order' => $order->uuid]), 'cancelUrl' => route('yoco-cancel', ['order' => $order->uuid]), 'failureUrl' => route('yoco-failure', ['order' => $order->uuid]), 'metadata' => [ 'order_uuid' => $order->uuid, 'order_number' => $order->order_number, 'order_type' => 'standard', 'site' => 'additional_design', ], ]; try { $response = \Illuminate\Support\Facades\Http::withHeaders([ 'Authorization' => 'Bearer ' . $secretKey, 'Content-Type' => 'application/json', ])->post($baseUrl, $checkoutData); if ($response->successful()) { $checkout = $response->json(); $checkoutId = $checkout['id'] ?? null; $redirectUrl = $checkout['redirectUrl'] ?? null; \Log::info('Yoco checkout created', [ 'order_uuid' => $order->uuid, 'checkout_id' => $checkoutId, 'redirect_url' => $redirectUrl, ]); if (!$checkoutId || !$redirectUrl) { throw new \Exception('Invalid Yoco checkout response: missing id or redirectUrl'); } // Persist checkout info to database try { $order->update([ 'yoco_checkout_id' => $checkoutId, 'yoco_redirect_url' => $redirectUrl, 'yoco_checkout_response' => json_encode($checkout), ]); // Reload the model to verify update $order->refresh(); if ($order->yoco_checkout_id) { //clear cart session()->forget('cart'); \Log::info('Yoco checkout info saved successfully', [ 'order_uuid' => $order->uuid, 'yoco_checkout_id' => $order->yoco_checkout_id, ]); } else { \Log::warning('Yoco checkout ID not saved after update', [ 'order_uuid' => $order->uuid, 'order_data' => $order->toArray(), ]); } } catch (\Exception $dbException) { \Log::error('Database error while saving Yoco checkout info', [ 'order_uuid' => $order->uuid, 'error_message' => $dbException->getMessage(), 'error_code' => $dbException->getCode(), 'checkout_id' => $checkoutId, 'redirect_url' => $redirectUrl, ]); throw $dbException; } return redirect($redirectUrl); } else { \Log::error('Yoco API Error', [ 'status' => $response->status(), 'body' => $response->body() ]); return redirect()->route('checkout')->with('error', 'Unable to initialize payment: ' . $response->body()); } } catch (\Exception $e) { \Log::error('Yoco Payment Exception', ['message' => $e->getMessage()]); return redirect()->route('checkout')->with('error', 'Payment error: ' . $e->getMessage()); } } public function yocoSuccess(Order $order) { // Verify order is pending payment if ($order->payment_status === 'paid') { return redirect()->route('order-success', ['order' => $order]) ->with('success', 'Payment was already processed for this order.'); } // // Update order status // $order->update([ // 'payment_status' => 'paid', // 'status' => 'processing', // ]); // // Reduce stock // foreach ($order->items as $item) { // $product = $item->product; // $product->stock -= $item->quantity; // $product->save(); // } // Clear pending order from session session()->forget('pending_order_uuid'); session()->forget('cart'); return redirect()->route('order-success', ['order' => $order]) ->with('success', 'Payment successful! Your order has been confirmed.'); } public function yocoCancel(Order $order) { return redirect()->route('checkout') ->with('error', 'Payment was cancelled. Your order is still pending.'); } public function yocoFailure(Order $order) { $order->update([ 'payment_status' => 'failed', ]); return redirect()->route('checkout') ->with('error', 'Payment failed. Please try again or use a different payment method.'); } public function yocoWebhook(Request $request) { // 1. Get Raw Body and Headers \Log::info('Yoco Webhook: Received webhook'); $rawBody = $request->getContent(); $trimmedBody = trim($rawBody); $webhookId = $_SERVER['HTTP_WEBHOOK_ID'] ?? null; $webhookTimestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? null; $webhookSignatureHeader = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? null; // Validate headers exist if (!$webhookId || !$webhookTimestamp || !$webhookSignatureHeader) { \Log::warning('Yoco Webhook: Missing headers', [ 'has_id' => !empty($webhookId), 'has_timestamp' => !empty($webhookTimestamp), 'has_signature' => !empty($webhookSignatureHeader), ]); return response()->json(['error' => 'Missing headers'], 400); } // 2. Parse the Secret Safely $envSecret = trim(config('services.yoco.webhook_secret')); if (empty($envSecret)) { \Log::error('Yoco Webhook: Missing webhook secret configuration'); return response()->json(['error' => 'Webhook secret not configured'], 500); } $secretKeyString = strpos($envSecret, 'whsec_') === 0 ? substr($envSecret, 6) : $envSecret; $secretBytes = base64_decode($secretKeyString); // 3. Parse Incoming Signature (Standardize) $incomingSignature = ''; if (preg_match('/(?:v1=|v1,)([^,\s]+)/', $webhookSignatureHeader, $matches)) { $incomingSignature = $matches[1]; } else { $incomingSignature = trim($webhookSignatureHeader); } // 4. Verification Function $verifySignature = function($id, $timestamp, $body, $secretBytes, $expectedSig) { $signedContent = $id . '.' . $timestamp . '.' . $body; $calculatedHmac = hash_hmac('sha256', $signedContent, $secretBytes, true); $calculatedSig = base64_encode($calculatedHmac); return hash_equals($expectedSig, $calculatedSig); }; // 5. Try Verification (Attempt both Trimmed and Raw) $isValid = false; $methodUsed = ''; // Attempt 1: Trimmed Body (Most likely correct for JSON) if ($verifySignature($webhookId, $webhookTimestamp, trim($rawBody), $secretBytes, $incomingSignature)) { $isValid = true; $methodUsed = 'trimmed'; } // Attempt 2: Raw Body (Fallback if Yoco signed the whitespace) elseif ($verifySignature($webhookId, $webhookTimestamp, $rawBody, $secretBytes, $incomingSignature)) { $isValid = true; $methodUsed = 'raw'; } if (!$isValid) { \Log::warning('Yoco Webhook: Signature verification failed', [ 'webhook_id' => $webhookId, 'timestamp' => $webhookTimestamp, 'received_signature' => $incomingSignature, ]); return response()->json(['error' => 'Invalid signature'], 403); } \Log::info('Yoco Webhook: Signature verified successfully', ['method' => $methodUsed]); // 6. Parse Event $event = json_decode($rawBody, true); if (!$event) { \Log::warning('Yoco Webhook: Failed to parse JSON payload'); return response()->json(['error' => 'Invalid JSON'], 400); } $type = $event['type'] ?? null; $payload = $event['payload'] ?? []; $metadata = $payload['metadata'] ?? []; $orderUuid = $metadata['order_uuid'] ?? null; $orderType = $metadata['order_type'] ?? null; $site = $metadata['site'] ?? null; $paymentId = $payload['id'] ?? null; $status = $payload['status'] ?? null; \Log::info('Yoco Webhook: Payload extracted', [ 'type' => $type, 'site' => $site, 'order_type' => $orderType, 'order_uuid' => $orderUuid, 'payment_id' => $paymentId, 'status' => $status, ]); if ($site !== 'additional_design') { \Log::warning('Yoco Webhook: Ignored event for different site', [ 'expected_site' => 'additional_design', 'received_site' => $site, ]); return response()->json(['status' => 'ignored'], 200); } if (!$orderUuid || !$status) { \Log::warning('Yoco Webhook: Validation failed', [ 'order_uuid' => $orderUuid, 'status' => $status, 'type' => $type, ]); return response()->json(['error' => 'Invalid payload'], 400); } // 7. Update Payment State where orderType is 'standard' if ($orderType === 'standard') { $order = Order::where('uuid', $orderUuid)->first(); if (!$order) { \Log::warning('Yoco Webhook: Order not found', ['order_uuid' => $orderUuid]); return response()->json(['error' => 'Order not found'], 404); } if ($status === 'succeeded') { \Log::info('Yoco Webhook: Processing successful payment', [ 'order_uuid' => $orderUuid, 'payment_id' => $paymentId, ]); if ($order->payment_status !== 'paid') { $order->update([ 'payment_status' => 'paid', 'status' => 'prep', 'yoco_checkout_response' => json_encode($payload), ]); // Reduce stock foreach ($order->items as $item) { $product = $item->product; $product->stock -= $item->quantity; $product->save(); } // Generate invoice PDF and send email via Mailjet try { $invoicePath = InvoiceService::generateInvoice($order); $fullPath = storage_path('app/public/' . $invoicePath); // Send invoice email directly via Mailjet API (no queue needed) $mailjetService = new MailjetService(); $customerName = $order->customer_name ?? explode('@', $order->customer_email)[0]; $success = $mailjetService->send( toEmail: $order->customer_email, toName: $customerName, subject: 'Invoice #' . $order->order_number . ' - ADDITIONAL DESIGN', htmlContent: $this->getBasicInvoiceHtml($order), attachments: [$fullPath] ); if ($success) { \Log::info('Yoco Webhook: Invoice generated and email sent via Mailjet', [ 'order_uuid' => $orderUuid, 'order_id' => $order->id, 'invoice_path' => $invoicePath, ]); } else { \Log::warning('Yoco Webhook: Mailjet email send returned false', [ 'order_uuid' => $orderUuid, 'order_id' => $order->id, ]); } } catch (\Exception $e) { \Log::error('Yoco Webhook: Invoice generation or email failed', [ 'order_uuid' => $orderUuid, 'order_id' => $order->id, 'error' => $e->getMessage(), ]); } // Emit OrderCreated and DepositPaid events (standard orders are fully paid upfront) OrderCreated::dispatch($order, 'standard'); DepositPaid::dispatch($order, $order->total); // Full payment is treated as deposit confirmation \Log::info('Yoco Webhook: Order updated for successful payment', [ 'order_uuid' => $orderUuid, 'order_id' => $order->id, ]); } } elseif ($status === 'failed' || $status === 'cancelled') { \Log::info('Yoco Webhook: Processing failed/cancelled payment', [ 'order_uuid' => $orderUuid, 'payment_id' => $paymentId, 'status' => $status, ]); $order->update([ 'payment_status' => 'failed', ]); } } elseif ($orderType === 'custom_deposit' || $orderType === 'custom_balance') { $order = CustomOrder::where('uuid', $orderUuid)->first(); if (!$order) { \Log::warning('Yoco Webhook: Order not found', ['order_uuid' => $orderUuid]); return response()->json(['error' => 'Order not found'], 404); } if ($status === 'succeeded') { \Log::info('Yoco Webhook: Processing successful payment', [ 'order_uuid' => $orderUuid, 'payment_id' => $paymentId, 'order_type' => $orderType, ]); if ($orderType === 'custom_deposit' && $order->deposit_status !== 'paid') { $order->update([ 'deposit_status' => 'paid', 'status' => 'design', 'yoco_checkout_response' => json_encode($payload), ]); // Emit events for custom order deposit OrderCreated::dispatch($order, 'custom'); DepositPaid::dispatch($order, $order->deposit_amount); \Log::info('Yoco Webhook: Order updated for successful deposit payment', [ 'order_uuid' => $orderUuid, 'order_id' => $order->id, 'order_type' => $orderType, ]); } elseif ($orderType === 'custom_balance' && $order->balance_status !== 'paid') { $order->update([ 'balance_status' => 'paid', 'status' => 'printing', 'yoco_checkout_response' => json_encode($payload), ]); // Emit BalancePaid event BalancePaid::dispatch($order, $order->balance_amount); \Log::info('Yoco Webhook: Order updated for successful balance payment', [ 'order_uuid' => $orderUuid, 'order_id' => $order->id, 'order_type' => $orderType, ]); } } elseif ($status === 'failed' || $status === 'cancelled') { \Log::info('Yoco Webhook: Processing failed/cancelled payment', [ 'order_uuid' => $orderUuid, 'payment_id' => $paymentId, 'status' => $status, ]); if ($orderType === 'custom_deposit') { $order->update(['deposit_status' => 'failed']); } elseif ($orderType === 'custom_balance') { $order->update(['balance_status' => 'failed']); } } } \Log::info('Yoco Webhook: Processed successfully', [ 'order_uuid' => $orderUuid, 'status' => $status, ]); return response()->json(['status' => 'success']); } public function trackForm() { return view('track-order'); } public function trackSearch(Request $request) { $key = 'track-order:' . $request->ip(); // Check if IP has already exceeded rate limit from previous failed attempts if (\Illuminate\Support\Facades\RateLimiter::tooManyAttempts($key, 5)) { return redirect()->route('track-order-form') ->withErrors(['error' => 'Too many search attempts. Please try again later.']); } $request->validate([ 'order_number' => 'required|string', 'email' => 'required|email', ]); $order = Order::where('order_number', strtoupper($request->input('order_number'))) ->where('customer_email', strtolower($request->input('email'))) ->first(); if (!$order) { // Only increment rate limit on failed searches \Illuminate\Support\Facades\RateLimiter::hit($key, 600); // 10 minutes return redirect()->route('track-order-form') ->withErrors(['error' => 'No order found with the provided information.']); } // Successful search - no rate limit increment return view('track-order-result', ['order' => $order]); } private function getBasicInvoiceHtml(Order $order): string { return <<

Invoice #{$order->order_number}

Dear {$order->customer_name},

Please find your invoice attached to this email.

Order Details

Order Number {$order->order_number}
Order Date {$order->created_at->format('d M Y')}
Total Amount R {$order->total}

Thank you for your order!

HTML; } }