diff --git a/openapi.yaml b/openapi.yaml index 8b652dc1..6f6bcb61 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1460,7 +1460,7 @@ paths: tags: - Subusers summary: List subusers for a superuser customer account - description: Returns paginated driver access grants for the customer number resolved from the selected user. + description: Returns paginated chauffeur accounts for the customer number resolved from the selected user. Each chauffeur appears once; visible customer access grants are returned in `grants`. operationId: listSuperuserUserSubusers security: - BearerAuth: [] @@ -16873,6 +16873,7 @@ components: SubuserManagementRow: type: object + description: Chauffeur management row. Superuser list endpoints return one row per chauffeur account; grant-specific fields mirror a primary grant for backwards compatibility, and all visible customer grants are listed in `grants`. properties: id: { type: integer } username: { type: string, nullable: true } @@ -16910,6 +16911,7 @@ components: grant_updated_at: { type: string, format: date-time, nullable: true } grants: type: array + description: Visible customer access grants for this chauffeur, grouped under the single chauffeur row. items: $ref: '#/components/schemas/SubuserManagementGrant' grant_count: { type: integer } diff --git a/services/nginx/app/classes/subuser_contact_verification_service.php b/services/nginx/app/classes/subuser_contact_verification_service.php new file mode 100644 index 00000000..831343fa --- /dev/null +++ b/services/nginx/app/classes/subuser_contact_verification_service.php @@ -0,0 +1,488 @@ +redis = $redis ?? (defined('redis') ? constant('redis') : null); + $this->codeGenerator = $codeGenerator; + $this->timeProvider = $timeProvider; + $this->smsSender = $smsSender; + $this->emailSender = $emailSender; + } + + /** + * @return array{state:string,email:array,phone:array} + */ + public function status(subusers_o $subuser): array + { + $email = $this->emailDestination($subuser); + $phone = $this->phoneDestination($subuser); + $emailVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_EMAIL); + $phoneVerifiedAt = $this->verifiedAtValue($subuser, self::CHANNEL_PHONE); + + $emailStatus = [ + 'channel' => self::CHANNEL_EMAIL, + 'value' => $email, + 'masked_value' => $email !== null ? $this->maskEmail($email) : null, + 'available' => $email !== null, + 'verified' => $email !== null && $emailVerifiedAt !== null, + 'verified_at' => $emailVerifiedAt, + ]; + $phoneStatus = [ + 'channel' => self::CHANNEL_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, + 'value' => $phone, + 'masked_value' => $phone !== null ? $this->maskPhone($phone) : null, + 'available' => $phone !== null, + 'verified' => $phone !== null && $phoneVerifiedAt !== null, + 'verified_at' => $phoneVerifiedAt, + ]; + + return [ + 'state' => $this->verificationState($emailStatus, $phoneStatus), + 'email' => $emailStatus, + 'phone' => $phoneStatus, + ]; + } + + /** + * @return array + */ + public function sendCode(subusers_o $subuser, string $channel, array $actor = []): array + { + $channel = $this->normalizeChannel($channel); + $destination = $this->destinationFor($subuser, $channel); + if ($destination === null) { + return $this->delivery($channel, 'missing_destination', 'No contact value is available for verification.'); + } + + if ($this->redis === null) { + return $this->delivery($channel, 'unavailable', 'Verification delivery is not available.'); + } + + $now = $this->now(); + $existing = $this->readChallenge($subuser, $channel); + if ($existing !== null) { + $sentAt = (int)($existing['sent_at'] ?? 0); + $retryAfter = self::RESEND_COOLDOWN_SECONDS - ($now - $sentAt); + if ($retryAfter > 0) { + return $this->delivery($channel, 'throttled', 'Please wait before requesting another code.', [ + 'retry_after' => $retryAfter, + ]); + } + } + + $code = $this->generateCode(); + $destinationKey = $this->destinationKey($subuser, $channel); + $nonce = bin2hex(random_bytes(16)); + $payload = [ + 'hash' => $this->hashCode($code, $destinationKey, $nonce), + 'nonce' => $nonce, + 'destination' => $destinationKey, + 'attempts' => 0, + 'sent_at' => $now, + 'expires_at' => $now + self::CODE_TTL_SECONDS, + ]; + + try { + $this->deliverCode($subuser, $channel, $destination, $code); + $this->writeChallenge($subuser, $channel, $payload); + $this->logEvent('SUBUSER_CONTACT_VERIFICATION_SENT', $subuser, $channel, $actor); + } catch (Exception $exception) { + $this->logEvent('SUBUSER_CONTACT_VERIFICATION_FAILED', $subuser, $channel, $actor); + return $this->delivery($channel, 'failed', 'Verification delivery failed.'); + } + + return $this->delivery($channel, 'sent', 'Verification code sent.', [ + 'masked_destination' => $channel === self::CHANNEL_EMAIL + ? $this->maskEmail($destination) + : $this->maskPhone($destination), + 'expires_in' => self::CODE_TTL_SECONDS, + ]); + } + + /** + * @return array + */ + public function verifyCode(subusers_o $subuser, string $channel, string $code, array $actor = []): array + { + $channel = $this->normalizeChannel($channel); + $code = trim($code); + if (!preg_match('/^[0-9]{6}$/', $code)) { + return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.'); + } + + $challenge = $this->readChallenge($subuser, $channel); + if ($challenge === null || (int)($challenge['expires_at'] ?? 0) < $this->now()) { + $this->deleteChallenge($subuser, $channel); + return $this->verificationResult($channel, 'expired', 'Verification code expired.'); + } + + if (($challenge['destination'] ?? null) !== $this->destinationKey($subuser, $channel)) { + $this->deleteChallenge($subuser, $channel); + return $this->verificationResult($channel, 'destination_changed', 'Contact value changed. Request a new code.'); + } + + $attempts = (int)($challenge['attempts'] ?? 0); + if ($attempts >= self::MAX_ATTEMPTS) { + $this->deleteChallenge($subuser, $channel); + return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.'); + } + + $expectedHash = (string)($challenge['hash'] ?? ''); + $nonce = (string)($challenge['nonce'] ?? ''); + if (!hash_equals($expectedHash, $this->hashCode($code, (string)$challenge['destination'], $nonce))) { + $nextAttempts = $attempts + 1; + if ($nextAttempts >= self::MAX_ATTEMPTS) { + $this->deleteChallenge($subuser, $channel); + return $this->verificationResult($channel, 'too_many_attempts', 'Too many verification attempts.'); + } + $challenge['attempts'] = $nextAttempts; + $remainingTtl = max(1, (int)($challenge['expires_at'] ?? $this->now()) - $this->now()); + $this->writeChallenge($subuser, $channel, $challenge, $remainingTtl); + return $this->verificationResult($channel, 'invalid_code', 'Invalid verification code.'); + } + + $this->markVerified($subuser, $channel); + $this->deleteChallenge($subuser, $channel); + $this->logEvent('SUBUSER_CONTACT_VERIFICATION_VERIFIED', $subuser, $channel, $actor); + + return $this->verificationResult($channel, 'verified', 'Contact value verified.', [ + 'verified_at' => $this->verifiedAtValue($subuser, $channel), + ]); + } + + /** + * @return array + */ + public function setVerificationState(subusers_o $subuser, string $channel, bool $verified, array $actor = []): array + { + $channel = $this->normalizeChannel($channel); + if ($verified && $this->destinationFor($subuser, $channel) === null) { + return $this->verificationResult($channel, 'missing_destination', 'No contact value is available for verification.'); + } + + if ($verified) { + $this->markVerified($subuser, $channel); + } else { + $this->markUnverified($subuser, $channel); + } + + $this->deleteChallenge($subuser, $channel); + $this->logEvent( + $verified + ? 'SUBUSER_CONTACT_VERIFICATION_MARKED_VERIFIED' + : 'SUBUSER_CONTACT_VERIFICATION_MARKED_UNVERIFIED', + $subuser, + $channel, + $actor + ); + + return $this->verificationResult($channel, $verified ? 'verified' : 'unverified', 'Contact verification state updated.', [ + 'verified_at' => $this->verifiedAtValue($subuser, $channel), + ]); + } + + /** + * @param array $updates + * @return array + */ + public function clearVerificationForChangedContacts(subusers_o $subuser, array $updates): array + { + if (array_key_exists('email', $updates)) { + $current = $this->normalizeNullableString($subuser->email->value()); + $next = $this->normalizeNullableString($updates['email']); + if ($current !== $next) { + $updates['email_verified_at'] = null; + $this->deleteChallenge($subuser, self::CHANNEL_EMAIL); + } + } + + $phoneChanged = false; + if (array_key_exists('phone_country_code', $updates)) { + $phoneChanged = (int)$subuser->phone_country_code->value() !== (int)$updates['phone_country_code']; + } + if (array_key_exists('phone', $updates)) { + $phoneChanged = $phoneChanged || (int)$subuser->phone->value() !== (int)$updates['phone']; + } + if ($phoneChanged) { + $updates['phone_verified_at'] = null; + $this->deleteChallenge($subuser, self::CHANNEL_PHONE); + } + + return $updates; + } + + public function normalizeChannel(string $channel): string + { + $channel = strtolower(trim($channel)); + if (!in_array($channel, [self::CHANNEL_EMAIL, self::CHANNEL_PHONE], true)) { + throw new Exception('Invalid verification channel'); + } + + return $channel; + } + + private function now(): int + { + if ($this->timeProvider !== null) { + return (int)call_user_func($this->timeProvider); + } + + return time(); + } + + private function generateCode(): string + { + if ($this->codeGenerator !== null) { + $code = (string)call_user_func($this->codeGenerator); + if (preg_match('/^[0-9]{6}$/', $code)) { + return $code; + } + } + + return (string)random_int(100000, 999999); + } + + private function hashCode(string $code, string $destination, string $nonce): string + { + $secret = (string)(getenv('APP_KEY') ?: getenv('JWT_SECRET') ?: __FILE__); + return hash('sha256', $secret . ':' . $nonce . ':' . $destination . ':' . $code); + } + + private function destinationFor(subusers_o $subuser, string $channel): ?string + { + return $channel === self::CHANNEL_EMAIL + ? $this->emailDestination($subuser) + : $this->phoneDestination($subuser); + } + + private function emailDestination(subusers_o $subuser): ?string + { + $email = $this->normalizeNullableString($subuser->email->value()); + return $email !== null && filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + } + + private function phoneDestination(subusers_o $subuser): ?string + { + $countryCode = $subuser->phone_country_code->value(); + $phone = $subuser->phone->value(); + if ($countryCode === null || $phone === null) { + return null; + } + + $normalized = preg_replace('/[^0-9]/', '', (string)$countryCode . (string)$phone); + return $normalized !== '' ? $normalized : null; + } + + private function destinationKey(subusers_o $subuser, string $channel): string + { + return $channel . ':' . (string)($this->destinationFor($subuser, $channel) ?? ''); + } + + private function key(subusers_o $subuser, string $channel): string + { + return 'subuser_contact_verification:' . (int)$subuser->id . ':' . $channel; + } + + private function readChallenge(subusers_o $subuser, string $channel): ?array + { + if ($this->redis === null) { + return null; + } + + $raw = $this->redis->get($this->key($subuser, $channel)); + if (!is_string($raw) || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + return is_array($decoded) ? $decoded : null; + } + + private function writeChallenge(subusers_o $subuser, string $channel, array $payload, ?int $ttl = null): void + { + if ($this->redis === null) { + throw new Exception('Verification cache is unavailable'); + } + + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new Exception('Failed to encode verification challenge'); + } + + $this->redis->setEx($this->key($subuser, $channel), $encoded, $ttl ?? self::CODE_TTL_SECONDS); + } + + private function deleteChallenge(subusers_o $subuser, string $channel): void + { + if ($this->redis !== null) { + $this->redis->delete($this->key($subuser, $channel)); + } + } + + private function deliverCode(subusers_o $subuser, string $channel, string $destination, string $code): void + { + if ($channel === self::CHANNEL_PHONE) { + if ($this->smsSender !== null) { + call_user_func($this->smsSender, $destination, $code, $subuser); + return; + } + + $gateway = new gatewayapi(); + if (!$gateway->isEnabled()) { + throw new Exception('SMS delivery is not configured.'); + } + $gateway->send([$destination], 'Truck Wash verifikationskode: ' . $code . '. Den udløber om 10 minutter.'); + return; + } + + if ($this->emailSender !== null) { + call_user_func($this->emailSender, $destination, $code, $subuser); + return; + } + + $recipientName = $this->normalizeNullableString($subuser->name->value()) ?? 'Chauffør'; + $message = '

Din verifikationskode til Truck Wash er ' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '.

' + . '

Koden udløber om 10 minutter.

'; + (new email())->sendEmail($destination, $recipientName, 'Truck Wash verifikationskode', $message); + } + + private function markVerified(subusers_o $subuser, string $channel): void + { + $timestamp = date('Y-m-d H:i:s', $this->now()); + if ($channel === self::CHANNEL_EMAIL) { + $subuser->email_verified_at->set($timestamp); + return; + } + + $subuser->phone_verified_at->set($timestamp); + } + + private function markUnverified(subusers_o $subuser, string $channel): void + { + if ($channel === self::CHANNEL_EMAIL) { + $subuser->email_verified_at->set(null); + return; + } + + $subuser->phone_verified_at->set(null); + } + + private function verifiedAtValue(subusers_o $subuser, string $channel): ?string + { + $value = $channel === self::CHANNEL_EMAIL + ? $subuser->email_verified_at->value() + : $subuser->phone_verified_at->value(); + $value = $this->normalizeNullableString($value); + return $value; + } + + private function verificationState(array $emailStatus, array $phoneStatus): string + { + if (!$phoneStatus['available'] && !$emailStatus['available']) { + return 'missing_contacts'; + } + if (!$phoneStatus['available']) { + return 'missing_phone'; + } + if (!$emailStatus['available']) { + return 'missing_email'; + } + if ($phoneStatus['verified'] && $emailStatus['verified']) { + return 'verified'; + } + if ($phoneStatus['verified'] || $emailStatus['verified']) { + return 'partial'; + } + + return 'unverified'; + } + + private function normalizeNullableString(mixed $value): ?string + { + if ($value === null) { + return null; + } + + $normalized = trim((string)$value); + return $normalized === '' ? null : $normalized; + } + + private function maskEmail(string $email): string + { + [$local, $domain] = array_pad(explode('@', $email, 2), 2, ''); + $prefix = substr($local, 0, 2); + return $prefix . str_repeat('*', max(2, strlen($local) - 2)) . '@' . $domain; + } + + private function maskPhone(string $phone): string + { + $suffix = substr($phone, -4); + return str_repeat('*', max(0, strlen($phone) - 4)) . $suffix; + } + + private function delivery(string $channel, string $status, string $message, array $extra = []): array + { + return [ + 'channel' => $channel, + 'status' => $status, + 'message' => $message, + ...$extra, + ]; + } + + private function verificationResult(string $channel, string $status, string $message, array $extra = []): array + { + return [ + 'channel' => $channel, + 'status' => $status, + 'message' => $message, + ...$extra, + ]; + } + + private function logEvent(string $action, subusers_o $subuser, string $channel, array $actor): void + { + try { + if (!defined('redis')) { + return; + } + $actorId = isset($actor['id']) ? (int)$actor['id'] : 0; + (new logs_o())->add( + 'subusers', + 'global', + 1, + $actorId, + $action, + 'Chauffeur contact verification ' . $channel . ': ' . (int)$subuser->id + ); + } catch (Exception) { + } + } +} diff --git a/services/nginx/app/classes/subusers_schema_bootstrap.php b/services/nginx/app/classes/subusers_schema_bootstrap.php index d068812d..232cad20 100644 --- a/services/nginx/app/classes/subusers_schema_bootstrap.php +++ b/services/nginx/app/classes/subusers_schema_bootstrap.php @@ -28,6 +28,16 @@ class subusers_schema_bootstrap 'idx_subuser_grants_assigned_vehicle_id', '`assigned_vehicle_id`' ); + self::ensureColumn( + 'subusers', + 'phone_verified_at', + 'DATETIME NULL AFTER `phone`' + ); + self::ensureColumn( + 'subusers', + 'email_verified_at', + 'DATETIME NULL AFTER `email`' + ); self::$initialized = true; } diff --git a/services/nginx/app/classes/superuser_system_status_service.php b/services/nginx/app/classes/superuser_system_status_service.php index cd423f82..ea9c47d3 100644 --- a/services/nginx/app/classes/superuser_system_status_service.php +++ b/services/nginx/app/classes/superuser_system_status_service.php @@ -798,7 +798,7 @@ class superuser_system_status_service protected function moduleDescriptors(): array { return [ - ['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)], + ['key' => 'economic', 'module' => 'economic', 'always_enabled' => true, 'required' => ['invoiceLayoutNumber', 'invoiceDiscountLayoutNumber', 'paymentTermsNumber', 'adminFeeMonthly', 'adminFeeOrder', 'feeProductId'], 'probe' => fn(array $config): array => $this->probeEconomicModule($config)], ['key' => 'reCAPTCHA', 'module' => 'reCAPTCHA', 'enabled_variable' => 'enabled', 'required' => ['site_key_v2', 'secret_key_v2'], 'probe' => fn(array $config): array => $this->probeRecaptchaModule($config)], ['key' => 'email', 'module' => 'Email', 'enabled_variable' => 'enabled', 'required' => ['smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_encryption', 'smtp_from', 'smtp_from_name', 'smtp_reply_to', 'smtp_reply_to_name'], 'probe' => fn(array $config): array => $this->probeEmailModule($config)], ['key' => 'backups', 'module' => 'Backups', 'enabled_variable' => 'enabled', 'required' => [], 'probe' => fn(array $config): array => $this->probeBackupsModule($config)], diff --git a/services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php b/services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php new file mode 100644 index 00000000..b493658d --- /dev/null +++ b/services/nginx/app/modules/economic/config/economic_invoice_discount_layout_c.php @@ -0,0 +1,28 @@ +setupConfig('economic'); $this->allowUpdate([ economic_invoice_layout_c::class, + economic_invoice_discount_layout_c::class, economic_payment_terms_c::class, economic_admin_fee_monthly_c::class, economic_admin_fee_order_c::class, @@ -41,6 +45,7 @@ class economic_c economic_transaction_draft_customer_number_c::class, ]); $this->invoice_layout = new economic_invoice_layout_c(); + $this->invoice_discount_layout = new economic_invoice_discount_layout_c(); $this->payment_terms = new economic_payment_terms_c(); $this->admin_fee_monthly = new economic_admin_fee_monthly_c(); $this->admin_fee_order = new economic_admin_fee_order_c(); diff --git a/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php index 10ab5f9a..fd697d4a 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php @@ -72,7 +72,7 @@ class economic_invoices_draft_endpoint * @return array{order_count:int,orders_with_invoice_lines:int,line_count:int,batch_count:int,batch_sizes:array} * @throws Exception If the request fails */ - public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500): array + public function add_orders(int $invoiceDraftId, array $orders, string $currency = 'DKK', int $line_batch_size = 500, bool $use_itemized_discounts = false): array { $draftInvoice = (new economic())->getInvoiceDraft($invoiceDraftId, strtoupper($currency), true); $orders_with_invoice_lines = 0; @@ -90,7 +90,7 @@ class economic_invoices_draft_endpoint // Add the transaction header (Timestamp, department, etc.) $draftInvoice->addNewTransactionHeader($order); // Add the order lines - $draftInvoice->addOrderItemLines($order); + $draftInvoice->addOrderItemLines($order, $use_itemized_discounts); // Add an empty line, so the invoice is not empty $draftInvoice->addTextLine(''); } diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php index b0f4b41e..96e7d721 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php @@ -104,10 +104,12 @@ class economic_invoices_drafts_endpoint * @return object {id: number, getExternalId: string} * @throws Exception If the request fails */ - public function add(int $customer_number, string $external_id = '', string|null $date = null): object + public function add(int $customer_number, string $external_id = '', string|null $date = null, ?int $layout_number = null): object { // Get the layout number - $layout_number = (int)(new economic())->config->invoice_layout->getVariableValue(); + $layout_number = $layout_number !== null && $layout_number > 0 + ? $layout_number + : (int)(new economic())->config->invoice_layout->getVariableValue(); // Check if the date is set if (is_null($date)) { diff --git a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php index 09596823..24fb6853 100644 --- a/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php +++ b/services/nginx/app/modules/economic/helpers/economic_invoice_draft.php @@ -249,7 +249,7 @@ class economic_invoice_draft * @throws Exception if the order is not found * @throws Exception if the order is not valid */ - public function addOrderItemLines(orders_o $order): void + public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false): void { // Get the order items $order_items = $order->getOrderItems($order->id); @@ -274,12 +274,12 @@ class economic_invoice_draft continue; } // Add the order item to the draft invoice - self::addOrderItemLine($order_item, $department); + self::addOrderItemLine($order_item, $department, false, $use_itemized_discounts); // Add the line discount to the total discount $total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity']; } // If the total discount is greater than 0, add it to the invoice - if ($total_discount > 0) { + if (!$use_itemized_discounts && $total_discount > 0) { // Add the discount to the invoice self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0); } @@ -295,7 +295,7 @@ class economic_invoice_draft * @throws Exception if the order item is not found * @throws Exception if the order item is not valid */ - public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false): void + public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false): void { // Check if the order item is valid if (!isset($order_item['id'])) { @@ -308,14 +308,21 @@ class economic_invoice_draft $economic_department_id = $department['economic_department_id'] ?? 0; // Get the dimension id $economic_dimension_id = $department['economic_dimension_id'] ?? 0; + $pricing = self::resolveOrderItemInvoicePricing($order_item); + $discount_percentage = $use_itemized_discount + ? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing) + : 0; // Add the order item to the draft invoice self::addProductLine( (string)$order_item['product']['economic_product_id'], (string)$order_item['product']['name'], (int)$order_item['quantity'] ?? 1, - (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']), + $use_itemized_discount + ? $pricing['invoice_unit_price'] + : (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']), (int)$economic_department_id, - $economic_dimension_id + $economic_dimension_id, + $discount_percentage ); // Calculate the discount percentage (If the final price is 0, set the discount percentage to 100) @@ -378,11 +385,90 @@ class economic_invoice_draft return abs($price) < 0.00001; } + /** + * Return exact line discount data from the original department price and stored final item price. + * + * @return array{ + * original_unit_price:float, + * final_unit_price:float, + * invoice_unit_price:float, + * quantity:float, + * discount_unit_amount:float, + * discount_total_amount:float, + * discount_percentage:float, + * has_discount:bool + * } + */ + public static function resolveOrderItemInvoicePricing(array $order_item): array + { + $quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity']) + ? (float)$order_item['quantity'] + : 0.0; + $final_unit_price = isset($order_item['price']) && is_numeric($order_item['price']) + ? (float)$order_item['price'] + : 0.0; + $original_unit_price = isset($order_item['product']['price']) && is_numeric($order_item['product']['price']) + ? (float)$order_item['product']['price'] + : 0.0; + + if (abs($original_unit_price) < 0.00001) { + $original_unit_price = $final_unit_price; + } + + $has_discount = $quantity > 0.0 + && $final_unit_price > 0.0 + && $original_unit_price > 0.0 + && $final_unit_price < ($original_unit_price - 0.00001); + + $discount_unit_amount = $has_discount + ? round($original_unit_price - $final_unit_price, 2) + : 0.0; + $discount_total_amount = round($discount_unit_amount * $quantity, 2); + + return [ + 'original_unit_price' => $original_unit_price, + 'final_unit_price' => $final_unit_price, + 'invoice_unit_price' => $has_discount ? $original_unit_price : $final_unit_price, + 'quantity' => $quantity, + 'discount_unit_amount' => $discount_unit_amount, + 'discount_total_amount' => $discount_total_amount, + 'discount_percentage' => $has_discount + ? round((1 - ($final_unit_price / $original_unit_price)) * 100, 10) + : 0.0, + 'has_discount' => $has_discount, + ]; + } + + public static function orderItemHasBillableDiscount(array $order_item): bool + { + return self::resolveOrderItemInvoicePricing($order_item)['has_discount']; + } + + /** + * Calculate the hidden e-conomic percentage from converted amounts so the rendered monetary discount stays exact. + * + * @param array{invoice_unit_price:float,final_unit_price:float,has_discount:bool,discount_percentage:float} $pricing + */ + private function resolveItemizedDiscountPercentageForInvoiceCurrency(array $pricing): float + { + if (!$pricing['has_discount']) { + return 0.0; + } + + $converted_original = self::convertCurrency($pricing['invoice_unit_price']); + $converted_final = self::convertCurrency($pricing['final_unit_price']); + if ($converted_original <= 0.0 || $converted_final <= 0.0) { + return $pricing['discount_percentage']; + } + + return round((1 - ($converted_final / $converted_original)) * 100, 10); + } + /** * Add a product line to the draft invoice * @note The lines won't be saved until the addLines() method is called. */ - public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension): void + public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void { // We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS. // Add a line to the invoice @@ -392,7 +478,7 @@ class economic_invoice_draft ], 'quantity' => $quantity, 'unitNetPrice' => self::convertCurrency($unitNetPrice), - 'discountPercentage' => 0, + 'discountPercentage' => $discountPercentage, 'description' => $description, ]; // If the department is set, add it to the line diff --git a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php index 3d004a84..79c3f915 100644 --- a/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php +++ b/services/nginx/app/modules/selfserve/traits/selfserve_lane_invoice_t.php @@ -297,7 +297,25 @@ trait selfserve_lane_invoice_t $billing_customer_number > 0 ? $billing_customer_number : null, $subuser_id ); - return $session->exists() ? $session : null; + if ($session->exists()) { + return $session; + } + + if ($subuser_id === null) { + return null; + } + + $session = (new selfserve_wash_sessions_o())->selectLatestOpenByLaneAndReg( + (int)$this->id, + selfserve::standardize_registration($license_plate), + $billing_customer_number > 0 ? $billing_customer_number : null + ); + if (!$session->exists()) { + return null; + } + + $sessionSubuserId = $session->subuser_id->value() === null ? null : (int)$session->subuser_id->value(); + return $sessionSubuserId === null || $sessionSubuserId === $subuser_id ? $session : null; } catch (\Throwable) { return null; } diff --git a/services/nginx/app/objects/collected_order_invoices_o.php b/services/nginx/app/objects/collected_order_invoices_o.php index 65734b7c..493df271 100644 --- a/services/nginx/app/objects/collected_order_invoices_o.php +++ b/services/nginx/app/objects/collected_order_invoices_o.php @@ -10,6 +10,7 @@ use config\economic_admin_fee_monthly_c; use config\economic_admin_fee_order_c; use config\economic_fee_product_id_c; use Exception; +use helpers\economic_invoice_draft; use objects\order_items_o; use objects\orders_o; use objects\users_o; @@ -427,7 +428,8 @@ class collected_order_invoices_o extends db if (empty($this->customer_number->value())) { throw new Exception('Customer number is not set'); } - (new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value()); + $economic = new economic(); + $economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value()); if (!$ignore_closed) { // Require the invoice collection to be open self::requireOpen(); @@ -481,7 +483,8 @@ class collected_order_invoices_o extends db { // Require the invoice collection to be selected self::requireSelected(); - (new economic())->assertCustomerNumberIsNotDraft((int)$this->customer_number->value()); + $economic = new economic(); + $economic->assertCustomerNumberIsNotDraft((int)$this->customer_number->value()); // Check if the invoice draft already exists if (self::isDraftExisting() || self::isBooked()) { throw new Exception('Invoice draft already exists, or invoice collection is already booked'); @@ -497,11 +500,12 @@ class collected_order_invoices_o extends db // Convert the date to the correct format $date = date('Y-m-d', strtotime($date)); // Create the invoice draft - $economic = new economic(); + $layout_number = $this->resolveInvoiceLayoutNumber($economic); $response = $economic->invoices->drafts->add( $this->customer_number->value(), self::getExternalId(), - $date + $date, + $layout_number ); // Validate the response, by checking if the external id is set if (empty($response->references->other)) { @@ -515,6 +519,70 @@ class collected_order_invoices_o extends db return $this; } + /** + * Resolve the e-conomic layout before draft creation. + * + * The default layout is the current non-discount layout. The discount layout + * must be the manually duplicated e-conomic layout configured to show exact + * monetary discounts in the Rabat column. + * + * @throws Exception + */ + private function resolveInvoiceLayoutNumber(economic $economic): int + { + if (!$this->hasDiscountedIncludedInvoiceItems()) { + return (int)$economic->config->invoice_layout->getVariableValue(); + } + + $layout_number = (int)$economic->config->invoice_discount_layout->getVariableValue(); + if ($layout_number <= 0) { + throw new Exception('Discount invoice layout is not configured'); + } + + return $layout_number; + } + + /** + * Detect whether any billable included product line should use the discount invoice layout. + * + * @throws Exception + */ + public function hasDiscountedIncludedInvoiceItems(): bool + { + foreach ( self::getOrders() as $order ) { + $order_object = new orders_o(); + $order_object->select((int)$order['id']); + $order_object->requireSelected(); + if (self::orderHasDiscountedIncludedInvoiceItems($order_object)) { + return true; + } + } + + return false; + } + + /** + * @throws Exception + */ + private static function orderHasDiscountedIncludedInvoiceItems(orders_o $order): bool + { + $order_items = $order->applyDepartmentPrices( + $order->getOrderItems((int)$order->id), + (int)$order->department_id->value() + ); + + foreach ( $order_items as $order_item ) { + if (empty($order_item['include_in_invoice'])) { + continue; + } + if (economic_invoice_draft::orderItemHasBillableDiscount($order_item)) { + return true; + } + } + + return false; + } + /** * Require the invoice draft to not already exist * @throws Exception If the request was not successful @@ -746,7 +814,14 @@ class collected_order_invoices_o extends db $order_object->requireSelected(); $order_objects[] = $order_object; } - $metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency); + $use_itemized_discounts = false; + foreach ( $order_objects as $order_object ) { + if (self::orderHasDiscountedIncludedInvoiceItems($order_object)) { + $use_itemized_discounts = true; + break; + } + } + $metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts); $this->last_economic_transfer_metrics = [ 'draft_invoice_id' => $draft_id, 'currency' => (string)$currency, diff --git a/services/nginx/app/objects/subusers_o.php b/services/nginx/app/objects/subusers_o.php index fea79fe0..8689ea5c 100644 --- a/services/nginx/app/objects/subusers_o.php +++ b/services/nginx/app/objects/subusers_o.php @@ -24,8 +24,10 @@ class subusers_o extends db public object_property $password; public object_property $name; public object_property $email; + public object_property $email_verified_at; public object_property $phone_country_code; public object_property $phone; + public object_property $phone_verified_at; public object_property $two_factor_secret; public object_property $two_factor_enabled; public object_property $created_at; @@ -49,8 +51,10 @@ class subusers_o extends db $this->password = new object_property($this->table, $this->id, 'password', 'string'); $this->name = new object_property($this->table, $this->id, 'name', 'string'); $this->email = new object_property($this->table, $this->id, 'email', 'string'); + $this->email_verified_at = new object_property($this->table, $this->id, 'email_verified_at', 'timestamp', false); $this->phone_country_code = new object_property($this->table, $this->id, 'phone_country_code', 'int'); $this->phone = new object_property($this->table, $this->id, 'phone', 'int'); + $this->phone_verified_at = new object_property($this->table, $this->id, 'phone_verified_at', 'timestamp', false); $this->two_factor_secret = new object_property($this->table, $this->id, 'two_factor_secret', 'string', false); $this->two_factor_enabled = new object_property($this->table, $this->id, 'two_factor_enabled', 'bool', false); $this->created_at = new object_property($this->table, $this->id, 'created_at', 'timestamp'); diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 051ab108..32136bc1 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -1556,12 +1556,151 @@ paths: '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalServerError' } + /superuser/subusers/{subuser_id}/password-guide/{channel}/send: + post: + tags: + - Subusers + summary: Send a chauffeur new-password guide with a pre-authorized login link + operationId: sendSuperuserSubuserPasswordGuideLink + security: + - BearerAuth: [] + parameters: + - name: subuser_id + in: path + required: true + schema: { type: integer, minimum: 1 } + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSubuserLoginLinkRequest' + responses: + '200': + description: Password-guide delivery attempted + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSubuserContactLinkSendResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/subusers/{subuser_id}/login-link/{channel}/send: + post: + tags: + - Subusers + summary: Send a chauffeur pre-authorized login link + operationId: sendSuperuserSubuserLoginLink + security: + - BearerAuth: [] + parameters: + - name: subuser_id + in: path + required: true + schema: { type: integer, minimum: 1 } + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSubuserLoginLinkRequest' + responses: + '200': + description: Login-link delivery attempted + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSubuserContactLinkSendResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/subusers/{subuser_id}/verification/{channel}/send: + post: + tags: + - Subusers + summary: Send a chauffeur contact verification code as a superuser + operationId: sendSuperuserSubuserVerificationCode + security: + - BearerAuth: [] + parameters: + - name: subuser_id + in: path + required: true + schema: { type: integer, minimum: 1 } + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + responses: + '200': + description: Verification delivery attempted + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserContactVerificationAdminSendResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '429': { $ref: '#/components/responses/TooManyRequests' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /superuser/subusers/{subuser_id}/verification/{channel}: + patch: + tags: + - Subusers + summary: Set a chauffeur contact verification state as a superuser + operationId: setSuperuserSubuserVerificationState + security: + - BearerAuth: [] + parameters: + - name: subuser_id + in: path + required: true + schema: { type: integer, minimum: 1 } + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSubuserVerificationStateRequest' + responses: + '200': + description: Verification state updated + content: + application/json: + schema: + $ref: '#/components/schemas/SuperuserSubuserVerificationStateResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '500': { $ref: '#/components/responses/InternalServerError' } + /superuser/users/{user_id}/subusers: get: tags: - Subusers summary: List subusers for a superuser customer account - description: Returns paginated driver access grants for the customer number resolved from the selected user. + description: Returns paginated chauffeur accounts for the customer number resolved from the selected user. Each chauffeur appears once; visible customer access grants are returned in `grants`. operationId: listSuperuserUserSubusers security: - BearerAuth: [] @@ -1790,12 +1929,24 @@ paths: type: string format: email nullable: true + email_verified: + type: boolean + email_verified_at: + type: string + format: date-time + nullable: true phone_country_code: type: integer nullable: true phone: type: integer nullable: true + phone_verified: + type: boolean + phone_verified_at: + type: string + format: date-time + nullable: true created_at: type: string format: date-time @@ -1894,6 +2045,110 @@ paths: '404': { $ref: '#/components/responses/NotFound' } '500': { $ref: '#/components/responses/InternalServerError' } + /subusers/me/verification: + get: + tags: + - Subusers + summary: Get current chauffeur contact verification status + operationId: getCurrentSubuserVerification + security: + - BearerAuth: [] + responses: + '200': + description: Current chauffeur verification status + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserContactVerificationStatus' + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/me/verification/{channel}/send: + post: + tags: + - Subusers + summary: Send a verification code for the current chauffeur + operationId: sendCurrentSubuserVerificationCode + security: + - BearerAuth: [] + parameters: + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + responses: + '200': + description: Verification delivery attempted + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserContactVerificationSendResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/TooManyRequests' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/me/verification/{channel}/verify: + post: + tags: + - Subusers + summary: Verify a current chauffeur contact code + operationId: verifyCurrentSubuserContact + security: + - BearerAuth: [] + parameters: + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserContactVerificationVerifyRequest' + responses: + '200': + description: Contact value verified + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserContactVerificationVerifyResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /subusers/{subuser_id}/verification/{channel}/send: + post: + tags: + - Subusers + summary: Send a verification code for an own-customer chauffeur + operationId: sendManagedSubuserVerificationCode + security: + - BearerAuth: [] + parameters: + - name: subuser_id + in: path + required: true + schema: { type: integer, minimum: 1 } + - name: channel + in: path + required: true + schema: { type: string, enum: [email, phone] } + responses: + '200': + description: Verification delivery attempted + content: + application/json: + schema: + $ref: '#/components/schemas/SubuserContactVerificationManagedSendResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '429': { $ref: '#/components/responses/TooManyRequests' } + '500': { $ref: '#/components/responses/InternalServerError' } + /subusers/{id}: get: tags: @@ -14456,6 +14711,12 @@ components: application/json: schema: $ref: '#/components/schemas/Error' + TooManyRequests: + description: Too many requests - Rate limit or resend cooldown exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/Error' InternalServerError: description: Internal server error content: @@ -16732,7 +16993,7 @@ components: type: object properties: module: { type: string, enum: [economic] } - variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber, defaultDepartmentId] } + variable: { type: string, enum: [adminFeeMonthly, adminFeeOrder, feeProductId, invoiceLayoutNumber, invoiceDiscountLayoutNumber, paymentTermsNumber, transactionDraftCustomerNumber, defaultDepartmentId] } type: { type: string, enum: [string, int] } value: oneOf: @@ -17894,10 +18155,19 @@ components: username: { type: string, nullable: true } name: { type: string, nullable: true } email: { type: string, format: email, nullable: true } + email_verified: { type: boolean } + email_verified_at: { type: string, format: date-time, nullable: true } phone_country_code: { type: integer, nullable: true } phone: { type: integer, nullable: true } + phone_verified: { type: boolean } + phone_verified_at: { type: string, format: date-time, nullable: true } setup_required: { type: boolean } updated_at: { type: string, format: date-time, nullable: true } + verification_state: + type: string + enum: [verified, partial, unverified, missing_email, missing_phone, missing_contacts] + verification: + $ref: '#/components/schemas/SubuserContactVerificationStatus' SuperuserSubuserProfileUpdateRequest: type: object @@ -17957,16 +18227,188 @@ components: login_path: type: string example: /login/qr?token=...&type=subuser&customer_number=12345678 + login_url: + type: string + format: uri + example: https://truckwash.io/login/qr?token=...&type=subuser&customer_number=12345678 + + SuperuserSubuserContactLinkSendResponse: + type: object + properties: + subuser_id: + type: integer + customer_number: + type: integer + login_path: + type: string + login_url: + type: string + format: uri + delivery: + $ref: '#/components/schemas/SubuserContactVerificationDelivery' + subuser: + $ref: '#/components/schemas/SuperuserSubuserAccount' + + SubuserContactVerificationChannelStatus: + type: object + properties: + channel: + type: string + enum: [email, phone] + value: + type: string + nullable: true + masked_value: + type: string + nullable: true + available: + type: boolean + verified: + type: boolean + verified_at: + type: string + format: date-time + nullable: true + country_code: + type: integer + nullable: true + description: Present for phone verification status. + phone: + type: integer + nullable: true + description: Present for phone verification status. + + SubuserContactVerificationStatus: + type: object + properties: + state: + type: string + enum: [verified, partial, unverified, missing_email, missing_phone, missing_contacts] + email: + $ref: '#/components/schemas/SubuserContactVerificationChannelStatus' + phone: + $ref: '#/components/schemas/SubuserContactVerificationChannelStatus' + + SubuserContactVerificationDelivery: + type: object + properties: + channel: + type: string + enum: [email, phone] + status: + type: string + enum: [sent, unavailable, failed, throttled, missing_destination] + message: + type: string + masked_destination: + type: string + nullable: true + expires_in: + type: integer + nullable: true + retry_after: + type: integer + nullable: true + + SubuserContactVerificationSendResponse: + type: object + properties: + delivery: + $ref: '#/components/schemas/SubuserContactVerificationDelivery' + verification: + $ref: '#/components/schemas/SubuserContactVerificationStatus' + + SubuserContactVerificationManagedSendResponse: + type: object + properties: + delivery: + $ref: '#/components/schemas/SubuserContactVerificationDelivery' + subuser: + $ref: '#/components/schemas/SubuserManagementRow' + + SubuserContactVerificationAdminSendResponse: + type: object + properties: + delivery: + $ref: '#/components/schemas/SubuserContactVerificationDelivery' + subuser: + $ref: '#/components/schemas/SuperuserSubuserAccount' + + SuperuserSubuserVerificationStateRequest: + type: object + required: [verified] + properties: + verified: + type: boolean + + SuperuserSubuserVerificationStateResponse: + type: object + properties: + result: + type: object + properties: + channel: + type: string + enum: [email, phone] + status: + type: string + enum: [verified, unverified] + message: + type: string + verified_at: + type: string + format: date-time + nullable: true + verification: + $ref: '#/components/schemas/SubuserContactVerificationStatus' + subuser: + $ref: '#/components/schemas/SuperuserSubuserAccount' + + SubuserContactVerificationVerifyRequest: + type: object + required: [code] + properties: + code: + type: string + pattern: '^[0-9]{6}$' + + SubuserContactVerificationVerifyResponse: + type: object + properties: + result: + type: object + properties: + channel: + type: string + enum: [email, phone] + status: + type: string + enum: [verified] + message: + type: string + verified_at: + type: string + format: date-time + nullable: true + verification: + $ref: '#/components/schemas/SubuserContactVerificationStatus' + subuser: + $ref: '#/components/schemas/SubuserSelf' SubuserManagementRow: type: object + description: Chauffeur management row. Superuser list endpoints return one row per chauffeur account; grant-specific fields mirror a primary grant for backwards compatibility, and all visible customer grants are listed in `grants`. properties: id: { type: integer } username: { type: string, nullable: true } name: { type: string, nullable: true } email: { type: string, format: email, nullable: true } + email_verified: { type: boolean } + email_verified_at: { type: string, format: date-time, nullable: true } phone_country_code: { type: integer, nullable: true } phone: { type: integer, nullable: true } + phone_verified: { type: boolean } + phone_verified_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time, nullable: true } updated_at: { type: string, format: date-time, nullable: true } suspended_at: { type: string, format: date-time, nullable: true } @@ -18006,6 +18448,7 @@ components: grant_updated_at: { type: string, format: date-time, nullable: true } grants: type: array + description: Visible customer access grants for this chauffeur, grouped under the single chauffeur row. items: $ref: '#/components/schemas/SubuserManagementGrant' grant_count: { type: integer } @@ -18015,6 +18458,11 @@ components: access_state: type: string enum: [active, pending_setup, disabled, inactive] + verification_state: + type: string + enum: [verified, partial, unverified, missing_email, missing_phone, missing_contacts] + verification: + $ref: '#/components/schemas/SubuserContactVerificationStatus' SubuserManagementGrant: type: object @@ -18170,12 +18618,24 @@ components: type: string format: email nullable: true + email_verified: + type: boolean + email_verified_at: + type: string + format: date-time + nullable: true phone_country_code: type: integer nullable: true phone: type: integer nullable: true + phone_verified: + type: boolean + phone_verified_at: + type: string + format: date-time + nullable: true grants: type: array description: Enabled, non-deleted grants for the subuser grouped by billing customer number @@ -18196,6 +18656,11 @@ components: two_factor_enabled: type: boolean description: Indicates if 2FA is enabled for this account + verification_state: + type: string + enum: [verified, partial, unverified, missing_email, missing_phone, missing_contacts] + verification: + $ref: '#/components/schemas/SubuserContactVerificationStatus' PermissionNode: type: object diff --git a/services/nginx/app/routes/customerTimeBookingsRoute.php b/services/nginx/app/routes/customerTimeBookingsRoute.php index 6e23118b..9d623095 100644 --- a/services/nginx/app/routes/customerTimeBookingsRoute.php +++ b/services/nginx/app/routes/customerTimeBookingsRoute.php @@ -15,19 +15,19 @@ class customerTimeBookingsRoute { use route_t; - private static function requirePublicTimeBookingsDepartment(string $parameterName = 'id'): departments_o + private function requirePublicTimeBookingsDepartment(string $parameterName = 'id'): departments_o { global $response; - if (!self::isParametersSet([$parameterName])) { + if (!$this->isParametersSet([$parameterName])) { $response->error('Missing ' . $parameterName . ' parameter', 400); } - self::requireType((int)self::getParameter($parameterName), self::type_int()); - self::requireMinValue((int)self::getParameter($parameterName), 1); - self::requireSameLength(self::getParameter($parameterName), (int)self::getParameter($parameterName)); + $this->requireType((int)$this->getParameter($parameterName), $this->type_int()); + $this->requireMinValue((int)$this->getParameter($parameterName), 1); + $this->requireSameLength($this->getParameter($parameterName), (int)$this->getParameter($parameterName)); $department = new departments_o(); - $department->select((int)self::getParameter($parameterName)); + $department->select((int)$this->getParameter($parameterName)); if (!$department->exists()) { $response->error('Department not found', 404); } @@ -98,7 +98,7 @@ class customerTimeBookingsRoute /** Guest Time Bookings -> Opening Hours -> GET */ $this->get('/department/timebookings/opening-hours/public', function () { global $response; - self::requirePublicTimeBookingsDepartment(); + $this->requirePublicTimeBookingsDepartment(); $department_time_bookings_opening_hours = new department_time_bookings_opening_hours_o(); $department_time_bookings_opening_hours->selectByDepartment( @@ -137,7 +137,7 @@ class customerTimeBookingsRoute /** Guest Time Bookings -> Types -> GET */ $this->get('/department/timebookings/types/public', function () { global $response; - self::requirePublicTimeBookingsDepartment(); + $this->requirePublicTimeBookingsDepartment(); $department_time_bookings_types = new department_time_bookings_types_o(); $booking_types_array = $department_time_bookings_types->getFieldsWhere( @@ -177,7 +177,7 @@ class customerTimeBookingsRoute * https://api.truckwash.dk:4433/department/timebookings/entries/public?id=1&filters=created_at-date_from:2025-04-01,created_at-date_to:2025-05-30&order=created_at:desc */ global $response; - $department = self::requirePublicTimeBookingsDepartment(); + $department = $this->requirePublicTimeBookingsDepartment(); $department_time_bookings_entries = new department_time_bookings_entries_o(); $booking_entries_array = $department_time_bookings_entries->listObjectsWithPaginationIfSet( @@ -207,7 +207,7 @@ class customerTimeBookingsRoute $this->post('/department/timebookings/entries/public', function () { global $response; self::requireParameters(['department', 'type', 'start']); - $department = self::requirePublicTimeBookingsDepartment('department'); + $department = $this->requirePublicTimeBookingsDepartment('department'); // Get the type self::requireType((int)self::getParameter('type'), self::type_int()); diff --git a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php index 59ad408e..869092aa 100644 --- a/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php +++ b/services/nginx/app/routes/departmentSelfserveVehicleConditionsRoute.php @@ -122,8 +122,9 @@ class departmentSelfserveVehicleConditionsRoute [$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal(); $own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST); + $customer_number_context = $this->resolveEffectiveCustomerNumber(); $has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions'); - $has_own = $this->hasPermission($own_permission); + $has_own = $this->hasPermission($own_permission, $customer_number_context); if (!$has_global && !$has_own) { $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); } @@ -168,8 +169,9 @@ class departmentSelfserveVehicleConditionsRoute [$user, $_actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal(); $own_permission = self::definePermission('list_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_LIST); + $customer_number_context = $this->resolveEffectiveCustomerNumber(); $has_global = $user !== null && $this->hasPermission('list_department_selfserve_vehicle_conditions'); - $has_own = $this->hasPermission($own_permission); + $has_own = $this->hasPermission($own_permission, $customer_number_context); if (!$has_global && !$has_own) { $response->forbidden(['list_department_selfserve_vehicle_conditions', 'list_own_department_selfserve_vehicle_conditions']); } @@ -251,8 +253,9 @@ class departmentSelfserveVehicleConditionsRoute [$user, $actor_id, $subuser_id] = $this->getAuthenticatedSelfServePrincipal(); $own_permission = self::definePermission('add_own_department_selfserve_vehicle_conditions', subusers_permission_node_key::SELFSERVE_ADD); + $customer_number_context = $this->resolveEffectiveCustomerNumber(); $has_global = $user !== null && $this->hasPermission('add_department_selfserve_vehicle_conditions'); - $has_own = $this->hasPermission($own_permission); + $has_own = $this->hasPermission($own_permission, $customer_number_context); if (!$has_global && !$has_own) { $response->forbidden(['add_department_selfserve_vehicle_conditions', 'add_own_department_selfserve_vehicle_conditions']); diff --git a/services/nginx/app/routes/moduleSelfServeRoute.php b/services/nginx/app/routes/moduleSelfServeRoute.php index 4fd4c113..9633ad42 100644 --- a/services/nginx/app/routes/moduleSelfServeRoute.php +++ b/services/nginx/app/routes/moduleSelfServeRoute.php @@ -1453,14 +1453,16 @@ class moduleSelfServeRoute return null; } - if (self::hasPermission($this->customerSelfServeUsePermission())) { - $customer_number = $this->resolveEffectiveCustomerNumber(); - if ($customer_number !== null && $customer_number > 0) { - return [ - 'customer_number' => (int)$customer_number, - 'subuser_id' => $this->authenticatedSubuserId(), - ]; - } + $customer_number = $this->resolveEffectiveCustomerNumber(); + if ( + $customer_number !== null + && $customer_number > 0 + && self::hasPermission($this->customerSelfServeUsePermission(), $customer_number) + ) { + return [ + 'customer_number' => (int)$customer_number, + 'subuser_id' => $this->authenticatedSubuserId(), + ]; } $this->emitForbidden([ @@ -1575,11 +1577,11 @@ class moduleSelfServeRoute $response->error('Authentication failed. Invalid or missing token.', 401); } - if (!self::hasPermission($this->customerSelfServeUsePermission())) { + $customer_number = $this->resolveEffectiveCustomerNumber(); + if (!self::hasPermission($this->customerSelfServeUsePermission(), $customer_number)) { $this->emitForbidden([$this->customerSelfServeUsePermission()]); } - $customer_number = $this->resolveEffectiveCustomerNumber(); if ($customer_number === null || $customer_number <= 0) { $response->error('No customer number found for authenticated user.', 404); } @@ -2356,13 +2358,13 @@ class moduleSelfServeRoute protected function canCustomerUseSelfServeLane(selfserve_lane $lane, int $customer_number): bool { return $customer_number > 0 - && $this->hasPermission($this->customerSelfServeUsePermission()) + && $this->hasPermission($this->customerSelfServeUsePermission(), $customer_number) && $this->isLaneSelfServeOperationallyEnabled($lane); } protected function canCustomerUseActiveSelfServeLane(selfserve_lane $lane, int $customer_number): bool { - if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) { + if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) { return false; } @@ -2410,7 +2412,7 @@ class moduleSelfServeRoute protected function canCustomerUseActiveSelfServeLaneSession(selfserve_lane $lane, int $customer_number): bool { - if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission())) { + if ($customer_number <= 0 || !$this->hasPermission($this->customerSelfServePermission(), $customer_number)) { return false; } diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index bdc12442..c8042569 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -4,8 +4,10 @@ namespace routes; use classes\authentication; use classes\economic; +use classes\email; use classes\gatewayapi; use classes\response; +use classes\subuser_contact_verification_service; use classes\subusers_schema_bootstrap; use classes\subuser_permission_templates_service; use classes\virkdata; @@ -349,7 +351,7 @@ class subusersRoute return count($matches) === 1 ? array_values($matches)[0] : null; } - private function buildSetupLink(string $token): string + private function frontendBaseUrl(): string { $frontendBaseUrl = trim((string)( getenv('FRONTEND_URL') @@ -358,8 +360,12 @@ class subusersRoute ?: ($_SERVER['APP_URL'] ?? '') ?: 'https://truckwash.io' )); - $frontendBaseUrl = rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/'); - return $frontendBaseUrl . '/complete-registration?token=' . rawurlencode($token); + return rtrim($frontendBaseUrl !== '' ? $frontendBaseUrl : 'https://truckwash.io', '/'); + } + + private function buildSetupLink(string $token): string + { + return $this->frontendBaseUrl() . '/complete-registration?token=' . rawurlencode($token); } private function buildDirectSubuserLoginPath(string $sessionToken, int $customerNumber): string @@ -368,6 +374,137 @@ class subusersRoute . '&type=subuser&customer_number=' . rawurlencode((string)$customerNumber); } + private function buildDirectSubuserLoginUrl(string $sessionToken, int $customerNumber): string + { + return $this->frontendBaseUrl() . $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber); + } + + private function createDirectSubuserLoginLinkPayload(subusers_o $subuser, string $logAction, string $logMessage): array + { + global $response; + + $customerNumber = $this->resolveDirectLoginCustomerNumber($subuser); + try { + $sessionToken = $subuser->generateSession(); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 500); + } + + $authUser = (new authentication())->get_user(); + (new logs_o())->add( + 'auth', + 'global', + 1, + $authUser !== false ? (int)$authUser->id : 0, + $logAction, + $logMessage . ' for subuser ' . (int)$subuser->id . ' and customer ' . $customerNumber + ); + + return [ + 'subuser_id' => (int)$subuser->id, + 'customer_number' => $customerNumber, + 'login_path' => $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber), + 'login_url' => $this->buildDirectSubuserLoginUrl($sessionToken, $customerNumber), + ]; + } + + private function subuserContactDestination(subusers_o $subuser, string $channel): ?string + { + $status = $this->verificationService()->status($subuser); + $channelStatus = $status[$channel] ?? null; + if (!is_array($channelStatus) || !($channelStatus['available'] ?? false)) { + return null; + } + + $value = trim((string)($channelStatus['value'] ?? '')); + return $value !== '' ? $value : null; + } + + private function subuserLinkDelivery(string $channel, string $status, string $message): array + { + return [ + 'channel' => $channel, + 'status' => $status, + 'message' => $message, + ]; + } + + private function deliverSubuserDirectLoginLink(subusers_o $subuser, string $channel, string $loginUrl, string $kind): array + { + $channel = $this->verificationService()->normalizeChannel($channel); + $destination = $this->subuserContactDestination($subuser, $channel); + if ($destination === null) { + return $this->subuserLinkDelivery($channel, 'missing_destination', 'Der er ingen kontaktoplysning at sende linket til.'); + } + + $isPasswordGuide = $kind === 'password_guide'; + $recipientName = trim((string)($subuser->name->value() ?? '')); + $recipientName = $recipientName !== '' ? $recipientName : 'Chauffør'; + + try { + if ($channel === subuser_contact_verification_service::CHANNEL_PHONE) { + $gateway = new gatewayapi(); + if (!$gateway->isEnabled()) { + return $this->subuserLinkDelivery($channel, 'unavailable', 'SMS-afsendelse er ikke konfigureret.'); + } + $message = $isPasswordGuide + ? 'Truck Wash: Brug dit personlige link til at logge ind og sætte en ny adgangskode: ' . $loginUrl + : 'Truck Wash: Dit personlige loginlink: ' . $loginUrl; + $gateway->send([$destination], $message); + } else { + $subject = $isPasswordGuide + ? 'Guide til ny adgangskode hos Truck Wash' + : 'Loginlink til Truck Wash'; + $message = $isPasswordGuide + ? '

Du kan bruge dit personlige link til at logge ind og sætte en ny adgangskode.

' + : '

Du kan bruge dit personlige link til at logge ind på Truck Wash.

'; + $message .= '

' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '

'; + (new email())->sendEmail($destination, $recipientName, $subject, $message); + } + } catch (Exception) { + return $this->subuserLinkDelivery($channel, 'failed', 'Linket kunne ikke sendes.'); + } + + return $this->subuserLinkDelivery( + $channel, + 'sent', + $isPasswordGuide ? 'Guide til ny adgangskode er sendt.' : 'Loginlink er sendt.' + ); + } + + private function sendSuperuserSubuserDirectLoginLink(string $kind): void + { + global $response; + + $this->requirePermission('edit_subusers'); + $this->requirePermission('SUPERUSER_INTIMIDATE'); + $subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id')); + try { + $channel = $this->verificationService()->normalizeChannel((string)$this->fromRoute('channel')); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + + $isPasswordGuide = $kind === 'password_guide'; + $linkPayload = $this->createDirectSubuserLoginLinkPayload( + $subuser, + $isPasswordGuide + ? 'SUPERUSER_SUBUSER_PASSWORD_GUIDE_LINK' + : 'SUPERUSER_SUBUSER_LOGIN_LINK_SENT', + $isPasswordGuide + ? 'Created chauffeur password guide link' + : 'Created chauffeur login link for delivery' + ); + $delivery = $this->deliverSubuserDirectLoginLink($subuser, $channel, (string)$linkPayload['login_url'], $kind); + $subuser = $this->loadSubuserOrFail((int)$subuser->id); + + $response->success([ + ...$linkPayload, + 'delivery' => $delivery, + 'subuser' => $this->buildSubuserAccountPayload($subuser), + ]); + } + private function loadSubuserOrFail(int $subuserId): subusers_o { global $response; @@ -382,7 +519,7 @@ class subusersRoute private function buildSubuserAccountPayload(subusers_o $subuser): array { - return [ + return $this->withVerificationPayload($subuser, [ 'id' => (int)$subuser->id, 'username' => $subuser->username->value(), 'name' => $subuser->name->value(), @@ -393,9 +530,71 @@ class subusersRoute 'phone' => $subuser->phone->value() !== null ? (int)$subuser->phone->value() : null, 'setup_required' => $subuser->requiresSetup(), 'updated_at' => $subuser->updated_at->value() ?? null, + ]); + } + + private function verificationService(): subuser_contact_verification_service + { + return new subuser_contact_verification_service(); + } + + private function withVerificationPayload(subusers_o $subuser, array $payload): array + { + $verification = $this->verificationService()->status($subuser); + + return [ + ...$payload, + 'email_verified' => (bool)($verification['email']['verified'] ?? false), + 'email_verified_at' => $verification['email']['verified_at'] ?? null, + 'phone_verified' => (bool)($verification['phone']['verified'] ?? false), + 'phone_verified_at' => $verification['phone']['verified_at'] ?? null, + 'verification_state' => $verification['state'], + 'verification' => $verification, ]; } + private function verificationActorContext(string $type, ?int $id = null): array + { + return [ + 'type' => $type, + 'id' => $id ?? 0, + ]; + } + + private function sendContactVerificationCode(subusers_o $subuser, string $channel, array $actor): array + { + global $response; + + try { + $delivery = $this->verificationService()->sendCode($subuser, $channel, $actor); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + + if (($delivery['status'] ?? null) === 'throttled') { + $response->error('Please wait before requesting another verification code.', 429); + } + + return $delivery; + } + + private function verifyContactVerificationCode(subusers_o $subuser, string $channel, string $code, array $actor): array + { + global $response; + + try { + $result = $this->verificationService()->verifyCode($subuser, $channel, $code, $actor); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + + if (($result['status'] ?? null) !== 'verified') { + $response->error((string)($result['message'] ?? 'Invalid verification code.'), 400); + } + + return $result; + } + private function parseSuperuserSubuserProfileUpdates(int $subuserId): array { global $response; @@ -637,7 +836,7 @@ class subusersRoute $accessState = 'disabled'; } - return [ + return $this->withVerificationPayload($subuser, [ 'id' => (int)$subuser->id, 'username' => $subuser->username->value(), 'name' => $subuser->name->value(), @@ -666,7 +865,7 @@ class subusersRoute 'permission_template_key' => $templateService->classify($grantPermissions, $grantEnabled), 'permission_groups' => $templateService->permissionGroups($grantPermissions), 'access_state' => $accessState, - ]; + ]); } private function buildCurrentSubuserPayload(subusers_o $subuser): array @@ -681,7 +880,7 @@ class subusersRoute $grants )); - return [ + return $this->withVerificationPayload($subuser, [ 'id' => (int)$subuser->id, 'username' => $subuser->username->value(), 'name' => $subuser->name->value(), @@ -701,7 +900,7 @@ class subusersRoute 'updated_at' => $subuser->updated_at->value() ?? null, 'suspended_at' => $subuser->suspended_at->value() ?? null, 'two_factor_enabled' => $subuser->isTwoFactorEnabled(), - ]; + ]); } private function parseSuperuserPaginationRequest(): array @@ -724,8 +923,8 @@ class subusersRoute 'id' => 's.`id`', 'created_at' => 's.`created_at`', 'updated_at' => 'row_updated_at', - 'customer_number' => 'g.`billing_customer_number`', - 'grant_id' => 'g.`id`', + 'customer_number' => 'sort_customer_number', + 'grant_id' => 'sort_grant_id', 'name' => 's.`name`', ]; @@ -821,7 +1020,7 @@ class subusersRoute $accessState = 'disabled'; } - return [ + return $this->withVerificationPayload($this->subuserFromSuperuserRow($row), [ 'id' => (int)$row['id'], 'username' => $row['username'] ?? null, 'name' => $row['name'] ?? null, @@ -858,7 +1057,14 @@ class subusersRoute $grants ))), 'access_state' => $accessState, - ]; + ]); + } + + private function subuserFromSuperuserRow(array $row): subusers_o + { + $subuser = (new subusers_o())->select((int)$row['id']); + $subuser->getObjectProperties(); + return $subuser; } private function dedupeSuperuserGrantRows(array $grantRows): array @@ -1067,7 +1273,7 @@ class subusersRoute LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id` AND cv.`deleted_at` IS NULL "; - $countSql = "SELECT COUNT(*) AS `count` $fromSql $whereSql"; + $countSql = "SELECT COUNT(DISTINCT s.`id`) AS `count` $fromSql $whereSql"; $countStatement = $db->conn->prepare($countSql); if ($countStatement === false) { throw new Exception('Failed to prepare subuser count query: ' . $db->conn->error); @@ -1091,18 +1297,23 @@ class subusersRoute s.`created_at`, s.`updated_at`, s.`suspended_at`, - COALESCE(g.`updated_at`, s.`updated_at`) AS `row_updated_at`, - g.`id` AS `grant_id`, - g.`billing_customer_number` AS `customer_number`, - g.`enabled` AS `grant_enabled`, - g.`note` AS `grant_note`, - g.`permissions` AS `grant_permissions`, - g.`assigned_vehicle_id`, - cv.`reg` AS `assigned_vehicle_reg`, - g.`created_at` AS `grant_created_at`, - g.`updated_at` AS `grant_updated_at` + MAX(COALESCE(g.`updated_at`, s.`updated_at`)) AS `row_updated_at`, + MIN(g.`billing_customer_number`) AS `sort_customer_number`, + MAX(g.`id`) AS `sort_grant_id` $fromSql $whereSql + GROUP BY + s.`id`, + s.`username`, + s.`password`, + s.`name`, + s.`email`, + s.`phone_country_code`, + s.`phone`, + s.`two_factor_enabled`, + s.`created_at`, + s.`updated_at`, + s.`suspended_at` ORDER BY {$pagination['order_sql']} {$pagination['order_direction']} LIMIT ? OFFSET ? "; @@ -1118,15 +1329,85 @@ class subusersRoute $rows = $result->fetch_all(MYSQLI_ASSOC); $pageStatement->close(); - $customerNames = $this->resolveCustomerNames(array_map( - static fn (array $row): int => (int)($row['customer_number'] ?? 0), + $subuserIds = array_values(array_map( + static fn (array $row): int => (int)($row['id'] ?? 0), $rows )); - $rows = array_map(function (array $row) use ($customerNames): array { + if ($subuserIds === []) { + $response->paginate( + (int)$pagination['page'], + (int)$pagination['limit'], + $total, + $pagination['search'], + null, + [$pagination['order_field'] => $pagination['order_direction']] + ); + + return []; + } + + $grantWhere = [ + 'g.`deleted_at` IS NULL', + 'g.`subuser` IN (' . implode(', ', array_fill(0, count($subuserIds), '?')) . ')', + ]; + $grantParams = $subuserIds; + $grantTypes = str_repeat('i', count($subuserIds)); + if (!$includeNonEnabled) { + $grantWhere[] = 'g.`enabled` = 1'; + } + if ($customerNumber !== null) { + $grantWhere[] = 'g.`billing_customer_number` = ?'; + $grantParams[] = $customerNumber; + $grantTypes .= 'i'; + } + + $grantSql = " + SELECT + g.`subuser` AS `subuser_id`, + g.`id` AS `grant_id`, + g.`billing_customer_number` AS `customer_number`, + g.`enabled` AS `grant_enabled`, + g.`note` AS `grant_note`, + g.`permissions` AS `grant_permissions`, + g.`assigned_vehicle_id`, + cv.`reg` AS `assigned_vehicle_reg`, + g.`created_at` AS `grant_created_at`, + g.`updated_at` AS `grant_updated_at` + FROM `subuser_grants` g + LEFT JOIN `customer_vehicles` cv ON cv.`id` = g.`assigned_vehicle_id` AND cv.`deleted_at` IS NULL + WHERE " . implode(' AND ', $grantWhere) . " + ORDER BY g.`enabled` DESC, g.`billing_customer_number` ASC, g.`id` DESC + "; + + $grantStatement = $db->conn->prepare($grantSql); + if ($grantStatement === false) { + throw new Exception('Failed to prepare subuser grant list query: ' . $db->conn->error); + } + $this->bindStatementParameters($grantStatement, $grantTypes, $grantParams); + $grantStatement->execute(); + $grantResult = $grantStatement->get_result(); + $grantRows = $grantResult->fetch_all(MYSQLI_ASSOC); + $grantStatement->close(); + + $customerNames = $this->resolveCustomerNames(array_map( + static fn (array $row): int => (int)($row['customer_number'] ?? 0), + $grantRows + )); + $grantRows = array_map(function (array $row) use ($customerNames): array { $customerNumberForGrant = (int)($row['customer_number'] ?? 0); $row['customer_name'] = $this->resolveCustomerName($customerNumberForGrant, $customerNames); return $row; - }, $rows); + }, $grantRows); + $grantRowsBySubuser = []; + foreach ($grantRows as $grantRow) { + $subuserId = (int)($grantRow['subuser_id'] ?? 0); + if ($subuserId <= 0) { + continue; + } + + $grantRowsBySubuser[$subuserId] ??= []; + $grantRowsBySubuser[$subuserId][] = $grantRow; + } $response->paginate( (int)$pagination['page'], @@ -1138,7 +1419,10 @@ class subusersRoute ); return array_map( - fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row, [$row]), + fn(array $row): array => $this->buildSuperuserSubuserManagementPayload( + $row, + $grantRowsBySubuser[(int)($row['id'] ?? 0)] ?? [] + ), $rows ); } @@ -1743,12 +2027,13 @@ class subusersRoute ); // Set the password for the subuser try { - $subuser->update([ + $setupUpdates = [ 'password' => password_hash($password, PASSWORD_DEFAULT), 'name' => $name, ...(!empty($username) ? ['username' => $username] : []), ...(!empty($email) ? ['email' => $email] : []), - ]); + ]; + $subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $setupUpdates)); // Invalidate the setup token $subuser->invalidateSetupToken($token); $this->clearThrottleAttempt($setupThrottleKey); @@ -1852,7 +2137,7 @@ class subusersRoute $updates = $this->parseSuperuserSubuserProfileUpdates((int)$subuser->id); try { - $subuser->update($updates); + $subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $updates)); } catch (Exception $exception) { $response->error($exception->getMessage(), 400); } @@ -1873,6 +2158,59 @@ class subusersRoute 'edit_subusers' => 'Edit chauffeur account profile fields as a superuser.', ]); + $this->post('/superuser/subusers/{subuser_id}/verification/{channel}/send', function () { + global $response; + + $this->requirePermission('edit_subusers'); + $subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id')); + $authUser = (new authentication())->get_user(); + $delivery = $this->sendContactVerificationCode( + $subuser, + (string)$this->fromRoute('channel'), + $this->verificationActorContext('user', $authUser !== false ? (int)$authUser->id : 0) + ); + $subuser = $this->loadSubuserOrFail((int)$subuser->id); + $response->success([ + 'delivery' => $delivery, + 'subuser' => $this->buildSubuserAccountPayload($subuser), + ]); + }, [ + 'edit_subusers' => 'Send chauffeur contact verification codes as a superuser.', + ]); + + $this->patch('/superuser/subusers/{subuser_id}/verification/{channel}', function () { + global $response; + + $this->requirePermission('edit_subusers'); + self::requireParameters(['verified']); + $verified = self::getParameter('verified'); + self::requireType($verified, self::type_bool()); + $subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id')); + $authUser = (new authentication())->get_user(); + try { + $result = $this->verificationService()->setVerificationState( + $subuser, + (string)$this->fromRoute('channel'), + (bool)$verified, + $this->verificationActorContext('user', $authUser !== false ? (int)$authUser->id : 0) + ); + } catch (Exception $exception) { + $response->error($exception->getMessage(), 400); + } + if (($result['status'] ?? null) === 'missing_destination') { + $response->error((string)($result['message'] ?? 'No contact value is available for verification.'), 400); + } + + $subuser = $this->loadSubuserOrFail((int)$subuser->id); + $response->success([ + 'result' => $result, + 'verification' => $this->verificationService()->status($subuser), + 'subuser' => $this->buildSubuserAccountPayload($subuser), + ]); + }, [ + 'edit_subusers' => 'Set chauffeur contact verification state as a superuser.', + ]); + $this->post('/superuser/subusers/{subuser_id}/password', function () { global $response; @@ -1913,34 +2251,30 @@ class subusersRoute $this->requirePermission('SUPERUSER_INTIMIDATE'); $subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id')); - $customerNumber = $this->resolveDirectLoginCustomerNumber($subuser); - - try { - $sessionToken = $subuser->generateSession(); - } catch (Exception $exception) { - $response->error($exception->getMessage(), 500); - } - - $authUser = (new authentication())->get_user(); - (new logs_o())->add( - 'auth', - 'global', - 1, - $authUser !== false ? (int)$authUser->id : 0, + $response->success($this->createDirectSubuserLoginLinkPayload( + $subuser, 'SUPERUSER_SUBUSER_DIRECT_LOGIN_LINK', - 'Created chauffeur direct login link for subuser ' . (int)$subuser->id . ' and customer ' . $customerNumber - ); - - $response->success([ - 'subuser_id' => (int)$subuser->id, - 'customer_number' => $customerNumber, - 'login_path' => $this->buildDirectSubuserLoginPath($sessionToken, $customerNumber), - ]); + 'Created chauffeur direct login link' + )); }, [ 'edit_subusers' => 'Create chauffeur direct login links as a superuser.', 'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.', ]); + $this->post('/superuser/subusers/{subuser_id}/password-guide/{channel}/send', function () { + $this->sendSuperuserSubuserDirectLoginLink('password_guide'); + }, [ + 'edit_subusers' => 'Send chauffeur password guide links as a superuser.', + 'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.', + ]); + + $this->post('/superuser/subusers/{subuser_id}/login-link/{channel}/send', function () { + $this->sendSuperuserSubuserDirectLoginLink('login_link'); + }, [ + 'edit_subusers' => 'Send chauffeur direct login links as a superuser.', + 'SUPERUSER_INTIMIDATE' => 'Create direct login sessions for chauffeur accounts.', + ]); + $this->get('/superuser/users/{user_id}/subusers', function () { global $response; $this->requirePermission('list_subusers'); @@ -2006,6 +2340,82 @@ class subusersRoute $response->success($this->buildCurrentSubuserPayload($subuser)); }, []); + $this->get('/subusers/me/verification', function () { + global $response; + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { + $response->error('Unauthorized', 401); + } + + $response->success($this->verificationService()->status($subuser)); + }, []); + + $this->post('/subusers/me/verification/{channel}/send', function () { + global $response; + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { + $response->error('Unauthorized', 401); + } + + $delivery = $this->sendContactVerificationCode( + $subuser, + (string)$this->fromRoute('channel'), + $this->verificationActorContext('subuser', (int)$subuser->id) + ); + $subuser = $this->loadSubuserOrFail((int)$subuser->id); + $response->success([ + 'delivery' => $delivery, + 'verification' => $this->verificationService()->status($subuser), + ]); + }, []); + + $this->post('/subusers/me/verification/{channel}/verify', function () { + global $response; + $subuser = (new authentication())->get_subuser(); + if ($subuser === false) { + $response->error('Unauthorized', 401); + } + + self::requireParameters(['code']); + $result = $this->verifyContactVerificationCode( + $subuser, + (string)$this->fromRoute('channel'), + (string)self::getParameter('code'), + $this->verificationActorContext('subuser', (int)$subuser->id) + ); + $subuser = $this->loadSubuserOrFail((int)$subuser->id); + $response->success([ + 'result' => $result, + 'verification' => $this->verificationService()->status($subuser), + 'subuser' => $this->buildCurrentSubuserPayload($subuser), + ]); + }, []); + + $this->post('/subusers/{subuser_id}/verification/{channel}/send', function () { + global $response; + + $subuser = $this->loadSubuserOrFail($this->routePositiveInt('subuser_id')); + $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_EDIT); + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer((int)$subuser->id, $customerNumber, true); + if ($grant === null) { + $response->error('Subuser grant not found for selected customer', 404); + } + + $authUser = (new authentication())->get_user(); + $delivery = $this->sendContactVerificationCode( + $subuser, + (string)$this->fromRoute('channel'), + $this->verificationActorContext('user', $authUser !== false ? (int)$authUser->id : 0) + ); + $subuser = $this->loadSubuserOrFail((int)$subuser->id); + $response->success([ + 'delivery' => $delivery, + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + ]); + }, [ + 'edit_own_subusers' => 'Send chauffeur contact verification codes for own-customer chauffeurs.', + ]); + $this->put('/subusers/me', function () { global $response; $subuser = (new authentication())->get_subuser(); @@ -2055,7 +2465,7 @@ class subusersRoute ); try { - $subuser->update($updates); + $subuser->update($this->verificationService()->clearVerificationForChangedContacts($subuser, $updates)); } catch (Exception $exception) { $response->error($exception->getMessage(), 400); } diff --git a/services/nginx/app/tests/Api/SubusersApiTest.php b/services/nginx/app/tests/Api/SubusersApiTest.php index b9a7c8be..84260d75 100644 --- a/services/nginx/app/tests/Api/SubusersApiTest.php +++ b/services/nginx/app/tests/Api/SubusersApiTest.php @@ -93,6 +93,131 @@ it('returns a generic error for invalid subuser credentials', function (): void ->assertMessage('Invalid credentials'); }); +it('lets drivers verify their email with a one-time code', function (): void { + api_test_covers('GET /subusers/me/verification', 'happy'); + api_test_covers('POST /subusers/me/verification/{channel}/send', 'happy'); + api_test_covers('POST /subusers/me/verification/{channel}/verify', 'happy'); + api_test_covers('POST /subusers/me/verification/{channel}/verify', 'failure'); + + $previousFakeMode = getenv('EMAIL_FAKE_MODE'); + $previousFakePath = getenv('EMAIL_FAKE_DELIVERIES_PATH'); + $fakePath = sys_get_temp_dir() . '/truckwash-subuser-verification-' . bin2hex(random_bytes(6)) . '.jsonl'; + + putenv('EMAIL_FAKE_MODE=1'); + putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $fakePath); + \classes\email::resetFakeDeliveries(); + api_fixtures()->setModuleConfig('Email', 'mailersend_enabled', 'true', 'bool'); + api_test_runtime()->restartServer(); + + try { + $customer = api_fixtures()->createUser(['display_name' => 'Verification Customer']); + $session = api_fixtures()->createSubuserSession( + (int)$customer['customer_number'], + ['VEHICLES_LIST'], + [ + 'name' => 'Verification Driver', + 'email' => 'driver.verify@example.test', + 'email_verified_at' => null, + 'phone_verified_at' => null, + ] + ); + + $status = api_client()->get('/subusers/me/verification', $session['headers']); + $send = api_client()->post('/subusers/me/verification/email/send', [], $session['headers']); + + $status + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $send + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($status->data()['email']['verified'] ?? null)->toBeFalse(); + expect($send->data()['delivery']['status'] ?? null)->toBe('sent'); + expect($send->data()['verification']['email']['verified'] ?? null)->toBeFalse(); + + \classes\email::syncFakeDeliveries(); + $deliveries = \classes\email::$fake_deliveries; + expect($deliveries)->toHaveCount(1); + $message = (string)($deliveries[0]['message'] ?? ''); + preg_match('/\b([0-9]{6})\b/', $message, $matches); + expect($matches[1] ?? null)->toBeString(); + + $wrong = api_client()->post('/subusers/me/verification/email/verify', [ + 'code' => '000000', + ], $session['headers']); + $wrong + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false); + + $verify = api_client()->post('/subusers/me/verification/email/verify', [ + 'code' => (string)$matches[1], + ], $session['headers']); + $verify + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($verify->data()['verification']['email']['verified'] ?? null)->toBeTrue(); + expect($verify->data()['subuser']['email_verified'] ?? null)->toBeTrue(); + + $row = api_fixtures()->fetchRowById('subusers', (int)$session['subuser']['id']); + expect($row['email_verified_at'] ?? null)->not->toBeNull(); + expect($row['phone_verified_at'] ?? null)->toBeNull(); + } finally { + api_test_runtime()->restartServer(); + \classes\email::resetFakeDeliveries(); + if ($previousFakeMode === false) { + putenv('EMAIL_FAKE_MODE'); + } else { + putenv('EMAIL_FAKE_MODE=' . $previousFakeMode); + } + if ($previousFakePath === false) { + putenv('EMAIL_FAKE_DELIVERIES_PATH'); + } else { + putenv('EMAIL_FAKE_DELIVERIES_PATH=' . $previousFakePath); + } + if (is_file($fakePath)) { + unlink($fakePath); + } + } +}); + +it('clears email verification when a driver changes their email', function (): void { + api_test_covers('PUT /subusers/me', 'happy'); + + $customer = api_fixtures()->createUser(['display_name' => 'Driver Email Change Customer']); + $session = api_fixtures()->createSubuserSession( + (int)$customer['customer_number'], + ['VEHICLES_LIST'], + [ + 'email' => 'old.driver.email@example.test', + 'email_verified_at' => '2026-07-13 10:00:00', + 'phone_verified_at' => '2026-07-13 10:00:00', + ] + ); + + $response = api_client()->request('PUT', '/subusers/me', [ + 'email' => 'new.driver.email@example.test', + ], $session['headers']); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($response->data()['email'] ?? null)->toBe('new.driver.email@example.test'); + expect($response->data()['email_verified'] ?? null)->toBeFalse(); + expect($response->data()['phone_verified'] ?? null)->toBeTrue(); + + $row = api_fixtures()->fetchRowById('subusers', (int)$session['subuser']['id']); + expect($row['email_verified_at'] ?? null)->toBeNull(); + expect($row['phone_verified_at'] ?? null)->not->toBeNull(); +}); + it('requires subuser management access before exposing permission nodes', function (): void { api_test_covers('GET /subusers/permission-nodes', 'auth'); @@ -206,7 +331,7 @@ it('validates setup identifiers before setting the driver password', function () } }); -it('lists chauffeur grants across customers for superusers', function (): void { +it('lists chauffeurs once with grouped grants across customers for superusers', function (): void { $session = api_fixtures()->createUserSession(['list_subusers']); $firstCustomer = api_fixtures()->createUser([ 'display_name' => 'Fleet Customer Alpha', @@ -253,23 +378,77 @@ it('lists chauffeur grants across customers for superusers', function (): void { && in_array((int)($item['id'] ?? 0), [(int)$firstSubuser['id'], (int)$secondSubuser['id']], true) )); - expect($rows)->toHaveCount(3); + expect($rows)->toHaveCount(2); + expect($response->meta()['pagination']['total'] ?? null)->toBe(2); - $byGrantId = []; + $bySubuserId = []; foreach ($rows as $row) { - $byGrantId[(int)$row['grant_id']] = $row; + $bySubuserId[(int)$row['id']] = $row; } - expect($byGrantId[$firstGrantId]['id'])->toBe((int)$firstSubuser['id']); - expect($byGrantId[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']); - expect($byGrantId[$firstGrantId]['assigned_vehicle_id'])->toBe((int)$firstVehicle['id']); - expect($byGrantId[$firstGrantId]['assigned_vehicle_reg'])->toBe('AB12345'); - expect($byGrantId[$firstGrantId]['dognvask_enabled'])->toBeTrue(); - expect($byGrantId[$sharedGrantId]['id'])->toBe((int)$firstSubuser['id']); - expect($byGrantId[$sharedGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']); - expect($byGrantId[$sharedGrantId]['dognvask_enabled'])->toBeFalse(); - expect($byGrantId[$secondGrantId]['id'])->toBe((int)$secondSubuser['id']); - expect($byGrantId[$secondGrantId]['customer_name'])->toBe('Fleet Customer Beta'); + expect($bySubuserId)->toHaveKeys([(int)$firstSubuser['id'], (int)$secondSubuser['id']]); + + $firstRow = $bySubuserId[(int)$firstSubuser['id']]; + expect($firstRow['grant_count'] ?? null)->toBe(2); + expect($firstRow['customer_numbers'] ?? [])->toContain((int)$firstCustomer['customer_number']); + expect($firstRow['customer_numbers'] ?? [])->toContain((int)$secondCustomer['customer_number']); + + $firstGrantsById = []; + foreach ($firstRow['grants'] ?? [] as $grant) { + $firstGrantsById[(int)$grant['grant_id']] = $grant; + } + + expect($firstGrantsById[$firstGrantId]['customer_number'])->toBe((int)$firstCustomer['customer_number']); + expect($firstGrantsById[$firstGrantId]['assigned_vehicle_id'])->toBe((int)$firstVehicle['id']); + expect($firstGrantsById[$firstGrantId]['assigned_vehicle_reg'])->toBe('AB12345'); + expect($firstGrantsById[$firstGrantId]['dognvask_enabled'])->toBeTrue(); + expect($firstGrantsById[$sharedGrantId]['customer_number'])->toBe((int)$secondCustomer['customer_number']); + expect($firstGrantsById[$sharedGrantId]['dognvask_enabled'])->toBeFalse(); + + $secondRow = $bySubuserId[(int)$secondSubuser['id']]; + expect($secondRow['grant_count'] ?? null)->toBe(1); + expect($secondRow['grants'][0]['grant_id'] ?? null)->toBe($secondGrantId); + expect($secondRow['grants'][0]['customer_name'] ?? null)->toBe('Fleet Customer Beta'); +}); + +it('paginates superuser chauffeur lists by unique chauffeur instead of grant count', function (): void { + $session = api_fixtures()->createUserSession(['list_subusers']); + $suffix = (string)random_int(100000, 999999); + $firstCustomer = api_fixtures()->createUser(['display_name' => 'Grouped Page Alpha ' . $suffix]); + $secondCustomer = api_fixtures()->createUser(['display_name' => 'Grouped Page Beta ' . $suffix]); + $firstSubuser = api_fixtures()->createSubuser(['name' => 'Grouped Page Driver Alpha ' . $suffix]); + $secondSubuser = api_fixtures()->createSubuser(['name' => 'Grouped Page Driver Beta ' . $suffix]); + + api_fixtures()->grantSubuser((int)$firstSubuser['id'], (int)$firstCustomer['customer_number'], ['BOOKINGS_LIST']); + api_fixtures()->grantSubuser((int)$firstSubuser['id'], (int)$secondCustomer['customer_number'], ['ORDERS_LIST']); + api_fixtures()->grantSubuser((int)$secondSubuser['id'], (int)$secondCustomer['customer_number'], ['VEHICLES_LIST']); + + $pageOne = api_client()->get( + '/superuser/subusers?page=1&limit=1&search=' . rawurlencode($suffix) . '&order=name:ASC', + $session['headers'] + ); + $pageTwo = api_client()->get( + '/superuser/subusers?page=2&limit=1&search=' . rawurlencode($suffix) . '&order=name:ASC', + $session['headers'] + ); + + $pageOne + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $pageTwo + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + expect($pageOne->meta()['pagination']['total'] ?? null)->toBe(2); + expect($pageTwo->meta()['pagination']['total'] ?? null)->toBe(2); + expect($pageOne->data())->toHaveCount(1); + expect($pageTwo->data())->toHaveCount(1); + expect($pageOne->data()[0]['id'] ?? null)->toBe((int)$firstSubuser['id']); + expect($pageOne->data()[0]['grant_count'] ?? null)->toBe(2); + expect($pageTwo->data()[0]['id'] ?? null)->toBe((int)$secondSubuser['id']); + expect($pageTwo->data()[0]['grant_count'] ?? null)->toBe(1); }); it('lets superusers invite chauffeurs for a selected customer', function (): void { @@ -565,6 +744,8 @@ it('lets superusers edit chauffeur account details and set passwords', function 'email' => null, 'phone_country_code' => 45, 'phone' => 73123456, + 'email_verified_at' => '2026-07-13 10:00:00', + 'phone_verified_at' => '2026-07-13 10:00:00', ]); $subuserObject = (new \objects\subusers_o())->select((int)$subuser['id']); $subuserObject->getObjectProperties(); @@ -602,11 +783,15 @@ it('lets superusers edit chauffeur account details and set passwords', function expect($profile->data()['subuser']['email'] ?? null)->toBe('admin.updated.driver@example.com'); expect($profile->data()['subuser']['phone_country_code'] ?? null)->toBe(46); expect($profile->data()['subuser']['phone'] ?? null)->toBe(73123457); + expect($profile->data()['subuser']['email_verified'] ?? null)->toBeFalse(); + expect($profile->data()['subuser']['phone_verified'] ?? null)->toBeFalse(); expect($password->data()['subuser']['setup_required'] ?? null)->toBeFalse(); expect((new \objects\subusers_o())->getSubuserBySetupToken($setupToken))->toBeNull(); $row = api_fixtures()->fetchRowById('subusers', (int)$subuser['id']); expect(password_verify('ValidPass123', (string)($row['password'] ?? '')))->toBeTrue(); + expect($row['email_verified_at'] ?? null)->toBeNull(); + expect($row['phone_verified_at'] ?? null)->toBeNull(); } finally { (new \objects\subusers_o())->invalidateSetupToken($setupToken); } diff --git a/services/nginx/app/tests/Support/Api/ApiFixtures.php b/services/nginx/app/tests/Support/Api/ApiFixtures.php index 4c824f2b..65c4cf2d 100644 --- a/services/nginx/app/tests/Support/Api/ApiFixtures.php +++ b/services/nginx/app/tests/Support/Api/ApiFixtures.php @@ -1042,8 +1042,10 @@ final class ApiFixtures 'password' => $passwordPlaintext === null ? null : password_hash((string)$passwordPlaintext, PASSWORD_DEFAULT), 'name' => $name, 'email' => $email, - 'phone_country_code' => 45, - 'phone' => 10000000 + (++self::$sequence), + 'email_verified_at' => $attributes['email_verified_at'] ?? null, + 'phone_country_code' => (int)($attributes['phone_country_code'] ?? 45), + 'phone' => (int)($attributes['phone'] ?? (10000000 + (++self::$sequence))), + 'phone_verified_at' => $attributes['phone_verified_at'] ?? null, 'two_factor_enabled' => 0, 'two_factor_secret' => null, 'created_at' => $attributes['created_at'] ?? $now, diff --git a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php index 355b088b..bde7920d 100644 --- a/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php +++ b/services/nginx/app/tests/Support/Api/ApiSchemaBootstrap.php @@ -822,8 +822,10 @@ CREATE TABLE IF NOT EXISTS `subusers` ( `password` VARCHAR(255) NULL, `name` VARCHAR(255) NULL, `email` VARCHAR(255) NULL, + `email_verified_at` DATETIME NULL, `phone_country_code` INT NULL, `phone` BIGINT NULL, + `phone_verified_at` DATETIME NULL, `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, `two_factor_secret` VARCHAR(255) NULL, `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php index 520d821b..8b793e49 100644 --- a/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php @@ -14,7 +14,7 @@ it('routes collected invoice draft line uploads through the multi-order batch en $methodBlock = substr($content, (int)$start, (int)$end - (int)$start); expect($methodBlock)->toContain('$order_objects = [];') - ->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency);') + ->and($methodBlock)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);') ->and($methodBlock)->toContain('...$metrics') ->and($methodBlock)->not->toContain('self::addInvoiceToDraft($order[\'id\'], true, $draft_id, $currency);'); }); @@ -36,9 +36,23 @@ it('keeps single-order draft uploads as a wrapper around the batch endpoint', fu $batchBlock = substr($content, (int)$singleEnd); expect($batchBlock)->toContain('$draftInvoice->flushLinesInBatches($line_batch_size);') + ->and($batchBlock)->toContain('$draftInvoice->addOrderItemLines($order, $use_itemized_discounts);') ->and($batchBlock)->toContain("'orders_with_invoice_lines' => \$orders_with_invoice_lines"); }); +it('selects itemized discount mode for collected invoice batch transfers', function (): void { + $content = file_get_contents(dirname(__DIR__, 3) . '/objects/collected_order_invoices_o.php'); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content)->toContain('resolveInvoiceLayoutNumber') + ->and($content)->toContain('invoice_discount_layout') + ->and($content)->toContain('hasDiscountedIncludedInvoiceItems') + ->and($content)->toContain('orderItemHasBillableDiscount') + ->and($content)->toContain('$metrics = (new economic())->invoices->draft->add_orders($draft_id, $order_objects, $currency, 500, $use_itemized_discounts);'); +}); + it('includes collected invoice batch transfer metrics in queue results when available', function (): void { $content = file_get_contents(dirname(__DIR__, 3) . '/classes/economic_transfer_executor.php'); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php index 3286e138..dbae79ac 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicDraftCustomerOpenApiSpecTest.php @@ -34,6 +34,7 @@ it('documents transaction draft customer config and auth runtime fields in all t expect($content, $path)->toContain('transaction_draft_customer_number:'); expect($content, $path)->toContain('EconomicConfigEntry:'); expect($content, $path)->toContain('transactionDraftCustomerNumber'); + expect($content, $path)->toContain('invoiceDiscountLayoutNumber'); expect($content, $path)->toContain('nullable: true'); } }); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php new file mode 100644 index 00000000..a1e704b0 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php @@ -0,0 +1,126 @@ +draft_invoice_number = 123; + $this->currency = 'DKK'; + $this->conversion_rate = 1.0; + $this->draft_invoice_data = (object)['draftInvoiceNumber' => 123]; + } + + protected function sendDraftLines(array $draft_lines): object + { + $this->sentBatches[] = $draft_lines; + return (object)['lines' => $draft_lines]; + } + } +} + +function economic_itemized_discount_order_item(array $overrides = []): array +{ + return array_replace_recursive([ + 'id' => 123, + 'quantity' => 2, + 'price' => 80, + 'reference' => '', + 'notes' => '', + 'include_in_invoice' => true, + 'product' => [ + 'economic_product_id' => '5', + 'name' => 'Premium wash', + 'price' => 100, + ], + ], $overrides); +} + +it('detects billable itemized discounts from original and final prices', function (): void { + $pricing = economic_invoice_draft::resolveOrderItemInvoicePricing(economic_itemized_discount_order_item()); + + expect($pricing['has_discount'])->toBeTrue() + ->and($pricing['original_unit_price'])->toBe(100.0) + ->and($pricing['final_unit_price'])->toBe(80.0) + ->and($pricing['discount_unit_amount'])->toBe(20.0) + ->and($pricing['discount_total_amount'])->toBe(40.0) + ->and($pricing['discount_percentage'])->toBe(20.0); +}); + +it('does not mark equal prices, zero quantities, or zero final prices as itemized discounts', function (): void { + expect(economic_invoice_draft::orderItemHasBillableDiscount(economic_itemized_discount_order_item([ + 'price' => 100, + ])))->toBeFalse(); + expect(economic_invoice_draft::orderItemHasBillableDiscount(economic_itemized_discount_order_item([ + 'quantity' => 0, + ])))->toBeFalse(); + expect(economic_invoice_draft::orderItemHasBillableDiscount(economic_itemized_discount_order_item([ + 'price' => 0, + ])))->toBeFalse(); +}); + +it('uses original price and exact discount basis for itemized discount product lines', function (): void { + $draft = new EconomicInvoiceDraftItemizedDiscountProbe(); + + $draft->addOrderItemLine( + economic_itemized_discount_order_item(), + [ + 'economic_department_id' => 75, + 'economic_dimension_id' => 1, + ], + false, + true + ); + $draft->flushLinesInBatches(); + + $line = $draft->sentBatches[0][0]; + expect($line['product']['productNumber'])->toBe('5') + ->and($line['description'])->toBe('Premium wash') + ->and($line['quantity'])->toBe(2.0) + ->and($line['unitNetPrice'])->toBe(100.0) + ->and($line['discountPercentage'])->toBe(20.0); +}); + +it('keeps non-discount itemized product lines at the exact final price', function (): void { + $draft = new EconomicInvoiceDraftItemizedDiscountProbe(); + + $draft->addOrderItemLine( + economic_itemized_discount_order_item([ + 'price' => 125, + 'product' => [ + 'price' => 100, + ], + ]), + [ + 'economic_department_id' => 75, + 'economic_dimension_id' => 1, + ], + false, + true + ); + $draft->flushLinesInBatches(); + + $line = $draft->sentBatches[0][0]; + expect($line['unitNetPrice'])->toBe(125.0) + ->and($line['discountPercentage'])->toBe(0.0); +}); + +it('keeps fractional discount percentages instead of rounding itemized discounts to whole percentages', function (): void { + $pricing = economic_invoice_draft::resolveOrderItemInvoicePricing(economic_itemized_discount_order_item([ + 'quantity' => 1, + 'price' => 2, + 'product' => [ + 'price' => 3, + ], + ])); + + expect($pricing['discount_unit_amount'])->toBe(1.0) + ->and($pricing['discount_total_amount'])->toBe(1.0) + ->and($pricing['discount_percentage'])->toBe(33.3333333333); +}); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php index a53c3c01..7a92e761 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveEligibilityLaneAccessTest.php @@ -46,9 +46,10 @@ it('authorizes customer eligibility preview lanes before returning task attachme $assertBlock = selfserve_eligibility_method_block($route, 'private function assertLaneAccess('); expect($allowedBlock)->toContain('$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);') - ->and($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)') + ->and($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id, [') + ->and($allowedBlock)->toContain("'subuser_id' => \$subuser_id") ->and(strpos($allowedBlock, '$lane = $this->assertLaneAccess($user, $lane_id, $has_global, $has_own);')) - ->toBeLessThan(strpos($allowedBlock, 'previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)')); + ->toBeLessThan(strpos($allowedBlock, 'previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id, [')); expect($assertBlock)->toContain('bool $hasGlobalPermission = true') ->and($assertBlock)->toContain('bool $hasOwnPermission = false') diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php index d0a674ed..755629c4 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveNonOwnedVehicleWashAccessTest.php @@ -39,7 +39,8 @@ it('allows own-permission customers to preview borrowed registration plates with $allowedBlock = selfserve_non_owned_vehicle_route_block($route, 'get', '/department/selfserve/vehicle/allowed'); expect($allowedBlock)->toContain("requireAuthenticatedCustomerNumber(\$user, 'list_department_selfserve_vehicle_conditions')"); - expect($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id)'); + expect($allowedBlock)->toContain('previewVehicleEligibility($lane_id, $reg, $customer_number, $vehicle_type_id, ['); + expect($allowedBlock)->toContain("'subuser_id' => \$subuser_id"); expect($allowedBlock)->not->toContain('assertOwnVehicle'); }); diff --git a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php index 8839effa..760aa925 100644 --- a/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php +++ b/services/nginx/app/tests/Unit/Selfserve/SelfserveWashCompletionRelayWiringTest.php @@ -115,7 +115,7 @@ it('normal STOP completion skips duplicate completion relay cleanup after STOP a $commandTrait = file_get_contents(app_path('modules/selfserve/traits/selfserve_lane_command_t.php')); expect($commandTrait)->not->toBeFalse(); - $methodOffset = strpos($commandTrait, 'protected function completeLatestSessionForStop(): void'); + $methodOffset = strpos($commandTrait, 'protected function completeLatestSessionForStop(?int $subuser_id = null): void'); expect($methodOffset)->not->toBeFalse(); $methodBody = substr($commandTrait, (int)$methodOffset, 1500); @@ -125,7 +125,8 @@ it('normal STOP completion skips duplicate completion relay cleanup after STOP a $this->getLicensePlate() ?: null, $this->getCustomerNumber() ?: null, method_exists($this, 'getLastInvoiceOrderId') ? $this->getLastInvoiceOrderId() : null, - false + false, + $subuser_id ); PHP); }); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserContactVerificationServiceTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserContactVerificationServiceTest.php new file mode 100644 index 00000000..a28058dd --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserContactVerificationServiceTest.php @@ -0,0 +1,148 @@ +id = -1; + $subuser->getObjectProperties(); + + $defaults = [ + 'name' => 'Test Driver', + 'email' => 'driver@example.com', + 'email_verified_at' => null, + 'phone_country_code' => 45, + 'phone' => 12345678, + 'phone_verified_at' => null, + ]; + + foreach ([...$defaults, ...$values] as $property => $value) { + $subuser->{$property}->set($value); + } + + return $subuser; +} + +function subuser_contact_verification_test_redis(): object +{ + return new class { + public array $values = []; + public array $ttls = []; + + public function get(string $key): ?string + { + return $this->values[$key] ?? null; + } + + public function setEx(string $key, string $value, int $expiration): self + { + $this->values[$key] = $value; + $this->ttls[$key] = $expiration; + return $this; + } + + public function delete(string $key): self + { + unset($this->values[$key], $this->ttls[$key]); + return $this; + } + }; +} + +it('sends and verifies a subuser email verification code without storing plaintext codes', function (): void { + $redis = subuser_contact_verification_test_redis(); + $sentEmails = []; + $now = 1_700_000_000; + $subuser = subuser_contact_verification_test_subuser(); + $service = new subuser_contact_verification_service( + redis: $redis, + codeGenerator: fn () => '123456', + timeProvider: fn () => $now, + emailSender: function (string $destination, string $code) use (&$sentEmails): void { + $sentEmails[] = compact('destination', 'code'); + } + ); + + $delivery = $service->sendCode($subuser, 'email', ['type' => 'test', 'id' => 42]); + + expect($delivery['status'])->toBe('sent') + ->and($delivery['expires_in'])->toBe(subuser_contact_verification_service::CODE_TTL_SECONDS) + ->and($sentEmails)->toBe([ + [ + 'destination' => 'driver@example.com', + 'code' => '123456', + ], + ]) + ->and($redis->values)->toHaveCount(1); + + $storedChallenge = json_decode((string)array_values($redis->values)[0], true); + expect($storedChallenge['hash'])->not->toBe('123456') + ->and($storedChallenge['attempts'])->toBe(0); + + $wrong = $service->verifyCode($subuser, 'email', '000000', ['type' => 'test', 'id' => 42]); + expect($wrong['status'])->toBe('invalid_code'); + + $updatedChallenge = json_decode((string)array_values($redis->values)[0], true); + expect($updatedChallenge['attempts'])->toBe(1); + + $verified = $service->verifyCode($subuser, 'email', '123456', ['type' => 'test', 'id' => 42]); + expect($verified['status'])->toBe('verified') + ->and($subuser->email_verified_at->value())->toBe(date('Y-m-d H:i:s', $now)) + ->and($redis->values)->toBe([]); + + $status = $service->status($subuser); + expect($status['email']['verified'])->toBeTrue() + ->and($status['phone']['verified'])->toBeFalse() + ->and($status['state'])->toBe('partial'); +}); + +it('clears verification timestamps when subuser contact values change', function (): void { + $subuser = subuser_contact_verification_test_subuser([ + 'email_verified_at' => '2026-07-13 10:00:00', + 'phone_verified_at' => '2026-07-13 10:00:00', + ]); + $service = new subuser_contact_verification_service(redis: subuser_contact_verification_test_redis()); + + $emailUpdate = $service->clearVerificationForChangedContacts($subuser, [ + 'email' => 'new-driver@example.com', + 'phone' => 12345678, + ]); + expect($emailUpdate['email_verified_at'])->toBeNull() + ->and($emailUpdate)->not->toHaveKey('phone_verified_at'); + + $phoneUpdate = $service->clearVerificationForChangedContacts($subuser, [ + 'phone_country_code' => 46, + ]); + expect($phoneUpdate['phone_verified_at'])->toBeNull() + ->and($phoneUpdate)->not->toHaveKey('email_verified_at'); +}); + +it('lets superusers set and clear contact verification state', function (): void { + $now = 1_800_000_000; + $subuser = subuser_contact_verification_test_subuser(); + $service = new subuser_contact_verification_service( + redis: subuser_contact_verification_test_redis(), + timeProvider: fn () => $now + ); + + $verified = $service->setVerificationState($subuser, 'email', true, ['type' => 'user', 'id' => 99]); + expect($verified['status'])->toBe('verified') + ->and($subuser->email_verified_at->value())->toBe(date('Y-m-d H:i:s', $now)); + + $unverified = $service->setVerificationState($subuser, 'email', false, ['type' => 'user', 'id' => 99]); + expect($unverified['status'])->toBe('unverified') + ->and($subuser->email_verified_at->value())->toBeNull(); +}); + +it('refuses to mark missing contact values as verified', function (): void { + $subuser = subuser_contact_verification_test_subuser([ + 'email' => null, + ]); + $service = new subuser_contact_verification_service(redis: subuser_contact_verification_test_redis()); + + $result = $service->setVerificationState($subuser, 'email', true, ['type' => 'user', 'id' => 99]); + expect($result['status'])->toBe('missing_destination') + ->and($subuser->email_verified_at->value())->toBeNull(); +}); diff --git a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php index 43fa914d..faa3fde1 100644 --- a/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php +++ b/services/nginx/app/tests/Unit/Subusers/SubusersRouteManagementContractTest.php @@ -14,6 +14,14 @@ it('exposes chauffeur management endpoints on the subusers route', function (): expect($normalized)->toContain("\$this->post('/superuser/subusers/invite/resend', function () {"); expect($normalized)->toContain("\$this->put('/subusers', function () {"); expect($normalized)->toContain("\$this->put('/subusers/me', function () {"); + expect($normalized)->toContain("\$this->get('/subusers/me/verification', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/me/verification/{channel}/send', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/me/verification/{channel}/verify', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/verification/{channel}/send', function () {"); + expect($normalized)->toContain("\$this->patch('/superuser/subusers/{subuser_id}/verification/{channel}', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/password-guide/{channel}/send', function () {"); + expect($normalized)->toContain("\$this->post('/superuser/subusers/{subuser_id}/login-link/{channel}/send', function () {"); + expect($normalized)->toContain("\$this->post('/subusers/{subuser_id}/verification/{channel}/send', function () {"); }); it('includes grant management fields in the subusers payload builder', function (): void { @@ -32,6 +40,9 @@ it('includes grant management fields in the subusers payload builder', function expect($normalized)->toContain("'can_resend_invite' =>"); expect($normalized)->toContain("'profile_editable_by_manager' => false"); expect($normalized)->toContain("'access_state' =>"); + expect($normalized)->toContain("'email_verified' =>"); + expect($normalized)->toContain("'phone_verified' =>"); + expect($normalized)->toContain("'verification_state' =>"); }); it('resolves subuser customer names without external lookups during list requests', function (): void { diff --git a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php index 52c7ce41..800e3cca 100644 --- a/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php +++ b/services/nginx/app/tests/Unit/SystemStatus/SuperuserSystemStatusServiceTest.php @@ -697,6 +697,7 @@ it('marks newly supported modules as probe backed and leaves only truly unsuppor $service->moduleConfigRowsOverride = [ 'economic' => [ 'invoiceLayoutNumber' => system_status_module_value('1'), + 'invoiceDiscountLayoutNumber' => system_status_module_value('6'), 'paymentTermsNumber' => system_status_module_value('2'), 'adminFeeMonthly' => system_status_module_value('3'), 'adminFeeOrder' => system_status_module_value('4'),