Files
api/services/nginx/app/routes/ordersRoute.php
T

1369 lines
60 KiB
PHP

<?php
namespace routes;
use attachments\helpers\attachment_content;
use classes\attachment_store;
use classes\attachments;
use classes\authentication;
use classes\orders_input_normalizer;
use classes\response;
use classes\stripe;
use JetBrains\PhpStorm\NoReturn;
use objects\collected_order_invoices_o;
use objects\departments_o;
use objects\economic_module_orders;
use objects\logs_o;
use objects\orders_o;
use objects\stripe_module_orders_o;
use objects\stripe_payment_intents_o;
use objects\users_o;
use traits\route_t;
use modules\subusers\helpers\subusers_permission_node_key;
class ordersRoute
{
use route_t;
public function run(): void
{
$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 = $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);
}
// Get the registration number
$reg_1 = $data['reg_1'];
// Get the registration numbers (If they are set, they 2-3 are optional)
$reg_2 = $data['reg_2'] ?? '';
$reg_3 = $data['reg_3'] ?? '';
// Strip the registration numbers of any whitespace
$reg_1 = preg_replace('/\s+/', '', $reg_1);
$reg_2 = preg_replace('/\s+/', '', $reg_2);
$reg_3 = preg_replace('/\s+/', '', $reg_3);
try {
$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] : []),
];
// 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();
// 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('Department is not configured for Stripe payments', 400);
}
$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 = [];
}
// 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',
];
// 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;
}
};
// PO
if (isset($data['po'])) {
$order->po->set((string)$data['po']);
}
// Registration numbers
if (isset($data['reg_1'])) {
$order->reg_1->set((string)$data['reg_1']);
}
if (isset($data['reg_2'])) {
$order->reg_2->set((string)$data['reg_2']);
}
if (isset($data['reg_3'])) {
$order->reg_3->set((string)$data['reg_3']);
}
// Reference
if (isset($data['reference'])) {
$order->reference->set((string)$data['reference']);
}
// Notes
if (isset($data['notes'])) {
$order->notes->set((string)$data['notes']);
}
// 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 */
// 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);
}
$order->customer_id->set((int)$data['customer_id']);
}
// 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'])) {
$order->reg_1->set($data['reg_1']);
}
// If the registration number 2 is set, validate it
if (isset($data['reg_2'])) {
$order->reg_2->set($data['reg_2']);
}
// If the registration number 3 is set, validate it
if (isset($data['reg_3'])) {
$order->reg_3->set($data['reg_3']);
}
// If the PO is set, validate it
if (isset($data['po'])) {
$order->po->set((string)$data['po']);
}
// 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'])) {
$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);
}
}
// 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);
}
}
/**
* @param array<int, array<string, mixed>> $orders
* @return array<int, array<string, mixed>>
*/
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['lane'] = $order['lane'] ?? null;
}
unset($order);
return $orders;
}
/**
* @param int[] $orderIds
* @return array<int, array<string, mixed>>
*/
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<int, int> 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<int, bool> 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);
}
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;
}
}