2a10f9af38
**Shiplogic API Integration:**
- Fixed API base URL configuration (removed /api suffix)
- Implemented comprehensive request/response logging for rates and shipments endpoints
- Fixed PDF fetching: API returns S3 URLs, now downloads actual PDFs from S3
- Added tests and mock API responses for local development (routes/shiplogic-mock.php)
**Courier Service Enhancements:**
- Added redownloadShipmentPdfs() public method for re-downloading corrupted PDFs
- Enhanced error logging with full request/response bodies for debugging
- Proper binary PDF storage using Laravel Storage facade
- URL and S3 download handling for Shiplogic API responses
**Workflow & Operations:**
- Changed to manual "Ready for Collection" button instead of automatic move
- Operators now: scan QR → apply labels → click "Ready for Collection" → moves to Awaiting Collection
- Removed duplicate PDF attachments to Trello (was adding twice from two listeners)
- Fixed NotifySlackOnShipmentCreated to only handle Slack notifications
**Mobile-Optimized Ops Page:**
- Removed QR code display from order detail page
- Implemented responsive single-column layout for mobile phones
- Large touch-friendly buttons (full width, increased padding)
- Bold typography for better readability on small screens
- Larger input fields and tracking number displays
- Clear step-by-step instructions for warehouse operators
- Re-download PDF button for damaged/corrupted labels
**New Features:**
- POST /ops/orders/{uuid}/ready-for-collection endpoint
- Re-download PDFs functionality accessible from awaiting_collection and in_transit states
- Full audit logging for all operations via ops interface
- Proper error handling and user feedback
**Testing:**
- Added ShipmentCreationTest with mock HTTP client
- Created comprehensive testing guide (SHIPLOGIC_TESTING.md)
- Mock API routes for local development without hitting live API
807 lines
33 KiB
PHP
807 lines
33 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\Services\MailjetService;
|
|
use App\Events\OrderCreated;
|
|
use App\Events\DepositPaid;
|
|
use App\Events\BalancePaid;
|
|
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_street_address' => 'required|string|max:255',
|
|
'shipping_unit_number' => 'nullable|string|max:255',
|
|
'shipping_local_area' => 'required|string|max:255',
|
|
'shipping_city' => 'required|string|max:255',
|
|
'shipping_zone' => 'required|string|max:255',
|
|
'shipping_postcode' => 'required|string|max:20',
|
|
'shipping_country' => 'required|string|max:255',
|
|
'shipping_type' => 'required|in:residential,business',
|
|
'business_name' => 'nullable|string|max:255',
|
|
'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 + $shippingFee,
|
|
'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_street_address' => $request->input('shipping_street_address'),
|
|
'shipping_unit_number' => $request->input('shipping_unit_number'),
|
|
'shipping_local_area' => $request->input('shipping_local_area'),
|
|
'shipping_city' => $request->input('shipping_city'),
|
|
'shipping_zone' => $request->input('shipping_zone'),
|
|
'shipping_postcode' => $request->input('shipping_postcode'),
|
|
'shipping_country' => $request->input('shipping_country'),
|
|
'shipping_type' => $request->input('shipping_type'),
|
|
'business_name' => $request->input('business_name'),
|
|
];
|
|
|
|
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 * 100), // Amount in cents (total already includes 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 <<<HTML
|
|
<html>
|
|
<head>
|
|
<style>
|
|
body { font-family: Arial, sans-serif; color: #333; }
|
|
.header { background-color: #f5f5f5; padding: 20px; text-align: center; }
|
|
.content { padding: 20px; }
|
|
.footer { background-color: #f5f5f5; padding: 20px; text-align: center; font-size: 12px; }
|
|
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
th { background-color: #f5f5f5; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>Invoice #{$order->order_number}</h1>
|
|
</div>
|
|
<div class="content">
|
|
<p>Dear {$order->customer_name},</p>
|
|
<p>Please find your invoice attached to this email.</p>
|
|
<h3>Order Details</h3>
|
|
<table>
|
|
<tr>
|
|
<th>Order Number</th>
|
|
<td>{$order->order_number}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Order Date</th>
|
|
<td>{$order->created_at->format('d M Y')}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Total Amount</th>
|
|
<td>R {$order->total}</td>
|
|
</tr>
|
|
</table>
|
|
<p>Thank you for your order!</p>
|
|
</div>
|
|
<div class="footer">
|
|
<p>© 2025 ADDITIONAL DESIGN. All rights reserved.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
HTML;
|
|
}
|
|
}
|
|
|