Merge branch 'refactor/integrations' into dev

This commit is contained in:
2026-09-07 11:12:16 -03:00
40 changed files with 1914 additions and 379 deletions

View File

@@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller
}
public function store(
StoreClientIntegrationRequest $request,
ConfigureIntegrationRequest $request,
Client $client,
string $integrationCode,
): JsonResponse {
@@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller
], 400);
}
}
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);
return response()->noContent();
}
}

View File

@@ -35,6 +35,7 @@ class IntegrationController extends Controller
public function destroy(Integration $integration)
{
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
$integration->delete();
return response()->noContent();

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Domains\Tenant\Models\WebsiteType;
use App\Http\Controllers\Controller;
class WebsiteTypeIntegrationController extends Controller
{
public function __construct(private readonly IntegrationAssociationService $service) {}
public function index(WebsiteType $websiteType)
{
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
}
public function show(WebsiteType $websiteType, string $integrationCode)
{
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
}
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
{
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
return new IntegrationAssociationResource($this->service->configure(
$websiteType,
$integration,
$request->validated('integration_data'),
));
}
public function destroy(WebsiteType $websiteType, string $integrationCode)
{
$this->service->detach($websiteType, $integrationCode);
return response()->noContent();
}
}

View File

@@ -3,22 +3,15 @@
namespace App\Domains\Integration\Models;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientIntegration extends Model
{
protected $hidden = ['integration_data'];
protected $fillable = [
'client_id',
'integration_code',
'integration_data',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
'integration_instance_id',
];
/** @return BelongsTo<Client, $this> */
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Integration extends Model
{
@@ -13,16 +14,29 @@ class Integration extends Model
'name',
'url',
'integration_data_schema',
'requires_client_configuration',
'requires_configuration',
];
protected $casts = [
'integration_data_schema' => 'array',
'requires_client_configuration' => 'boolean',
'requires_configuration' => 'boolean',
];
public function clientIntegrations()
/** @return HasMany<IntegrationInstance, $this> */
public function instances(): HasMany
{
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class IntegrationInstance extends Model
{
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
public function tokenCacheKey(): string
{
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
}
protected $fillable = ['integration_code', 'name', 'integration_data'];
protected $hidden = ['integration_data'];
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class);
}
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebsiteTypeIntegration extends Model
{
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
/** @return BelongsTo<WebsiteType, $this> */
public function websiteType(): BelongsTo
{
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Integration\Policies;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class IntegrationPolicy
{
public function manage(User $user): bool
{
return $user->rol_codigo === RoleCode::Admin->value;
}
}

View File

@@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreClientIntegrationRequest extends FormRequest
class ConfigureIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return true;
return $this->user()?->can('manage', Integration::class) ?? false;
}
protected function prepareForValidation(): void
{
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::query()
->where('integration_code', $integrationCode)
->where('integration_code', $this->route('integration_code'))
->first();
if (! $this->integrationModel) {
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
public function rules(): array
{
$rules = [];
$rules = ['integration_data' => ['present', 'array']];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule;

View File

@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'],
'requires_configuration' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIntegrationRequest extends FormRequest
{
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
'requires_configuration' => ['sometimes', 'boolean'],
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationAssociationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'client_id' => $this->when(isset($this->client_id), $this->client_id),
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
'integration_code' => $this->integration_code,
'integration_instance_id' => $this->integration_instance_id,
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationInstanceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'integration_code' => $this->integration_code,
'name' => $this->name,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

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_client_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,13 +30,7 @@ class ClientIntegrationService
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],
);
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();

View File

@@ -0,0 +1,98 @@
<?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 App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Support\Facades\DB;
class IntegrationAssociationService
{
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
$instance = IntegrationInstance::create([
'integration_code' => $integration->integration_code,
'name' => $integration->name.' / '.$this->ownerName($owner),
'integration_data' => $integrationData,
]);
return $this->associate($owner, $integration->integration_code, $instance);
});
}
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
$association = $owner->integrations()
->where('integration_code', $code)
->lockForUpdate()
->first();
$previousInstanceId = $association?->integration_instance_id;
$instanceIds = array_values(array_unique(array_filter([
$previousInstanceId,
$instance->id,
])));
sort($instanceIds);
$instances = IntegrationInstance::query()
->whereKey($instanceIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
$association = $owner->integrations()->updateOrCreate(
['integration_code' => $code],
['integration_instance_id' => $instance->id],
);
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
$this->deleteIfUnused($previousInstanceId);
}
return $association->load('integrationInstance');
});
}
public function detach(Client|WebsiteType $owner, string $code): void
{
DB::transaction(function () use ($owner, $code): void {
$association = $owner->integrations()
->where('integration_code', $code)
->lockForUpdate()
->first();
if (! $association) {
return;
}
$instanceId = $association->integration_instance_id;
$association->delete();
$this->deleteIfUnused($instanceId);
});
}
private function deleteIfUnused(int $instanceId): void
{
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
if ($instance
&& ! $instance->clientIntegrations()->exists()
&& ! $instance->websiteTypeIntegrations()->exists()) {
$instance->delete();
}
}
private function ownerName(Client|WebsiteType $owner): string
{
return $owner instanceof Client ? $owner->name : $owner->nombre;
}
}

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);
}

View File

@@ -1,33 +1,48 @@
# Dominio Integration
## Propósito
## Modelo
Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos.
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
## Modelo y seguridad
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
- `Integration`: definición global de una integración.
- `ClientIntegration`: configuración y credenciales de una integración para un cliente.
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
## Resolución
## Servicios externos
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
- `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
## Endpoints
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
- CRUD global bajo `/integrations`.
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
## Administración
## Logging de Telepagos
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
| Método | Ruta | Operación |
| --- | --- | --- |
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
## Dependencias y reglas
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
## Webhooks y contexto operativo
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
## Despliegue
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
## Logging
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.

View File

@@ -1,22 +1,25 @@
<?php
use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\TelepagosWebhookController;
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
use App\Domains\Integration\Models\Integration;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'integrations'], function () {
Route::get('/', [IntegrationController::class, 'index']);
Route::post('/', [IntegrationController::class, 'store']);
Route::get('/{integration}', [IntegrationController::class, 'show']);
Route::put('/{integration}', [IntegrationController::class, 'update']);
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
});
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
});
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
});
});
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -36,6 +37,12 @@ class WebsiteType extends Model
protected $table = 'website_type';
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function integrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo');
}
/**
* @return BelongsTo<Attachment, $this>
*/

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Policies\IntegrationPolicy;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
@@ -13,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
@@ -31,6 +34,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Gate::policy(
Integration::class,
IntegrationPolicy::class,
);
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
Event::listen(UserRegistered::class, SendWelcomeEmail::class);