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