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:
464
app/Services/WeeztixService.php
Normal file
464
app/Services/WeeztixService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user