refactor(integration): resolver credenciales mediante instancias

Resuelve primero la instancia del cliente y luego la del tipo de sitio cuando existe contexto de tenant. Adapta SMTP y Telepagos, con tokens identificados por instancia y version de credenciales.

Separa la edicion de instancias de sus asociaciones y conserva el guardado por cliente creando una instancia nueva. Impide eliminar instancias en uso y adapta las pruebas existentes, incluida la herencia SMTP.
This commit is contained in:
2026-09-04 12:47:47 -03:00
parent e4146288f1
commit c0057c237a
12 changed files with 190 additions and 54 deletions

View File

@@ -5,6 +5,8 @@ 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\IntegrationInstance;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\Tenant;
use Exception;
use Illuminate\Http\Client\PendingRequest;
@@ -32,9 +34,9 @@ abstract class BaseIntegrationService
protected ?Integration $integration = null;
/**
* The client-owned integration configuration.
* The effective integration instance.
*/
protected ?ClientIntegration $clientIntegration = null;
protected ?IntegrationInstance $integrationInstance = null;
/**
* Set the integration code.
@@ -85,7 +87,7 @@ abstract class BaseIntegrationService
}
/**
* Load the integration definition and its client-owned configuration.
* Load the integration definition and its effective instance configuration.
*
* @throws Exception
*/
@@ -95,6 +97,7 @@ abstract class BaseIntegrationService
throw new Exception('Integration code is not set.');
}
$this->integrationInstance = null;
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
if (! $this->integration) {
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
@@ -104,11 +107,18 @@ abstract class BaseIntegrationService
throw new Exception('Client context is not set.');
}
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode)
->first();
->first()?->integrationInstance;
if (! $this->clientIntegration && $this->integration->requires_configuration) {
if (! $this->integrationInstance && $this->tenant?->website_type_code) {
$this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance')
->where('website_type_code', $this->tenant->website_type_code)
->where('integration_code', $this->integrationCode)
->first()?->integrationInstance;
}
if (! $this->integrationInstance && $this->integration->requires_configuration) {
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
}
}
@@ -131,15 +141,15 @@ abstract class BaseIntegrationService
}
/**
* Get an integration setting from the client-owned configuration.
* Get an integration setting from the effective instance configuration.
*/
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
return $default;
}
return $this->clientIntegration->integration_data[$key] ?? $default;
return $this->integrationInstance->integration_data[$key] ?? $default;
}
/**

View File

@@ -13,6 +13,7 @@ class ClientIntegrationService
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{
return $client->integrations()
->with(['integration', 'integrationInstance'])
->where('integration_code', $integrationCode)
->first();
}
@@ -20,7 +21,7 @@ class ClientIntegrationService
/** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection
{
return $client->integrations()->with('integration')->get();
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
}
public function updateOrCreateIntegration(
@@ -29,12 +30,18 @@ class ClientIntegrationService
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
// Legacy configuration endpoint replaces this client's instance only.
$instance = app(IntegrationInstanceService::class)->create([
'integration_code' => $integration->integration_code,
'name' => $integration->name.' / '.$client->name,
'integration_data' => $data,
]);
$clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
['integration_instance_id' => $instance->id],
);
$service = $this->resolveService($integration->integration_code);

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\WebsiteType;
class IntegrationAssociationService
{
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
{
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
return $owner->integrations()->updateOrCreate(
['integration_code' => $code],
['integration_instance_id' => $instance->id],
)->load('integrationInstance');
}
public function detach(Client|WebsiteType $owner, string $code): void
{
$owner->integrations()->where('integration_code', $code)->delete();
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\IntegrationInstance;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class IntegrationInstanceService
{
public function create(array $data): IntegrationInstance
{
return IntegrationInstance::create($data);
}
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
{
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
$cacheKey = $instance->tokenCacheKey();
$instance->update($data);
if (array_key_exists('integration_data', $data)) {
DB::afterCommit(fn () => Cache::forget($cacheKey));
}
return $instance;
});
}
public function delete(IntegrationInstance $instance): void
{
DB::transaction(function () use ($instance): void {
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
$cacheKey = $instance->tokenCacheKey();
$instance->delete();
DB::afterCommit(fn () => Cache::forget($cacheKey));
});
}
}

View File

@@ -29,7 +29,7 @@ class MailService extends BaseIntegrationService
private ?Mailer $mailer = null;
private bool $usesClientMailer = false;
private bool $usesInstanceMailer = false;
public function __construct(?MailFactory $mailFactory = null)
{
@@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService
{
parent::forTenant($tenantCode);
if ($this->clientIntegration) {
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesClientMailer = true;
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesClientMailer = false;
$this->usesInstanceMailer = false;
}
return $this;
@@ -56,12 +56,12 @@ class MailService extends BaseIntegrationService
parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first();
if ($this->clientIntegration) {
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesClientMailer = true;
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesClientMailer = false;
$this->usesInstanceMailer = false;
}
return $this;
@@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService
public function mailerName(): string
{
return $this->usesClientMailer
? 'client-smtp'
return $this->usesInstanceMailer
? 'integration-smtp'
: (string) config('mail.default');
}
@@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService
private function resolveMailer(): Mailer
{
$data = $this->clientIntegration?->integration_data;
$data = $this->integrationInstance?->integration_data;
if (! is_array($data)) {
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
@@ -205,7 +205,7 @@ class MailService extends BaseIntegrationService
}
$mailer = $this->mailFactory->build([
'name' => 'client-smtp-'.$this->clientContext?->id,
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'],

View File

@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/
public function getToken(): string
{
if (! $this->clientIntegration || ! $this->clientContext) {
if (! $this->integrationInstance) {
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
}
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
$cacheKey = $this->integrationInstance->tokenCacheKey();
$token = Cache::get($cacheKey);
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
// Calculate TTL and subtract a buffer of 60 seconds
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
$cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::put($cacheKey, $token, $ttlSeconds);
return $token;
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/
public function clearToken(): void
{
if (! $this->clientContext) {
if (! $this->integrationInstance) {
return;
}
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
$cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::forget($cacheKey);
}