feat: add Weeztix OAuth, coupon codes, and Mailwizz mapping

Implement Weeztix integration per documentation: database config and
subscriber coupon_code, OAuth redirect/callback, admin setup UI with
company/coupon selection via AJAX, synchronous coupon creation on public
subscribe with duplicate and rate-limit handling, Mailwizz field mapping
for coupon codes, subscriber table and CSV export, and connection hint
on the pages list.

Made-with: Cursor
This commit is contained in:
2026-04-04 14:52:41 +02:00
parent 17e784fee7
commit d3abdb7ed9
30 changed files with 2272 additions and 5 deletions

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use RuntimeException;
final class WeeztixCouponCodeConflictException extends RuntimeException {}

View File

@@ -28,6 +28,7 @@ class PageController extends Controller
{
$query = PreregistrationPage::query()
->withCount('subscribers')
->with('weeztixConfig')
->orderByDesc('start_date');
if (! $request->user()?->isSuperadmin()) {

View File

@@ -89,7 +89,7 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$headers[] = 'Phone';
}
$headers = array_merge($headers, ['Registered At', 'Synced to Mailwizz', 'Synced At']);
$headers = array_merge($headers, ['Coupon Code', 'Registered At', 'Synced to Mailwizz', 'Synced At']);
fputcsv($handle, $headers);
foreach ($subscribers as $sub) {
@@ -97,6 +97,7 @@ class SubscriberController extends Controller
if ($phoneEnabled) {
$row[] = $sub->phoneDisplay() ?? '';
}
$row[] = $sub->coupon_code ?? '';
$row[] = $sub->created_at?->toDateTimeString() ?? '';
$row[] = $sub->synced_to_mailwizz ? 'Yes' : 'No';
$row[] = $sub->synced_at?->toDateTimeString() ?? '';

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PreregistrationPage;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class WeeztixApiController extends Controller
{
public function companies(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return response()->json([
'message' => __('Niet verbonden met Weeztix.'),
], 422);
}
try {
$companies = (new WeeztixService($config))->getCompanies();
return response()->json(['companies' => $companies]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon bedrijven niet laden. Vernieuw de verbinding indien nodig.'),
], 422);
}
}
public function coupons(Request $request): JsonResponse
{
$request->validate([
'page_id' => ['required', 'integer', 'exists:preregistration_pages,id'],
'company_guid' => ['required', 'string', 'max:255'],
]);
$page = PreregistrationPage::query()->findOrFail($request->integer('page_id'));
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null || ! $config->is_connected) {
return response()->json([
'message' => __('Niet verbonden met Weeztix.'),
], 422);
}
$companyGuid = $request->string('company_guid')->toString();
$previousGuid = $config->company_guid;
$config->setAttribute('company_guid', $companyGuid);
try {
$raw = (new WeeztixService($config))->getCoupons();
$coupons = $this->normalizeCouponsPayload($raw);
return response()->json(['coupons' => $coupons]);
} catch (RuntimeException) {
return response()->json([
'message' => __('Kon kortingsbonnen niet laden.'),
], 422);
} finally {
$config->setAttribute('company_guid', $previousGuid);
}
}
/**
* @param array<string, mixed> $raw
* @return list<array{guid: string, name: string}>
*/
private function normalizeCouponsPayload(array $raw): array
{
$list = $raw;
if (isset($raw['data']) && is_array($raw['data'])) {
$list = $raw['data'];
}
if (! is_array($list)) {
return [];
}
$out = [];
foreach ($list as $row) {
if (! is_array($row)) {
continue;
}
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
if (! is_string($guid) || $guid === '') {
continue;
}
$name = data_get($row, 'name') ?? data_get($row, 'title') ?? $guid;
$out[] = [
'guid' => $guid,
'name' => is_string($name) ? $name : $guid,
];
}
return $out;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateWeeztixConfigRequest;
use App\Models\PreregistrationPage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;
class WeeztixController extends Controller
{
public function edit(PreregistrationPage $page): View
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
return view('admin.weeztix.edit', compact('page'));
}
public function update(UpdateWeeztixConfigRequest $request, PreregistrationPage $page): RedirectResponse
{
$validated = $request->validated();
foreach (['client_id', 'client_secret'] as $key) {
if (array_key_exists($key, $validated) && $validated[$key] === '' && $page->weeztixConfig !== null) {
unset($validated[$key]);
}
}
$validated['redirect_uri'] = route('admin.weeztix.callback', absolute: true);
DB::transaction(function () use ($page, $validated): void {
$page->weeztixConfig()->updateOrCreate(
['preregistration_page_id' => $page->id],
array_merge($validated, ['preregistration_page_id' => $page->id])
);
});
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-configuratie opgeslagen.'));
}
public function destroy(PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->weeztixConfig()?->delete();
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Weeztix-integratie verwijderd.'));
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\PreregistrationPage;
use App\Services\WeeztixService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
class WeeztixOAuthController extends Controller
{
public function redirect(PreregistrationPage $page): RedirectResponse
{
$this->authorize('update', $page);
$page->load('weeztixConfig');
$config = $page->weeztixConfig;
if ($config === null) {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Sla eerst je client ID en client secret op.'));
}
$clientId = $config->client_id;
if (! is_string($clientId) || $clientId === '') {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Vul een geldige Weeztix client ID in.'));
}
$state = Str::random(40);
session([
'weeztix_oauth_state' => $state,
'weeztix_page_id' => $page->id,
]);
$redirectUri = $config->redirect_uri;
if (! is_string($redirectUri) || $redirectUri === '') {
$redirectUri = route('admin.weeztix.callback', absolute: true);
}
$query = http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'state' => $state,
]);
$authorizeBase = rtrim(config('weeztix.auth_base_url'), '/').'/tokens/authorize';
return redirect()->away($authorizeBase.'?'.$query);
}
public function callback(Request $request): RedirectResponse
{
if ($request->filled('error')) {
Log::warning('Weeztix OAuth provider error', [
'error' => $request->string('error')->toString(),
'description' => $request->string('error_description')->toString(),
]);
return $this->redirectToWeeztixEditWithSessionPage(__('Weeztix heeft de verbinding geweigerd. Probeer opnieuw.'));
}
$request->validate([
'state' => ['required', 'string'],
'code' => ['required', 'string'],
]);
$storedState = session('weeztix_oauth_state');
$pageId = session('weeztix_page_id');
if (! is_string($storedState) || $storedState === '' || ($pageId === null || (! is_int($pageId) && ! is_numeric($pageId)))) {
return redirect()
->route('admin.dashboard')
->with('error', __('Ongeldige OAuth-sessie. Start opnieuw vanaf de Weeztix-pagina.'));
}
if ($request->string('state')->toString() !== $storedState) {
abort(403, 'Invalid OAuth state');
}
$page = PreregistrationPage::query()->findOrFail((int) $pageId);
$this->authorize('update', $page);
$config = $page->weeztixConfig;
if ($config === null) {
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Geen Weeztix-configuratie gevonden voor deze pagina.'));
}
try {
$service = new WeeztixService($config);
$service->exchangeAuthorizationCode($request->string('code')->toString());
} catch (RuntimeException $e) {
Log::error('Weeztix OAuth callback failed', [
'page_id' => $page->id,
'message' => $e->getMessage(),
]);
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', __('Verbinden met Weeztix is mislukt. Controleer je gegevens en probeer opnieuw.'));
}
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('status', __('Succesvol verbonden met Weeztix.'));
}
private function redirectToWeeztixEditWithSessionPage(string $message): RedirectResponse
{
$pageId = session('weeztix_page_id');
session()->forget(['weeztix_oauth_state', 'weeztix_page_id']);
if (is_int($pageId) || is_numeric($pageId)) {
$page = PreregistrationPage::query()->find((int) $pageId);
if ($page !== null) {
return redirect()
->route('admin.pages.weeztix.edit', $page)
->with('error', $message);
}
}
return redirect()
->route('admin.dashboard')
->with('error', $message);
}
}

View File

@@ -4,13 +4,19 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Exceptions\WeeztixCouponCodeConflictException;
use App\Http\Requests\SubscribePublicPageRequest;
use App\Jobs\SyncSubscriberToMailwizz;
use App\Models\PageBlock;
use App\Models\PreregistrationPage;
use App\Models\Subscriber;
use App\Models\WeeztixConfig;
use App\Services\WeeztixService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PublicPageController extends Controller
{
@@ -52,13 +58,70 @@ class PublicPageController extends Controller
$subscriber = $publicPage->subscribers()->create($validated);
$publicPage->loadMissing('weeztixConfig');
$weeztix = $publicPage->weeztixConfig;
if ($this->weeztixCanIssueCodes($weeztix)) {
$this->tryAttachWeeztixCouponCode($subscriber, $weeztix);
}
if ($publicPage->mailwizzConfig !== null) {
SyncSubscriberToMailwizz::dispatch($subscriber);
SyncSubscriberToMailwizz::dispatch($subscriber->fresh());
}
return response()->json([
'success' => true,
'message' => $publicPage->thank_you_message ?? __('Thank you for registering!'),
'coupon_code' => $subscriber->fresh()?->coupon_code,
]);
}
private function weeztixCanIssueCodes(?WeeztixConfig $config): bool
{
if ($config === null || ! $config->is_connected) {
return false;
}
$company = $config->company_guid;
$coupon = $config->coupon_guid;
return is_string($company) && $company !== '' && is_string($coupon) && $coupon !== '';
}
private function tryAttachWeeztixCouponCode(Subscriber $subscriber, WeeztixConfig $config): void
{
$freshConfig = $config->fresh();
if ($freshConfig === null) {
return;
}
$service = new WeeztixService($freshConfig);
$maxAttempts = 5;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
$code = WeeztixService::generateUniqueCode(
is_string($freshConfig->code_prefix) && $freshConfig->code_prefix !== ''
? $freshConfig->code_prefix
: 'PREREG'
);
$service->createCouponCode($code);
$subscriber->update(['coupon_code' => $code]);
return;
} catch (WeeztixCouponCodeConflictException) {
continue;
} catch (Throwable $e) {
Log::error('Weeztix coupon creation failed', [
'subscriber_id' => $subscriber->id,
'message' => $e->getMessage(),
]);
return;
}
}
Log::warning('Weeztix coupon: exhausted duplicate retries', [
'subscriber_id' => $subscriber->id,
]);
}

View File

@@ -42,6 +42,7 @@ class UpdateMailwizzConfigRequest extends FormRequest
'field_first_name' => ['required', 'string', 'max:255'],
'field_last_name' => ['required', 'string', 'max:255'],
'field_phone' => ['nullable', 'string', 'max:255'],
'field_coupon_code' => ['nullable', 'string', 'max:255'],
'tag_field' => ['required', 'string', 'max:255'],
'tag_value' => ['required', 'string', 'max:255'],
];

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Admin;
use App\Models\PreregistrationPage;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateWeeztixConfigRequest extends FormRequest
{
public function authorize(): bool
{
$page = $this->route('page');
if (! $page instanceof PreregistrationPage) {
return false;
}
return $this->user()?->can('update', $page) ?? false;
}
/**
* @return array<string, array<int, ValidationRule|string>>
*/
public function rules(): array
{
/** @var PreregistrationPage $page */
$page = $this->route('page');
return [
'client_id' => [
'sometimes',
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
'nullable',
'string',
'max:2048',
],
'client_secret' => [
'sometimes',
Rule::requiredIf(fn (): bool => $page->weeztixConfig === null),
'nullable',
'string',
'max:2048',
],
'company_guid' => ['nullable', 'string', 'max:255'],
'company_name' => ['nullable', 'string', 'max:255'],
'coupon_guid' => ['nullable', 'string', 'max:255'],
'coupon_name' => ['nullable', 'string', 'max:255'],
'code_prefix' => ['nullable', 'string', 'max:32'],
'usage_count' => ['nullable', 'integer', 'min:1', 'max:99999'],
];
}
}

View File

@@ -134,6 +134,11 @@ class SyncSubscriberToMailwizz implements ShouldBeUnique, ShouldQueue
}
}
$couponField = $config->field_coupon_code;
if (is_string($couponField) && $couponField !== '' && $subscriber->coupon_code !== null && $subscriber->coupon_code !== '') {
$data[$couponField] = $subscriber->coupon_code;
}
return $data;
}

View File

@@ -21,6 +21,7 @@ class MailwizzConfig extends Model
'field_first_name',
'field_last_name',
'field_phone',
'field_coupon_code',
'tag_field',
'tag_value',
];

View File

@@ -162,6 +162,11 @@ class PreregistrationPage extends Model
return $this->hasOne(MailwizzConfig::class);
}
public function weeztixConfig(): HasOne
{
return $this->hasOne(WeeztixConfig::class);
}
public function isBeforeStart(): bool
{
return Carbon::now()->lt($this->start_date);

View File

@@ -22,6 +22,7 @@ class Subscriber extends Model
'phone',
'synced_to_mailwizz',
'synced_at',
'coupon_code',
];
protected function casts(): array
@@ -95,7 +96,8 @@ class Subscriber extends Model
$q->where('first_name', 'like', $like)
->orWhere('last_name', 'like', $like)
->orWhere('email', 'like', $like)
->orWhere('phone', 'like', $like);
->orWhere('phone', 'like', $like)
->orWhere('coupon_code', 'like', $like);
});
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WeeztixConfig extends Model
{
use HasFactory;
protected $fillable = [
'preregistration_page_id',
'client_id',
'client_secret',
'redirect_uri',
'access_token',
'refresh_token',
'token_expires_at',
'refresh_token_expires_at',
'company_guid',
'company_name',
'coupon_guid',
'coupon_name',
'code_prefix',
'usage_count',
'is_connected',
];
protected function casts(): array
{
return [
'client_id' => 'encrypted',
'client_secret' => 'encrypted',
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
'token_expires_at' => 'datetime',
'refresh_token_expires_at' => 'datetime',
'is_connected' => 'boolean',
];
}
public function preregistrationPage(): BelongsTo
{
return $this->belongsTo(PreregistrationPage::class);
}
public function isTokenExpired(): bool
{
return ! $this->token_expires_at || $this->token_expires_at->isPast();
}
public function isRefreshTokenExpired(): bool
{
if ($this->refresh_token_expires_at === null) {
return false;
}
return $this->refresh_token_expires_at->isPast();
}
}

View File

@@ -0,0 +1,464 @@
<?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;
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);
}
/**
* 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;
}
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
$name = data_get($row, 'name');
if (! is_string($guid) || $guid === '') {
return;
}
$this->config->update([
'company_guid' => $guid,
'company_name' => is_string($name) ? $name : null,
]);
$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);
}
$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;
}
$guid = data_get($row, 'guid') ?? data_get($row, 'id');
if (! is_string($guid) || $guid === '') {
continue;
}
$name = data_get($row, 'name');
$out[] = [
'guid' => $guid,
'name' => is_string($name) ? $name : null,
];
}
return $out;
}
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(),
]);
}
}