From ebf7e820d5e6e83ecc1be181745b9efa837c9125 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Tue, 14 Apr 2026 10:51:25 +0200 Subject: [PATCH] Add safety seal support to orders and related logic for wash certificates - Introduced `safety_seal` column in the `orders` table. - Updated order creation and completion logic to handle safety seal values. - Enhanced order and booking classes to manage safety seal attachment and retrieval. - Added tests to validate safety seal functionality in order processing. --- .../app/classes/orders_schema_bootstrap.php | 5 + .../nginx/app/objects/order_bookings_o.php | 59 +- services/nginx/app/objects/orders_o.php | 98 ++- .../nginx/app/objects/subuser_grants_o.php | 66 +- services/nginx/app/objects/subusers_o.php | 35 +- services/nginx/app/routes/authRoute.php | 2 + .../nginx/app/routes/moduleStripeRoute.php | 8 +- services/nginx/app/routes/orderRoute.php | 10 +- services/nginx/app/routes/ordersRoute.php | 18 +- services/nginx/app/routes/subusersRoute.php | 588 +++++++++++++++--- services/nginx/app/tests/Api/AuthApiTest.php | 33 + .../nginx/app/tests/Api/OrdersApiTest.php | 12 + .../nginx/app/tests/Api/StripeApiTest.php | 64 ++ .../app/tests/Support/Api/ApiFixtures.php | 1 + .../OrderBookingsCompletionDedupTest.php | 104 ++++ ...rdersAutoWashCertificateCompletionTest.php | 143 +++++ .../OrdersIncludeInInvoiceOverrideTest.php | 2 + .../SubusersRouteManagementContractTest.php | 64 ++ 18 files changed, 1161 insertions(+), 151 deletions(-) create mode 100644 services/nginx/app/tests/Api/StripeApiTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php create mode 100644 services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php create mode 100644 services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php diff --git a/services/nginx/app/classes/orders_schema_bootstrap.php b/services/nginx/app/classes/orders_schema_bootstrap.php index d79fa52b..1fd61a63 100644 --- a/services/nginx/app/classes/orders_schema_bootstrap.php +++ b/services/nginx/app/classes/orders_schema_bootstrap.php @@ -26,6 +26,11 @@ class orders_schema_bootstrap ADD COLUMN IF NOT EXISTS include_in_invoice TINYINT(1) NULL DEFAULT NULL AFTER created_at" ); + $db->query( + "ALTER TABLE orders + ADD COLUMN IF NOT EXISTS safety_seal VARCHAR(255) NULL DEFAULT NULL + AFTER po" + ); self::$initialized = true; } diff --git a/services/nginx/app/objects/order_bookings_o.php b/services/nginx/app/objects/order_bookings_o.php index 76017060..875c7be1 100644 --- a/services/nginx/app/objects/order_bookings_o.php +++ b/services/nginx/app/objects/order_bookings_o.php @@ -350,16 +350,32 @@ class order_bookings_o extends db public function completeBooking(int $user_id, string $safety_seal = null): void { self::requireSelected(); + $orderWasCreatedDuringCompletion = false; if (!$this->order_id->value()) { // Create order, if not already created - self::createOrderBy($user_id); + $this->createOrderBy($user_id); // Add order items, re-calculate the prices to be customer-specific - self::createOrderItemsBy($user_id); + $this->createOrderItemsBy($user_id); + $orderWasCreatedDuringCompletion = true; } - // Create a wash certificate (If applicable) - if (self::containsWashCertificateItem()) self::attachWashCertificate($user_id, $safety_seal); - // Send wash certificate - self::sendWashCertificateToCustomer(); + + if (!$this->containsWashCertificateItem()) { + return; + } + + $order = $this->getOrder(); + $normalizedSafetySeal = orders_o::normalizeSafetySealValue($safety_seal); + if ($normalizedSafetySeal !== null) { + $order->setSafetySealValue($normalizedSafetySeal); + $order->objectChanged(); + } + + if (!$orderWasCreatedDuringCompletion) { + return; + } + + $this->attachWashCertificate($user_id, $order->getSafetySealValue()); + $this->sendWashCertificateToCustomer(); } /** @@ -407,21 +423,18 @@ class order_bookings_o extends db public function containsWashCertificateItem(): bool { self::requireSelected(); - return self::containsProductId(41); - } + foreach ($this->items->value() as $item) { + $product_id = (int)($item['id'] ?? 0); + if ($product_id <= 0) { + continue; + } - /** - * @throws Exception - */ - private function containsProductId(int $productId): bool - { - self::requireSelected(); - $items = $this->items->value(); - foreach ($items as $item) { - if (isset($item['id']) && (int)$item['id'] == $productId) { + $product = (new products_o())->select($product_id); + if ($product->exists() && $product->isWashCertificate()) { return true; } } + return false; } @@ -445,11 +458,11 @@ class order_bookings_o extends db /** * @throws Exception */ - private function attachWashCertificate(int $user_id, string $safety_seal = null): void + protected function attachWashCertificate(int $user_id, string $safety_seal = null): void { self::requireSelected(); // Check if the order already has a wash certificate attached - if (self::getOrder()->hasWashCertificateAttached()) { + if ($this->getOrder()->hasWashCertificateAttached()) { return; } // Get the operator name @@ -458,7 +471,7 @@ class order_bookings_o extends db throw new Exception('Operator not found'); } // Generate wash certificate - self::generateWashCertificate($safety_seal, $operator->display_name->value()); + $this->generateWashCertificate($safety_seal, $operator->display_name->value()); } /** @@ -468,7 +481,7 @@ class order_bookings_o extends db * @throws Exception If the object is not selected * @throws Exception If the booking already has a wash certificate */ - public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null): void + public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null): void { self::requireSelected(); // Generate the wash certificate @@ -510,7 +523,7 @@ class order_bookings_o extends db ]) ->addData([ 'booking_number' => $this->id, - 'seal_number' => ($safety_seal ?? null), + 'seal_number' => orders_o::normalizeSafetySealValue($safety_seal), 'reg_1' => $booking_array['reg_1'], 'reg_2' => $booking_array['reg_2'], 'date' => date('d-m-Y'), @@ -556,4 +569,4 @@ class order_bookings_o extends db return count($order_ids); } -} \ No newline at end of file +} diff --git a/services/nginx/app/objects/orders_o.php b/services/nginx/app/objects/orders_o.php index 4b18ccec..ee50e526 100644 --- a/services/nginx/app/objects/orders_o.php +++ b/services/nginx/app/objects/orders_o.php @@ -42,6 +42,7 @@ class orders_o extends db public object_property $wash_id; // The XL Vask Wash ID, if any public object_property $lane; // The lane used for the order, if any public object_property $po; // The (optional) PO number, filled by the customer. + public object_property $safety_seal; // The optional safety seal value for wash certificates. public object_property $using_hand_held; // Whether the order is being processed using a handheld device /** @@ -99,6 +100,7 @@ class orders_o extends db $this->wash_id = new object_property($this->table, $this->id, 'wash_id', 'string', false); $this->lane = new object_property($this->table, $this->id, 'lane', 'string', false); $this->po = new object_property($this->table, $this->id, 'po', 'string', false); + $this->safety_seal = new object_property($this->table, $this->id, 'safety_seal', 'string', false); $this->using_hand_held = new object_property($this->table, $this->id, 'using_hand_held', 'bool', false); } @@ -267,10 +269,8 @@ class orders_o extends db * @throws Exception If the order is not selected * @throws Exception If the order is already completed */ - public function markAsCompleted(): void + public function markAsCompleted(string|null $operator = null): void { - global /** @var db $db */ - $db; self::requireSelected(); // Check if the order is already completed if ($this->completed_at->value() !== null) { @@ -278,9 +278,15 @@ class orders_o extends db } // Set the completed_at property to the current timestamp $this->completed_at->set(date('Y-m-d H:i:s')); - $sql = "UPDATE $this->table SET completed_at = '" . $this->completed_at->value() . "' WHERE id = " . $this->id; - $db->query($sql); + $washCertificateCreated = $this->completeWashCertificateIfNeeded( + $operator, + (string)$this->completed_at->value() + ); $this->objectChanged(); + + if ($washCertificateCreated && (int)$this->booking_id->value() > 0) { + $this->getOrderBooking()?->sendWashCertificateToCustomer(); + } } /** @@ -581,6 +587,7 @@ class orders_o extends db 'wash_id' => $this->wash_id->value(), 'lane' => $this->lane->value(), 'po' => $this->po->value(), + 'safety_seal' => $this->getSafetySealValue(), 'closed_at' => (int)$this->invoice_collection_id->value() ? (new collected_order_invoices_o())->select((int)$this->invoice_collection_id->value())->closed_at->value() : null, 'pending_handheld' => $this->isPendingHandheld(), ]; @@ -1381,14 +1388,89 @@ class orders_o extends db return false; // No wash certificate product found in the order items } + /** + * @throws Exception + */ + public function containsWashCertificateItem(): bool + { + self::requireSelected(); + $products = new products_o(); + + foreach ($this->getOrderItems((int)$this->id) as $item) { + $product_id = (int)($item['product_id'] ?? 0); + if ($product_id <= 0) { + continue; + } + + $product = $products->select($product_id); + if ($product->exists() && $product->isWashCertificate()) { + return true; + } + } + + return false; + } + + public static function normalizeSafetySealValue(mixed $value): ?string + { + if ($value === null) { + return null; + } + + if (is_string($value)) { + $normalized = trim($value); + return $normalized === '' ? null : $normalized; + } + + if (is_scalar($value)) { + $normalized = trim((string)$value); + return $normalized === '' ? null : $normalized; + } + + return null; + } + + /** + * @throws Exception + */ + public function getSafetySealValue(): ?string + { + self::requireSelected(); + return self::normalizeSafetySealValue($this->safety_seal->value()); + } + + /** + * @throws Exception + */ + public function setSafetySealValue(mixed $value): void + { + self::requireSelected(); + $normalized = self::normalizeSafetySealValue($value); + $this->safety_seal->set($normalized); + } + + /** + * @throws Exception + */ + public function completeWashCertificateIfNeeded(string|null $operator = null, $date = null): bool + { + self::requireSelected(); + if (!$this->containsWashCertificateItem() || $this->hasWashCertificateAttached()) { + return false; + } + + $this->generateWashCertificate($this->getSafetySealValue(), $operator, $date); + return $this->hasWashCertificateAttached(); + } + /** * Generate and attach a wash certificate directly on an order (without a booking) - * @param int|null $safety_seal Optional safety seal number + * @param string|null $safety_seal Optional safety seal number * @param string|null $operator Optional operator/employee name who carried out the wash * @param string|DateTime|null $date Optional date of the wash (defaults to current date) * @throws Exception If the order is not selected or required related objects are missing */ - public function generateWashCertificate(int|null $safety_seal = null, string|null $operator = null, $date = null): void + public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null, $date = null): void { self::requireSelected(); // Avoid generating duplicate certificates @@ -1437,7 +1519,7 @@ class orders_o extends db ]) ->addData([ 'booking_number' => $this->id, // Used as document number on the template - 'seal_number' => ($safety_seal ?? null), + 'seal_number' => self::normalizeSafetySealValue($safety_seal), 'reg_1' => $order_array['reg_1'], 'reg_2' => $order_array['reg_2'], 'date' => $date_formatted, diff --git a/services/nginx/app/objects/subuser_grants_o.php b/services/nginx/app/objects/subuser_grants_o.php index 6278eb95..1447b9e9 100644 --- a/services/nginx/app/objects/subuser_grants_o.php +++ b/services/nginx/app/objects/subuser_grants_o.php @@ -30,6 +30,26 @@ class subuser_grants_o extends db subusers_permission_node_key::BOOKINGS_DELETE, ]; + private static function normalizePermissionsValue(mixed $raw): array + { + if ($raw === null || $raw === '') { + return []; + } + + if (is_array($raw)) { + return array_values(array_filter($raw, static fn ($permission) => is_string($permission) && trim($permission) !== '')); + } + + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + return array_values(array_filter($decoded, static fn ($permission) => is_string($permission) && trim($permission) !== '')); + } + } + + return []; + } + public function structure(): void { @@ -62,23 +82,7 @@ class subuser_grants_o extends db 'subuser' => (int)$this->subuser->value(), 'enabled' => (bool)$this->enabled->value(), 'note' => $this->note->value(), - 'permissions' => (function ($raw) { - // Handle different representations from object_property: - // - When type is 'json', object_property::value() may already return an array - // - In older behavior, it could return a JSON string - // Normalize to an array for API output - if ($raw === null || $raw === '') { - return []; - } - if (is_array($raw)) { - return $raw; - } - if (is_string($raw)) { - $decoded = json_decode($raw, true); - return is_array($decoded) ? $decoded : []; - } - return []; - })($this->permissions->value()), + 'permissions' => self::normalizePermissionsValue($this->permissions->value()), 'created_at' => $this->created_at->value(), 'updated_at' => $this->updated_at->value(), 'deleted_at' => $this->deleted_at->value(), @@ -122,11 +126,33 @@ class subuser_grants_o extends db // Extract permissions from the grants $permissions = []; foreach ($grants as $grant) { - $grant_permissions = json_decode($grant['permissions'], true); + $grant_permissions = self::normalizePermissionsValue($grant['permissions'] ?? null); if (is_array($grant_permissions)) { $permissions = array_merge($permissions, $grant_permissions); } } - return $permissions; + return array_values(array_unique($permissions)); } -} \ No newline at end of file + + public function getGrantForSubuserAndCustomer(int $subuser_id, int $customer_number, bool $includeDisabled = true): ?subuser_grants_o + { + $grants = self::getFieldsWhere([ + 'billing_customer_number' => $customer_number, + 'subuser' => $subuser_id, + 'deleted_at' => null, + ], ['id', 'enabled']); + + if (!$includeDisabled) { + $grants = array_values(array_filter($grants, static fn (array $grant): bool => (int)($grant['enabled'] ?? 0) === 1)); + } + + if (count($grants) === 0) { + return null; + } + + usort($grants, static fn (array $left, array $right): int => (int)$right['id'] <=> (int)$left['id']); + $grant = (new subuser_grants_o())->select((int)$grants[0]['id']); + $grant->getObjectProperties(); + return $grant; + } +} diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index 2a5f95a4..b9402864 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -261,6 +261,34 @@ class subusers_o extends db return $subuser; } + /** + * @throws Exception + */ + public function getSubuserByEmail(string $email): ?subusers_o + { + global $db; + $email = $db->escape_string($email); + $tmp = self::getFieldsWhere([ + 'email' => $email, + ], ['id']); + if (count($tmp) === 0) { + return null; + } + $subuser = (new subusers_o())->select((int)$tmp[0]['id']); + $subuser->getObjectProperties(); + return $subuser; + } + + /** + * @throws Exception + */ + public function requiresSetup(): bool + { + self::requireSelected(); + $password = $this->password->value(); + return !is_string($password) || trim($password) === ''; + } + /** * @throws RandomException * @throws Exception @@ -277,6 +305,11 @@ class subusers_o extends db return $session_token; } + public function invalidateSessionToken(string $token): void + { + $this->deleteCached('session_token:' . $token, 'subuser_sessions'); + } + /** * @param string $token The session token * @return subusers_o|null The subuser object or null if the token is invalid or expired @@ -332,4 +365,4 @@ class subusers_o extends db $grant = new subuser_user_grant((int)$this->id, (int)$customer_number); return $grant->hasNode($permission_node_key); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/authRoute.php b/services/nginx/app/routes/authRoute.php index 2cbed675..cff66068 100644 --- a/services/nginx/app/routes/authRoute.php +++ b/services/nginx/app/routes/authRoute.php @@ -95,6 +95,8 @@ class authRoute (new tokens_o())->delete($token); // Clear any cached session for this token try { redis->clear_auth_session($token); } catch (\Throwable $e) {} + // Clear any cached subuser session for this token + try { (new subusers_o())->invalidateSessionToken($token); } catch (\Throwable $e) {} // Return a success message $response->success(['message' => 'Logged out']); }); diff --git a/services/nginx/app/routes/moduleStripeRoute.php b/services/nginx/app/routes/moduleStripeRoute.php index c3b6718e..5098930c 100644 --- a/services/nginx/app/routes/moduleStripeRoute.php +++ b/services/nginx/app/routes/moduleStripeRoute.php @@ -272,6 +272,12 @@ class moduleStripeRoute $department = (new departments_o())->select((int)self::fromRequest('id')); $department->requireSelected(); (new logs_o())->add('modules_stripe', 'global', 1, 0, 'MODULES_STRIPE', 'User accessed the department terminal readers'); + if (!$department->isStripeTerminalLocationSet()) { + $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); + } // Get the department terminal readers $readers = $department->getStripeTerminalReaders(); // Return the result @@ -287,4 +293,4 @@ class moduleStripeRoute ] ); } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/orderRoute.php b/services/nginx/app/routes/orderRoute.php index 900f1811..06fe201d 100644 --- a/services/nginx/app/routes/orderRoute.php +++ b/services/nginx/app/routes/orderRoute.php @@ -85,9 +85,11 @@ class orderRoute self::requireDepartmentAccess($orders_o->department_id->value()); // Collect optional params - $safety_seal = null; - if (isset($data['safety_seal']) && $data['safety_seal'] !== '') { - $safety_seal = (int)$data['safety_seal']; + $safety_seal = $orders_o->getSafetySealValue(); + if (array_key_exists('safety_seal', $data)) { + $orders_o->setSafetySealValue($data['safety_seal']); + $orders_o->objectChanged(); + $safety_seal = $orders_o->getSafetySealValue(); } $operator = isset($data['operator']) && $data['operator'] !== '' ? (string)$data['operator'] : (string)$user->display_name->value(); // Set the date to the creation date of the order if not provided @@ -162,4 +164,4 @@ class orderRoute * } */ } -} \ No newline at end of file +} diff --git a/services/nginx/app/routes/ordersRoute.php b/services/nginx/app/routes/ordersRoute.php index a1000728..b49df00d 100644 --- a/services/nginx/app/routes/ordersRoute.php +++ b/services/nginx/app/routes/ordersRoute.php @@ -159,6 +159,7 @@ class ordersRoute ...(!empty($data['booking_id']) ? ['booking_id' => (int)$data['booking_id']] : []), // Optional booking ID 'created_at' => $createdAt, // Default to current time if not set ...(array_key_exists('include_in_invoice', $data) ? ['include_in_invoice' => $includeInInvoice] : []), + ...(array_key_exists('safety_seal', $data) ? ['safety_seal' => orders_o::normalizeSafetySealValue($data['safety_seal'])] : []), ]; // Create the order //$order = (new orders_o())->add((int)$data['customer_id'], $user->id, $data['reference'], $data['notes'], (int)$data['department_id'], (string)$reg_1, (string)$reg_2, (string)$reg_3); @@ -477,7 +478,7 @@ class ordersRoute $response->error('Order not found', 400); } // Mark the order as completed - $order->markAsCompleted(); + $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 @@ -518,7 +519,10 @@ class ordersRoute $department = (new departments_o())->selectId((int)$order->department_id->value()); if (!$department->isStripeConfigured()) { - $response->error('Department is not configured for Stripe payments', 400); + $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'] ?? '')); @@ -970,6 +974,7 @@ class ordersRoute 'po', 'reference', 'notes', + 'safety_seal', 'reg_1', 'reg_2', 'reg_3', @@ -1003,6 +1008,9 @@ class ordersRoute if (isset($data['notes'])) { $order->notes->set((string)$data['notes']); } + if (array_key_exists('safety_seal', $data)) { + $order->setSafetySealValue($data['safety_seal']); + } // Register the change $order->objectChanged(); // Return a success message @@ -1042,6 +1050,9 @@ class ordersRoute if (isset($data['po'])) { $order->po->set((string)$data['po']); } + if (array_key_exists('safety_seal', $data)) { + $order->setSafetySealValue($data['safety_seal']); + } // If the lane is set, validate it if (isset($data['lane'])) { $order->lane->set((int)$data['lane']); @@ -1108,7 +1119,7 @@ class ordersRoute } $field = is_string($data['field']) ? trim($data['field']) : ''; - $allowedLegacyFields = ['reference', 'notes', 'reg_1', 'reg_2', 'reg_3']; + $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 @@ -1200,6 +1211,7 @@ class ordersRoute $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); diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 3906eb26..2023e6a2 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -25,6 +25,17 @@ class subusersRoute { use route_t; + private function getOwnPermissionForNode(subusers_permission_node_key $node) + { + return match ($node) { + subusers_permission_node_key::SUBUSERS_LIST => self::definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST), + subusers_permission_node_key::SUBUSERS_ADD => self::definePermission('add_own_subusers', subusers_permission_node_key::SUBUSERS_ADD), + subusers_permission_node_key::SUBUSERS_EDIT => self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT), + subusers_permission_node_key::SUBUSERS_DELETE => self::definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE), + default => self::definePermission(strtolower($node->name), $node), + }; + } + private static function requireRegex(string $input, string $regex, string $error_message): void { if (!preg_match($regex, $input)) { @@ -33,6 +44,274 @@ class subusersRoute } } + private function requireManagedCustomerScope(subusers_permission_node_key $node, ?int $targetCustomerNumber = null): int + { + global $response; + + $auth = new authentication(); + $permission = $this->getOwnPermissionForNode($node); + + $subuser = $auth->get_subuser(); + if ($subuser !== false) { + $customerNumber = (int)$auth->get_subuser_customer_number_target(); + if ($customerNumber <= 0) { + $response->error('Unauthorized', 401); + } + if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) { + $this->emitForbidden([$permission]); + } + self::requirePermission($permission); + return $customerNumber; + } + + $user = $auth->get_user(); + if ($user !== false) { + $customerNumber = (int)$user->customer_number->value(); + if ($customerNumber <= 0) { + $response->error('Unauthorized', 401); + } + if ($targetCustomerNumber !== null && $customerNumber !== (int)$targetCustomerNumber) { + $this->emitForbidden([$permission]); + } + return $customerNumber; + } + + $response->error('Unauthorized', 401); + return 0; + } + + private function parsePermissionsPayload(mixed $raw, ?array $default = null): ?array + { + global $response; + + if ($raw === null) { + return $default; + } + + if (is_string($raw)) { + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + $response->error('Invalid permissions payload', 400); + } + $raw = $decoded; + } + + if (!is_array($raw)) { + $response->error('Invalid permissions type', 400); + } + + $permissions = []; + foreach ($raw as $permission) { + if (!is_string($permission) || subusers_permission_node_key::tryFrom($permission) === null) { + $response->error('Unknown permission key: ' . (string)$permission, 400); + } + $permissions[] = strtoupper(trim($permission)); + } + + return array_values(array_unique($permissions)); + } + + private function normalizeOptionalString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized === '' ? null : $normalized; + } + + private function assertSubuserIdentifiersAvailable( + ?int $phoneCountryCode, + ?int $phone, + ?string $username = null, + ?string $email = null, + ?int $ignoreSubuserId = null + ): void { + global $response; + + if ($phoneCountryCode !== null && $phone !== null) { + $existingByPhone = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($existingByPhone !== null && (int)$existingByPhone->id !== (int)$ignoreSubuserId) { + $response->error('Account already exists with this phone number', 400); + } + } + + if ($username !== null) { + $existingByUsername = (new subusers_o())->getSubuserByUsername($username); + if ($existingByUsername !== null && (int)$existingByUsername->id !== (int)$ignoreSubuserId) { + $response->error('Account already exists with this username', 400); + } + } + + if ($email !== null) { + $existingByEmail = (new subusers_o())->getSubuserByEmail($email); + if ($existingByEmail !== null && (int)$existingByEmail->id !== (int)$ignoreSubuserId) { + $response->error('Account already exists with this email address', 400); + } + } + } + + private function resolveSubuserByIdentifiers( + ?int $phoneCountryCode, + ?int $phone, + ?string $username = null, + ?string $email = null + ): ?subusers_o { + global $response; + + $matches = []; + + if ($phoneCountryCode !== null && $phone !== null) { + $existingByPhone = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($existingByPhone !== null) { + $matches[(int)$existingByPhone->id] = $existingByPhone; + } + } + + if ($username !== null) { + $existingByUsername = (new subusers_o())->getSubuserByUsername($username); + if ($existingByUsername !== null) { + $matches[(int)$existingByUsername->id] = $existingByUsername; + } + } + + if ($email !== null) { + $existingByEmail = (new subusers_o())->getSubuserByEmail($email); + if ($existingByEmail !== null) { + $matches[(int)$existingByEmail->id] = $existingByEmail; + } + } + + if (count($matches) > 1) { + $response->error('Provided driver identifiers match multiple existing accounts', 409); + } + + return count($matches) === 1 ? array_values($matches)[0] : null; + } + + private function buildSetupLink(string $token): string + { + return 'https://truckwash.io/complete-registration?token=' . $token; + } + + private function issueSetupInvite(subusers_o $subuser): array + { + if (!$subuser->requiresSetup()) { + return [ + 'setup_token' => null, + 'setup_link' => null, + 'delivery' => [ + 'channel' => 'sms', + 'status' => 'not_required', + 'message' => 'Driver account already accepted the invitation.', + ], + ]; + } + + $token = $subuser->generateSetupToken(); + $link = $this->buildSetupLink($token); + $delivery = [ + 'channel' => 'sms', + 'status' => 'unavailable', + 'message' => 'SMS delivery is not configured.', + ]; + + try { + $gatewayAPI = new gatewayapi(); + if ($gatewayAPI->isEnabled()) { + $phoneNumber = (string)$subuser->phone_country_code->value() . (string)$subuser->phone->value(); + $message = 'Tak for din oprettelse af chaufførkonto hos Truck Wash! Klik på linket for at fuldføre registreringen: ' . $link; + $gatewayAPI->send([$phoneNumber], $message); + $delivery = [ + 'channel' => 'sms', + 'status' => 'sent', + 'message' => 'Invite sent successfully.', + ]; + } + } catch (Exception $exception) { + $delivery = [ + 'channel' => 'sms', + 'status' => 'failed', + 'message' => $exception->getMessage(), + ]; + } + + return [ + 'setup_token' => $token, + 'setup_link' => $link, + 'delivery' => $delivery, + ]; + } + + private function buildSubuserManagementPayload(subusers_o $subuser, int $customerNumber): array + { + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + $grantPermissions = $grant ? $this->parsePermissionsPayload($grant->permissions->value(), []) : []; + $setupRequired = $subuser->requiresSetup(); + $grantEnabled = $grant ? (bool)$grant->enabled->value() : false; + $inviteAccepted = !$setupRequired; + + $accessState = 'inactive'; + if ($grant !== null && $grantEnabled) { + $accessState = $setupRequired ? 'pending_setup' : 'active'; + } elseif ($grant !== null) { + $accessState = 'disabled'; + } + + return [ + 'id' => (int)$subuser->id, + 'username' => $subuser->username->value(), + 'name' => $subuser->name->value(), + 'email' => $subuser->email->value(), + 'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, + 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, + 'created_at' => $subuser->created_at->value() ?? null, + 'updated_at' => $subuser->updated_at->value() ?? null, + 'suspended_at' => $subuser->suspended_at->value() ?? null, + 'two_factor_enabled' => $subuser->isTwoFactorEnabled(), + 'setup_required' => $setupRequired, + 'invite_accepted' => $inviteAccepted, + 'can_resend_invite' => $setupRequired, + 'profile_editable_by_manager' => false, + 'grant_id' => $grant ? (int)$grant->id : null, + 'grant_enabled' => $grantEnabled, + 'grant_note' => $grant ? $grant->note->value() : null, + 'grant_permissions' => $grantPermissions, + 'permissions' => $grantPermissions, + 'access_state' => $accessState, + ]; + } + + private function buildCurrentSubuserPayload(subusers_o $subuser): array + { + $grants = (new subuser_grants_o())->getFieldsWhere([ + 'subuser' => $subuser->id, + 'enabled' => 1, + 'deleted_at' => null, + ], ['permissions', 'billing_customer_number']); + + return [ + 'id' => (int)$subuser->id, + 'username' => $subuser->username->value(), + 'name' => $subuser->name->value(), + 'email' => $subuser->email->value(), + 'phone_country_code' => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, + 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, + 'grants' => array_map(function ($grant) { + return [ + 'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']), + 'billing_customer_number' => (int)$grant['billing_customer_number'], + 'permissions' => $this->parsePermissionsPayload($grant['permissions'] ?? null, []), + ]; + }, $grants), + 'created_at' => $subuser->created_at->value() ?? null, + 'updated_at' => $subuser->updated_at->value() ?? null, + 'suspended_at' => $subuser->suspended_at->value() ?? null, + 'two_factor_enabled' => $subuser->isTwoFactorEnabled(), + ]; + } + public function run(): void { // ============================= @@ -131,15 +410,9 @@ class subusersRoute self::requireType($customer_number, self::type_int()); self::requireType($subuser_id, self::type_int()); - // Enforce own-vs-admin access using target customer number for context - self::allowOwnOrDepartmentAccess( - $permission_own, - $permission_other, - (int)$customer_number, - null, - null, - 'You do not have permission to create subuser grants for this customer.' - ); + if (!self::hasPermission($permission_other, (int)$customer_number)) { + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD, (int)$customer_number); + } $enabled = true; if (self::isParametersSet(['enabled'])) { @@ -189,6 +462,7 @@ class subusersRoute global $response; /** Permissions (subuser-aware) */ $permission_own = self::definePermission('edit_own_subusers', subusers_permission_node_key::SUBUSERS_EDIT); + $permission_delete_own = self::definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE); $permission_other = self::definePermission('manage_subuser_grants'); self::requireParameters(['id']); @@ -198,16 +472,17 @@ class subusersRoute $response->error('Grant not found', 404); } - // Enforce own-vs-admin using the grant's customer number $targetCustomer = (int)$grant->billing_customer_number->value(); - self::allowOwnOrDepartmentAccess( - $permission_own, - $permission_other, - $targetCustomer, - null, - null, - 'You do not have permission to modify this subuser grant.' - ); + if (!self::hasPermission($permission_other, $targetCustomer)) { + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT, $targetCustomer); + } + + if (self::isParametersSet(['enabled'])) { + $enabledPreview = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($enabledPreview === false && !self::hasPermission($permission_other)) { + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_DELETE, $targetCustomer); + } + } // Update fields provided in the request if (self::isParametersSet(['enabled'])) { @@ -247,7 +522,8 @@ class subusersRoute }, [ 'manage_subuser_grants' => 'Edit subuser grants for any customer (admin).', - 'edit_own_subusers' => 'Edit subuser grants for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.' + 'edit_own_subusers' => 'Edit subuser grants for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.', + 'delete_own_subusers' => 'Disable subuser grants for own customer. Subusers require node: SUBUSERS_DELETE and X-Customer-Number header.' ] ); @@ -496,69 +772,37 @@ class subusersRoute // ============================= $this->get('/subusers', function () { global $response; - // Require authenticated principal (user or subuser) - $auth = new authentication(); - $user = $auth->get_user(); - $subuser = $auth->get_subuser(); - if ($user === false && $subuser === false) { - $response->error('Unauthorized', 401); - } - - // Link route permission to Subusers node so subusers can be constrained by grants. - // For classic users we keep current behavior (no extra user permission enforced here). - $permission_list = self::definePermission('list_own_subusers', subusers_permission_node_key::SUBUSERS_LIST); - if ($subuser !== false) { - // Only enforce for subuser principals; classic users are governed by existing user ACLs elsewhere. - self::requirePermission($permission_list); - } - - // Determine effective customer number (user's customer number or subuser's target header) - if ($user !== false) { - $customerNumber = (int)$user->customer_number->value(); - } else { - $customerNumber = (int)$auth->get_subuser_customer_number_target(); - } - if (empty($customerNumber)) { - $response->error('Unauthorized', 401); - } - - // Optional: include_non_enabled (boolean) — when true, include subusers that only have non-enabled grants + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST); $includeNonEnabled = false; if (self::isParametersSet(['include_non_enabled'])) { $tmp = filter_var(self::getParameter('include_non_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $includeNonEnabled = $tmp === null ? false : (bool)$tmp; } - // Only list subusers that have an enabled grant for the caller's customer number $existsClause = sprintf( "EXISTS (SELECT 1 FROM `subuser_grants` sg WHERE sg.`subuser` = `subusers`.`id` AND %ssg.`deleted_at` IS NULL AND sg.`billing_customer_number` = %d)", $includeNonEnabled ? '' : 'sg.`enabled` = 1 AND ', $customerNumber ); - // Use pagination helper with additional where $objects = (new subusers_o()) ->listObjectsWithPaginationIfSet(function ($o) use ($customerNumber) { - $o = (object)$o; - $permissions = (new subuser_grants_o()) - ->getGrantsForSubuserAndCustomer((int)$o->id, $customerNumber); - return [ - 'id' => (int)$o->id, - 'username' => $o->username, - 'name' => $o->name, - 'email' => $o->email, - 'phone_country_code' => isset($o->phone_country_code) ? (int)$o->phone_country_code : null, - 'phone' => isset($o->phone) ? (int)$o->phone : null, - 'created_at' => $o->created_at ?? null, - 'updated_at' => $o->updated_at ?? null, - 'suspended_at' => $o->suspended_at ?? null, - 'permissions' => $permissions, - 'two_factor_enabled' => $o->isTwoFactorEnabled(), - ]; + $subuser = (new subusers_o())->select((int)$o['id']); + if (!$subuser->exists()) { + return null; + } + $subuser->getObjectProperties(); + return $this->buildSubuserManagementPayload($subuser, $customerNumber); }, null, [], $existsClause); + if (is_array($objects)) { + $objects = array_values(array_filter($objects, static fn ($item) => $item !== null)); + } + $response->success($objects); - }, []); + }, [ + 'list_own_subusers' => 'List chauffeurs for own customer. Subusers require node: SUBUSERS_LIST and X-Customer-Number header.', + ]); $this->get('/subusers/me', function () { global $response; @@ -567,34 +811,196 @@ class subusersRoute $response->error('Unauthorized', 401); } - $grants = (new subuser_grants_o())->getFieldsWhere([ - 'subuser' => $subuser->id, - 'enabled' => 1, - 'deleted_at' => null, - ], ['permissions', 'billing_customer_number']); - - $result = [ - "id" => (int)$subuser->id, - "username" => $subuser->username->value(), - "name" => $subuser->name->value(), - "email" => $subuser->email->value(), - "phone_country_code" => $subuser->phone_country_code->value() !== null ? (int)$subuser->phone_country_code->value() : null, - "phone" => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, - "grants" => array_map(function ($grant) { - return [ - 'name' => (new users_o())->getCustomerName((int)$grant['billing_customer_number']), - 'billing_customer_number' => (int)$grant['billing_customer_number'], - 'permissions' => json_decode($grant['permissions'], true) ?: [], - ]; - }, $grants), - "created_at" => $subuser->created_at->value() ?? null, - "updated_at" => $subuser->updated_at->value() ?? null, - "suspended_at" => $subuser->suspended_at->value() ?? null, - "two_factor_enabled" => $subuser->isTwoFactorEnabled(), - ]; - $response->success($result); + $response->success($this->buildCurrentSubuserPayload($subuser)); }, []); + $this->put('/subusers/me', function () { + global $response; + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { + $response->error('Unauthorized', 401); + } + + $updates = []; + + if (self::isParametersSet(['name'])) { + $name = $this->normalizeOptionalString(self::getParameter('name')); + if ($name === null || strlen($name) < 3 || strlen($name) > 255) { + $response->error('Name must be between 3 and 255 characters long', 400); + } + $updates['name'] = $name; + } + + if (self::isParametersSet(['username'])) { + $username = $this->normalizeOptionalString(self::getParameter('username')); + if ($username !== null && (strlen($username) < 3 || strlen($username) > 50)) { + $response->error('Username must be between 3 and 50 characters long', 400); + } + $updates['username'] = $username; + } + + if (self::isParametersSet(['email'])) { + $email = $this->normalizeOptionalString(self::getParameter('email')); + if ($email !== null && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $response->error('Invalid email format', 400); + } + if ($email !== null && strlen($email) > 255) { + $response->error('Email must be at most 255 characters long', 400); + } + $updates['email'] = $email; + } + + if (count($updates) === 0) { + $response->error('No fields to update', 400); + } + + $this->assertSubuserIdentifiersAvailable( + null, + null, + $updates['username'] ?? null, + $updates['email'] ?? null, + (int)$subuser->id + ); + + try { + $subuser->update($updates); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + + $subuser = (new subusers_o())->select((int)$subuser->id); + $subuser->getObjectProperties(); + $response->success($this->buildCurrentSubuserPayload($subuser)); + }, []); + + $this->post('/subusers/invite', function () { + global $response; + + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD); + self::requireParameters(['name', 'phone_country_code', 'phone']); + + $name = $this->normalizeOptionalString(self::getParameter('name')); + $phoneCountryCode = (int)self::getParameter('phone_country_code'); + $phone = (int)self::getParameter('phone'); + $note = self::isParametersSet(['note']) ? $this->normalizeOptionalString(self::getParameter('note')) : null; + $enabled = true; + if (self::isParametersSet(['enabled'])) { + $tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + $enabled = $tmp === null ? true : (bool)$tmp; + } + $permissions = self::isParametersSet(['permissions']) + ? $this->parsePermissionsPayload(self::getParameter('permissions'), []) + : null; + + if ($name === null || strlen($name) < 3 || strlen($name) > 255) { + $response->error('Name must be between 3 and 255 characters long', 400); + } + self::requireType($phoneCountryCode, self::type_int()); + self::requireType($phone, self::type_int()); + self::requireMinLength('phone_country_code', 1); + self::requireMaxLength('phone_country_code', 3); + self::requireMinLength('phone', 4); + self::requireMaxLength('phone', 15); + if ($note !== null && strlen($note) > 65535) { + $response->error('Note must be at most 65535 characters long', 400); + } + + $subuser = (new subusers_o())->getSubuserByPhone($phoneCountryCode, $phone); + if ($subuser === null) { + try { + $subuser = (new subusers_o())->add( + null, + null, + $name, + null, + $phoneCountryCode, + $phone + ); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + } + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + if ($grant === null) { + try { + $grant = (new subuser_grants_o())->add( + $customerNumber, + (int)$subuser->id, + $enabled, + $note, + $permissions ?? subuser_grants_o::defaultPermissions + ); + } catch (Exception $exception) { + $response->error('Failed to create subuser grant', 500); + } + } else { + $grantUpdates = ['enabled' => $enabled]; + if (self::isParametersSet(['note'])) { + $grantUpdates['note'] = $note; + } + if ($permissions !== null) { + $grantUpdates['permissions'] = $permissions; + } + try { + $grant->update($grantUpdates); + } catch (Exception $exception) { + $response->error('Failed to update subuser grant', 500); + } + $grant = (new subuser_grants_o())->select((int)$grant->id); + $grant->getObjectProperties(); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + }, [ + 'add_own_subusers' => 'Invite or link chauffeurs for own customer. Subusers require node: SUBUSERS_ADD and X-Customer-Number header.', + ]); + + $this->post('/subusers/invite/resend', function () { + global $response; + + self::requireParameters(['id']); + $subuserId = (int)self::getParameter('id'); + self::requireType($subuserId, self::type_int()); + + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); + $subuser = (new subusers_o())->select($subuserId); + if (!$subuser->exists()) { + $response->error('Subuser not found', 404); + } + $subuser->getObjectProperties(); + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true); + if ($grant === null) { + $response->error('Subuser grant not found for selected customer', 404); + } + + if (!$subuser->requiresSetup()) { + $response->error('Driver account already accepted the invitation.', 409); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + }, [ + 'edit_own_subusers' => 'Resend chauffeur invite for own customer. Subusers require node: SUBUSERS_EDIT and X-Customer-Number header.', + ]); + + $this->put('/subusers', function () { + global $response; + $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); + $response->error('Customers can only manage subuser grants. Drivers own their account profile.', 403); + }, [ + 'edit_own_subusers' => 'Customers cannot edit chauffeur account profiles. They may only manage grants, permissions, and enabled state.', + ]); // Public registration endpoint (alias of POST /subusers) matching OpenAPI: POST /subusers/me $this->post('/subusers/me', function () { global /** @var response $response */ diff --git a/services/nginx/app/tests/Api/AuthApiTest.php b/services/nginx/app/tests/Api/AuthApiTest.php index bac0e568..769f890d 100644 --- a/services/nginx/app/tests/Api/AuthApiTest.php +++ b/services/nginx/app/tests/Api/AuthApiTest.php @@ -136,6 +136,39 @@ it('logs out and invalidates the token for future session calls', function (): v ->assertMessageContains('Token not found'); }); +it('logs out and invalidates cached subuser sessions', function (): void { + api_test_covers('GET /auth/logout', 'happy'); + + $user = api_fixtures()->createUser(); + + $session = api_fixtures()->createSubuserSession((int)$user['customer_number'], [], [ + 'username' => 'logout-driver', + ]); + + $warmCache = api_client()->get('/subusers/me', $session['headers']); + + $warmCache + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $logoutResponse = api_client()->get('/auth/logout', $session['headers']); + + $logoutResponse + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess() + ->assertMessage('Logged out'); + + $followUpProfile = api_client()->get('/subusers/me', $session['headers']); + + $followUpProfile + ->assertStatus(401) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Unauthorized'); +}); + it('rejects invalid logout tokens', function (): void { api_test_covers('GET /auth/logout', 'auth'); diff --git a/services/nginx/app/tests/Api/OrdersApiTest.php b/services/nginx/app/tests/Api/OrdersApiTest.php index 6a45c81b..8c77ebff 100644 --- a/services/nginx/app/tests/Api/OrdersApiTest.php +++ b/services/nginx/app/tests/Api/OrdersApiTest.php @@ -133,6 +133,7 @@ it('creates orders through the real endpoint', function (): void { 'department_id' => $department['id'], 'reference' => 'ORDER-POST', 'notes' => 'Created through HTTP', + 'safety_seal' => 'SEAL-CREATE', 'reg_1' => ' post-123 ', 'reg_2' => ' tr 9-8 ', 'reg_3' => ' 7z/x ', @@ -152,6 +153,7 @@ it('creates orders through the real endpoint', function (): void { expect($row['reg_1'] ?? null)->toBe('POST123'); expect($row['reg_2'] ?? null)->toBe('TR98'); expect($row['reg_3'] ?? null)->toBe('7ZX'); + expect($row['safety_seal'] ?? null)->toBe('SEAL-CREATE'); api_fixtures()->cleanupDeleteById('orders', $orderId); }); @@ -244,6 +246,7 @@ it('updates orders through the primary endpoint', function (): void { 'lane' => 2, 'wash_id' => 'WASH-BEFORE', 'booking_id' => 321, + 'safety_seal' => 'SEAL-BEFORE', 'include_in_invoice' => true, 'created_at' => '2026-04-08 08:44:07', ]); @@ -262,6 +265,7 @@ it('updates orders through the primary endpoint', function (): void { 'lane' => 7, 'wash_id' => 'WASH-123', 'booking_id' => 9876, + 'safety_seal' => 'SEAL-AFTER', 'invoice_collection_id' => $updatedInvoiceCollection['id'], 'created_at' => '2026-04-09 13:37:00', 'include_in_invoice' => false, @@ -287,6 +291,7 @@ it('updates orders through the primary endpoint', function (): void { expect((int)($row['lane'] ?? 0))->toBe(7); expect($row['wash_id'] ?? null)->toBe('WASH-123'); expect((int)($row['booking_id'] ?? 0))->toBe(9876); + expect($row['safety_seal'] ?? null)->toBe('SEAL-AFTER'); expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']); expect($row['created_at'] ?? null)->toBe('2026-04-09 13:37:00'); expect((int)($row['include_in_invoice'] ?? 1))->toBe(0); @@ -313,6 +318,7 @@ it('supports legacy field-value metadata updates through the primary endpoint', $payloads = [ ['field' => 'reference', 'value' => 'LEGACY-REF'], ['field' => 'notes', 'value' => 'Legacy note'], + ['field' => 'safety_seal', 'value' => 'LEGACY-SEAL'], ['field' => 'reg_1', 'value' => ' ab-12 34 '], ['field' => 'reg_2', 'value' => ' cd/56 78 '], ['field' => 'reg_3', 'value' => ' ef_90 12 '], @@ -334,6 +340,7 @@ it('supports legacy field-value metadata updates through the primary endpoint', expect($row)->not->toBeNull(); expect($row['reference'] ?? null)->toBe('LEGACY-REF'); expect($row['notes'] ?? null)->toBe('Legacy note'); + expect($row['safety_seal'] ?? null)->toBe('LEGACY-SEAL'); expect($row['reg_1'] ?? null)->toBe('AB1234'); expect($row['reg_2'] ?? null)->toBe('CD5678'); expect($row['reg_3'] ?? null)->toBe('EF9012'); @@ -390,6 +397,7 @@ it('updates orders through the legacy alias endpoint', function (): void { 'lane' => 4, 'wash_id' => 'LEGACY-WASH', 'booking_id' => 654, + 'safety_seal' => 'LEGACY-SEAL-BEFORE', 'include_in_invoice' => false, 'created_at' => '2026-04-10 08:15:00', ]); @@ -408,6 +416,7 @@ it('updates orders through the legacy alias endpoint', function (): void { 'lane' => 9, 'wash_id' => 'LEGACY-WASH-NEW', 'booking_id' => 7654, + 'safety_seal' => 'LEGACY-SEAL-AFTER', 'invoice_collection_id' => $updatedInvoiceCollection['id'], 'created_at' => '2026-04-11 11:22:33', 'include_in_invoice' => true, @@ -433,6 +442,7 @@ it('updates orders through the legacy alias endpoint', function (): void { expect((int)($row['lane'] ?? 0))->toBe(9); expect($row['wash_id'] ?? null)->toBe('LEGACY-WASH-NEW'); expect((int)($row['booking_id'] ?? 0))->toBe(7654); + expect($row['safety_seal'] ?? null)->toBe('LEGACY-SEAL-AFTER'); expect((int)($row['invoice_collection_id'] ?? 0))->toBe((int)$updatedInvoiceCollection['id']); expect($row['created_at'] ?? null)->toBe('2026-04-11 11:22:33'); expect((int)($row['include_in_invoice'] ?? 0))->toBe(1); @@ -459,6 +469,7 @@ it('supports legacy field-value metadata updates through the alias endpoint', fu $payloads = [ ['field' => 'reference', 'value' => 'ALIAS-REF'], ['field' => 'notes', 'value' => 'Alias updated note'], + ['field' => 'safety_seal', 'value' => 'ALIAS-SEAL'], ['field' => 'reg_1', 'value' => ' gh-12 34 '], ['field' => 'reg_2', 'value' => ' ij/56 78 '], ['field' => 'reg_3', 'value' => ' kl_90 12 '], @@ -480,6 +491,7 @@ it('supports legacy field-value metadata updates through the alias endpoint', fu expect($row)->not->toBeNull(); expect($row['reference'] ?? null)->toBe('ALIAS-REF'); expect($row['notes'] ?? null)->toBe('Alias updated note'); + expect($row['safety_seal'] ?? null)->toBe('ALIAS-SEAL'); expect($row['reg_1'] ?? null)->toBe('GH1234'); expect($row['reg_2'] ?? null)->toBe('IJ5678'); expect($row['reg_3'] ?? null)->toBe('KL9012'); diff --git a/services/nginx/app/tests/Api/StripeApiTest.php b/services/nginx/app/tests/Api/StripeApiTest.php new file mode 100644 index 00000000..77b2fa4a --- /dev/null +++ b/services/nginx/app/tests/Api/StripeApiTest.php @@ -0,0 +1,64 @@ +createDepartment([ + 'name' => 'Stripe Setup Pending Department', + ]); + $session = api_fixtures()->createUserSession([ + 'modules_stripe_department_terminal_readers_list', + 'department_access_' . $department['id'], + ]); + + $response = api_client()->get( + '/modules/stripe/department/terminal/readers?id=' . $department['id'], + $session['headers'] + ); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Card payments are not ready for this department. Open Stripe setup and choose a terminal location.'); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('code', 'stripe_terminal_setup_required'); +}); + +it('returns a setup required error when creating a payment intent for a department without terminal setup', function (): void { + $department = api_fixtures()->createDepartment([ + 'name' => 'Stripe Payment Intent Pending Department', + ]); + $customer = api_fixtures()->createUser([ + 'display_name' => 'Stripe Payment Intent Customer', + ]); + $order = api_fixtures()->createOrder([ + 'customer_id' => $customer['customer_number'], + 'department_id' => $department['id'], + 'reference' => 'STRIPE-SETUP-REQUIRED', + 'reg_1' => 'STRIPE01', + ]); + $session = api_fixtures()->createUserSession([ + 'charge_order', + ]); + + $response = api_client()->post('/orders/module/stripe/payment_intent', [ + 'id' => $order['id'], + 'reader' => 'reader_pending_setup', + 'tax_percentage' => 25, + ], $session['headers']); + + $response + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Card payments are not ready for this department. Open Stripe setup and choose a terminal location.'); + + expect($response->data()) + ->toBeArray() + ->toHaveKey('code', 'stripe_terminal_setup_required'); +}); diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index b8cf6b18..6717df14 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -263,6 +263,7 @@ final class ApiFixtures 'wash_id' => $attributes['wash_id'] ?? null, 'lane' => $attributes['lane'] ?? null, 'po' => $attributes['po'] ?? null, + 'safety_seal' => $attributes['safety_seal'] ?? null, 'using_hand_held' => (int)($attributes['using_hand_held'] ?? 0), 'include_in_invoice' => $attributes['include_in_invoice'] ?? 1, 'created_at' => $attributes['created_at'] ?? $this->now(), diff --git a/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php b/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php new file mode 100644 index 00000000..ececa258 --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrderBookingsCompletionDedupTest.php @@ -0,0 +1,104 @@ +id = -1; + $this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false); + $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); + $this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false); + } + + public function objectChanged(): void + { + } + } +} + +if (!class_exists('OrderBookingsCompletionDouble')) { + class OrderBookingsCompletionDouble extends order_bookings_o + { + public bool $containsWashCertificate = false; + public bool $orderWasCreated = false; + public bool $orderItemsWereCreated = false; + public int $attachCalls = 0; + public int $sendCalls = 0; + public orders_o $linkedOrder; + + public function __construct(orders_o $linkedOrder) + { + $this->id = -1; + $this->linkedOrder = $linkedOrder; + $this->order_id = new object_property('order_bookings', -1, 'order_id', 'int', false); + $this->items = new object_property('order_bookings', -1, 'items', 'json', false); + $this->items->set([]); + } + + public function createOrderBy(int $user_id): void + { + $this->orderWasCreated = true; + $this->order_id->set(999); + } + + public function createOrderItemsBy(int $user_id): void + { + $this->orderItemsWereCreated = true; + } + + public function containsWashCertificateItem(): bool + { + return $this->containsWashCertificate; + } + + public function getOrder(): orders_o + { + return $this->linkedOrder; + } + + protected function attachWashCertificate(int $user_id, string $safety_seal = null): void + { + $this->attachCalls++; + } + + public function sendWashCertificateToCustomer(): void + { + $this->sendCalls++; + } + } +} + +it('does not create or email a duplicate wash certificate when a booking is already linked to a pos order', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $booking = new OrderBookingsCompletionDouble($order); + $booking->order_id->set(321); + $booking->containsWashCertificate = true; + + $booking->completeBooking(77, 'LINKED-SEAL'); + + expect($order->getSafetySealValue())->toBe('LINKED-SEAL'); + expect($booking->attachCalls)->toBe(0); + expect($booking->sendCalls)->toBe(0); +}); + +it('keeps standalone booking completion behavior unchanged for wash certificates', function (): void { + $order = new OrderBookingsCompletionOrderDouble(); + $booking = new OrderBookingsCompletionDouble($order); + $booking->containsWashCertificate = true; + + $booking->completeBooking(77, null); + + expect($booking->orderWasCreated)->toBeTrue(); + expect($booking->orderItemsWereCreated)->toBeTrue(); + expect($booking->attachCalls)->toBe(1); + expect($booking->sendCalls)->toBe(1); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php b/services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php new file mode 100644 index 00000000..52f21fcf --- /dev/null +++ b/services/nginx/app/tests/Unit/Orders/OrdersAutoWashCertificateCompletionTest.php @@ -0,0 +1,143 @@ +send_count++; + } + } +} + +if (!class_exists('OrdersAutoWashCertificateCompletionDouble')) { + class OrdersAutoWashCertificateCompletionDouble extends orders_o + { + public bool $containsWashCertificate = false; + public bool $washCertificateAttached = false; + /** @var array */ + public array $generatedCertificates = []; + public ?order_bookings_o $linkedBooking = null; + + public function __construct() + { + $this->id = -1; + $this->customer_id = new object_property('orders', -1, 'customer_id', 'int', true); + $this->cashier_id = new object_property('orders', -1, 'cashier_id', 'int', true); + $this->reference = new object_property('orders', -1, 'reference', 'string', false); + $this->notes = new object_property('orders', -1, 'notes', 'string', false); + $this->department_id = new object_property('orders', -1, 'department_id', 'int', true); + $this->reg_1 = new object_property('orders', -1, 'reg_1', 'string', false); + $this->reg_2 = new object_property('orders', -1, 'reg_2', 'string', false); + $this->reg_3 = new object_property('orders', -1, 'reg_3', 'string', false); + $this->created_at = new object_property('orders', -1, 'created_at', 'timestamp', false); + $this->include_in_invoice = new object_property('orders', -1, 'include_in_invoice', 'bool', false); + $this->completed_at = new object_property('orders', -1, 'completed_at', 'timestamp', false); + $this->deleted_at = new object_property('orders', -1, 'deleted_at', 'timestamp', false); + $this->invoice_collection_id = new object_property('orders', -1, 'invoice_collection_id', 'int', false); + $this->booking_id = new object_property('orders', -1, 'booking_id', 'int', false); + $this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false); + $this->lane = new object_property('orders', -1, 'lane', 'string', false); + $this->po = new object_property('orders', -1, 'po', 'string', false); + $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); + $this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false); + } + + public function objectChanged(): void + { + } + + protected function resolveDepartmentIncludedInInvoicing(): bool + { + return true; + } + + public function isPendingHandheld(): bool + { + return false; + } + + public function containsWashCertificateItem(): bool + { + return $this->containsWashCertificate; + } + + public function hasWashCertificateAttached(): bool + { + return $this->washCertificateAttached; + } + + public function generateWashCertificate(string|null $safety_seal = null, string|null $operator = null, $date = null): void + { + if ($this->washCertificateAttached) { + return; + } + + $this->generatedCertificates[] = [ + 'seal_number' => self::normalizeSafetySealValue($safety_seal), + 'operator' => $operator, + 'date' => $date, + ]; + $this->washCertificateAttached = true; + } + + public function getOrderBooking(): order_bookings_o|null + { + return $this->linkedBooking; + } + } +} + +it('auto-attaches a wash certificate on order completion when a wash certificate item is present', function (): void { + $booking = new OrdersAutoWashCertificateLinkedBookingDouble(); + $order = new OrdersAutoWashCertificateCompletionDouble(); + $order->containsWashCertificate = true; + $order->booking_id->set(123); + $order->linkedBooking = $booking; + $order->safety_seal->set('SEAL-41'); + + $order->markAsCompleted('Operator One'); + + expect($order->completed_at->value())->not->toBeNull(); + expect($order->generatedCertificates)->toHaveCount(1); + expect($order->generatedCertificates[0])->toMatchArray([ + 'seal_number' => 'SEAL-41', + 'operator' => 'Operator One', + ]); + expect($booking->send_count)->toBe(1); +}); + +it('keeps order completion idempotent when a wash certificate is already attached', function (): void { + $booking = new OrdersAutoWashCertificateLinkedBookingDouble(); + $order = new OrdersAutoWashCertificateCompletionDouble(); + $order->containsWashCertificate = true; + $order->washCertificateAttached = true; + $order->booking_id->set(456); + $order->linkedBooking = $booking; + + $order->markAsCompleted('Operator Two'); + + expect($order->generatedCertificates)->toBe([]); + expect($booking->send_count)->toBe(0); +}); + +it('allows blank safety seal values when auto-attaching a wash certificate on completion', function (): void { + $order = new OrdersAutoWashCertificateCompletionDouble(); + $order->containsWashCertificate = true; + $order->safety_seal->set(null); + + $order->markAsCompleted('Operator Three'); + + expect($order->generatedCertificates)->toHaveCount(1); + expect($order->generatedCertificates[0]['seal_number'])->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php b/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php index a5dce72b..2b20e41e 100644 --- a/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php +++ b/services/nginx/app/tests/Unit/Orders/OrdersIncludeInInvoiceOverrideTest.php @@ -31,6 +31,7 @@ if (!class_exists('OrdersIncludeInInvoiceOverrideOrderDouble')) { $this->wash_id = new object_property('orders', -1, 'wash_id', 'string', false); $this->lane = new object_property('orders', -1, 'lane', 'string', false); $this->po = new object_property('orders', -1, 'po', 'string', false); + $this->safety_seal = new object_property('orders', -1, 'safety_seal', 'string', false); $this->using_hand_held = new object_property('orders', -1, 'using_hand_held', 'bool', false); $this->temporary_net_amount = 125.5; @@ -88,6 +89,7 @@ it('serializes both raw and effective include_in_invoice values', function (): v expect($order->asArray(true, false))->toMatchArray([ 'include_in_invoice' => null, 'include_in_invoice_effective' => true, + 'safety_seal' => null, 'created_at' => '2026-04-09 12:34:56', 'total_net_amount' => 125.5, ]); diff --git a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php new file mode 100644 index 00000000..bb5d285e --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php @@ -0,0 +1,64 @@ +toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("\$this->post('/subusers/invite', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/invite/resend', function () {"); + expect($normalized)->toContain("\$this->put('/subusers', function () {"); + expect($normalized)->toContain("\$this->put('/subusers/me', function () {"); +}); + +it('includes grant management fields in the subusers payload builder', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("'grant_id' =>"); + expect($normalized)->toContain("'grant_enabled' =>"); + expect($normalized)->toContain("'grant_note' =>"); + expect($normalized)->toContain("'grant_permissions' =>"); + expect($normalized)->toContain("'setup_required' =>"); + expect($normalized)->toContain("'invite_accepted' =>"); + expect($normalized)->toContain("'can_resend_invite' =>"); + expect($normalized)->toContain("'profile_editable_by_manager' => false"); + expect($normalized)->toContain("'access_state' =>"); +}); + +it('links grant disable operations to SUBUSERS_DELETE for own-customer managers', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("definePermission('delete_own_subusers', subusers_permission_node_key::SUBUSERS_DELETE)"); + expect($normalized)->toContain("requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_DELETE, \$targetCustomer);"); +}); + +it('prevents own-customer managers from editing driver-owned account profiles', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("Customers can only manage subuser grants. Drivers own their account profile."); +}); + +it('only allows invite resend while setup is still pending', function (): void { + $routeFile = app_path('routes/subusersRoute.php'); + expect(is_file($routeFile))->toBeTrue(); + + $code = (string)file_get_contents($routeFile); + $normalized = preg_replace('/\s+/', ' ', $code); + + expect($normalized)->toContain("Driver account already accepted the invitation."); + expect($normalized)->toContain("if (!\$subuser->requiresSetup()) {"); +});