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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
usesApiSuite();
|
||||
|
||||
it('returns a setup required error when department terminal readers are requested without terminal setup', function (): void {
|
||||
$department = api_fixtures()->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');
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/object_property.php');
|
||||
app_require('objects/order_bookings_o.php');
|
||||
app_require('objects/orders_o.php');
|
||||
|
||||
use classes\object_property;
|
||||
use objects\order_bookings_o;
|
||||
use objects\orders_o;
|
||||
|
||||
if (!class_exists('OrderBookingsCompletionOrderDouble')) {
|
||||
class OrderBookingsCompletionOrderDouble extends orders_o
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
app_require('classes/object_property.php');
|
||||
app_require('objects/order_bookings_o.php');
|
||||
app_require('objects/orders_o.php');
|
||||
|
||||
use classes\object_property;
|
||||
use objects\order_bookings_o;
|
||||
use objects\orders_o;
|
||||
|
||||
if (!class_exists('OrdersAutoWashCertificateLinkedBookingDouble')) {
|
||||
class OrdersAutoWashCertificateLinkedBookingDouble extends order_bookings_o
|
||||
{
|
||||
public int $send_count = 0;
|
||||
|
||||
public function sendWashCertificateToCustomer(): void
|
||||
{
|
||||
$this->send_count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists('OrdersAutoWashCertificateCompletionDouble')) {
|
||||
class OrdersAutoWashCertificateCompletionDouble extends orders_o
|
||||
{
|
||||
public bool $containsWashCertificate = false;
|
||||
public bool $washCertificateAttached = false;
|
||||
/** @var array<int, array{seal_number:?string,operator:?string,date:mixed}> */
|
||||
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();
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
it('exposes chauffeur management endpoints on the subusers route', 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("\$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()) {");
|
||||
});
|
||||
Reference in New Issue
Block a user