config->access_token && ! $this->config->isTokenExpired()) { return (string) $this->config->access_token; } $this->refreshAccessToken(); $this->config->refresh(); $token = $this->config->access_token; if (! is_string($token) || $token === '') { throw new RuntimeException('Weeztix access token missing after refresh.'); } return $token; } /** * @return list */ public function getCompanies(): array { $token = $this->getValidAccessToken(); $url = config('weeztix.user_profile_url'); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.$token, ]) ->acceptJson() ->get($url); if ($response->status() === 401) { $this->refreshAccessToken(); $this->config->refresh(); $response = Http::withHeaders([ 'Authorization' => 'Bearer '.(string) $this->config->access_token, ]) ->acceptJson() ->get($url); } if ($response->failed()) { $this->logFailedResponse('getCompanies', $url, $response); throw new RuntimeException('Weeztix user profile request failed: '.$response->status()); } Log::debug('Weeztix API', [ 'action' => 'getCompanies', 'url' => $url, 'http_status' => $response->status(), ]); $json = $response->json(); if (! is_array($json)) { return []; } return $this->normalizeCompaniesFromProfile($json); } /** * Sync company_guid/name from the Weeztix user profile for the current access token (after OAuth). * Runs on every successful connect or reconnect so a different company chosen in Weeztix is stored here. * Uses the first company returned from the profile when several are present. */ public function ensureCompanyStoredFromWeeztix(): void { $this->config->refresh(); try { $companies = $this->getCompanies(); if ($companies === []) { Log::warning('Weeztix: geen bedrijf uit profiel voor automatische koppeling.', [ 'weeztix_config_id' => $this->config->id, ]); return; } $row = $companies[0]; $this->config->update([ 'company_guid' => $row['guid'], 'company_name' => $row['name'], ]); $this->config->refresh(); } catch (Throwable $e) { Log::warning('Weeztix: automatisch bedrijf vastleggen mislukt', [ 'weeztix_config_id' => $this->config->id, 'message' => $e->getMessage(), ]); } } /** * Exchange OAuth authorization code for tokens (admin callback). */ public function exchangeAuthorizationCode(string $code): void { $redirectUri = $this->config->redirect_uri; if (! is_string($redirectUri) || $redirectUri === '') { throw new LogicException('Weeztix redirect_uri is not set.'); } $tokenUrl = config('weeztix.auth_base_url').'/tokens'; $response = Http::asForm()->post($tokenUrl, [ 'grant_type' => 'authorization_code', 'client_id' => $this->config->client_id, 'client_secret' => $this->config->client_secret, 'redirect_uri' => $redirectUri, 'code' => $code, ]); if ($response->failed()) { Log::error('Weeztix OAuth code exchange failed', [ 'url' => $tokenUrl, 'status' => $response->status(), 'body' => $response->json(), ]); throw new RuntimeException('Weeztix OAuth code exchange failed: '.$response->status()); } $json = $response->json(); if (! is_array($json)) { throw new RuntimeException('Weeztix token response was not valid JSON.'); } $this->applyTokenResponseToConfig($json); $this->hydrateCompanyFromTokenInfo($json); Log::debug('Weeztix API', [ 'action' => 'oauth_authorization_code', 'url' => $tokenUrl, 'http_status' => $response->status(), ]); } /** * @return array */ public function getCoupons(): array { $this->assertCompanyGuid(); $url = config('weeztix.api_base_url').'/coupon'; return $this->apiRequest('get', $url, []); } /** * Creates a single coupon code on the coupon selected in config. * * @return array */ /** * @return array * * @throws WeeztixCouponCodeConflictException When the code already exists (HTTP 409). */ public function createCouponCode(string $code): array { $this->assertCompanyGuid(); $couponGuid = $this->config->coupon_guid; if (! is_string($couponGuid) || $couponGuid === '') { throw new LogicException('Weeztix coupon is not configured.'); } $url = config('weeztix.api_base_url').'/coupon/'.$couponGuid.'/codes'; $payload = [ 'usage_count' => $this->config->usage_count, 'applies_to_count' => null, 'codes' => [ ['code' => $code], ], ]; $rateAttempts = 3; for ($rateAttempt = 0; $rateAttempt < $rateAttempts; $rateAttempt++) { $token = $this->getValidAccessToken(); $response = $this->sendApiRequest('put', $url, $payload, $token); if ($response->status() === 401) { $this->refreshAccessToken(); $this->config->refresh(); $response = $this->sendApiRequest('put', $url, $payload, (string) $this->config->access_token); } if ($response->status() === 429) { $waitSeconds = min(8, 2 ** $rateAttempt); Log::warning('Weeztix API rate limited', [ 'url' => $url, 'retry_in_seconds' => $waitSeconds, ]); sleep($waitSeconds); continue; } if ($response->status() === 409) { throw new WeeztixCouponCodeConflictException('Weeztix coupon code already exists.'); } if ($response->failed()) { $this->logFailedResponse('createCouponCode', $url, $response); throw new RuntimeException('Weeztix API request failed: '.$response->status()); } Log::debug('Weeztix API', [ 'action' => 'createCouponCode', 'url' => $url, 'http_status' => $response->status(), ]); $json = $response->json(); return is_array($json) ? $json : []; } Log::error('Weeztix API rate limited after retries', ['url' => $url]); throw new RuntimeException('Weeztix API rate limited after retries.'); } public static function generateUniqueCode(string $prefix = 'PREREG', int $length = 6): string { $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; $code = ''; for ($i = 0; $i < $length; $i++) { $code .= $chars[random_int(0, strlen($chars) - 1)]; } return strtoupper($prefix).'-'.$code; } private function assertCompanyGuid(): void { $guid = $this->config->company_guid; if (! is_string($guid) || $guid === '') { throw new LogicException('Weeztix company is not configured.'); } } /** * @param array $data * @return array */ private function apiRequest(string $method, string $url, array $data = []): array { $this->assertCompanyGuid(); $token = $this->getValidAccessToken(); $response = $this->sendApiRequest($method, $url, $data, $token); if ($response->status() === 401) { $this->refreshAccessToken(); $this->config->refresh(); $response = $this->sendApiRequest($method, $url, $data, (string) $this->config->access_token); } if ($response->failed()) { $this->logFailedResponse('apiRequest', $url, $response); throw new RuntimeException('Weeztix API request failed: '.$response->status()); } Log::debug('Weeztix API', [ 'action' => 'apiRequest', 'method' => $method, 'url' => $url, 'http_status' => $response->status(), ]); $json = $response->json(); return is_array($json) ? $json : []; } /** * @param array $data */ private function sendApiRequest(string $method, string $url, array $data, string $token): Response { $client = Http::withHeaders([ 'Authorization' => 'Bearer '.$token, 'Company' => (string) $this->config->company_guid, ])->acceptJson(); return match (strtolower($method)) { 'get' => $client->get($url, $data), 'post' => $client->asJson()->post($url, $data), 'put' => $client->asJson()->put($url, $data), 'patch' => $client->asJson()->patch($url, $data), 'delete' => $client->delete($url, $data), default => throw new InvalidArgumentException('Unsupported HTTP method: '.$method), }; } private function refreshAccessToken(): void { if (! $this->config->refresh_token || $this->config->isRefreshTokenExpired()) { $this->config->update([ 'is_connected' => false, ]); $this->config->refresh(); throw new RuntimeException('Weeztix refresh token missing or expired; reconnect OAuth.'); } $tokenUrl = config('weeztix.auth_base_url').'/tokens'; $response = Http::asForm()->post($tokenUrl, [ 'grant_type' => 'refresh_token', 'refresh_token' => $this->config->refresh_token, 'client_id' => $this->config->client_id, 'client_secret' => $this->config->client_secret, ]); if ($response->failed()) { Log::error('Weeztix token refresh failed', [ 'url' => $tokenUrl, 'status' => $response->status(), 'body' => $response->json(), ]); $this->config->update([ 'is_connected' => false, ]); $this->config->refresh(); throw new RuntimeException('Weeztix token refresh failed: '.$response->status()); } Log::debug('Weeztix API', [ 'action' => 'refresh_token', 'url' => $tokenUrl, 'http_status' => $response->status(), ]); $json = $response->json(); if (! is_array($json)) { throw new RuntimeException('Weeztix token refresh returned invalid JSON.'); } $this->applyTokenResponseToConfig($json); } /** * @param array $json */ private function applyTokenResponseToConfig(array $json): void { $access = $json['access_token'] ?? null; $refresh = $json['refresh_token'] ?? null; if (! is_string($access) || $access === '') { throw new RuntimeException('Weeztix token response missing access_token.'); } $expiresIn = isset($json['expires_in']) ? (int) $json['expires_in'] : 0; $refreshExpiresIn = isset($json['refresh_token_expires_in']) ? (int) $json['refresh_token_expires_in'] : 0; $updates = [ 'access_token' => $access, 'token_expires_at' => $expiresIn > 0 ? Carbon::now()->addSeconds($expiresIn) : null, 'is_connected' => true, ]; if (is_string($refresh) && $refresh !== '') { $updates['refresh_token'] = $refresh; $updates['refresh_token_expires_at'] = $refreshExpiresIn > 0 ? Carbon::now()->addSeconds($refreshExpiresIn) : null; } $this->config->update($updates); $this->config->refresh(); } /** * When the token response includes exactly one company, store it to reduce admin steps. * * @param array $json */ private function hydrateCompanyFromTokenInfo(array $json): void { $companies = data_get($json, 'info.companies'); if (! is_array($companies) || count($companies) !== 1) { return; } $row = $companies[0]; if (! is_array($row)) { return; } $merged = $this->mergeCompanyRowWithNested($row); $guid = data_get($merged, 'guid') ?? data_get($merged, 'id'); if (! is_string($guid) || $guid === '') { return; } $name = $this->resolveCompanyNameFromRow($merged, $guid); $this->config->update([ 'company_guid' => $guid, 'company_name' => $name, ]); $this->config->refresh(); } /** * @param array $profile * @return list */ private function normalizeCompaniesFromProfile(array $profile): array { $fromInfo = data_get($profile, 'info.companies'); if (is_array($fromInfo) && $fromInfo !== []) { $normalized = $this->normalizeCompanyRows($fromInfo); if ($normalized !== []) { return $normalized; } } $companies = data_get($profile, 'companies'); if (is_array($companies) && $companies !== []) { return $this->normalizeCompanyRows($companies); } $defaultCompany = data_get($profile, 'default_company'); if (is_array($defaultCompany)) { $merged = $this->mergeCompanyRowWithNested($defaultCompany); $guid = data_get($merged, 'guid') ?? data_get($merged, 'id'); if (is_string($guid) && $guid !== '') { return [ [ 'guid' => $guid, 'name' => $this->resolveCompanyNameFromRow($merged, $guid), ], ]; } } $defaultId = data_get($profile, 'default_company_id'); if (is_string($defaultId) && $defaultId !== '') { return [ ['guid' => $defaultId, 'name' => null], ]; } return []; } /** * @param array $rows * @return list */ private function normalizeCompanyRows(array $rows): array { $out = []; foreach ($rows as $row) { if (! is_array($row)) { continue; } $merged = $this->mergeCompanyRowWithNested($row); $guid = data_get($merged, 'guid') ?? data_get($merged, 'id') ?? data_get($merged, 'company_id'); if (! is_string($guid) || $guid === '') { continue; } $out[] = [ 'guid' => $guid, 'name' => $this->resolveCompanyNameFromRow($merged, $guid), ]; } return $out; } /** * Flatten `{ "company": { ... } }` style payloads so name fields resolve reliably. * * @param array $row * @return array */ private function mergeCompanyRowWithNested(array $row): array { $nested = data_get($row, 'company'); if (! is_array($nested)) { return $row; } return array_merge($row, $nested); } /** * Weeztix / Open Ticket payloads use varying keys; `name` is sometimes a duplicate of the GUID. * * @param array $row */ private function resolveCompanyNameFromRow(array $row, ?string $companyGuid = null): ?string { $candidates = [ data_get($row, 'trade_name'), data_get($row, 'commercial_name'), data_get($row, 'business_name'), data_get($row, 'legal_name'), data_get($row, 'company_name'), data_get($row, 'display_name'), data_get($row, 'title'), data_get($row, 'label'), data_get($row, 'general.name'), data_get($row, 'company.trade_name'), data_get($row, 'company.legal_name'), data_get($row, 'company.name'), data_get($row, 'name'), ]; foreach ($candidates as $value) { if (! is_string($value)) { continue; } $trimmed = trim($value); if ($trimmed === '') { continue; } if ($companyGuid !== null && strcasecmp($trimmed, $companyGuid) === 0) { continue; } if ($this->stringLooksLikeUuid($trimmed)) { continue; } return $trimmed; } return null; } private function stringLooksLikeUuid(string $value): bool { return preg_match( '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/', $value ) === 1; } private function logFailedResponse(string $action, string $url, Response $response): void { Log::error('Weeztix API request failed', [ 'action' => $action, 'url' => $url, 'status' => $response->status(), 'body' => $response->json(), ]); } }