post('/auth/login', function () { // Get the post data global $response; $this->requireRecaptcha(); $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } self::requireParameters(['customer_number', 'password']); self::requireType((int)$data['customer_number'], $this->type_int()); self::requireMinValue((int)$data['customer_number'], 1); self::requireType((string)$data['password'], $this->type_string()); self::requireMinLength('password', 1); $data['customer_number'] = (int)$data['customer_number']; $data['password'] = (string)$data['password']; // Check if the customer number, and password are set if (!isset($data['customer_number']) || empty($data['customer_number']) || !is_numeric($data['customer_number']) || $data['customer_number'] < 1) { $response->error('Customer number is required', 400); } if (!isset($data['password']) || empty($data['password']) || strlen($data['password']) < 1) { $response->error('Password is required', 400); } // Use Redis-backed user and property caches to validate credentials with a single user load $user = (new users_o())->getUserByCustomerNumber($data['customer_number']); if (!$user->exists() || !$user->hasPassword()) { (new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']); $response->error('Invalid credentials', 401); } $isCredentialsValid = false; try { $isCredentialsValid = $user->passwordMatches($data['password']); } catch (Exception $e) { $isCredentialsValid = false; } // Log the incident if ($isCredentialsValid) { (new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Customer number: ' . $data['customer_number']); } else { (new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Customer number: ' . $data['customer_number']); $response->error('Invalid credentials', 401); } // If the credentials are valid, check 2FA and create a token if ($user->exists() && $user->isTwoFactorEnabled()) { $token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER'); $response->success(['2fa_required' => true, '2fa_token' => $token]); } // Create the auth token without reloading the user from DB $token = (new authentication())->create_token_by_user_id((int)$user->id); // Return the token $response->success(['token' => $token]); }); $this->get('/auth/logout', function () { // Get the token from the headers global $response; $token = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; // Remove the Bearer prefix $token = str_replace('Bearer ', '', $token); // Check if the token is valid if (!(new authentication())->validate_token($token)) { $response->error('Invalid token', 401); } // Delete the token (new tokens_o())->delete($token); // Clear any cached session for this token try { redis->clear_auth_session($token); } catch (\Throwable $e) {} // Clear any cached subuser session for this token try { (new subusers_o())->invalidateSessionToken($token); } catch (\Throwable $e) {} // Return a success message $response->success(['message' => 'Logged out']); }); $this->get('/auth/session', function () { // Get the token from the headers global $response; $token = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; // Default to empty string if not set // Remove the Bearer prefix $token = str_replace('Bearer ', '', $token); // Check if the token is valid if (!(new authentication())->validate_token($token)) { $response->error('Invalid token', 401); } // Try Redis cache first for session payload try { $cached = redis->get_auth_session($token); if (is_array($cached)) { $cached = $this->appendRuntimeConfig($cached); try { redis->cache_auth_session($token, $cached, 60); } catch (\Throwable $e) { // Best-effort caching only } $response->success($cached); } } catch (\Throwable $e) { // Ignore Redis errors and continue to compute session } // Get the user object $user = (new authentication())->get_user(); // Check if the user exists if (!$user) { $response->error('User not found', 400); } $user_data = $user->includeIncludes(['economicCustomer', 'permissions'])->asArray(); $user_data['two_factor_enabled'] = $user->isTwoFactorEnabled(); $user_data = $this->appendRuntimeConfig($user_data); // Cache the session payload briefly to reduce DB load on hot paths try { redis->cache_auth_session($token, $user_data, 60); } catch (\Throwable $e) { // Best-effort caching only } // Return the (session) user object $response->success($user_data); }); $this->post('/auth/2fa/setup', function () { global $response; $auth = new authentication(); $user = $auth->get_user(); $subuser = $auth->get_subuser(); if ($user === false && $subuser === false) { $response->error('Unauthorized', 401); } $principal = $subuser ?: $user; $totp = new totp(); $secret = $totp->generateSecret(); $principal->setTwoFactorSecret($secret); if ($principal instanceof subusers_o) { $name = $principal->username->value() ?? ('subuser#' . (string)$principal->id); } else { $name = $principal->customer_number->value(); } $qrCodeUrl = $totp->getQrCodeUrl($secret, $name, 'Truck Wash'); $response->success([ 'secret' => $secret, 'qr_code_url' => $qrCodeUrl ]); }); $this->post('/auth/2fa/enable', function () { global $response; $auth = new authentication(); $user = $auth->get_user(); $subuser = $auth->get_subuser(); if ($user === false && $subuser === false) { $response->error('Unauthorized', 401); } self::requireParameters(['code']); $code = (string)self::getParameter('code'); $principal = $subuser ?: $user; if ($auth->verify_2fa_code($principal, $code)) { $principal->setTwoFactorEnabled(true); $response->success(['message' => '2FA enabled successfully']); } else { $response->error('Invalid 2FA code', 400); } }); $this->post('/auth/2fa/disable', function () { global $response; $auth = new authentication(); $user = $auth->get_user(); $subuser = $auth->get_subuser(); if ($user === false && $subuser === false) { $response->error('Unauthorized', 401); } self::requireParameters(['code']); $code = (string)self::getParameter('code'); $principal = $subuser ?: $user; if ($auth->verify_2fa_code($principal, $code)) { $principal->setTwoFactorEnabled(false); $principal->setTwoFactorSecret(null); $response->success(['message' => '2FA disabled successfully']); } else { $response->error('Invalid 2FA code', 400); } }); $this->post('/auth/2fa/verify', function () { global $response; self::requireParameters(['2fa_token', 'code']); $token_str = (string)self::getParameter('2fa_token'); $code = (string)self::getParameter('code'); $token_o = new tokens_o(); try { $token = $token_o->getToken($token_str); } catch (Exception $e) { $response->error('Invalid or expired 2FA token', 401); } $auth = new authentication(); $token_type = $token->type->value(); $user_id = (int)$token->user_id->value(); if ($token_type === '2FA_VERIFICATION_USER') { $user = (new users_o())->getUserById($user_id); if ($user->exists() && $auth->verify_2fa_code($user, $code)) { $token_o->delete($token_str); $new_token = $auth->create_employee_token($user_id); // Works for both users and employees $response->success(['token' => $new_token]); } } elseif ($token_type === '2FA_VERIFICATION_SUBUSER') { $subuser = (new subusers_o())->select($user_id); if ($auth->verify_2fa_code($subuser, $code)) { $token_o->delete($token_str); $new_token = $subuser->generateSession(); $response->success(['session' => $new_token]); } } $response->error('Invalid 2FA code', 400); }); $this->post('/auth/employee/login', function () { // Get the post data global $response; $this->requireRecaptcha(); $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } // Check if the employee number, and password are set if (!isset($data['user_id'])) { $response->error('Employee number is required', 400); } if (!isset($data['password'])) { $response->error('Password is required', 400); } // Try to log the user in $isCredentialsValid = (new authentication())->authenticateEmployee($data['user_id'], $data['password']); // Log the incident if ($isCredentialsValid) { (new logs_o())->add('auth', 'global', 1, 0, 'AUTH_SUCCESS', 'Employee number: ' . $data['user_id']); } else { (new logs_o())->add('auth', 'global', 1, 0, 'AUTH_FAILURE', 'Employee number: ' . $data['user_id']); $response->error('Invalid credentials', 401); } $user = (new users_o())->getUserById($data['user_id']); if ($user->exists() && $user->isTwoFactorEnabled()) { $token = (new authentication())->create_2fa_token($user->id, '2FA_VERIFICATION_USER'); $response->success(['2fa_required' => true, '2fa_token' => $token]); } // If the credentials are valid, create a token $token = (new authentication())->create_employee_token($data['user_id']); // Return the token $response->success(['token' => $token]); }); $this->get('/auth/reCAPTCHA/public', function () { // Check if the user: // 1. Is rate limited (future feature) // 2. Is required to solve a reCAPTCHA global $response; $recaptcha = (new recaptcha())->getPublicConfig(); $response->success([ 'rate_limit' => [ 'enabled' => false, 'limit' => 0, 'remaining' => 0, 'reset' => 0, 'warning' => null ], 'recaptcha' => $recaptcha ]); }); $this->post('/auth/register/cvr', function () { // Get the post data global $response; $this->requireRecaptcha(); /** * { * "cvr": "44794780", * "companyPhone": 21754690, * "invoiceEmail": "mikkel@truckwash.dk", * "contactEmail": "mikkel@truckwash.dk", * "contactPhone": 21754690, * "searchResult": { * "vat": 41004355, * "status": "Normal", * "name": "Truckwash ApS", * "address": "Letland Alle 2", * "zipcode": 2630, * "city": "Taastrup", * "protected": true, * "phone": "21754690", * "website": null, * "email": "mikkel@truckwash.dk", * "fax": null, * "startdate": "2019-12-11", * "enddate": null, * "employees": 14, * "industrycode": 953190, * "industrydesc": "Reparation og vedligeholdelse af motorkøretøjer i.a.n.", * "companytype": "APS", * "companydesc": "Anpartsselskab", * "owners": [ * "DELOITTE STATSAUTORISERET REVISIONSPARTNERSELSKAB", * "MBL Revision I/S", * "WASH GROUP A/S" * ] * } * } */ /** * Parameters: */ self::requireParameters(['cvr', 'companyPhone', 'invoiceEmail', 'contactEmail', 'contactPhone']); $cvr = self::getParameter('cvr'); $companyPhone = (int)self::getParameter('companyPhone'); $invoiceEmail = self::getParameter('invoiceEmail'); $contactEmail = self::getParameter('contactEmail'); $contactPhone = (int)self::getParameter('contactPhone'); $contactName = self::getParameter('contactName'); /** * Validate */ self::requireType($cvr, $this->type_string()); self::requireMinLength('cvr', 8); self::requireMaxLength('cvr', 20); self::requireType($companyPhone, $this->type_int()); self::requireMinValue($companyPhone, 10000000); self::requireMaxValue($companyPhone, 9999999999); if (self::isParametersSet(['invoiceEmail']) && !is_null($invoiceEmail)) { self::requireType($invoiceEmail, $this->type_string()); self::requireMinLength('invoiceEmail', 5); self::requireMaxLength('invoiceEmail', 255); } if (self::isParametersSet(['contactEmail']) && !is_null($contactEmail)) { self::requireType($contactEmail, $this->type_string()); self::requireMinLength('contactEmail', 5); self::requireMaxLength('contactEmail', 255); } /** * If the contact phone is set, validate it */ if (self::isParametersSet(['contactPhone']) && !empty($contactPhone)) { self::requireType($contactPhone, $this->type_int()); self::requireMinValue($contactPhone, 10000000); self::requireMaxValue($contactPhone, 9999999999); } /** * If the contact phone is empty, default to company phone */ if (empty($contactPhone)) { $contactPhone = $companyPhone; } /** * If the emails are empty, default to jb@truckwash.dk */ if (empty($invoiceEmail)) { $invoiceEmail = 'jb@truckwash.dk'; } if (empty($contactEmail)) { $contactEmail = 'jb@truckwash.dk'; } /** * Check if the cvr already exists */ $economic = new economic(); $economic_response = $this->searchEconomicCustomersByCvr($economic, (string)$cvr); $localUserExists = $this->localCustomerNumberExists($companyPhone); $matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $companyPhone); if ($matchingEconomicCustomer !== null) { if ($localUserExists) { $response->error('Company phone number already registered', 400); } $this->bootstrapLocalCustomerOrFail($companyPhone); $this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail); $response->success($matchingEconomicCustomer, 200); } if (count($economic_response) > 0) { $existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economic_response[0]); $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONFLICT', [ 'phase' => 'search', 'cvr' => (string)$cvr, 'requestedCustomerNumber' => $companyPhone, 'existingCustomerNumber' => $existingEconomicCustomerNumber, ]); $response->error( 'CVR already registered under customer number ' . $existingEconomicCustomerNumber . '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.', 409 ); } if ($localUserExists) { $response->error('Company phone number already registered', 400); } // Get the CVR company information used for the e-conomic customer payload. $companyInformation = (new virkdata())->getCompanyInformation($cvr, '', []); $name = (string)($companyInformation->name ?? ''); try { $result = $economic->createCustomer( (int)$companyPhone, $name, (int)$cvr, (string)$invoiceEmail, (int)$companyPhone, (int)$contactPhone, $companyInformation, ); } catch (Exception $exception) { $recoveredCustomer = $this->recoverRegistrationAfterCreateFailure( $economic, (string)$cvr, $companyPhone, (string)$invoiceEmail ); if ($recoveredCustomer !== null) { $response->success($recoveredCustomer, 200); } $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_FAILED', [ 'phase' => 'create', 'cvr' => (string)$cvr, 'requestedCustomerNumber' => $companyPhone, 'message' => $exception->getMessage(), ]); $response->error('Failed to create customer in e-conomic.', 502); } if (!isset($result->customerNumber) || !is_numeric($result->customerNumber)) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_INVALID_CREATE_RESPONSE', [ 'phase' => 'create', 'cvr' => (string)$cvr, 'requestedCustomerNumber' => $companyPhone, 'response' => $result, ]); $response->error('Customer creation did not return a valid customer number.', 500); } $createdCustomerNumber = (int)$result->customerNumber; if ($createdCustomerNumber !== $companyPhone) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CONFLICT', [ 'phase' => 'create', 'cvr' => (string)$cvr, 'requestedCustomerNumber' => $companyPhone, 'createdCustomerNumber' => $createdCustomerNumber, 'logId' => isset($result->logId) ? (string)$result->logId : null, 'message' => isset($result->message) ? (string)$result->message : null, ]); $response->error( 'E-conomic created the customer under customer number ' . $createdCustomerNumber . ' instead of the submitted phone number ' . $companyPhone . '. Manual cleanup or reassignment is required before retrying.', 409 ); } $this->bootstrapLocalCustomerOrFail($companyPhone, $result); $this->sendRegistrationWelcomeEmails($companyPhone, (string)$invoiceEmail); $response->success($result, 201); }); $this->post('/auth/password-reset/request', function () { global $response; $this->requireRecaptcha(); self::requireParameters(['customer_number']); $customer_number = (int)self::getParameter('customer_number'); $user = (new users_o())->getUserByCustomerNumber($customer_number); if (!$user->exists()) { // For security reasons, don't reveal if the user exists $response->success(['message' => 'If the customer exists, a password reset email has been sent.']); } $email_address = $user->email->value(); if (empty($email_address)) { // If no email is set, we can't send the reset email $response->error('Der er ingen email-addresse tilknyttet denne konto, ring venligst på +45 43 71 78 86 for konto gendannelse.', 400); } // Generate token $token = customer_password_reset_keys_o::generateToken(); // Save token $reset_key_o = new customer_password_reset_keys_o(); $reset_key_o->add([ 'customer_id' => $customer_number, 'token' => $token, 'note' => 'Requested via API' ]); // Send email $email = new email(); $reset_link = "https://truckwash.io/auth/password-reset/" . $token; $subject = 'Adgangskode nulstilling'; $message = "Du har anmodet om at nulstille din adgangskode. Klik på linket herunder for at fortsætte:

$reset_link

Linket er gyldigt i 1 time."; try { $email->sendEmail($email_address, $user->display_name->value() ?? 'Kunde', $subject, $message, null); $response->success(['message' => 'If the customer exists, a password reset email has been sent.']); } catch (Exception $e) { $response->error('Failed to send email: ' . $e->getMessage(), 500); } }); $this->get('/auth/password-reset/validate', function () { global $response; self::requireParameters(['token']); $token = self::getParameter('token'); $reset_key_o = new customer_password_reset_keys_o(); $found_key = $reset_key_o->findValidByToken($token); if ($found_key === null) { $response->error('Invalid or expired token', 404); } $response->success([ 'valid' => true, 'customer_id' => $found_key->customer_id->value() ]); }); $this->post('/auth/password-reset/set', function () { global $response; $this->requireRecaptcha(); self::requireParameters(['token', 'password']); $token = self::getParameter('token'); $password = self::getParameter('password'); $reset_key_o = new customer_password_reset_keys_o(); $found_key = $reset_key_o->findValidByToken($token); if ($found_key === null) { $response->error('Invalid or expired token', 404); } try { $found_key->setPassword($password); $response->success(['message' => 'Password updated successfully']); } catch (Exception $e) { $response->error($e->getMessage(), 400); } }); $this->post('/auth/passkey/challenge', function () { global $response; $this->requireRecaptcha(); $customer_number = self::getParameter('customer_number'); $user_id = 0; $allowCredentials = []; if ($customer_number !== null) { $customer_number = (int)$customer_number; self::requireType($customer_number, $this->type_int()); self::requireMinValue($customer_number, 1); // Attempt to locate user; do not reveal existence in response $user = (new users_o())->getUserByCustomerNumber($customer_number); if ($user->exists()) { $user_id = (int)$user->id; // Load passkeys for this user (non-subuser) $passkeys = new passkeys_o(); $passkeys->setAdditionalWhereClause('`user_id` = ' . (int)$user_id . ' AND `is_subuser` = 0'); $list = $passkeys->listObjectsWithPaginationIfSet(function ($o) { $transports = null; if (isset($o['transports'])) { $decoded = json_decode($o['transports'], true); $transports = is_array($decoded) ? $decoded : null; } return [ 'type' => 'public-key', 'id' => $o['credential_id'] ?? null, 'transports' => $transports, ]; }); // Ensure we return a simple array of credentials (without pagination wrapper) if (isset($list['items']) && is_array($list['items'])) { $allowCredentials = array_values(array_filter($list['items'], function ($item) { return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0; })); } elseif (is_array($list)) { $allowCredentials = array_values(array_filter($list, function ($item) { return isset($item['id']) && is_string($item['id']) && strlen($item['id']) > 0; })); } } } // Generate a challenge (Base64URL without padding) $challenge_token = bin2hex(random_bytes(32)); $rawChallenge = hex2bin($challenge_token); $challenge = rtrim(strtr(base64_encode($rawChallenge), '+/', '-_'), '='); // rpId for passkeys $originHost = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST); $httpHost = isset($_SERVER['HTTP_HOST']) ? explode(':', (string)$_SERVER['HTTP_HOST'])[0] : null; $serverName = $_SERVER['SERVER_NAME'] ?? null; $rpId = $originHost ?: $httpHost ?: $serverName ?: 'truckwash.io'; // Create an ephemeral token to bind the challenge to the (potential) user (new tokens_o())->create($user_id, $challenge_token, 'PASSKEY_CHALLENGE'); $logDetails = $customer_number ? 'Issued passkey challenge for customer ' . $customer_number : 'Issued passkey challenge (discoverable)'; (new logs_o())->add('auth', 'global', 1, $user_id, 'AUTH_PASSKEY_CHALLENGE', $logDetails); $response->success([ 'challenge_token' => $challenge_token, 'publicKey' => [ 'challenge' => $challenge, 'rpId' => $rpId, 'timeout' => 60000, 'userVerification' => 'preferred', 'allowCredentials' => $allowCredentials ] ]); }); $this->post('/auth/passkey/verify', function () { global $response; $this->requireRecaptcha(); // Parse JSON body $data = json_decode(file_get_contents('php://input'), true); if (!is_array($data)) { $data = []; } if (!isset($data['challenge_token']) || !is_string($data['challenge_token']) || strlen($data['challenge_token']) < 10) { $response->error('Invalid or missing challenge_token', 400); } if (!isset($data['credential'])) { $response->error('Missing credential', 400); } $challenge_token = (string)$data['challenge_token']; $credentialPayload = $data['credential']; $credentialJson = is_string($credentialPayload) ? $credentialPayload : json_encode($credentialPayload, JSON_UNESCAPED_SLASHES); if (!is_string($credentialJson) || $credentialJson === false) { $response->error('Invalid credential payload', 400); } // Load and validate the ephemeral challenge token $token_o = new tokens_o(); try { $token = $token_o->getToken($challenge_token); } catch (Exception $e) { $response->error('Invalid or expired challenge', 401); } $token_type = $token->type->value(); if ($token_type !== 'PASSKEY_CHALLENGE') { $response->error('Invalid token type', 401); } // Determine rpId/host $host = parse_url((string)($_SERVER['HTTP_ORIGIN'] ?? ''), PHP_URL_HOST) ?: ($_SERVER['SERVER_NAME'] ?? 'localhost'); // Extract credential id $credentialArr = is_array($credentialPayload) ? $credentialPayload : json_decode($credentialJson, true); $credentialId = $credentialArr['id'] ?? $credentialArr['rawId'] ?? null; if (!is_string($credentialId) || $credentialId === '') { $response->error('Invalid credential id', 400); } // Find corresponding stored passkey $user_id_hint = (int)$token->user_id->value(); $passkeyRepo = new passkeys_o(); $passkey = $passkeyRepo->findByCredentialId($credentialId, $user_id_hint > 0 ? $user_id_hint : null); if ($passkey === null) { // As a fallback for discoverable credentials, try without user hint $passkey = (new passkeys_o())->findByCredentialId($credentialId, null); } if ($passkey === null) { (new logs_o())->add('auth', 'global', 1, $user_id_hint, 'AUTH_PASSKEY_VERIFY_FAILURE', 'Unknown credential'); $response->error('Invalid credential', 404); } // Verify assertion using the WebAuthn library $wa = new webauthn(); $ok = $wa->verifyAssertion($credentialJson, $challenge_token, $passkey, $host); if (!$ok) { (new logs_o())->add('auth', 'global', 1, (int)$passkey->user_id->value(), 'AUTH_PASSKEY_VERIFY_FAILURE', 'Assertion verification failed'); $response->error('Invalid passkey assertion', 401); } // Success → issue session token accordingly and delete the challenge token $issued_to_user_id = (int)$passkey->user_id->value(); $is_subuser = (bool)$passkey->is_subuser->value(); $token_o->delete($challenge_token); (new logs_o())->add('auth', 'global', 1, $issued_to_user_id, 'AUTH_PASSKEY_VERIFY_SUCCESS', 'Passkey assertion accepted'); if ($is_subuser) { $subuser = (new subusers_o())->select($issued_to_user_id); $session = $subuser->generateSession(); $response->success(['session' => $session]); } else { // For customers, user_id stores the customer number $auth = new authentication(); $jwt = $auth->create_token_by_user_id($issued_to_user_id); $response->success(['token' => $jwt]); } }); } private function localCustomerNumberExists(int $customerNumber): bool { $rows = (new users_o())->getFieldsWhere([ 'customer_number' => (string)$customerNumber, ], ['id']); return count($rows) > 0; } private function searchEconomicCustomersByCvr(economic $economic, string $cvr): array { $economic_response = ($economic->customers->customers->search([ 'corporateIdentificationNumber' => $cvr, ], [ 'skipPages' => 0, 'pageSize' => 1, ])->collection); return is_array($economic_response) ? $economic_response : []; } private function recoverRegistrationAfterCreateFailure( economic $economic, string $cvr, int $customerNumber, string $invoiceEmail ): ?object { // The upstream POST can commit before the client receives a validation/transport error. // Re-read by CVR and only recover when e-conomic confirms the requested customer number. try { $economic_response = $this->searchEconomicCustomersByCvr($economic, $cvr); } catch (Exception $searchException) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_CREATE_RECOVERY_SEARCH_FAILED', [ 'phase' => 'create_recovery', 'cvr' => $cvr, 'requestedCustomerNumber' => $customerNumber, 'message' => $searchException->getMessage(), ]); return null; } $matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economic_response, $customerNumber); if ($matchingEconomicCustomer === null || $this->localCustomerNumberExists($customerNumber)) { return null; } $this->bootstrapLocalCustomerOrFail($customerNumber, $matchingEconomicCustomer); $this->sendRegistrationWelcomeEmails($customerNumber, $invoiceEmail); return $matchingEconomicCustomer; } private function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object { foreach ($customers as $customer) { if (!is_object($customer)) { continue; } if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) { return $customer; } } return null; } private function extractEconomicCustomerNumber(object $customer): int { if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) { return 0; } return (int)$customer->customerNumber; } /** * @throws Exception */ private function bootstrapLocalCustomerOrFail(int $customerNumber, ?object $economicCustomer = null): users_o { global $response; $customer = new users_o(); try { $customer = $customer->getUserByCustomerNumber($customerNumber); if (method_exists($customer, 'exists') && $customer->exists()) { return $customer; } } catch (Exception $exception) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_LOOKUP_FAILED', [ 'customerNumber' => $customerNumber, 'message' => $exception->getMessage(), ]); } if ( $economicCustomer !== null && $this->extractEconomicCustomerNumber($economicCustomer) === $customerNumber && method_exists($customer, 'importCustomerFromEconomicCustomerData') ) { try { $importedCustomer = $customer->importCustomerFromEconomicCustomerData($economicCustomer); if (is_object($importedCustomer) && method_exists($importedCustomer, 'exists') && $importedCustomer->exists()) { return $importedCustomer; } } catch (Exception $exception) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_SNAPSHOT_BOOTSTRAP_FAILED', [ 'customerNumber' => $customerNumber, 'message' => $exception->getMessage(), ]); } } $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_LOCAL_BOOTSTRAP_FAILED', [ 'customerNumber' => $customerNumber, ]); $response->error('Customer was created in e-conomic but could not be imported locally.', 500); throw new Exception('Customer was created in e-conomic but could not be imported locally.'); } /** * @throws Exception */ private function sendRegistrationWelcomeEmails(int $customerNumber, string $invoiceEmail): void { $email = new email(); $jimmyEmail = 'jm@truckwash.dk'; //$infoEmail = "info@truckwash.dk"; //$email->sendWelcomeEmailToCustomer($customerNumber, (string)$infoEmail); - Disabled, requested by Christian. $email->sendWelcomeEmailToCustomer($customerNumber, $jimmyEmail); $email->sendWelcomeEmailToCustomer($customerNumber, $invoiceEmail); try { $email->sendNewCustomerRegistrationNotifications($customerNumber); } catch (Exception $exception) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SUPERUSER_NOTIFICATION_FAILED', [ 'customerNumber' => $customerNumber, 'message' => $exception->getMessage(), ]); } try { (new slack())->send_customer_registration_notification($customerNumber); } catch (Exception $exception) { $this->logRegisterCvrIssue('AUTH_REGISTER_CVR_SLACK_NOTIFICATION_FAILED', [ 'customerNumber' => $customerNumber, 'message' => $exception->getMessage(), ]); } } private function logRegisterCvrIssue(string $action, array $context): void { $message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($message === false) { $message = 'Unable to encode register/cvr context'; } (new logs_o())->add('auth', 'global', 0, 0, $action, $message); } private function appendRuntimeConfig(array $payload): array { $payload['runtime_config'] = array_replace_recursive( is_array($payload['runtime_config'] ?? null) ? $payload['runtime_config'] : [], [ 'economic' => [ 'transaction_draft_customer_number' => (new economic())->getTransactionDraftCustomerNumber(), 'default_distribution_department_id' => (new economic())->getDefaultDistributionDepartmentId(), ], 'release' => (new release_manager())->runtimeForPayload($payload, $this->getParametersAsArray()), ] ); return $payload; } }