Files
preregister/app/Services/WeeztixService.php
bert.hausmans 217e1d9afb fix(weeztix): allow OAuth reconnect in wizard step 2 and re-sync company
Always sync company from profile after OAuth; remove skip when company_guid
was already set. Step 2 shows reconnect for connected users plus link to step 3.

Made-with: Cursor
2026-04-05 11:16:49 +02:00

585 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Models\WeeztixConfig;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use Throwable;
final class WeeztixService
{
public function __construct(
private WeeztixConfig $config
) {}
/**
* Bearer token for API calls, refreshing from the stored refresh token when needed.
*/
public function getValidAccessToken(): string
{
if ($this->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<array{guid: string, name: string|null}>
*/
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<string, mixed>
*/
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<string, mixed>
*/
/**
* @return array<string, mixed>
*
* @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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $profile
* @return list<array{guid: string, name: string|null}>
*/
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<int, mixed> $rows
* @return list<array{guid: string, name: string|null}>
*/
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<string, mixed> $row
* @return array<string, mixed>
*/
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<string, mixed> $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(),
]);
}
}