Implement subuser verification and invoice/self-serve API fixes
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use Exception;
|
||||
use objects\logs_o;
|
||||
use objects\subusers_o;
|
||||
|
||||
class subuser_contact_verification_service
|
||||
{
|
||||
public const CHANNEL_EMAIL = 'email';
|
||||
public const CHANNEL_PHONE = 'phone';
|
||||
public const CODE_TTL_SECONDS = 600;
|
||||
public const RESEND_COOLDOWN_SECONDS = 60;
|
||||
public const MAX_ATTEMPTS = 5;
|
||||
|
||||
private ?object $redis;
|
||||
private $codeGenerator;
|
||||
private $timeProvider;
|
||||
private $smsSender;
|
||||
private $emailSender;
|
||||
|
||||
public function __construct(
|
||||
?object $redis = null,
|
||||
?callable $codeGenerator = null,
|
||||
?callable $timeProvider = null,
|
||||
?callable $smsSender = null,
|
||||
?callable $emailSender = null
|
||||
) {
|
||||
$this->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<string,mixed>,phone:array<string,mixed>}
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<string,mixed> $updates
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
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 = '<p>Din verifikationskode til Truck Wash er <strong>' . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . '</strong>.</p>'
|
||||
. '<p>Koden udløber om 10 minutter.</p>';
|
||||
(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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace config;
|
||||
|
||||
use traits\module_config_variable;
|
||||
|
||||
class economic_invoice_discount_layout_c
|
||||
{
|
||||
use module_config_variable;
|
||||
|
||||
/**
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
self::setupConfigVariable(
|
||||
'economic',
|
||||
'invoiceDiscountLayoutNumber',
|
||||
'int',
|
||||
false,
|
||||
null,
|
||||
'The duplicated invoice layout number used when collected invoices include itemized discounts',
|
||||
'1',
|
||||
false,
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
require_once WD . '/modules/economic/config/economic_invoice_layout_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_invoice_discount_layout_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_payment_terms_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_admin_fee_monthly_c.php';
|
||||
require_once WD . '/modules/economic/config/economic_admin_fee_order_c.php';
|
||||
@@ -8,6 +9,7 @@ require_once WD . '/modules/economic/config/economic_transaction_draft_customer_
|
||||
require_once WD . '/modules/economic/config/economic_default_department_id_c.php';
|
||||
|
||||
use config\economic_invoice_layout_c;
|
||||
use config\economic_invoice_discount_layout_c;
|
||||
use config\economic_payment_terms_c;
|
||||
use config\economic_admin_fee_monthly_c;
|
||||
use config\economic_admin_fee_order_c;
|
||||
@@ -21,6 +23,7 @@ class economic_c
|
||||
use module_config_t;
|
||||
|
||||
public economic_invoice_layout_c $invoice_layout;
|
||||
public economic_invoice_discount_layout_c $invoice_discount_layout;
|
||||
public economic_payment_terms_c $payment_terms;
|
||||
public economic_admin_fee_monthly_c $admin_fee_monthly;
|
||||
public economic_admin_fee_order_c $admin_fee_order;
|
||||
@@ -33,6 +36,7 @@ class economic_c
|
||||
$this->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();
|
||||
|
||||
+2
-2
@@ -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<int,int>}
|
||||
* @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('');
|
||||
}
|
||||
|
||||
+4
-2
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
? '<p>Du kan bruge dit personlige link til at logge ind og sætte en ny adgangskode.</p>'
|
||||
: '<p>Du kan bruge dit personlige link til at logge ind på Truck Wash.</p>';
|
||||
$message .= '<p><a href="' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '">' . htmlspecialchars($loginUrl, ENT_QUOTES, 'UTF-8') . '</a></p>';
|
||||
(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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-1
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
app_require('modules/economic/helpers/economic_invoice_draft.php');
|
||||
|
||||
use helpers\economic_invoice_draft;
|
||||
|
||||
if (!class_exists('EconomicInvoiceDraftItemizedDiscountProbe')) {
|
||||
class EconomicInvoiceDraftItemizedDiscountProbe extends economic_invoice_draft
|
||||
{
|
||||
public array $sentBatches = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->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);
|
||||
});
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
use classes\subuser_contact_verification_service;
|
||||
use objects\subusers_o;
|
||||
|
||||
function subuser_contact_verification_test_subuser(array $values = []): subusers_o
|
||||
{
|
||||
$subuser = new subusers_o();
|
||||
$subuser->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();
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user