feat: Introduce client management and integration updates

- Added client_id field to StoreTenantRequest and UpdateTenantRequest for tenant management.
- Updated TenantResource to include client_id in the response.
- Created migration to establish clients table and link tenants to clients.
- Migrated existing tenant integrations to client_integrations table.
- Grouped specific tenants under a shared client (OnTicket) in a new migration.
- Updated seeders to reflect new client structure and relationships.
- Adjusted integration configurations to require client instead of tenant.
- Added tests for client integration functionality and ensured existing tests reflect the new client structure.
This commit is contained in:
2026-08-18 16:18:34 -03:00
parent e6785eb5af
commit d73b4d1daf
42 changed files with 931 additions and 392 deletions

View File

@@ -2,58 +2,54 @@
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
use App\Domains\Tenant\Models\Tenant;
use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
abstract class BaseIntegrationService
{
/**
* The unique code of the integration.
*
* @var string
*/
protected string $integrationCode;
/**
* The current tenant code.
*
* @var string
*/
protected string $tenantCode;
protected ?Tenant $tenant = null;
protected ?Client $clientContext = null;
/**
* The integration model instance.
*
* @var Integration|null
*/
protected ?Integration $integration = null;
/**
* The tenant-specific integration model instance.
*
* @var TenantIntegration|null
* The client-owned integration configuration.
*/
protected ?TenantIntegration $tenantIntegration = null;
protected ?ClientIntegration $clientIntegration = null;
/**
* Set the integration code.
*
* @param string $integrationCode
* @return $this
*/
public function setIntegrationCode(string $integrationCode): self
{
$this->integrationCode = $integrationCode;
return $this;
}
/**
* Get the integration code.
*
* @return string
*/
public function getIntegrationCode(): string
{
@@ -63,53 +59,69 @@ abstract class BaseIntegrationService
/**
* Set the tenant code and load the integration models.
*
* @param string $tenantCode
* @return $this
*
* @throws Exception
*/
public function forTenant(string $tenantCode): self
{
$this->tenantCode = $tenantCode;
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
$this->clientContext = $this->tenant->client;
$this->loadIntegration();
return $this;
}
public function forClient(Client|string $client): self
{
$this->clientContext = $client instanceof Client
? $client
: Client::query()->where('code', $client)->firstOrFail();
$this->tenant = null;
$this->loadIntegration();
return $this;
}
/**
* Load the Integration and TenantIntegration models.
* Load the integration definition and its client-owned configuration.
*
* @throws Exception
*/
protected function loadIntegration(): void
{
if (empty($this->integrationCode)) {
throw new Exception("Integration code is not set.");
throw new Exception('Integration code is not set.');
}
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
if (!$this->integration) {
if (! $this->integration) {
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
}
$this->tenantIntegration = TenantIntegration::where('tenant_code', $this->tenantCode)
if (! $this->clientContext) {
throw new Exception('Client context is not set.');
}
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode)
->first();
if (!$this->tenantIntegration && $this->integration->requires_tenant_configuration) {
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured.");
if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
}
}
/**
* Build the request URL.
*
* @param string $path
* @return string
* @throws Exception
*/
public function getUrl(string $path = ''): string
{
if (!$this->integration) {
throw new Exception("Integration is not loaded. Call forTenant() first.");
if (! $this->integration) {
throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
}
$baseUrl = rtrim($this->integration->url, '/');
@@ -119,25 +131,20 @@ abstract class BaseIntegrationService
}
/**
* Get integration setting by key from tenant's integration data.
*
* @param string $key
* @param mixed $default
* @return mixed
* Get an integration setting from the client-owned configuration.
*/
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{
if (!$this->tenantIntegration || !$this->tenantIntegration->integration_data) {
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
return $default;
}
return $this->tenantIntegration->integration_data[$key] ?? $default;
return $this->clientIntegration->integration_data[$key] ?? $default;
}
/**
* Get a pre-configured HTTP client builder.
*
* @return PendingRequest
* @throws Exception
*/
public function client(): PendingRequest
@@ -148,17 +155,13 @@ abstract class BaseIntegrationService
/**
* Get the headers for the integration.
*
* @return array
*/
abstract public function getHeaders(): array;
/**
* Hook called after the integration is configured for the tenant.
* Hook called after the integration is configured for the client.
* Can be used to validate credentials or perform initial setups.
* Throw an Exception on failure.
*
* @return void
*/
public function onSetup(): void
{

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ClientIntegrationService
{
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{
return $client->integrations()
->where('integration_code', $integrationCode)
->first();
}
/** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection
{
return $client->integrations()->with('integration')->get();
}
public function updateOrCreateIntegration(
Client $client,
Integration $integration,
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();
return $clientIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
return match ($integrationCode) {
'email' => new MailService,
'telepagos', 'telepagos_homo' => new TelepagosIntegrationService($integrationCode),
default => null,
};
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Integration\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Client\Models\Client;
use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer;
@@ -27,9 +27,7 @@ class MailService extends BaseIntegrationService
private ?Mailer $mailer = null;
private ?Tenant $tenant = null;
private bool $usesTenantMailer = false;
private bool $usesClientMailer = false;
public function __construct(?MailFactory $mailFactory = null)
{
@@ -40,16 +38,28 @@ class MailService extends BaseIntegrationService
{
parent::forTenant($tenantCode);
$this->tenant = Tenant::query()
->where('codigo', $tenantCode)
->firstOrFail();
if ($this->tenantIntegration) {
if ($this->clientIntegration) {
$this->mailer = $this->resolveMailer();
$this->usesTenantMailer = true;
$this->usesClientMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesTenantMailer = false;
$this->usesClientMailer = false;
}
return $this;
}
public function forClient(Client|string $client): self
{
parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first();
if ($this->clientIntegration) {
$this->mailer = $this->resolveMailer();
$this->usesClientMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesClientMailer = false;
}
return $this;
@@ -63,7 +73,7 @@ class MailService extends BaseIntegrationService
public function send(string|array $recipient, string $subject, string $content): void
{
if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
@@ -91,43 +101,50 @@ class MailService extends BaseIntegrationService
public function mailerName(): string
{
return $this->usesTenantMailer
? 'tenant-smtp'
return $this->usesClientMailer
? 'client-smtp'
: (string) config('mail.default');
}
public function onSetup(): void
{
if (! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
if (! $this->mailer || ! $this->clientContext) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
if (! is_string($recipient) || $recipient === '') {
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del tenant.');
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del cliente.');
}
$this->send(
$recipient,
'Configuración de correo validada',
'<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
.'<p>La integración SMTP de '.e($this->tenant->nombre).' fue configurada correctamente.</p>'
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>',
$subject = 'Configuración de correo validada';
$content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
.'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
if ($this->tenant) {
$this->send($recipient, $subject, $content);
return;
}
$this->mailer->to($recipient)->send(
(new Mailable)->subject($subject)->html($content)
);
}
private function resolveMailer(): Mailer
{
$data = $this->tenantIntegration?->integration_data;
$data = $this->clientIntegration?->integration_data;
if (! is_array($data)) {
throw new InvalidArgumentException('La configuración SMTP del tenant no es válida.');
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
}
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del tenant.");
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del cliente.");
}
}
@@ -137,7 +154,7 @@ class MailService extends BaseIntegrationService
}
$mailer = $this->mailFactory->build([
'name' => "tenant-smtp-{$this->tenantCode}",
'name' => 'client-smtp-'.$this->clientContext?->id,
'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'],
@@ -150,7 +167,7 @@ class MailService extends BaseIntegrationService
$mailer->alwaysFrom(
$data['MAIL_FROM_ADDRESS'],
$data['MAIL_FROM_NAME'] ?? $this->tenant?->nombre,
$data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
);
return $mailer;

View File

@@ -2,39 +2,37 @@
namespace App\Domains\Integration\Services;
use Carbon\Carbon;
use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class TelepagosIntegrationService extends BaseIntegrationService
{
/**
* TelepagosIntegrationService constructor.
*
* @param string $integrationCode
*/
public function __construct(string $integrationCode = 'telepagos')
{
// Force homologation code if not in production and using default
if ($integrationCode === 'telepagos' && !app()->environment('production')) {
if ($integrationCode === 'telepagos' && ! app()->environment('production')) {
$integrationCode = 'telepagos_homo';
}
$this->integrationCode = $integrationCode;
}
/**
* Get the headers for Telepagos integration.
*
* @return array
* @throws Exception
*/
public function getHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->getToken(),
'Authorization' => 'Bearer '.$this->getToken(),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
];
@@ -43,16 +41,15 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Get a valid token, either from cache or by performing a login.
*
* @return string
* @throws Exception
*/
public function getToken(): string
{
if (!$this->tenantIntegration) {
throw new Exception("Tenant integration is not loaded. Call forTenant() first.");
if (! $this->clientIntegration || ! $this->clientContext) {
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
}
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
$token = Cache::get($cacheKey);
@@ -66,7 +63,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Authenticate with Telepagos and cache the returned token.
*
* @return string
* @throws Exception
*/
public function login(): string
@@ -75,7 +71,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
$password = $this->getIntegrationSetting('password');
if (empty($username) || empty($password)) {
throw new Exception("Missing username or password in Telepagos integration settings.");
throw new Exception('Missing username or password in Telepagos integration settings.');
}
$url = $this->getUrl('/v2/auth/token');
@@ -92,15 +88,15 @@ class TelepagosIntegrationService extends BaseIntegrationService
$token = $data['token'] ?? null;
$expiresAtStr = $data['expires_at'] ?? null;
if (!$token || !$expiresAtStr) {
throw new Exception("Telepagos authentication response is missing token or expires_at.");
if (! $token || ! $expiresAtStr) {
throw new Exception('Telepagos authentication response is missing token or expires_at.');
}
$expiresAt = Carbon::parse($expiresAtStr);
// Calculate TTL and subtract a buffer of 60 seconds
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
Cache::put($cacheKey, $token, $ttlSeconds);
return $token;
@@ -108,21 +104,16 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
*
* @param string $method
* @param string $endpoint
* @param array $data
* @return \Illuminate\Http\Client\Response
*/
protected function sendRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
protected function sendRequest(string $method, string $endpoint, array $data = []): Response
{
$response = $this->client()->$method($endpoint, $data);
if ($response->status() === 401) {
Log::info("Telepagos 401 Unauthorized. Refreshing token and retrying...");
Log::info('Telepagos 401 Unauthorized. Refreshing token and retrying...');
$this->clearToken();
$response = $this->client()->$method($endpoint, $data);
}
@@ -132,10 +123,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Generate a QR code for cash-in.
*
* @param float $amount
* @param string $concept
* @param string $description
* @return array
* @throws Exception
*/
public function generateQr(float $amount, string $concept, string $description): array
@@ -154,8 +141,8 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Get the details of a cash-in payment.
*
* @param int $cashinId
* @return array
* @param int $cashinId
*
* @throws Exception
*/
public function getCashinDetails(string $cashinId): array
@@ -170,25 +157,21 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Get the account info.
*
* @return array
* @throws Exception
*/
public function getAccountInfo(): array
{
$response = $this->sendRequest('get', '/v2/account/info');
return $this->handleResponse($response, 'get account info');
}
/**
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
*
* @param \Illuminate\Http\Client\Response $response
* @param string $actionDescription
* @param array $context
* @return array
* @throws Exception
*/
protected function handleResponse(\Illuminate\Http\Client\Response $response, string $actionDescription, array $context = []): array
protected function handleResponse(Response $response, string $actionDescription, array $context = []): array
{
if ($response->failed() || $response->json('status') !== 'ok') {
$errorMessage = $response->json('message') ?? $response->body();
@@ -205,19 +188,20 @@ class TelepagosIntegrationService extends BaseIntegrationService
/**
* Clear the cached token.
*
* @return void
*/
public function clearToken(): void
{
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
if (! $this->clientContext) {
return;
}
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
Cache::forget($cacheKey);
}
/**
* Perform initial setup validation for Telepagos.
*
* @return void
* @throws Exception
*/
public function onSetup(): void

View File

@@ -2,11 +2,11 @@
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -22,12 +22,10 @@ class TelepagosWebhookService
*
* @throws Exception
*/
public function handleWebhook(string $tenantCodigo, string $cashinId): void
public function handleWebhook(Client $client, string $cashinId): void
{
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo);
$telepagosService->forClient($client);
try {
$details = $telepagosService->getCashinDetails($cashinId);
@@ -52,7 +50,8 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1);
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
$tenantCodes = $client->tenants()->pluck('codigo');
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
->where('transfer_payer_dni', $dni)
->whereIn('status', [
Purchase::STATUS_CREATED,
@@ -61,10 +60,13 @@ class TelepagosWebhookService
->where('payment_method', 'transfer')
->where('total', $amount)
->latest()
->first();
->limit(2)
->get();
$compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) {
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
Log::warning("Telepagos webhook: Expected one matching purchase for client {$client->code}, DNI {$dni}, amount {$amount} and cashin {$cashinId}; found {$purchases->count()}");
return;
}
@@ -91,6 +93,12 @@ class TelepagosWebhookService
return;
}
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
Log::warning("Telepagos webhook: Purchase {$compra->id} does not belong to client {$client->code}");
return;
}
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
], true)) {

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use Illuminate\Support\Facades\DB;
class TenantIntegrationService
{
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
{
return TenantIntegration::where('tenant_code', $tenantCode)
->where('integration_code', $integrationCode)
->first();
}
public function getAllForTenant(string $tenantCode)
{
return TenantIntegration::with('integration')
->where('tenant_code', $tenantCode)
->get();
}
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
{
return DB::transaction(function () use ($tenantCode, $integration, $data) {
$tenantIntegration = TenantIntegration::updateOrCreate(
[
'tenant_code' => $tenantCode,
'integration_code' => $integration->integration_code,
],
[
'integration_data' => $data,
]
);
$service = $this->resolveService($integration->integration_code);
if ($service) {
$service->forTenant($tenantCode)->onSetup();
}
return $tenantIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
switch ($integrationCode) {
case 'email':
return new MailService;
case 'telepagos':
case 'telepagos_homo':
return new TelepagosIntegrationService($integrationCode);
default:
return null;
}
}
}