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:
Jeppe Bundgaard
2026-04-14 10:51:25 +02:00
parent 98f3188a98
commit ebf7e820d5
18 changed files with 1161 additions and 151 deletions
@@ -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;
}
+36 -23
View File
@@ -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);
}
}
}
+90 -8
View File
@@ -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,
+46 -20
View File
@@ -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;
}
}
+34 -1
View File
@@ -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);
}
}
}
+2
View File
@@ -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
@@ -287,4 +293,4 @@ class moduleStripeRoute
]
);
}
}
}
+6 -4
View File
@@ -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
* }
*/
}
}
}
+15 -3
View File
@@ -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);
+497 -91
View File
@@ -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()) {");
});