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 = $user->getGroup()->getDepartments(); } if (self::isParametersSet(['show_wash_subscription'])) { if (self::getParameter('show_wash_subscription') === '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); } // 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); } $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'] ?? '', '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 ...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // 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 (!$order->exists()) { $response->error('Order not found', 400); } // Check if the user has access to the department self::requireDepartmentAccess((int)$order->department_id->value()); // Delete the order $order->delete(); // Log the incident (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 () { // Require the user to be logged in global $response; // Permissions (subuser-aware) $permission_own = self::definePermission('download_order_attachments_own', subusers_permission_node_key::ORDERS_LIST); $permission_other = self::definePermission('download_order_attachments'); $has_permission_other = self::hasPermission($permission_other); if (!$has_permission_other) { self::requirePermission($permission_own); } // 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); } // If operating under own-scope (classic user or subuser), ensure the order belongs to the effective customer context if (!$has_permission_other) { $effectiveCustomer = self::resolveEffectiveCustomerNumber(); if ($effectiveCustomer === null || (int)$order->customer_id->value() !== (int)$effectiveCustomer) { $response->forbidden([$permission_other->permission]); } } // Get the attachment $attachment = $order->getAttachment((int)$attachment_id); if (!$attachment->exists()) { $response->error('Attachment not found', 400); } // Create a download link $attachment_store = new attachment_store(); $attachments = new attachments(); $attachment_formatted = $attachments->format($attachment); $download_link = $attachment_store->generateDirectDownloadUrl( $attachment_formatted->content->document ); // Log the incident (new logs_o())->add('orders', $order->department_id->value(), 1, $user->id, 'DOWNLOAD_ORDER_ATTACHMENT', 'Successfully downloaded an attachment for an order (Order ID: ' . $order_id . ', Attachment ID: ' . $attachment_id . ')'); // Return the download link $response->success(['download_link' => $download_link]); } else { // Log the incident (new logs_o())->add('orders', 'global', 1, 0, 'DOWNLOAD_ORDER_ATTACHMENT', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ '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', 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); } // 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); } // 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); } // 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); } // 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); } $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 = isset($data['tax_percentage']) ? (int)$data['tax_percentage'] : null; if ($tax_percentage !== null && ($tax_percentage < 0 || $tax_percentage > 100)) { $response->error('Invalid tax percentage', 400); } $stripe = new stripe(); $stripePaymentIntents = new stripe_payment_intents_o(); 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); $stripePaymentIntents->setReaderId($readerId); if ($tax_percentage !== null) { $stripePaymentIntents->tax_percentage->set($tax_percentage); } if ($this->isStripePaymentIntentReusable($storedPaymentIntent)) { (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, ])); } $stripePaymentIntents->deletePermanently(); } catch (\Stripe\Exception\InvalidRequestException) { $stripePaymentIntents->deletePermanently(); } } $paymentIntent = $stripe->payment_intents->create( (int)round($this->addTaxNetAmount( (float)$order->getNetAmount() * 100, $tax_percentage ?? 0 )), [ '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 ?? 0), '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) { try { $stripePaymentIntents->delete(); } catch (Exception) { $stripePaymentIntents->deletePermanently(); } $response->error('Unable to start payment on the selected reader', 409); } try { $paymentIntent = $stripe->payment_intents->get($paymentIntent->id); $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); } 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); } $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.', ])); } $stripePaymentIntents->updateStoredPaymentIntent($paymentIntent); $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); } $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); try { $stripePaymentIntents->delete(); } catch (\Stripe\Exception\InvalidRequestException) { $stripePaymentIntents->deletePermanently(); } (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); } $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') { $response->error('Payment intent has already been captured.', 409); } 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); } try { $paymentIntent = $stripe->payment_intents->capture( $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); if (strtolower((string)($paymentIntent->status ?? '')) !== 'succeeded') { $response->error('Payment intent is not ready to capture.', 409); } $order_collection = $order->getOrderCollection(); $order_collection->paidWithStripe($paymentIntent->id); (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 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 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) { // 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); break; } }; $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 */ $originalCustomerNumber = (int)$order->customer_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 ); $order->customer_id->set($newCustomerNumber); } // 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); } $order->department_id->set((int)$data['department_id']); } // If the booking ID is set, validate it if (isset($data['booking_id'])) { $order->booking_id->set((int)$data['booking_id']); } // Check if the invoice collection is set if (isset($data['invoice_collection_id']) && !$shouldAutoReassignInvoiceCollection) { $order->invoice_collection_id->set((int)$data['invoice_collection_id']); } // 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 ($shouldAutoReassignInvoiceCollection) { $order->assignToInvoiceCollection(null, false); } 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 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; } /** * @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; } }