get('/orders/reference-suggestions', function () { global $response; $auth = new authentication(); $user = $auth->get_user(); if ($user === false) { (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'No user found, or invalid session'); $response->error('Invalid session', 400); } $this->requirePermission('list_orders'); self::requireParameters(['department_id']); $departmentId = (int)self::getParameter('department_id'); self::requireParameterIntPositive($departmentId, 'department_id'); self::requireDepartmentAccess((string)$departmentId); $search = trim((string)(self::getParameter('search') ?? '')); if (strlen($search) > 255) { $response->error('Parameter search must be at most 255 characters long', 400); } foreach (['reg_1', 'reg_2', 'reg_3'] as $plateParameter) { $plateValue = (string)(self::getParameter($plateParameter) ?? ''); if (strlen($plateValue) > 32) { $response->error('Parameter ' . $plateParameter . ' must be at most 32 characters long', 400); } } $suggestions = (new order_reference_suggestions_service())->suggest([ 'search' => $search, 'department_id' => $departmentId, 'customer_id' => self::getParameter('customer_id') ?? null, 'reg_1' => self::getParameter('reg_1') ?? '', 'reg_2' => self::getParameter('reg_2') ?? '', 'reg_3' => self::getParameter('reg_3') ?? '', 'limit' => self::getParameter('limit') ?? null, ]); (new logs_o())->add('orders', (string)$departmentId, 1, (int)$user->id, 'LIST_ORDER_REFERENCE_SUGGESTIONS', 'Successfully listed POS reference suggestions'); $response->success($suggestions); }, [ 'list_orders' => 'List POS order reference suggestions', 'department_access_:id' => 'Access to the department used for reference suggestions', ] ); $this->get('/orders', function () { // Require the user to be logged in global $response; /** Authentication */ $auth = new authentication(); $user = $auth->get_user(); if ($user === false) { (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDERS', 'No user found, or invalid session'); $response->error('Invalid session', 400); } /** Permissions (subuser-aware) */ $permission_own = self::definePermission('list_own_orders', subusers_permission_node_key::ORDERS_LIST); $permission_other = self::definePermission('list_orders'); $has_permission_other = self::hasPermission($permission_other); $targetCustomerNumber = self::resolveEffectiveCustomerNumber(); self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, $targetCustomerNumber, null, null, 'You do not have permission to list orders.' ); // Log the incident (new logs_o())->add('orders', 'global', 1, $user->id, 'LIST_ORDERS', 'Successfully listed orders'); // Build department filter when listing as department/admin $department_ids = []; if ($has_permission_other) { $department_ids = $this->effectiveDepartmentIds($user); } if (self::isParametersSet(['show_wash_subscription'])) { if (self::getParameter('show_wash_subscription') === 'true') { $managedScope = $this->limitedBackofficeDepartmentScope($user); if ($managedScope === null || in_array(10, $department_ids, true)) { $department_ids[] = 10; } } } $orders = new orders_o(); $orders->setView('orders_with_invoice_collections'); $effectiveCustomer = self::resolveEffectiveCustomerNumber(); $forcedFilters = $orders->forceRestrictFilters([ ...($has_permission_other ? [ 'department_id' => $department_ids ] : []), ...(!$has_permission_other && $effectiveCustomer !== null ? [ 'customer_id' => $effectiveCustomer ] : []), ]); $rawOrders = $orders->listObjectsWithPaginationIfSet( null, $forcedFilters ); $response->success( $this->enrichOrderListRows($rawOrders) ); }, [ 'list_orders' => 'List all orders', 'list_own_orders' => 'List own orders. Subusers require node: ORDERS_LIST and X-Customer-Number header.', ] ); $this->post('/orders', function () { // Require the user to be logged in global $response; $this->requirePermission('add_order'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the post data $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } // Check if the required fields are set $data = $this->getData($data, $response); // This is used to determine if the order is created from a handheld device, // If it is, we will add an indicator "pending" to the order, in the cache. // This will automatically be removed at midnight, or when the order is completed. if (isset($data['is_handheld']) && !is_bool($data['is_handheld'])) { $response->error('is_handheld must be a boolean', 400); } if (isset($data['is_handheld'])) { $isHandHeld = (bool)$data['is_handheld']; unset($data['is_handheld']); } else { $isHandHeld = false; } // Validate the department if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { $response->error('Department not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((string)(int)$data['department_id']); // Make sure the customer number set is valid $targetUser = (new users_o())->getUserByCustomerNumber((int)$data['customer_id']); if (!$targetUser->exists()) { $response->error('Customer not found', 400); } // Check if the user requires a reference if ($targetUser->requiresReference() && empty($data['reference'])) { $response->error('Reference is required by the customer', 400); } try { $reg_1 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_1']); $reg_2 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_2'] ?? ''); $reg_3 = orders_input_normalizer::normalizeRegistrationNumber($data['reg_3'] ?? ''); $createdAt = orders_input_normalizer::normalizeCreatedAt($data['created_at'] ?? date('Y-m-d H:i:s')); $includeInInvoice = array_key_exists('include_in_invoice', $data) ? orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice']) : null; } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } $bookingId = !empty($data['booking_id']) ? (int)$data['booking_id'] : null; $po = $this->resolveOrderPoForBookingDefault( array_key_exists('po', $data) ? $data['po'] : null, array_key_exists('po', $data), $bookingId, (int)$data['customer_id'], (int)$data['department_id'] ); $new_data = [ 'customer_id' => (int)$data['customer_id'], 'department_id' => (int)$data['department_id'], 'reference' => (string)$data['reference'] ?? '', 'cashier_id' => (int)$user->id, // The user who created the order 'notes' => (string)$data['notes'] ?? '', ...($po !== null ? ['po' => $po] : []), 'reg_1' => (string)$reg_1, 'reg_2' => (string)$reg_2, 'reg_3' => (string)$reg_3, ...(!empty($data['lane']) ? ['lane' => (int)$data['lane']] : []), // Optional lane ...(!empty($data['wash_id']) ? ['wash_id' => (string)$data['wash_id']] : []), // Optional wash ID ...($bookingId !== null ? ['booking_id' => $bookingId] : []), // Optional booking ID 'created_at' => $createdAt, // Default to current time if not set ...(array_key_exists('include_in_invoice', $data) ? ['include_in_invoice' => $includeInInvoice] : []), ...(array_key_exists('safety_seal', $data) ? ['safety_seal' => orders_o::normalizeSafetySealValue($data['safety_seal'])] : []), ]; // Create the order //$order = (new orders_o())->add((int)$data['customer_id'], $user->id, $data['reference'], $data['notes'], (int)$data['department_id'], (string)$reg_1, (string)$reg_2, (string)$reg_3); $order = (new orders_o())->addArray($new_data); // If the order is created from a handheld device, we will add an indicator "pending" to the order, in the cache. if ($isHandHeld) { $order->setPendingHandheldIndicator(); } // Log the incident (new logs_o())->add('orders', $data['department_id'], 1, $user->id, 'ADD_ORDER', 'Successfully added an order (ID: ' . $data['department_id'] . ')'); // Return a success message, containing the orders array $response->success($order->asArray()); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'ADD_ORDER', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'add_order' => 'Add an order' ] ); $this->put('/order', function () { self::updateOrder(); }, [ 'edit_order' => 'Edit an order' ] ); $this->put('/orders', function () { self::updateOrder(); }, [ 'edit_order' => 'Edit an order' ] ); $this->delete('/orders', function () { // Require the user to be logged in global $response; $this->requirePermission('delete_order'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the payload $id = $this->fromRequest('id'); // Check if the ID is set if (!$id) { $response->error('ID is required', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$id); // Check if the order exists if (!isset($order->id) || (int)$order->id < 1 || !$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((int)$order->department_id->value()); $orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id); $confirmed = filter_var($this->fromRequest('confirmed'), FILTER_VALIDATE_BOOLEAN) === true; $deleteProtection = $order->getDeleteProtectionSummary(); if ($deleteProtection['requires_confirmation'] && !$confirmed) { (new logs_o())->add( 'orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER_CONFIRMATION_REQUIRED', 'Order deletion requires confirmation (ID: ' . $id . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) . '; order_items: ' . $deleteProtection['order_item_count'] . '; attachments: ' . $deleteProtection['attachment_count'] . ')' ); $response->error([ 'message' => 'Order deletion requires confirmation', ...$deleteProtection, ], 409); } // Delete the order $order->delete(); // Log the incident if ($deleteProtection['requires_confirmation']) { (new logs_o())->add( 'orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER_CONFIRMED', 'Successfully deleted a protected order after confirmation (ID: ' . $id . '; reasons: ' . implode(',', $deleteProtection['protected_reasons']) . '; order_items: ' . $deleteProtection['order_item_count'] . '; attachments: ' . $deleteProtection['attachment_count'] . ')' ); } else { (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER', 'Successfully deleted an order (ID: ' . $id . ')'); } // Return a success message $response->success(['message' => 'Order deleted successfully']); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'DELETE_ORDER', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'delete_order' => 'Delete an order' ] ); $this->get('/orders/attachments/download', function () { global $response; $context = $this->requireOrderAttachmentDownloadContext(); $downloadLink = $context['store'] instanceof pdf_store ? $context['store']->getPresignedUrl($context['object_key']) : $context['store']->generateDirectDownloadUrl($context['object_key']); $this->logOrderAttachmentDownload($context, 'DOWNLOAD_ORDER_ATTACHMENT'); $response->success(['download_link' => $downloadLink]); }, [ 'download_order_attachments' => 'Download attachments for an order', 'download_order_attachments_own' => 'Download attachments for an order (Only for own orders). Subusers require node: ORDERS_LIST and X-Customer-Number header.' ] ); $this->get('/orders/attachments/content', function () { global $response; $context = $this->requireOrderAttachmentDownloadContext(); $disposition = strtolower(trim((string)(self::getParameter('disposition') ?? 'inline'))); if (!in_array($disposition, ['inline', 'attachment'], true)) { $response->error('disposition must be either inline or attachment', 400); } $temporaryPath = null; try { $temporaryPath = $context['store']->downloadToTemporaryFile($context['object_key']); $mimeType = $this->detectAttachmentMimeType($temporaryPath); $fileName = $this->sanitizeAttachmentDownloadFileName( $context['file_name'], $context['object_key'] ); $fileSize = filesize($temporaryPath); if ($fileSize === false) { throw new \RuntimeException('Unable to determine attachment size'); } $this->logOrderAttachmentDownload($context, 'STREAM_ORDER_ATTACHMENT'); header('Content-Type: ' . $mimeType); header('Content-Disposition: ' . $disposition . '; filename="' . addcslashes($fileName, "\\\"") . '"; filename*=UTF-8\'\'' . rawurlencode($fileName)); header('Content-Length: ' . $fileSize); header('Cache-Control: private, no-store'); header('X-Content-Type-Options: nosniff'); http_response_code(200); readfile($temporaryPath); } finally { if (is_string($temporaryPath) && file_exists($temporaryPath)) { unlink($temporaryPath); } } exit; }, [ 'download_order_attachments' => 'Stream attachments for an order', 'download_order_attachments_own' => 'Stream attachments for an order (Only for own orders). Subusers require node: ORDERS_LIST and X-Customer-Number header.' ] ); $this->get('/orders/attachments', function () { // Require the user to be logged in global $response; // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the order ID from the request self::requireParameters([ 'id' ]); $order_id = self::getParameter('id'); if (!is_numeric($order_id) || (int)$order_id < 1) { $response->error('Invalid order ID', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$order_id); // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 400); } $this->requireLimitedBackofficeDepartmentAccess( $user, (int)$order->department_id->value() ); // Permissions (subuser-aware) $permission_own = self::definePermission('list_own_order_attachments', subusers_permission_node_key::ORDERS_LIST); $permission_other = self::definePermission('list_order_attachments'); $has_permission_other = self::hasPermission($permission_other); if (!$has_permission_other) { self::requirePermission($permission_own); $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { $response->forbidden([$permission_other->permission]); } } // Get the attachments $attachments = $order->listAttachments(); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'LIST_ORDER_ATTACHMENTS', 'Successfully listed attachments for an order (ID: ' . $order_id . ')'); // Return the attachments $response->success($attachments); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'LIST_ORDER_ATTACHMENTS', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'list_order_attachments' => 'List attachments for an order', 'list_own_order_attachments' => 'List attachments for an order (Only for own orders). Subusers require node: ORDERS_LIST and X-Customer-Number header.' ] ); $this->post('/orders/attachments/upload', function () { // Require the user to be logged in global $response; $this->requirePermission('add_order_attachments'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Check if the order ID and file are set $this->requireParameters(['order_id', 'base64_file', 'file_name']); $order_id = (int)$this->getParameter('order_id'); if (!is_numeric($order_id) || (int)$order_id < 1) { $response->error('Invalid order ID', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$order_id); // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((string)(int)$order->department_id->value()); // Get the base64 file $base64_file = (string)$this->getParameter('base64_file'); $attachment_store = new attachment_store(); // Determine the file extension from the file name $file_name = (string)$this->getParameter('file_name'); $extension = pathinfo($file_name, PATHINFO_EXTENSION); $object_name = $attachment_store->storeTempFileFromBase64( $base64_file, $extension ); if ($object_name === false) { $response->error('Failed to store the attachment file.', 500); } $object_attachment = $order->addAttachment((new attachment_content())->setDocument($object_name)->setOther((string)self::getParameter('file_name'))); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'ADD_ORDER_ATTACHMENT', 'Successfully added an attachment for an order (Order ID: ' . $order_id . ')'); // Return a success message $attachments = new attachments(); $response->success($attachments->format($attachments->get($object_attachment->id))); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'ADD_ORDER_ATTACHMENT', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'add_order_attachments' => 'Add attachments for an order' ] ); $this->delete('/orders/attachments', function () { // Require the user to be logged in global $response; $this->requirePermission('delete_order_attachments'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the order ID and attachment ID from the request self::requireParameters([ 'order_id', 'attachment_id' ]); $order_id = self::getParameter('order_id'); $attachment_id = self::getParameter('attachment_id'); if (!is_numeric($order_id) || (int)$order_id < 1) { $response->error('Invalid order ID', 400); } if (!is_numeric($attachment_id) || (int)$attachment_id < 1) { $response->error('Invalid attachment ID', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$order_id); // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((string)(int)$order->department_id->value()); // Delete the attachment $order->removeAttachment((int)$attachment_id); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_ORDER_ATTACHMENT', 'Successfully deleted an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')'); // Return a success message $response->success(['message' => 'Attachment deleted successfully']); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'DELETE_ORDER_ATTACHMENT', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'delete_order_attachments' => 'Delete attachments for an order' ] ); $this->post('/orders/mark_as_completed', function () { // Require the user to be logged in global $response; $this->requirePermission('mark_order_as_completed'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the post data $data = json_decode(file_get_contents('php://input'), true); // Check if the required fields are set if (!isset($data['id'])) { $response->error('ID is required', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$data['id']); // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((string)(int)$order->department_id->value()); // Mark the order as completed $order->markAsCompleted((string)$user->display_name->value()); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'MARK_ORDER_AS_COMPLETED', 'Successfully marked an order as completed (ID: ' . $data['id'] . ')'); // Return a success message $response->success(['message' => 'Order marked as completed successfully']); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'MARK_ORDER_AS_COMPLETED', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'mark_order_as_completed' => 'Mark an order as completed' ] ); $this->post('/orders/module/stripe/payment_intent', function () { global $response; $this->requirePermission('charge_order'); $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'CHARGE_ORDER', 'No user found, or invalid session'); $response->error('Invalid session', 400); } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } if (!isset($data['id'])) { $response->error('ID is required', 400); } $order = (new orders_o())->getOrderById((int)$data['id']); if (!$order->exists()) { $response->error('Order not found', 400); } self::requireDepartmentAccess((int)$order->department_id->value()); $department = (new departments_o())->selectId((int)$order->department_id->value()); if (!$department->isStripeConfigured()) { $response->error([ 'message' => 'Card payments are not ready for this department. Open Stripe setup and choose a terminal location.', 'code' => 'stripe_terminal_setup_required', ], 409); } $readerId = trim((string)($data['reader'] ?? '')); if ($readerId === '') { $response->error('Reader ID is required', 400); } $tax_percentage = self::CARD_PAYMENT_TAX_PERCENTAGE; $stripe = new stripe(); $stripePaymentIntents = new stripe_payment_intents_o(); [$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order); $expectedPaymentIntentAmount = $this->getStripePaymentIntentAmountForOrder($order, $tax_percentage); if ($stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); try { $storedPaymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value()); $stripePaymentIntents->updateStoredPaymentIntent($storedPaymentIntent); $storedPaymentIntentStatus = strtolower((string)($storedPaymentIntent->status ?? '')); if ($storedPaymentIntentStatus === 'succeeded') { if (!$this->doesStripePaymentIntentMatchOrder( $storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage )) { $response->error([ 'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } $order = (new orders_o())->getOrderById((int)$order->id); $stripePaymentIntents->tax_percentage->set($tax_percentage); $stripePaymentIntents->objectChanged(); $this->recordSucceededStripePayment($order, $storedPaymentIntent); (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused completed Stripe payment intent for order (ID: ' . $data['id'] . ')'); $response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [ 'reused' => true, 'already_succeeded' => true, ])); } if ( $this->isStripePaymentIntentReusable($storedPaymentIntent) && $this->doesStripePaymentIntentMatchOrder($storedPaymentIntent, $expectedPaymentIntentAmount, $tax_percentage) ) { $stripePaymentIntents->tax_percentage->set($tax_percentage); $stripePaymentIntents->objectChanged(); $stripePaymentIntents->setReaderId($readerId); if ($storedPaymentIntentStatus === 'requires_capture') { $storedPaymentIntent = $this->captureApprovedStripePaymentIntent( $order, $stripePaymentIntents, $stripe, $storedPaymentIntent, $orderPaymentLock ); } (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Reused Stripe payment intent for order (ID: ' . $data['id'] . ')'); $response->success($this->buildStripePaymentIntentResponse($storedPaymentIntent, $stripePaymentIntents, [ 'reused' => true, ])); } if (!$stripePaymentIntents->delete()) { $response->error([ 'message' => 'The card payment completed while it was being replaced. Reconcile it before starting another payment.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } } catch (\Stripe\Exception\InvalidRequestException) { $stripePaymentIntents->deletePermanently(); } } $this->requireStripePaymentCollectionAvailable($order); $paymentIntent = $stripe->payment_intents->create( $expectedPaymentIntentAmount, [ 'description' => 'Order ID: ' . $order->id, 'metadata' => [ 'order_id' => (string)$order->id, 'customer_id' => (string)$order->customer_id->value(), 'department_id' => (string)$order->department_id->value(), 'tax_percentage' => (string)$tax_percentage, 'reader_id' => $readerId, 'reader' => $readerId, ], 'payment_method_types' => ['card_present'], 'capture_method' => 'manual', ] ); $stripePaymentIntents->add( (int)$order->id, $paymentIntent->id, $paymentIntent->client_secret, $paymentIntent->toJSON(), $readerId, $tax_percentage ); try { $stripe->readers->sendPaymentIntent($readerId, $paymentIntent->id); } catch (\Stripe\Exception\InvalidRequestException) { if (!$stripePaymentIntents->delete()) { $response->error([ 'message' => 'The card payment completed while the reader operation failed. It was not cleared; reconcile the completed payment.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } $response->error('Unable to start payment on the selected reader', 409); } try { $paymentIntent = $stripe->payment_intents->get($paymentIntent->id); $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); if (strtolower((string)($paymentIntent->status ?? '')) === 'requires_capture') { $paymentIntent = $this->captureApprovedStripePaymentIntent( $order, $stripePaymentIntents, $stripe, $paymentIntent, $orderPaymentLock ); } } catch (\Stripe\Exception\InvalidRequestException) { // Keep the created intent payload if Stripe retrieve is temporarily unavailable. } (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CHARGE_ORDER', 'Successfully charged an order (ID: ' . $data['id'] . ')'); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents, [ 'reused' => false, ])); }, [ 'charge_order' => 'Charge an order' ] ); $this->get('/orders/module/stripe/payment_intent', function () { global $response; $this->requirePermission('get_payment_intent'); $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'GET_PAYMENT_INTENT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } self::requireParameters([ 'id' ]); $id = self::getParameter('id'); if (!isset($id)) { $response->error('ID is required', 400); } $order = (new orders_o())->getOrderById((int)$id); if (!$order->exists()) { $response->error('Order not found', 400); } self::requireDepartmentAccess((int)$order->department_id->value()); [$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order); $stripePaymentIntents = new stripe_payment_intents_o(); if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { $response->success($this->buildStripePaymentIntentResponse(null, null, [ 'message' => 'No active payment intent for this order.', ])); } $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); $stripe = new stripe(); try { $paymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value()); } catch (\Stripe\Exception\InvalidRequestException) { $stripePaymentIntents->deletePermanently(); $response->success($this->buildStripePaymentIntentResponse(null, null, [ 'message' => 'No active payment intent for this order.', ])); } $status = strtolower((string)($paymentIntent->status ?? '')); if ($status === 'canceled') { $stripePaymentIntents->deletePermanently(); $response->success($this->buildStripePaymentIntentResponse(null, null, [ 'message' => 'No active payment intent for this order.', ])); } if ($status === 'requires_capture') { $paymentIntent = $this->captureApprovedStripePaymentIntent( $order, $stripePaymentIntents, $stripe, $paymentIntent, $orderPaymentLock ); } elseif ($status === 'succeeded') { $expectedAmount = $this->getStripePaymentIntentAmountForOrder( $order, self::CARD_PAYMENT_TAX_PERCENTAGE ); if (!$this->doesStripePaymentIntentMatchOrder( $paymentIntent, $expectedAmount, self::CARD_PAYMENT_TAX_PERCENTAGE )) { $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); $response->error([ 'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } $order = (new orders_o())->getOrderById((int)$order->id); $this->recordSucceededStripePayment($order, $paymentIntent); } $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); if ($status === 'succeeded') { $stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE); $stripePaymentIntents->objectChanged(); } $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); }, [ 'get_payment_intent' => 'Get a payment intent' ] ); $this->delete('/orders/module/stripe/payment_intent', function () { global $response; $this->requirePermission('charge_order'); $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'DELETE_PAYMENT_INTENT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } $id = $data['id'] ?? self::fromRequest('id'); if (!isset($id)) { $response->error('ID is required', 400); } $order = (new orders_o())->getOrderById((int)$id); if (!$order->exists()) { $response->error('Order not found', 400); } self::requireDepartmentAccess((int)$order->department_id->value()); [$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order); $stripePaymentIntents = new stripe_payment_intents_o(); if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { $response->success($this->buildStripePaymentIntentResponse(null, null, [ 'message' => 'Payment intent cleared successfully.', 'cleared' => true, ])); } $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); if (!$stripePaymentIntents->delete()) { $response->error([ 'message' => 'The card payment has already completed and was not cleared. Reconcile it before continuing.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DELETE_PAYMENT_INTENT', 'Successfully deleted a payment intent (ID: ' . $id . ')'); $response->success($this->buildStripePaymentIntentResponse(null, null, [ 'message' => 'Payment intent cleared successfully.', 'cleared' => true, ])); }, [ 'charge_order' => 'Delete a payment intent' ] ); $this->post('/orders/module/stripe/payment_intent/capture', function () { global $response; $this->requirePermission('confirm_payment_intent'); $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'CONFIRM_PAYMENT_INTENT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } if (!isset($data['id'])) { $response->error('ID is required', 400); } $order = (new orders_o())->getOrderById((int)$data['id']); if (!$order->exists()) { $response->error('Order not found', 400); } [$order, $orderPaymentLock] = $this->acquireStripePaymentLocks($order); self::requireDepartmentAccess((int)$order->department_id->value()); $stripePaymentIntents = new stripe_payment_intents_o(); if (!$stripePaymentIntents->doesOrderHavePaymentIntent((int)$order->id)) { $response->error('No active payment intent for this order.', 409); } $stripePaymentIntents->selectOrderPaymentIntent((int)$order->id); $stripe = new stripe(); try { $paymentIntent = $stripe->payment_intents->get($stripePaymentIntents->payment_intent_id->value()); } catch (\Stripe\Exception\InvalidRequestException) { $stripePaymentIntents->deletePermanently(); $response->error('Stored payment intent is stale. Start the payment again.', 409); } $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); $status = strtolower((string)($paymentIntent->status ?? '')); if ($status === 'succeeded') { $expectedAmount = $this->getStripePaymentIntentAmountForOrder( $order, self::CARD_PAYMENT_TAX_PERCENTAGE ); if (!$this->doesStripePaymentIntentMatchOrder( $paymentIntent, $expectedAmount, self::CARD_PAYMENT_TAX_PERCENTAGE )) { $response->error([ 'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } $order = (new orders_o())->getOrderById((int)$order->id); $this->recordSucceededStripePayment($order, $paymentIntent); $stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE); $stripePaymentIntents->objectChanged(); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents, [ 'already_succeeded' => true, ])); } if ($status === 'canceled') { $stripePaymentIntents->deletePermanently(); $response->error('Payment intent was cancelled. Start the payment again.', 409); } if ($status !== 'requires_capture') { $response->error('Payment intent is not ready to capture.', 409); } $paymentIntent = $this->captureApprovedStripePaymentIntent( $order, $stripePaymentIntents, $stripe, $paymentIntent, $orderPaymentLock ); (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'CONFIRM_PAYMENT_INTENT', 'Successfully confirmed a payment intent (ID: ' . $data['id'] . ')'); $response->success($this->buildStripePaymentIntentResponse($paymentIntent, $stripePaymentIntents)); }, [ 'confirm_payment_intent' => 'Confirm a payment intent' ] ); $this->post('/orders/module/stripe/debug/simulate_payment', function () { // Require the user to be logged in global $response; $this->requirePermission('debug_simulate_payment_intent'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Get the post data $data = json_decode(file_get_contents('php://input'), true); // Check if the required fields are set if (!isset($data['id'])) { $response->error('ID is required', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$data['id']); // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 400); } // Check if the order has a payment intent $stripe_payment_intents = new stripe_payment_intents_o(); if (!$stripe_payment_intents->doesOrderHavePaymentIntent((int)$order->id)) { $response->error('Order does not have a payment intent', 400); } $stripe_payment_intents->selectOrderPaymentIntent((int)$order->id); // Simulate the payment $stripe = new stripe(); //$paymentIntent = $stripe->readers->simulatePayment( // $stripe_payment_intents->payment_intent_id->value(), //); $response->success('TEST_SUCCESS', 200); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'SIMULATE_PAYMENT_INTENT', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'simulate_payment_intent' => 'Simulate a payment intent, this is only for testing purposes and should under no circumstances be used in production' ] ); } private function addTaxNetAmount(float $net_amount, ?int $tax_percentage): float { if (empty($tax_percentage) || $tax_percentage <= 0) { return $net_amount; } return $net_amount + ($net_amount * ($tax_percentage / 100)); } private function getStripePaymentIntentAmountForOrder(orders_o $order, ?int $tax_percentage): int { return (int)round($this->addTaxNetAmount( (float)$order->getNetAmount() * 100, $tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE )); } private function doesStripePaymentIntentMatchOrder(object $paymentIntent, int $expectedAmount, ?int $tax_percentage): bool { if (!isset($paymentIntent->amount) || (int)$paymentIntent->amount !== $expectedAmount) { return false; } if ( strtolower((string)($paymentIntent->status ?? '')) === 'succeeded' && (!isset($paymentIntent->amount_received) || (int)$paymentIntent->amount_received !== $expectedAmount) ) { return false; } $metadata = $paymentIntent->metadata ?? null; $storedTaxPercentage = null; if (is_array($metadata)) { $storedTaxPercentage = $metadata['tax_percentage'] ?? null; } elseif (is_object($metadata)) { $storedTaxPercentage = $metadata->tax_percentage ?? null; } if ($storedTaxPercentage === null || !is_numeric($storedTaxPercentage)) { return ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE) === self::CARD_PAYMENT_TAX_PERCENTAGE; } return (int)$storedTaxPercentage === ($tax_percentage ?? self::CARD_PAYMENT_TAX_PERCENTAGE); } private function isStripePaymentIntentReusable(object $paymentIntent): bool { $status = strtolower((string)($paymentIntent->status ?? '')); return in_array($status, [ 'requires_payment_method', 'requires_confirmation', 'requires_action', 'processing', 'requires_capture', 'succeeded', ], true); } private function captureApprovedStripePaymentIntent( orders_o $order, stripe_payment_intents_o $stripePaymentIntents, stripe $stripe, object $paymentIntent, order_payment_lock $orderPaymentLock ): object { global $response; $order = (new orders_o())->getOrderById((int)$order->id); $invoiceCollectionId = (int)$order->invoice_collection_id->value(); if ((int)$order->invoice_collection_id->value() !== $invoiceCollectionId) { $response->error([ 'message' => 'The order moved to another invoice collection. Start the card payment again.', 'code' => 'stripe_payment_intent_contract_mismatch', ], 409); } $expectedAmount = $this->getStripePaymentIntentAmountForOrder( $order, self::CARD_PAYMENT_TAX_PERCENTAGE ); if (!$this->doesStripePaymentIntentMatchOrder( $paymentIntent, $expectedAmount, self::CARD_PAYMENT_TAX_PERCENTAGE )) { if (!$stripePaymentIntents->delete()) { $response->error([ 'message' => 'The card payment completed while its order changed. Reconcile it before starting another payment.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } $response->error([ 'message' => 'The card payment no longer matches the current order. Start the payment again.', 'code' => 'stripe_payment_intent_contract_mismatch', ], 409); } $this->requireStripePaymentCollectionAvailable($order); try { $paymentIntent = $stripe->payment_intents->capture( $stripePaymentIntents->payment_intent_id->value(), [] ); } catch (\Stripe\Exception\InvalidRequestException) { try { $paymentIntent = $stripe->payment_intents->get( $stripePaymentIntents->payment_intent_id->value() ); $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); } catch (\Stripe\Exception\InvalidRequestException) { $stripePaymentIntents->deletePermanently(); $response->error('Stored payment intent is stale. Start the payment again.', 409); } if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') { $response->error('Payment intent could not be captured. Try again.', 409); } } $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') { $response->error('Payment intent is not ready to capture.', 409); } $order = (new orders_o())->getOrderById((int)$order->id); $expectedAmountAfterCapture = $this->getStripePaymentIntentAmountForOrder( $order, self::CARD_PAYMENT_TAX_PERCENTAGE ); if ( $expectedAmountAfterCapture !== $expectedAmount || !$this->doesStripePaymentIntentMatchOrder( $paymentIntent, $expectedAmountAfterCapture, self::CARD_PAYMENT_TAX_PERCENTAGE ) ) { $response->error([ 'message' => 'The completed card payment no longer matches the current order. Reconcile it before closing the order.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } $stripePaymentIntents->tax_percentage->set(self::CARD_PAYMENT_TAX_PERCENTAGE); $stripePaymentIntents->objectChanged(); $this->recordSucceededStripePayment($order, $paymentIntent); return $paymentIntent; } private function recordSucceededStripePayment(orders_o $order, object $paymentIntent): void { global $response; $orderCollection = $order->getOrderCollection(); $closedAt = trim((string)($orderCollection->closed_at->value() ?? '')); $processor = (int)($orderCollection->processor->value() ?? 0); $externalId = trim((string)($orderCollection->external_id->value() ?? '')); $isExactRecordedPayment = ( $closedAt !== '' && $processor === STRIPE_PROCESSOR && $externalId === (string)$paymentIntent->id ); $isCompatiblePartialPayment = ( $closedAt === '' && ($processor === 0 || $processor === STRIPE_PROCESSOR) && ($externalId === '' || $externalId === (string)$paymentIntent->id) ); if ($isExactRecordedPayment) { return; } if ($isCompatiblePartialPayment) { $orderCollection->paidWithStripe((string)$paymentIntent->id); return; } $response->error([ 'message' => 'The order collection is already closed with another payment. Reconcile the completed card payment manually.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } private function requireStripePaymentCollectionAvailable(orders_o $order): void { global $response; $orderCollection = $order->getOrderCollection(); $closedAt = trim((string)($orderCollection->closed_at->value() ?? '')); $processor = (int)($orderCollection->processor->value() ?? 0); $externalId = trim((string)($orderCollection->external_id->value() ?? '')); if ($closedAt !== '' || $processor !== 0 || $externalId !== '') { $response->error([ 'message' => 'The order collection is already closed or assigned to another payment. Reconcile it before capturing funds.', 'code' => 'stripe_payment_reconciliation_conflict', ], 409); } } private function acquireOrderPaymentLock(int $orderId): order_payment_lock { global $response; $lock = order_payment_lock::tryAcquireOrderMutation($orderId); if ($lock === null) { $response->error([ 'message' => 'The order is currently being changed or paid. Try again.', 'code' => 'order_payment_locked', ], 409); } return $lock; } /** * @return array{0:orders_o,1:order_payment_lock} */ private function acquireStripePaymentLocks(orders_o $order): array { global $response; $invoiceCollectionId = (int)$order->invoice_collection_id->value(); if ($invoiceCollectionId <= 0) { $response->error([ 'message' => 'The order is not assigned to an invoice collection. Repair it before starting card payment.', 'code' => 'stripe_payment_collection_missing', ], 409); } $lock = order_payment_lock::tryAcquireOrderMutation((int)$order->id); if ($lock === null) { $response->error([ 'message' => 'The order or invoice collection is currently being changed or paid. Try again.', 'code' => 'order_payment_locked', ], 409); } $freshOrder = (new orders_o())->getOrderById((int)$order->id); if (!$freshOrder->exists() || (int)$freshOrder->invoice_collection_id->value() !== $invoiceCollectionId) { $response->error([ 'message' => 'The order moved to another invoice collection. Start the card payment again.', 'code' => 'stripe_payment_intent_contract_mismatch', ], 409); } $invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId); if (!$invoiceCollection->exists()) { $response->error([ 'message' => 'The order invoice collection does not exist. Repair it before starting card payment.', 'code' => 'stripe_payment_collection_missing', ], 409); } return [$freshOrder, $lock]; } private function buildStripePaymentIntentResponse(?object $paymentIntent, ?stripe_payment_intents_o $storedIntent, array $extra = []): array { $paymentIntentPayload = null; if ($paymentIntent !== null) { if (method_exists($paymentIntent, 'toJSON')) { $decoded = json_decode($paymentIntent->toJSON(), true); $paymentIntentPayload = is_array($decoded) ? $decoded : null; } else { $decoded = json_decode(json_encode($paymentIntent), true); $paymentIntentPayload = is_array($decoded) ? $decoded : null; } } if ($paymentIntentPayload !== null) { $metadata = $paymentIntentPayload['metadata'] ?? []; if (!is_array($metadata)) { $metadata = []; } if ($storedIntent !== null && isset($storedIntent->reader_id) && !empty($storedIntent->reader_id->value())) { $metadata['reader_id'] = (string)$storedIntent->reader_id->value(); if (empty($metadata['reader'])) { $metadata['reader'] = $metadata['reader_id']; } } if ($storedIntent !== null && isset($storedIntent->tax_percentage) && $storedIntent->tax_percentage->value() !== null) { $metadata['tax_percentage'] = (string)$storedIntent->tax_percentage->value(); } $paymentIntentPayload['metadata'] = $metadata; } return array_merge([ 'payment_intent' => $paymentIntentPayload, 'has_payment_intent' => $paymentIntentPayload !== null, ], $extra); } /** * @throws \Exception */ #[NoReturn] private function updateOrder(): void { // Require the user to be logged in global $response; // Auth & permissions (subuser-aware) $auth = new authentication(); $user = $auth->get_user(); $permission_own = self::definePermission('edit_own_orders', subusers_permission_node_key::ORDERS_EDIT); $permission_other = self::definePermission('edit_order'); $has_permission_other = self::hasPermission($permission_other); // Classic user own-edit path (legacy behaviour) $classic_own_path = ($user !== false && $user->hasPermission('user') && !$has_permission_other); // Subuser own-edit path via node ORDERS_EDIT $subuser_own_path = (!$has_permission_other && self::hasPermission($permission_own)); $isOwnPath = $classic_own_path || $subuser_own_path; if (!$isOwnPath && !$has_permission_other) { // Neither own nor admin permission — deny via admin requirement to unify error shape self::requirePermission($permission_other); } // Check if the request was successful if ($user) { // Get the post data $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } $data = $this->normalizeLegacyEditableFieldPayload($data, $response); // Check if the required fields are set if (!isset($data['id'])) { $response->error('ID is required', 400); } // Get the current order $order = (new orders_o())->getOrderById((int)$data['id']); // Check if the order exists if (!$order->exists()) { $response->error('Order not found', 400); } // Own path (classic or subuser) — limited field edits only if ($isOwnPath) { $orderPaymentLock = $this->acquireOrderPaymentLock((int)$order->id); // Validate that the order belongs to the effective customer context if ($subuser_own_path) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { $response->forbidden([$permission_other->permission]); } } else { // Classic own path — compare against authenticated user customer number if ($order->customer_id->value() !== $user->customer_number->value()) { $response->forbidden([$permission_other->permission]); } // Ensure classic own-path requires user permission $this->requirePermission('user'); } // Allowed to edit list $allowed_to_edit = [ // Include the order ID (Even though it is not editable) 'id', 'po', 'reference', 'notes', 'safety_seal', 'reg_1', 'reg_2', 'reg_3', ]; // Check if the $data contains any non-allowed keys foreach ( $data as $key => $value ) { if (!in_array($key, $allowed_to_edit)) { $response->error('You do not have permission to edit this order field (key: ' . $key . ')', 400); } }; $shouldRefreshAttachedWashCertificate = false; // PO if (isset($data['po'])) { $order->po->set((string)$data['po']); } // Registration numbers if (isset($data['reg_1'])) { $normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']); $shouldRefreshAttachedWashCertificate = true; $order->reg_1->set($normalizedReg1); } if (isset($data['reg_2'])) { $normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']); $shouldRefreshAttachedWashCertificate = true; $order->reg_2->set($normalizedReg2); } if (isset($data['reg_3'])) { $normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']); $shouldRefreshAttachedWashCertificate = true; $order->reg_3->set($normalizedReg3); } // Reference if (isset($data['reference'])) { $order->reference->set((string)$data['reference']); } // Notes if (isset($data['notes'])) { $order->notes->set((string)$data['notes']); } if (array_key_exists('safety_seal', $data)) { $normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']); $shouldRefreshAttachedWashCertificate = true; $order->setSafetySealValue($normalizedSafetySeal); } if ($shouldRefreshAttachedWashCertificate) { $order->regenerateAttachedWashCertificate(); } // Register the change $order->objectChanged(); // Return a success message $response->success($order->asArray()); } // Admin/department path (requires edit_order) self::requirePermission($permission_other); /** Departmental access — user must have access to the order's current department */ self::requireDepartmentAccess((string)(int)$order->department_id->value()); $originalCustomerNumber = (int)$order->customer_id->value(); $originalInvoiceCollectionId = (int)$order->invoice_collection_id->value(); $newCustomerNumber = $originalCustomerNumber; $shouldAutoReassignInvoiceCollection = false; $shouldRefreshAttachedWashCertificate = false; // If the customer ID is set, validate it if (isset($data['customer_id'])) { if (!(new users_o())->getUserByCustomerNumber((int)$data['customer_id'])->exists() || empty($data['customer_id'])) { $response->error('Customer not found or invalid', 400); } $newCustomerNumber = (int)$data['customer_id']; $shouldRefreshAttachedWashCertificate = true; $shouldAutoReassignInvoiceCollection = $this->shouldAutoReassignInvoiceCollectionForDraftTransition( $originalCustomerNumber, $newCustomerNumber ); } $targetCustomerNumber = isset($data['customer_id']) ? (int)$data['customer_id'] : (int)$order->customer_id->value(); $targetInvoiceCollectionId = $originalInvoiceCollectionId; if ($shouldAutoReassignInvoiceCollection) { $targetInvoiceCollectionId = (new users_o()) ->getUserByCustomerNumber($targetCustomerNumber) ->getNewOrderInvoiceCollectionId(); } elseif (isset($data['invoice_collection_id'])) { $targetInvoiceCollectionId = (int)$data['invoice_collection_id']; if ($targetInvoiceCollectionId > 0) { $invoiceCollection = (new collected_order_invoices_o()) ->select($targetInvoiceCollectionId); if (!$invoiceCollection->exists()) { $response->error('Invoice collection not found', 400); } if ((int)$invoiceCollection->customer_number->value() !== $targetCustomerNumber) { $response->error('Invoice collection does not belong to the order customer', 400); } } } $assignmentChanges = ( $targetInvoiceCollectionId !== $originalInvoiceCollectionId || $newCustomerNumber !== $originalCustomerNumber ); $orderPaymentLock = $assignmentChanges ? order_payment_lock::tryAcquireReassignment( (int)$order->id, $targetInvoiceCollectionId ) : order_payment_lock::tryAcquireOrderMutation((int)$order->id); if ($orderPaymentLock === null) { $response->error([ 'message' => 'The order or invoice collection is currently being changed or paid. Try again.', 'code' => 'order_payment_locked', ], 409); } $order = (new orders_o())->getOrderById((int)$order->id); if ((int)$order->customer_id->value() !== $originalCustomerNumber || (int)$order->invoice_collection_id->value() !== $originalInvoiceCollectionId) { $response->error([ 'message' => 'The order assignment changed while the update was being prepared.', 'code' => 'order_payment_contract_mismatch', ], 409); } if ($assignmentChanges) { $order->assignToInvoiceCollection( $targetInvoiceCollectionId, false, $targetCustomerNumber ); } // If the reference is set, validate it if (isset($data['reference'])) { $order->reference->set($data['reference']); } // If the notes are set, validate them if (isset($data['notes'])) { $order->notes->set($data['notes']); } // If the registration number is set, validate it if (isset($data['reg_1'])) { $normalizedReg1 = $this->normalizeRegistrationNumberOrError($data['reg_1']); $shouldRefreshAttachedWashCertificate = true; $order->reg_1->set($normalizedReg1); } // If the registration number 2 is set, validate it if (isset($data['reg_2'])) { $normalizedReg2 = $this->normalizeRegistrationNumberOrError($data['reg_2']); $shouldRefreshAttachedWashCertificate = true; $order->reg_2->set($normalizedReg2); } // If the registration number 3 is set, validate it if (isset($data['reg_3'])) { $normalizedReg3 = $this->normalizeRegistrationNumberOrError($data['reg_3']); $shouldRefreshAttachedWashCertificate = true; $order->reg_3->set($normalizedReg3); } // If the PO is set, validate it if (isset($data['po'])) { $order->po->set((string)$data['po']); } if (array_key_exists('safety_seal', $data)) { $normalizedSafetySeal = orders_o::normalizeSafetySealValue($data['safety_seal']); $shouldRefreshAttachedWashCertificate = true; $order->setSafetySealValue($normalizedSafetySeal); } // If the lane is set, validate it if (isset($data['lane'])) { $order->lane->set((int)$data['lane']); } // If the department ID is set, validate it if (isset($data['department_id'])) { if (!(new departments_o())->getDepartmentById((int)$data['department_id'])) { $response->error('Department not found', 400); } // Check if the user has access to the target department self::requireDepartmentAccess((string)(int)$data['department_id']); $order->department_id->set((int)$data['department_id']); } // If the booking ID is set, validate it if (isset($data['booking_id'])) { $bookingId = (int)$data['booking_id']; $order->booking_id->set($bookingId); $this->applyBookingPoDefaultToOrder($order, $bookingId); } // Check if the wash_id is set if (isset($data['wash_id'])) { $order->wash_id->set($data['wash_id']); } // Check if the created_at is set if (isset($data['created_at'])) { try { $order->created_at->set(orders_input_normalizer::normalizeCreatedAt($data['created_at'])); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } } // Check if include_in_invoice is set if (array_key_exists('include_in_invoice', $data)) { try { $order->include_in_invoice->set( orders_input_normalizer::normalizeIncludeInInvoice($data['include_in_invoice']) ); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } } if ($shouldRefreshAttachedWashCertificate) { $order->regenerateAttachedWashCertificate(); } // Void any cached key for the order $order->objectChanged(); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'EDIT_ORDER', 'Successfully updated an order (ID: ' . $data['id'] . ')'); // Return a success message $response->success(['message' => 'Order updated successfully']); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'EDIT_ORDER', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } } private function resolveOrderPoForBookingDefault( mixed $po, bool $poProvided, ?int $bookingId, int $customerNumber, int $departmentId ): ?string { $currentPo = is_scalar($po) || $po === null ? trim((string)$po) : ''; if ($currentPo !== '') { return $currentPo; } $bookingPo = $this->getBookingPoDefault($bookingId, $customerNumber, $departmentId); if ($bookingPo !== null) { return $bookingPo; } return $poProvided ? '' : null; } private function applyBookingPoDefaultToOrder(orders_o $order, ?int $bookingId = null): void { $currentPo = trim((string)($order->po->value() ?? '')); if ($currentPo !== '') { return; } $bookingPo = $this->getBookingPoDefault( $bookingId ?? (int)($order->booking_id->value() ?? 0), (int)$order->customer_id->value(), (int)$order->department_id->value() ); if ($bookingPo === null) { return; } $order->po->set($bookingPo); } private function getBookingPoDefault(?int $bookingId, int $customerNumber, int $departmentId): ?string { if ($bookingId === null || $bookingId <= 0 || $customerNumber <= 0 || $departmentId <= 0) { return null; } if (!$this->canUseBookingPoDefault($customerNumber, $departmentId)) { return null; } try { $booking = (new order_bookings_o())->select($bookingId); if (!$booking->exists()) { return null; } if ((int)$booking->customer_number->value() !== $customerNumber) { return null; } if ((int)$booking->department->value() !== $departmentId) { return null; } if (trim((string)($booking->deleted_at->value() ?? '')) !== '') { return null; } $bookingPo = trim((string)($booking->po->value() ?? '')); return $bookingPo !== '' ? $bookingPo : null; } catch (\Throwable) { return null; } } private function canUseBookingPoDefault(int $customerNumber, int $departmentId): bool { try { $user = (new authentication())->get_user(); return $user !== false && isset($user->customer_number) && (int)$user->customer_number->value() === $customerNumber; } catch (\Throwable) { return false; } } private function normalizeLegacyEditableFieldPayload(array $data, response $response): array { if (!array_key_exists('field', $data) && !array_key_exists('value', $data)) { return $data; } if (!array_key_exists('field', $data) || !array_key_exists('value', $data)) { $response->error('Both legacy field and value are required', 400); } $field = is_string($data['field']) ? trim($data['field']) : ''; $allowedLegacyFields = ['reference', 'notes', 'safety_seal', 'reg_1', 'reg_2', 'reg_3']; if ($field === '' || !in_array($field, $allowedLegacyFields, true)) { $displayField = is_scalar($data['field']) || $data['field'] === null ? (string)$data['field'] : gettype($data['field']); $response->error('Unsupported legacy order field: ' . $displayField, 400); } if (!array_key_exists($field, $data)) { $data[$field] = $data['value']; } unset($data['field'], $data['value']); return $data; } private function normalizeRegistrationNumberOrError(mixed $value): string { global $response; try { return orders_input_normalizer::normalizeRegistrationNumber($value); } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } } private function shouldAutoReassignInvoiceCollectionForDraftTransition(int $originalCustomerNumber, int $newCustomerNumber): bool { if ($originalCustomerNumber <= 0 || $newCustomerNumber <= 0 || $originalCustomerNumber === $newCustomerNumber) { return false; } $economic = new economic(); return $economic->isDraftCustomerNumber($originalCustomerNumber) || $economic->isDraftCustomerNumber($newCustomerNumber); } /** * @param array> $orders * @return array> */ private function enrichOrderListRows(array $orders): array { if (empty($orders)) { return []; } $orderIds = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'id')), static fn(int $id): bool => $id > 0))); $customerNumbers = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'customer_id')), static fn(int $id): bool => $id > 0))); $cashierIds = array_values(array_unique(array_filter(array_map('intval', array_column($orders, 'cashier_id')), static fn(int $id): bool => $id > 0))); $netAmountsByOrderId = (new orders_o())->getNetAmountForOrders($orderIds); $economicModules = new economic_module_orders(); $economicModules->ensureRowsForOrderIds($orderIds); $economicByOrderId = $economicModules->getByOrderIdsAsArray($orderIds); $stripeByOrderId = $this->getStripeModulesByOrderIds($orderIds); $users = new users_o(); $customerNamesByCustomerNumber = $users->getCustomerNames($customerNumbers); $userIdsByCustomerNumber = $this->getUserIdsByCustomerNumbers($customerNumbers); $cashierNamesById = $users->getCashierNames($cashierIds); $pendingHandheldByOrderId = $this->getPendingHandheldFlags($orderIds); $attachmentsByOrderId = (new attachments())->listMany('orders', $orderIds); foreach ($orders as &$order) { $orderId = (int)($order['id'] ?? 0); $customerNumber = (int)($order['customer_id'] ?? 0); $cashierId = (int)($order['cashier_id'] ?? 0); $order['economic_invoice_module'] = $economicByOrderId[$orderId] ?? [ 'id' => $orderId, 'invoice_draft_id' => null, 'invoice_id' => null, ]; $order['total_net_amount'] = (float)($netAmountsByOrderId[$orderId] ?? 0); if (isset($stripeByOrderId[$orderId])) { $order['stripe_invoice_module'] = $stripeByOrderId[$orderId]; } if (!empty($order['invoice_collection_id'])) { $order['invoice_collection'] = [ 'id' => $order['invoice_collection_id'], 'closed_at' => $order['closed_at'] ?? null, 'booked_invoice_id' => $order['booked_invoice_id'] ?? null, 'processor' => (int)($order['processor'] ?? 0), ]; } $customerName = $customerNamesByCustomerNumber[(string)$customerNumber] ?? null; if ($customerName === null && $customerNumber > 0) { $customerName = $users->getCustomerName($customerNumber); } $order['customer_name'] = $customerName; $order['user_id'] = (int)($userIdsByCustomerNumber[$customerNumber] ?? 0); $order['cashier_name'] = $cashierNamesById[$cashierId] ?? 'Unknown Cashier'; $order['pending_handheld'] = (bool)($pendingHandheldByOrderId[$orderId] ?? false); $order['attachments'] = $attachmentsByOrderId[$orderId] ?? []; $order['po'] = $order['po'] ?? null; $order['safety_seal'] = $order['safety_seal'] ?? null; $order['lane'] = $order['lane'] ?? null; } unset($order); return $orders; } /** * @param int[] $orderIds * @return array> */ private function getStripeModulesByOrderIds(array $orderIds): array { $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0))); if (empty($orderIds)) { return []; } $rows = (new stripe_module_orders_o())->getFieldsWhereIn( ['id' => $orderIds], ['id', 'invoice_id', 'customer_id', 'url', 'created_at'] ); $byOrderId = []; foreach ($rows as $row) { $orderId = (int)($row['id'] ?? 0); if ($orderId <= 0) { continue; } $invoiceId = trim((string)($row['invoice_id'] ?? '')); if ($invoiceId === '') { continue; } $stripeSnapshot = $this->getStripeInvoiceSnapshot($invoiceId); $byOrderId[$orderId] = [ 'id' => $orderId, 'invoice_id' => $invoiceId, 'customer_id' => (string)($row['customer_id'] ?? ''), 'url' => (string)($row['url'] ?? ''), 'created_at' => (string)($row['created_at'] ?? ''), 'paid' => (bool)($stripeSnapshot['paid'] ?? false), 'status' => $stripeSnapshot['status'] ?? null, 'amount_due' => $stripeSnapshot['amount_due'] ?? null, 'amount_paid' => $stripeSnapshot['amount_paid'] ?? null, ]; } return $byOrderId; } /** * @return array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed} */ private function getStripeInvoiceSnapshot(string $invoiceId): array { $cached = $this->getCachedStripeInvoiceSnapshot($invoiceId); if ($cached !== null) { return $cached; } $invoice = (new stripe())->invoice->retrieve($invoiceId); $snapshot = [ 'paid' => (bool)($invoice->paid ?? false), 'status' => $invoice->status ?? null, 'amount_due' => $invoice->amount_due ?? null, 'amount_paid' => $invoice->amount_paid ?? null, ]; $this->cacheStripeInvoiceSnapshot($invoiceId, $snapshot); return $snapshot; } /** * @return array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed}|null */ private function getCachedStripeInvoiceSnapshot(string $invoiceId): ?array { if (!defined('redis')) { return null; } $cacheKey = 'orders_stripe_invoice_snapshot_' . $invoiceId; $cachedRaw = redis->get($cacheKey); if (!is_string($cachedRaw) || $cachedRaw === '') { return null; } $decoded = json_decode($cachedRaw, true); return is_array($decoded) ? $decoded : null; } /** * @param array{paid: bool, status: mixed, amount_due: mixed, amount_paid: mixed} $snapshot */ private function cacheStripeInvoiceSnapshot(string $invoiceId, array $snapshot): void { if (!defined('redis')) { return; } $encoded = json_encode($snapshot); if (!is_string($encoded) || $encoded === '') { return; } $cacheKey = 'orders_stripe_invoice_snapshot_' . $invoiceId; redis->set($cacheKey, $encoded); redis->expire($cacheKey, 30); } /** * @param int[] $customerNumbers * @return array map: customer_number => user_id */ private function getUserIdsByCustomerNumbers(array $customerNumbers): array { $customerNumbers = array_values(array_unique(array_filter(array_map('intval', $customerNumbers), static fn(int $id): bool => $id > 0))); if (empty($customerNumbers)) { return []; } $rows = (new users_o())->getFieldsWhereIn( ['customer_number' => $customerNumbers], ['id', 'customer_number'] ); usort($rows, static fn(array $a, array $b): int => ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0))); $map = []; foreach ($rows as $row) { $customerNumber = (int)($row['customer_number'] ?? 0); if ($customerNumber <= 0 || isset($map[$customerNumber])) { continue; } $map[$customerNumber] = (int)($row['id'] ?? 0); } // Keep parity with existing behavior that imports missing customer users via getUserByCustomerNumber. foreach ($customerNumbers as $customerNumber) { if (isset($map[$customerNumber])) { continue; } $user = (new users_o())->getUserByCustomerNumber($customerNumber); if ($user->exists()) { $map[$customerNumber] = (int)$user->id; } } return $map; } /** * @param int[] $orderIds * @return array map: order_id => pending_handheld */ private function getPendingHandheldFlags(array $orderIds): array { $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds), static fn(int $id): bool => $id > 0))); if (empty($orderIds)) { return []; } $flags = []; foreach ($orderIds as $orderId) { $flags[$orderId] = false; } if (!defined('redis')) { return $flags; } $cached = (new orders_o())->getCachedForMultipleObjects('pending_handheld_cache_indicator', $orderIds); foreach ($orderIds as $index => $orderId) { $flags[$orderId] = ((int)($cached[$index] ?? 0) === 1); } return $flags; } /** * @return array{user:object,order:orders_o,order_id:int,attachment_id:int,object_key:string,file_name:string,store:attachment_store|pdf_store} */ private function requireOrderAttachmentDownloadContext(): array { global $response; $permissionOwn = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST); $permissionOther = self::definePermission('download_order_attachments'); $hasPermissionOther = self::hasPermission($permissionOther); if (!$hasPermissionOther) { self::requirePermission($permissionOwn); } $user = (new authentication())->get_user(); if (!$user) { (new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session'); $response->error('Invalid session', 400); } self::requireParameters(['order_id', 'attachment_id']); $orderId = self::getParameter('order_id'); $attachmentId = self::getParameter('attachment_id'); if (!is_numeric($orderId) || (int)$orderId < 1) { $response->error('Invalid order ID', 400); } if (!is_numeric($attachmentId) || (int)$attachmentId < 1) { $response->error('Invalid attachment ID', 400); } $orderId = (int)$orderId; $attachmentId = (int)$attachmentId; $order = (new orders_o())->getOrderById($orderId); if (!$order->exists()) { $response->error('Order not found', 404); } $this->requireLimitedBackofficeDepartmentAccess( $user, (int)$order->department_id->value() ); if (!$hasPermissionOther) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { $response->forbidden([$permissionOther->permission]); } } $attachment = $order->getAttachment($attachmentId); $attachmentType = $attachment->exists() ? trim((string)$attachment->object_type->value(), '`') : ''; if ( !$attachment->exists() || $attachmentType !== 'orders' || (int)$attachment->object_id->value() !== $orderId || !empty($attachment->deleted_at->value()) ) { $response->error('Attachment not found for order', 404); } $formattedAttachment = (new attachments())->format($attachment); $objectKey = trim((string)( $formattedAttachment->content->document ?? $formattedAttachment->content->image ?? '' )); if ($objectKey === '') { $response->error('Attachment has no stored file', 404); } $attachmentStore = new attachment_store(); if (!$attachmentStore->isValidFilePath($objectKey)) { $response->error('Attachment contains an invalid stored file path', 400); } $isWashCertificate = $formattedAttachment->isWashCertificate(); $store = $isWashCertificate ? new pdf_store() : $attachmentStore; try { if (!$store->doesObjectExist($objectKey)) { $response->error('Attachment file not found', 404); } } catch (\Throwable) { $response->error('Attachment storage is unavailable', 502); } $originalFileName = is_string($formattedAttachment->content->other) ? $formattedAttachment->content->other : ''; if ($isWashCertificate) { $originalFileName = 'wash_certificate.pdf'; } return [ 'user' => $user, 'order' => $order, 'order_id' => $orderId, 'attachment_id' => $attachmentId, 'object_key' => $objectKey, 'file_name' => $originalFileName, 'store' => $store, ]; } private function logOrderAttachmentDownload(array $context, string $action): void { (new logs_o())->add( 'orders', $context['order']->department_id->value(), 1, $context['user']->id, $action, 'Successfully accessed an attachment for an order (Order ID: ' . $context['order_id'] . ', Attachment ID: ' . $context['attachment_id'] . ')' ); } private function detectAttachmentMimeType(string $path): string { $mimeType = false; if (class_exists(\finfo::class)) { $mimeType = (new \finfo(FILEINFO_MIME_TYPE))->file($path); } if ((!is_string($mimeType) || $mimeType === '') && function_exists('mime_content_type')) { $mimeType = mime_content_type($path); } return is_string($mimeType) && preg_match('#^[a-z0-9.+-]+/[a-z0-9.+-]+$#i', $mimeType) === 1 ? $mimeType : 'application/octet-stream'; } private function sanitizeAttachmentDownloadFileName(string $fileName, string $objectKey): string { $fileName = trim(str_replace(["\r", "\n", "\0"], '', basename($fileName))); if ($fileName === '' || $fileName === '.' || $fileName === '..') { $fileName = basename($objectKey); } $fileName = preg_replace('/[\\x00-\\x1F\\x7F\\/\\\\]/u', '_', $fileName) ?? 'attachment'; return trim($fileName) !== '' ? $fileName : 'attachment'; } /** * @param mixed $data * @param response $response * @return mixed */ private function getData(mixed $data, response $response): mixed { if (!isset($data['customer_id'])) { $response->error('Customer ID is required', 400); } if ((int)$data['customer_id'] < 1 || !is_numeric((int)$data['customer_id'])) { $response->error('Customer ID is required', 400); } if (!isset($data['department_id'])) { $response->error('Department ID is required', 400); } if (!isset($data['reference'])) { $response->error('Reference is required', 400); } if (!isset($data['notes'])) { $response->error('Notes is required', 400); } if (!isset($data['reg_1'])) { $response->error('Registration number 1 is required', 400); } try { $data['reg_1'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_1']); if (array_key_exists('reg_2', $data)) { $data['reg_2'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_2']); } if (array_key_exists('reg_3', $data)) { $data['reg_3'] = orders_input_normalizer::normalizeRegistrationNumber($data['reg_3']); } } catch (\InvalidArgumentException $e) { $response->error($e->getMessage(), 400); } if (strlen($data['reg_1']) < 4) { $response->error('Registration number 1 must be at least 4 characters', 400); } // Optional fields are not checked here, as they are optional and can be empty return $data; } }