Files
Additional/app/Http/Controllers/OrderController.php
T
twotalesanimation e8e9b1f03c relocating
2025-12-30 20:59:58 +02:00

720 lines
28 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\CustomOrder;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\PrintStock;
use App\Services\ShippingService;
use App\Services\InvoiceService;
use App\Mail\InvoiceEmail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function checkout()
{
$cart = session()->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' => 'processing',
'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
try {
$invoicePath = InvoiceService::generateInvoice($order);
// Send invoice email via Mailjet
Mail::to($order->customer_email)
->send(new InvoiceEmail($order, $invoicePath));
\Log::info('Yoco Webhook: Invoice generated and email sent', [
'order_uuid' => $orderUuid,
'order_id' => $order->id,
'invoice_path' => $invoicePath,
]);
} catch (\Exception $e) {
\Log::error('Yoco Webhook: Invoice generation or email failed', [
'order_uuid' => $orderUuid,
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
}
\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' => 'submitted',
'yoco_checkout_response' => json_encode($payload),
]);
\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' => 'in production',
'yoco_checkout_response' => json_encode($payload),
]);
\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']);
}
/**
* Show the track order search form
*/
public function trackForm()
{
return view('track-order');
}
/**
* Search for an order by order number and email
*/
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]);
}
}