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

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Client\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Client\Requests\StoreClientRequest;
use App\Domains\Client\Requests\UpdateClientRequest;
use App\Domains\Client\Resources\ClientResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class ClientController extends Controller
{
public function index(): JsonResponse
{
return ClientResource::collection(
Client::query()->with('tenants')->latest()->paginateFromRequest()
)->response();
}
public function store(StoreClientRequest $request): JsonResponse
{
return ClientResource::make(
Client::query()->create($request->validated())->load('tenants')
)->response()->setStatusCode(201);
}
public function show(Client $client): ClientResource
{
return ClientResource::make($client->load('tenants'));
}
public function update(UpdateClientRequest $request, Client $client): ClientResource
{
$client->update($request->validated());
return ClientResource::make($client->fresh()->load('tenants'));
}
public function destroy(Client $client): Response
{
$client->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Client\Models;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['code', 'name'])]
class Client extends Model
{
public function getRouteKeyName(): string
{
return 'code';
}
/** @return HasMany<Tenant, $this> */
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class);
}
/** @return HasMany<ClientIntegration, $this> */
public function integrations(): HasMany
{
return $this->hasMany(ClientIntegration::class);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Client\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:255', Rule::unique('clients', 'code')],
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Client\Requests;
use App\Domains\Client\Models\Client;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
/** @var Client|null $client */
$client = $this->route('client');
return [
'code' => ['sometimes', 'string', 'max:255', Rule::unique('clients', 'code')->ignore($client?->id)],
'name' => ['sometimes', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Client\Resources;
use App\Domains\Client\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Client */
class ClientResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->name,
'tenants' => $this->whenLoaded('tenants', fn () => $this->tenants->map(fn ($tenant): array => [
'id' => $tenant->id,
'codigo' => $tenant->codigo,
'nombre' => $tenant->nombre,
'dominio' => $tenant->dominio,
])),
];
}
}

View File

@@ -0,0 +1,6 @@
<?php
use App\Domains\Client\Controllers\ClientController;
use Illuminate\Support\Facades\Route;
Route::apiResource('clients', ClientController::class);

View File

@@ -0,0 +1,64 @@
<?php
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\Services\ClientIntegrationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ClientIntegrationController extends Controller
{
public function __construct(
private readonly ClientIntegrationService $clientIntegrationService,
) {}
public function index(Client $client): JsonResponse
{
return response()->json($this->clientIntegrationService->getAllForClient($client));
}
public function show(Client $client, string $integrationCode): JsonResponse
{
$integration = $this->clientIntegrationService->getClientIntegration($client, $integrationCode);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
}
public function store(
StoreClientIntegrationRequest $request,
Client $client,
string $integrationCode,
): JsonResponse {
$integration = Integration::query()
->where('integration_code', $integrationCode)
->firstOrFail();
try {
$this->clientIntegrationService->updateOrCreateIntegration(
$client,
$integration,
$request->input('integration_data', []),
);
return response()->json([
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $exception) {
return response()->json([
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $exception->getMessage()]),
], 400);
}
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Integration\Controllers; namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Requests\TelepagosWebhookRequest; use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService; use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@@ -12,12 +13,12 @@ class TelepagosWebhookController extends Controller
/** /**
* Handle the incoming Telepagos webhook. * Handle the incoming Telepagos webhook.
*/ */
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
{ {
try { try {
$cashinId = $request->validated('id'); $cashinId = $request->validated('id');
$service->handleWebhook($tenantCodigo, $cashinId); $service->handleWebhook($client, $cashinId);
return response()->json(['status' => 'success']); return response()->json(['status' => 'success']);
} catch (\Exception $e) { } catch (\Exception $e) {

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
use App\Domains\Integration\Services\TenantIntegrationService;
use Illuminate\Routing\Controller;
class TenantIntegrationController extends Controller
{
protected TenantIntegrationService $tenantIntegrationService;
public function __construct(TenantIntegrationService $tenantIntegrationService)
{
$this->tenantIntegrationService = $tenantIntegrationService;
}
public function index(string $tenantCode)
{
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
}
public function show(string $tenantCode, string $integrationCode)
{
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
}
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
try {
$this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
$integration,
$request->input('integration_data', [])
);
return response()->json([
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $e) {
return response()->json([
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
], 400);
}
}
}

View File

@@ -2,16 +2,18 @@
namespace App\Domains\Integration\Models; namespace App\Domains\Integration\Models;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Casts\EncryptedIntegrationData; use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantIntegration extends Model class ClientIntegration extends Model
{ {
protected $table = 'tenant_integration'; protected $hidden = ['integration_data'];
protected $fillable = [ protected $fillable = [
'client_id',
'integration_code', 'integration_code',
'tenant_code',
'integration_data', 'integration_data',
]; ];
@@ -19,7 +21,14 @@ class TenantIntegration extends Model
'integration_data' => EncryptedIntegrationData::class, 'integration_data' => EncryptedIntegrationData::class,
]; ];
public function integration() /** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{ {
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
} }

View File

@@ -13,16 +13,16 @@ class Integration extends Model
'name', 'name',
'url', 'url',
'integration_data_schema', 'integration_data_schema',
'requires_tenant_configuration', 'requires_client_configuration',
]; ];
protected $casts = [ protected $casts = [
'integration_data_schema' => 'array', 'integration_data_schema' => 'array',
'requires_tenant_configuration' => 'boolean', 'requires_client_configuration' => 'boolean',
]; ];
public function tenantIntegrations() public function clientIntegrations()
{ {
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code'); return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
} }
} }

View File

@@ -6,7 +6,7 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class StoreTenantIntegrationRequest extends FormRequest class StoreClientIntegrationRequest extends FormRequest
{ {
protected ?Integration $integrationModel = null; protected ?Integration $integrationModel = null;
@@ -15,10 +15,12 @@ class StoreTenantIntegrationRequest extends FormRequest
return true; return true;
} }
protected function prepareForValidation() protected function prepareForValidation(): void
{ {
$integrationCode = $this->route('integration_code'); $integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first(); $this->integrationModel = Integration::query()
->where('integration_code', $integrationCode)
->first();
if (! $this->integrationModel) { if (! $this->integrationModel) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -31,11 +33,8 @@ class StoreTenantIntegrationRequest extends FormRequest
{ {
$rules = []; $rules = [];
// Dynamic validation rules based on the integration data schema foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
if ($this->integrationModel && $this->integrationModel->integration_data_schema) { $rules['integration_data.'.$field] = $rule;
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.'.$field] = $rule;
}
} }
return $rules; return $rules;

View File

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

View File

@@ -14,14 +14,14 @@ class UpdateIntegrationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$integration = $this->route('integration'); $integration = $this->route('integration');
return [ return [
'name' => ['sometimes', 'required', 'string', 'max:255'], 'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'], 'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'], 'integration_data_schema' => ['nullable', 'array'],
'requires_tenant_configuration' => ['sometimes', 'boolean'], 'requires_client_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is: // the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')], 'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
]; ];
} }
} }

View File

@@ -2,58 +2,54 @@
namespace App\Domains\Integration\Services; 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\Integration;
use App\Domains\Integration\Models\TenantIntegration; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
use Exception; use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
abstract class BaseIntegrationService abstract class BaseIntegrationService
{ {
/** /**
* The unique code of the integration. * The unique code of the integration.
*
* @var string
*/ */
protected string $integrationCode; protected string $integrationCode;
/** /**
* The current tenant code. * The current tenant code.
*
* @var string
*/ */
protected string $tenantCode; protected string $tenantCode;
protected ?Tenant $tenant = null;
protected ?Client $clientContext = null;
/** /**
* The integration model instance. * The integration model instance.
*
* @var Integration|null
*/ */
protected ?Integration $integration = null; protected ?Integration $integration = null;
/** /**
* The tenant-specific integration model instance. * The client-owned integration configuration.
*
* @var TenantIntegration|null
*/ */
protected ?TenantIntegration $tenantIntegration = null; protected ?ClientIntegration $clientIntegration = null;
/** /**
* Set the integration code. * Set the integration code.
* *
* @param string $integrationCode
* @return $this * @return $this
*/ */
public function setIntegrationCode(string $integrationCode): self public function setIntegrationCode(string $integrationCode): self
{ {
$this->integrationCode = $integrationCode; $this->integrationCode = $integrationCode;
return $this; return $this;
} }
/** /**
* Get the integration code. * Get the integration code.
*
* @return string
*/ */
public function getIntegrationCode(): string public function getIntegrationCode(): string
{ {
@@ -63,53 +59,69 @@ abstract class BaseIntegrationService
/** /**
* Set the tenant code and load the integration models. * Set the tenant code and load the integration models.
* *
* @param string $tenantCode
* @return $this * @return $this
*
* @throws Exception * @throws Exception
*/ */
public function forTenant(string $tenantCode): self public function forTenant(string $tenantCode): self
{ {
$this->tenantCode = $tenantCode; $this->tenantCode = $tenantCode;
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
$this->clientContext = $this->tenant->client;
$this->loadIntegration(); $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; return $this;
} }
/** /**
* Load the Integration and TenantIntegration models. * Load the integration definition and its client-owned configuration.
* *
* @throws Exception * @throws Exception
*/ */
protected function loadIntegration(): void protected function loadIntegration(): void
{ {
if (empty($this->integrationCode)) { 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(); $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."); 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) ->where('integration_code', $this->integrationCode)
->first(); ->first();
if (!$this->tenantIntegration && $this->integration->requires_tenant_configuration) { if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured."); throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
} }
} }
/** /**
* Build the request URL. * Build the request URL.
* *
* @param string $path
* @return string
* @throws Exception * @throws Exception
*/ */
public function getUrl(string $path = ''): string public function getUrl(string $path = ''): string
{ {
if (!$this->integration) { if (! $this->integration) {
throw new Exception("Integration is not loaded. Call forTenant() first."); throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
} }
$baseUrl = rtrim($this->integration->url, '/'); $baseUrl = rtrim($this->integration->url, '/');
@@ -119,25 +131,20 @@ abstract class BaseIntegrationService
} }
/** /**
* Get integration setting by key from tenant's integration data. * Get an integration setting from the client-owned configuration.
*
* @param string $key
* @param mixed $default
* @return mixed
*/ */
protected function getIntegrationSetting(string $key, mixed $default = null): mixed 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 $default;
} }
return $this->tenantIntegration->integration_data[$key] ?? $default; return $this->clientIntegration->integration_data[$key] ?? $default;
} }
/** /**
* Get a pre-configured HTTP client builder. * Get a pre-configured HTTP client builder.
* *
* @return PendingRequest
* @throws Exception * @throws Exception
*/ */
public function client(): PendingRequest public function client(): PendingRequest
@@ -148,17 +155,13 @@ abstract class BaseIntegrationService
/** /**
* Get the headers for the integration. * Get the headers for the integration.
*
* @return array
*/ */
abstract public function getHeaders(): 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. * Can be used to validate credentials or perform initial setups.
* Throw an Exception on failure. * Throw an Exception on failure.
*
* @return void
*/ */
public function onSetup(): 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; namespace App\Domains\Integration\Services;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Client\Models\Client;
use Exception; use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer; use Illuminate\Contracts\Mail\Mailer;
@@ -27,9 +27,7 @@ class MailService extends BaseIntegrationService
private ?Mailer $mailer = null; private ?Mailer $mailer = null;
private ?Tenant $tenant = null; private bool $usesClientMailer = false;
private bool $usesTenantMailer = false;
public function __construct(?MailFactory $mailFactory = null) public function __construct(?MailFactory $mailFactory = null)
{ {
@@ -40,16 +38,28 @@ class MailService extends BaseIntegrationService
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
$this->tenant = Tenant::query() if ($this->clientIntegration) {
->where('codigo', $tenantCode)
->firstOrFail();
if ($this->tenantIntegration) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesTenantMailer = true; $this->usesClientMailer = true;
} else { } else {
$this->mailer = $this->mailFactory->mailer(); $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; return $this;
@@ -63,7 +73,7 @@ class MailService extends BaseIntegrationService
public function send(string|array $recipient, string $subject, string $content): void public function send(string|array $recipient, string $subject, string $content): void
{ {
if (! $this->mailer || ! $this->tenant) { 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']); $this->tenant->loadMissing(['headerLogo', 'footerLogo']);
@@ -91,43 +101,50 @@ class MailService extends BaseIntegrationService
public function mailerName(): string public function mailerName(): string
{ {
return $this->usesTenantMailer return $this->usesClientMailer
? 'tenant-smtp' ? 'client-smtp'
: (string) config('mail.default'); : (string) config('mail.default');
} }
public function onSetup(): void public function onSetup(): void
{ {
if (! $this->tenant) { if (! $this->mailer || ! $this->clientContext) {
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
} }
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
if (! is_string($recipient) || $recipient === '') { 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( $subject = 'Configuración de correo validada';
$recipient, $content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
'Configuración de correo validada', .'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
'<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>' .'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
.'<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>', if ($this->tenant) {
$this->send($recipient, $subject, $content);
return;
}
$this->mailer->to($recipient)->send(
(new Mailable)->subject($subject)->html($content)
); );
} }
private function resolveMailer(): Mailer private function resolveMailer(): Mailer
{ {
$data = $this->tenantIntegration?->integration_data; $data = $this->clientIntegration?->integration_data;
if (! is_array($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) { foreach (self::REQUIRED_SMTP_FIELDS as $field) {
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$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([ $mailer = $this->mailFactory->build([
'name' => "tenant-smtp-{$this->tenantCode}", 'name' => 'client-smtp-'.$this->clientContext?->id,
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null, 'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'], 'host' => $data['MAIL_HOST'],
@@ -150,7 +167,7 @@ class MailService extends BaseIntegrationService
$mailer->alwaysFrom( $mailer->alwaysFrom(
$data['MAIL_FROM_ADDRESS'], $data['MAIL_FROM_ADDRESS'],
$data['MAIL_FROM_NAME'] ?? $this->tenant?->nombre, $data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
); );
return $mailer; return $mailer;

View File

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

View File

@@ -2,11 +2,11 @@
namespace App\Domains\Integration\Services; namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\TelepagosPayment; use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr; use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Exception; use Exception;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -22,12 +22,10 @@ class TelepagosWebhookService
* *
* @throws Exception * @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 = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo); $telepagosService->forClient($client);
try { try {
$details = $telepagosService->getCashinDetails($cashinId); $details = $telepagosService->getCashinDetails($cashinId);
@@ -52,7 +50,8 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $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) ->where('transfer_payer_dni', $dni)
->whereIn('status', [ ->whereIn('status', [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
@@ -61,10 +60,13 @@ class TelepagosWebhookService
->where('payment_method', 'transfer') ->where('payment_method', 'transfer')
->where('total', $amount) ->where('total', $amount)
->latest() ->latest()
->first(); ->limit(2)
->get();
$compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) { 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; return;
} }
@@ -91,6 +93,12 @@ class TelepagosWebhookService
return; 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, [ if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
], true)) { ], 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;
}
}
}

View File

@@ -2,18 +2,18 @@
## Propósito ## Propósito
Gestiona integraciones externas disponibles y su configuración por tenant. Incluye correo y pagos mediante Telepagos. 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.
## Modelo y seguridad ## Modelo y seguridad
- `Integration`: definición global de una integración. - `Integration`: definición global de una integración.
- `TenantIntegration`: configuración y credenciales de una integración para un tenant. - `ClientIntegration`: configuración y credenciales de una integración para un cliente.
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos. - `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
- `TenantIntegrationService`: consulta y configura integraciones del tenant. - `ClientIntegrationService`: consulta y configura integraciones del cliente.
## Servicios externos ## Servicios externos
- `BaseIntegrationService`: base para resolver configuración, URL y cliente del tenant. - `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. - `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros. - `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos. - `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
@@ -21,9 +21,9 @@ Gestiona integraciones externas disponibles y su configuración por tenant. Incl
## Endpoints ## Endpoints
- CRUD global bajo `/integrations`. - CRUD global bajo `/integrations`.
- Consulta y configuración por tenant bajo `/{tenant_code}/integrations`. - Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- `POST /webhooks/telepagos/{tenant_codigo}` para notificaciones del proveedor. - `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
## Dependencias y reglas ## Dependencias y reglas
Se integra con `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. Las credenciales no deben exponerse en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra. 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.

View File

@@ -1,7 +1,8 @@
<?php <?php
use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\IntegrationController; use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\TenantIntegrationController; use App\Domains\Integration\Controllers\TelepagosWebhookController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'integrations'], function () { Route::group(['prefix' => 'integrations'], function () {
@@ -12,10 +13,10 @@ Route::group(['prefix' => 'integrations'], function () {
Route::delete('/{integration}', [IntegrationController::class, 'destroy']); Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
}); });
Route::group(['prefix' => '{tenant_code}/integrations'], function () { Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [TenantIntegrationController::class, 'index']); Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']); Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']); Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
}); });
Route::post('webhooks/telepagos/{tenant_codigo}', [\App\Domains\Integration\Controllers\TelepagosWebhookController::class, 'handle']); Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout; use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Client\Models\Client;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu; use App\Domains\Menu\Models\TenantMenu;
@@ -16,8 +17,10 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Schema;
#[Fillable([ #[Fillable([
'client_id',
'codigo', 'codigo',
'nombre', 'nombre',
'dominio', 'dominio',
@@ -64,6 +67,28 @@ class Tenant extends Model
return 'codigo'; return 'codigo';
} }
protected static function booted(): void
{
static::creating(function (Tenant $tenant): void {
if ($tenant->client_id !== null || ! Schema::hasTable('clients')) {
return;
}
$client = Client::query()->firstOrCreate(
['code' => $tenant->codigo],
['name' => $tenant->nombre],
);
$tenant->client()->associate($client);
});
}
/** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function requiresScannerCategoryValidation(): bool public function requiresScannerCategoryValidation(): bool
{ {
return $this->scanner_category_validation_enabled; return $this->scanner_category_validation_enabled;

View File

@@ -41,6 +41,7 @@ class StoreTenantRequest extends FormRequest
$logoRule = ['required', new ImageOrBase64Rule]; $logoRule = ['required', new ImageOrBase64Rule];
return array_merge([ return array_merge([
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')], 'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
'nombre' => ['required', 'string', 'max:255'], 'nombre' => ['required', 'string', 'max:255'],
'dominio' => [ 'dominio' => [

View File

@@ -46,6 +46,7 @@ class UpdateTenantRequest extends FormRequest
$logoRule = ['nullable', new ImageOrBase64Rule]; $logoRule = ['nullable', new ImageOrBase64Rule];
return [ return [
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
'codigo' => [ 'codigo' => [
'nullable', 'nullable',
'string', 'string',

View File

@@ -24,6 +24,7 @@ class TenantResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'client_id' => $this->client_id,
'codigo' => $this->codigo, 'codigo' => $this->codigo,
'nombre' => $this->nombre, 'nombre' => $this->nombre,
'dominio' => $this->dominio, 'dominio' => $this->dominio,

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('clients', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->timestamps();
});
Schema::table('tenants', function (Blueprint $table): void {
$table->foreignId('client_id')->nullable()->after('id')->constrained('clients')->restrictOnDelete();
});
DB::table('tenants')
->select(['id', 'codigo', 'nombre', 'created_at', 'updated_at'])
->orderBy('id')
->each(function (object $tenant): void {
$clientId = DB::table('clients')->insertGetId([
'code' => $tenant->codigo,
'name' => $tenant->nombre,
'created_at' => $tenant->created_at ?? now(),
'updated_at' => $tenant->updated_at ?? now(),
]);
DB::table('tenants')->where('id', $tenant->id)->update(['client_id' => $clientId]);
});
Schema::table('tenants', function (Blueprint $table): void {
$table->unsignedBigInteger('client_id')->nullable(false)->change();
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropConstrainedForeignId('client_id');
});
Schema::dropIfExists('clients');
}
};

View File

@@ -0,0 +1,107 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('client_integrations', function (Blueprint $table): void {
$table->id();
$table->foreignId('client_id')->constrained('clients')->cascadeOnDelete();
$table->string('integration_code');
$table->longText('integration_data')->nullable();
$table->timestamps();
$table->foreign('integration_code')
->references('integration_code')
->on('integrations')
->cascadeOnDelete();
$table->unique(['client_id', 'integration_code']);
});
DB::table('tenant_integration')
->join('tenants', 'tenants.codigo', '=', 'tenant_integration.tenant_code')
->select([
'tenants.client_id',
'tenant_integration.integration_code',
'tenant_integration.integration_data',
'tenant_integration.created_at',
'tenant_integration.updated_at',
])
->orderBy('tenant_integration.id')
->each(function (object $configuration): void {
DB::table('client_integrations')->updateOrInsert(
[
'client_id' => $configuration->client_id,
'integration_code' => $configuration->integration_code,
],
[
'integration_data' => $configuration->integration_data,
'created_at' => $configuration->created_at,
'updated_at' => $configuration->updated_at,
],
);
});
Schema::table('integrations', function (Blueprint $table): void {
$table->boolean('requires_client_configuration')->default(true);
});
DB::table('integrations')->update([
'requires_client_configuration' => DB::raw('requires_tenant_configuration'),
]);
Schema::table('integrations', function (Blueprint $table): void {
$table->dropColumn('requires_tenant_configuration');
});
Schema::dropIfExists('tenant_integration');
}
public function down(): void
{
Schema::table('integrations', function (Blueprint $table): void {
$table->boolean('requires_tenant_configuration')->default(true);
});
DB::table('integrations')->update([
'requires_tenant_configuration' => DB::raw('requires_client_configuration'),
]);
Schema::table('integrations', function (Blueprint $table): void {
$table->dropColumn('requires_client_configuration');
});
Schema::create('tenant_integration', function (Blueprint $table): void {
$table->id();
$table->string('integration_code');
$table->string('tenant_code');
$table->longText('integration_data')->nullable();
$table->timestamps();
$table->foreign('integration_code')->references('integration_code')->on('integrations')->cascadeOnDelete();
$table->foreign('tenant_code')->references('codigo')->on('tenants')->cascadeOnDelete();
$table->unique(['integration_code', 'tenant_code']);
});
DB::table('client_integrations')
->join('tenants', 'tenants.client_id', '=', 'client_integrations.client_id')
->select([
'tenants.codigo as tenant_code',
'client_integrations.integration_code',
'client_integrations.integration_data',
'client_integrations.created_at',
'client_integrations.updated_at',
])
->orderBy('client_integrations.id')
->each(function (object $configuration): void {
DB::table('tenant_integration')->insert((array) $configuration);
});
Schema::dropIfExists('client_integrations');
}
};

View File

@@ -0,0 +1,101 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const CLIENT_CODE = 'onticket';
private const TENANT_CODES = [
'fiesta_futbol_infantil',
'desfile_pura_tendencia',
];
public function up(): void
{
$now = now();
$clientId = DB::table('clients')->where('code', self::CLIENT_CODE)->value('id');
if ($clientId === null) {
$clientId = DB::table('clients')->insertGetId([
'code' => self::CLIENT_CODE,
'name' => 'OnTicket',
'created_at' => $now,
'updated_at' => $now,
]);
} else {
DB::table('clients')->where('id', $clientId)->update([
'name' => 'OnTicket',
'updated_at' => $now,
]);
}
$tenants = DB::table('tenants')
->whereIn('codigo', self::TENANT_CODES)
->orderByRaw("CASE codigo WHEN 'fiesta_futbol_infantil' THEN 0 ELSE 1 END")
->get(['codigo', 'client_id']);
foreach ($tenants as $tenant) {
DB::table('client_integrations')
->where('client_id', $tenant->client_id)
->orderBy('id')
->get()
->each(function (object $integration) use ($clientId): void {
DB::table('client_integrations')->insertOrIgnore([
'client_id' => $clientId,
'integration_code' => $integration->integration_code,
'integration_data' => $integration->integration_data,
'created_at' => $integration->created_at,
'updated_at' => $integration->updated_at,
]);
});
}
DB::table('tenants')
->whereIn('codigo', self::TENANT_CODES)
->update(['client_id' => $clientId]);
}
public function down(): void
{
foreach (self::TENANT_CODES as $tenantCode) {
$tenant = DB::table('tenants')->where('codigo', $tenantCode)->first(['id']);
if (! $tenant) {
continue;
}
DB::table('clients')->insertOrIgnore([
'code' => $tenantCode,
'name' => $tenantCode === 'fiesta_futbol_infantil'
? 'Fiesta Fútbol Infantil'
: 'Desfile Pura Tendencia',
'created_at' => now(),
'updated_at' => now(),
]);
$individualClientId = DB::table('clients')->where('code', $tenantCode)->value('id');
$sharedClientId = DB::table('clients')->where('code', self::CLIENT_CODE)->value('id');
DB::table('client_integrations')
->where('client_id', $sharedClientId)
->orderBy('id')
->get()
->each(function (object $integration) use ($individualClientId): void {
DB::table('client_integrations')->insertOrIgnore([
'client_id' => $individualClientId,
'integration_code' => $integration->integration_code,
'integration_data' => $integration->integration_data,
'created_at' => $integration->created_at,
'updated_at' => $integration->updated_at,
]);
});
DB::table('tenants')->where('id', $tenant->id)->update([
'client_id' => $individualClientId,
]);
}
}
};

View File

@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantService; use App\Domains\Tenant\Services\TenantService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -20,6 +21,8 @@ use RuntimeException;
class DesfilePuraTendenciaSeeder extends Seeder class DesfilePuraTendenciaSeeder extends Seeder
{ {
private const ONTICKET_CLIENT_CODE = 'onticket';
private const TENANT_CODE = 'desfile_pura_tendencia'; private const TENANT_CODE = 'desfile_pura_tendencia';
public function __construct( public function __construct(
@@ -29,20 +32,30 @@ class DesfilePuraTendenciaSeeder extends Seeder
public function run(): void public function run(): void
{ {
if (Tenant::query()->where('codigo', self::TENANT_CODE)->exists()) { $client = Client::query()->firstOrCreate(
['code' => self::ONTICKET_CLIENT_CODE],
['name' => 'OnTicket'],
);
$tenant = Tenant::query()->where('codigo', self::TENANT_CODE)->first();
if ($tenant) {
$tenant->update(['client_id' => $client->id]);
return; return;
} }
DB::transaction(function (): void { DB::transaction(function () use ($client): void {
$this->createTenant(); $this->createTenant($client);
$this->createEventDate(); $this->createEventDate();
$this->createEntryCatalog(); $this->createEntryCatalog();
}); });
} }
private function createTenant(): void private function createTenant(Client $client): void
{ {
$this->tenantService->create([ $this->tenantService->create([
'client_id' => $client->id,
'codigo' => self::TENANT_CODE, 'codigo' => self::TENANT_CODE,
'nombre' => 'Desfile Pura Tendencia', 'nombre' => 'Desfile Pura Tendencia',
'dominio' => 'desfile-pura-tendencia.localhost', 'dominio' => 'desfile-pura-tendencia.localhost',

View File

@@ -17,7 +17,7 @@ class EmailIntegrationSeeder extends Seeder
[ [
'name' => 'Email', 'name' => 'Email',
'url' => null, 'url' => null,
'requires_tenant_configuration' => false, 'requires_client_configuration' => false,
'integration_data_schema' => [ 'integration_data_schema' => [
'MAIL_MAILER' => 'required|string|in:smtp', 'MAIL_MAILER' => 'required|string|in:smtp',
'MAIL_SCHEME' => 'required|string|in:smtp', 'MAIL_SCHEME' => 'required|string|in:smtp',

View File

@@ -17,11 +17,11 @@ class TelepagosIntegrationSeeder extends Seeder
[ [
'name' => 'Telepagos', 'name' => 'Telepagos',
'url' => 'https://api.telepagos.com.ar', 'url' => 'https://api.telepagos.com.ar',
'requires_tenant_configuration' => true, 'requires_client_configuration' => true,
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
] ]
); );
@@ -30,11 +30,11 @@ class TelepagosIntegrationSeeder extends Seeder
[ [
'name' => 'Telepagos Homologación', 'name' => 'Telepagos Homologación',
'url' => 'https://api.homo.telepagos.com.ar', 'url' => 'https://api.homo.telepagos.com.ar',
'requires_tenant_configuration' => true, 'requires_client_configuration' => true,
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
] ]
); );
} }

View File

@@ -4,6 +4,7 @@ namespace Database\Seeders;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService; use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantService; use App\Domains\Tenant\Services\TenantService;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -13,6 +14,8 @@ use Throwable;
class TenantSeeder extends Seeder class TenantSeeder extends Seeder
{ {
private const ONTICKET_CLIENT_CODE = 'onticket';
private const SOCIAL_MEDIA = [ private const SOCIAL_MEDIA = [
[ [
'code' => 'instagram', 'code' => 'instagram',
@@ -40,6 +43,11 @@ class TenantSeeder extends Seeder
public function run(): void public function run(): void
{ {
$onTicketClient = Client::query()->firstOrCreate(
['code' => self::ONTICKET_CLIENT_CODE],
['name' => 'OnTicket'],
);
$this->deleteTenant('sonder'); $this->deleteTenant('sonder');
Tenant::query() Tenant::query()
@@ -101,6 +109,7 @@ class TenantSeeder extends Seeder
->delete(); ->delete();
$this->tenantService->create([ $this->tenantService->create([
'client_id' => $onTicketClient->id,
'codigo' => 'fiesta_futbol_infantil', 'codigo' => 'fiesta_futbol_infantil',
'nombre' => 'Fiesta Fútbol Infantil', 'nombre' => 'Fiesta Fútbol Infantil',
'dominio' => $fiestaDomain, 'dominio' => $fiestaDomain,

View File

@@ -73,7 +73,7 @@ return [
'scanner_category_forbidden' => 'The scanner is not assigned to the ticket category.', 'scanner_category_forbidden' => 'The scanner is not assigned to the ticket category.',
], ],
'integration' => [ 'integration' => [
'not_configured' => 'The integration is not configured for this tenant.', 'not_configured' => 'The integration is not configured for this client.',
'configured' => 'Integration configured successfully.', 'configured' => 'Integration configured successfully.',
'validation_failed' => 'Configuration validation failed: :error', 'validation_failed' => 'Configuration validation failed: :error',
'invalid_payment_method' => 'Invalid payment method.', 'invalid_payment_method' => 'Invalid payment method.',

View File

@@ -73,7 +73,7 @@ return [
'scanner_category_forbidden' => 'El scanner no está asignado a la categoría del ticket.', 'scanner_category_forbidden' => 'El scanner no está asignado a la categoría del ticket.',
], ],
'integration' => [ 'integration' => [
'not_configured' => 'La integración no está configurada para este tenant.', 'not_configured' => 'La integración no está configurada para este cliente.',
'configured' => 'Integración configurada correctamente.', 'configured' => 'Integración configurada correctamente.',
'validation_failed' => 'Error validando la configuración: :error', 'validation_failed' => 'Error validando la configuración: :error',
'invalid_payment_method' => 'Método de pago inválido.', 'invalid_payment_method' => 'Método de pago inválido.',

View File

@@ -9,6 +9,7 @@ require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Sale/routes/api.php'; require __DIR__.'/../app/Domains/Sale/routes/api.php';
require __DIR__.'/../app/Domains/Bootstrap/routes/api.php'; require __DIR__.'/../app/Domains/Bootstrap/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php'; require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/Client/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php'; require __DIR__.'/../app/Domains/Integration/routes/api.php';
require __DIR__.'/../app/Domains/Menu/routes/api.php'; require __DIR__.'/../app/Domains/Menu/routes/api.php';
require __DIR__.'/../app/Domains/Ticket/routes/api.php'; require __DIR__.'/../app/Domains/Ticket/routes/api.php';

View File

@@ -2,14 +2,15 @@
namespace Tests\Feature\Integration; namespace Tests\Feature\Integration;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration; use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\TenantIntegrationService;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery; use Mockery;
use Tests\TestCase; use Tests\TestCase;
class TenantIntegrationControllerTest extends TestCase class ClientIntegrationControllerTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
@@ -22,19 +23,20 @@ class TenantIntegrationControllerTest extends TestCase
'api_key' => 'required|string', 'api_key' => 'required|string',
], ],
]); ]);
$client = Client::query()->create(['code' => 'test-client', 'name' => 'Test Client']);
$this->mock(TenantIntegrationService::class, function ($mock) use ($integration) { $this->mock(ClientIntegrationService::class, function ($mock) use ($client, $integration) {
$mock->shouldReceive('updateOrCreateIntegration') $mock->shouldReceive('updateOrCreateIntegration')
->once() ->once()
->with( ->with(
'test-tenant', Mockery::on(fn (Client $argument) => $argument->is($client)),
Mockery::on(fn (Integration $argument) => $argument->is($integration)), Mockery::on(fn (Integration $argument) => $argument->is($integration)),
['api_key' => 'secret'] ['api_key' => 'secret']
) )
->andReturn(new TenantIntegration); ->andReturn(new ClientIntegration);
}); });
$this->postJson('/api/test-tenant/integrations/test_integration', [ $this->putJson('/api/clients/test-client/integrations/test_integration', [
'integration_data' => [ 'integration_data' => [
'api_key' => 'secret', 'api_key' => 'secret',
], ],

View File

@@ -2,15 +2,20 @@
namespace Tests\Feature\Integration; namespace Tests\Feature\Integration;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use App\Domains\Integration\Services\TelepagosIntegrationService; use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
use Exception; use Exception;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Tests\TestCase;
class IntegrationServiceTest extends TestCase class IntegrationServiceTest extends TestCase
{ {
@@ -23,26 +28,26 @@ class IntegrationServiceTest extends TestCase
parent::setUp(); parent::setUp();
// Set the integrations secret for tests // Set the integrations secret for tests
config(['services.integrations.secret' => 'base64:' . base64_encode(random_bytes(32))]); config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]);
// Clear cache to prevent test pollution // Clear cache to prevent test pollution
Cache::flush(); Cache::flush();
$hdrKey = (string) \Illuminate\Support\Str::uuid(); $hdrKey = (string) Str::uuid();
$ftrKey = (string) \Illuminate\Support\Str::uuid(); $ftrKey = (string) Str::uuid();
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $headerAttachment = Attachment::create([
'key' => $hdrKey, 'key' => $hdrKey,
'path' => 'tenants/' . $hdrKey . '.png', 'path' => 'tenants/'.$hdrKey.'.png',
'filename' => 'logo_header.png', 'filename' => 'logo_header.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([ $footerAttachment = Attachment::create([
'key' => $ftrKey, 'key' => $ftrKey,
'path' => 'tenants/' . $ftrKey . '.png', 'path' => 'tenants/'.$ftrKey.'.png',
'filename' => 'logo_footer.png', 'filename' => 'logo_footer.png',
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image, 'type' => AttachmentType::Image,
'mime_type' => 'image/png', 'mime_type' => 'image/png',
]); ]);
@@ -72,7 +77,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo); $service->forTenant($this->tenant->codigo);
} }
public function test_it_throws_exception_if_tenant_integration_is_not_configured(): void public function test_it_throws_exception_if_client_integration_is_not_configured(): void
{ {
// Seed integration but don't configure for tenant // Seed integration but don't configure for tenant
Integration::create([ Integration::create([
@@ -82,13 +87,13 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage("Tenant 'test-tenant' does not have integration 'telepagos_homo' configured."); $this->expectExceptionMessage("Client 'test-tenant' does not have integration 'telepagos_homo' configured.");
$service->forTenant($this->tenant->codigo); $service->forTenant($this->tenant->codigo);
} }
@@ -102,16 +107,16 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'user123', 'username' => 'user123',
'password' => 'pass123', 'password' => 'pass123',
] ],
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
@@ -131,16 +136,16 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
// Mock HTTP response sequence for authentication // Mock HTTP response sequence for authentication
@@ -149,13 +154,13 @@ class IntegrationServiceTest extends TestCase
->push([ ->push([
'status' => 'ok', 'status' => 'ok',
'token' => 'mock-jwt-token-123', 'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200) ], 200)
->push([ ->push([
'status' => 'ok', 'status' => 'ok',
'token' => 'new-mock-jwt-token', 'token' => 'new-mock-jwt-token',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200) ], 200),
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
@@ -181,6 +186,50 @@ class IntegrationServiceTest extends TestCase
Http::assertSentCount(2); Http::assertSentCount(2);
} }
public function test_tenants_from_the_same_client_share_configuration_and_cached_token(): void
{
Integration::create([
'integration_code' => 'telepagos_homo',
'name' => 'Telepagos',
'url' => 'https://api.telepagos.com.ar',
'integration_data_schema' => [
'username' => 'required|string',
'password' => 'required|string',
],
]);
ClientIntegration::create([
'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo',
'integration_data' => [
'username' => 'shared-user',
'password' => 'shared-password',
],
]);
$secondTenant = $this->tenant->replicate()->fill([
'codigo' => 'second-tenant',
'nombre' => 'Second Tenant',
'dominio' => 'second.test.com',
]);
$secondTenant->save();
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'shared-token',
'expires_at' => now()->addHour()->toDateTimeString(),
]),
]);
$firstService = (new TelepagosIntegrationService('telepagos'))->forTenant($this->tenant->codigo);
$secondService = (new TelepagosIntegrationService('telepagos'))->forTenant($secondTenant->codigo);
$this->assertSame('shared-token', $firstService->getToken());
$this->assertSame('shared-token', $secondService->getToken());
Http::assertSentCount(1);
}
public function test_it_throws_exception_if_credentials_are_missing(): void public function test_it_throws_exception_if_credentials_are_missing(): void
{ {
Integration::create([ Integration::create([
@@ -190,23 +239,23 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => '', 'username' => '',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
$service->forTenant($this->tenant->codigo); $service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage("Missing username or password in Telepagos integration settings."); $this->expectExceptionMessage('Missing username or password in Telepagos integration settings.');
$service->getToken(); $service->getToken();
} }
@@ -220,26 +269,26 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
Http::fake([ Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'error', 'status' => 'error',
'message' => 'Invalid credentials' 'message' => 'Invalid credentials',
], 401) ], 401),
]); ]);
\Illuminate\Support\Facades\Log::shouldReceive('error') Log::shouldReceive('error')
->once() ->once()
->with('Telepagos authentication failed: Invalid credentials', \Mockery::on(function ($context) { ->with('Telepagos authentication failed: Invalid credentials', \Mockery::on(function ($context) {
return $context['username'] === 'tele_user' return $context['username'] === 'tele_user'
@@ -251,7 +300,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo); $service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage("Telepagos authentication failed: Invalid credentials"); $this->expectExceptionMessage('Telepagos authentication failed: Invalid credentials');
$service->getToken(); $service->getToken();
} }
@@ -265,28 +314,28 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
Http::fake([ Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok', 'status' => 'ok',
'token' => 'mock-jwt-token-123', 'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200), ], 200),
'https://api.telepagos.com.ar/v1/payments' => Http::response([ 'https://api.telepagos.com.ar/v1/payments' => Http::response([
'status' => 'success', 'status' => 'success',
'payment_id' => 999 'payment_id' => 999,
], 200) ], 200),
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
@@ -294,7 +343,7 @@ class IntegrationServiceTest extends TestCase
// Get configured client and perform GET request // Get configured client and perform GET request
$client = $service->client(); $client = $service->client();
$this->assertInstanceOf(\Illuminate\Http\Client\PendingRequest::class, $client); $this->assertInstanceOf(PendingRequest::class, $client);
$response = $client->get('/v1/payments'); $response = $client->get('/v1/payments');
@@ -317,29 +366,29 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
Http::fake([ Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok', 'status' => 'ok',
'token' => 'mock-jwt-token-123', 'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200), ], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([ 'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([
'status' => 'ok', 'status' => 'ok',
'qr_code' => 'mock-qr-code-data', 'qr_code' => 'mock-qr-code-data',
'qr_order_id' => 6353 'qr_order_id' => 6353,
], 200) ], 200),
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
@@ -369,31 +418,31 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
Http::fake([ Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok', 'status' => 'ok',
'token' => 'mock-jwt-token-123', 'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200), ], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([ 'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([
'status' => 'error', 'status' => 'error',
'message' => 'Importe inválido' 'message' => 'Importe inválido',
], 422) ], 422),
]); ]);
\Illuminate\Support\Facades\Log::shouldReceive('error') Log::shouldReceive('error')
->once() ->once()
->with('Telepagos QR generation failed: Importe inválido', \Mockery::on(function ($context) { ->with('Telepagos QR generation failed: Importe inválido', \Mockery::on(function ($context) {
return $context['amount'] === 1200.00 return $context['amount'] === 1200.00
@@ -407,7 +456,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo); $service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage("Telepagos QR generation failed: Importe inválido"); $this->expectExceptionMessage('Telepagos QR generation failed: Importe inválido');
$service->generateQr(1200.00, 'Test Concept', 'Test Description'); $service->generateQr(1200.00, 'Test Concept', 'Test Description');
} }
@@ -421,29 +470,29 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
Http::fake([ Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok', 'status' => 'ok',
'token' => 'mock-jwt-token-123', 'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200), ], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([ 'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
'status' => 'ok', 'status' => 'ok',
'buyer' => [ 'buyer' => [
'cuit' => '20416561398', 'cuit' => '20416561398',
'cvu' => '0000124900000000011974' 'cvu' => '0000124900000000011974',
], ],
'amount' => 1200, 'amount' => 1200,
'concept' => 'VAR', 'concept' => 'VAR',
@@ -452,8 +501,8 @@ class IntegrationServiceTest extends TestCase
'description' => 'Pago prueba', 'description' => 'Pago prueba',
'transaction_id' => '2026070364', 'transaction_id' => '2026070364',
'qr_order_id' => 6351, 'qr_order_id' => 6351,
'link_id' => null 'link_id' => null,
], 200) ], 200),
]); ]);
$service = new TelepagosIntegrationService('telepagos'); $service = new TelepagosIntegrationService('telepagos');
@@ -482,31 +531,31 @@ class IntegrationServiceTest extends TestCase
'integration_data_schema' => [ 'integration_data_schema' => [
'username' => 'required|string', 'username' => 'required|string',
'password' => 'required|string', 'password' => 'required|string',
] ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $this->tenant->codigo, 'client_id' => $this->tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'tele_user', 'username' => 'tele_user',
'password' => 'tele_pass', 'password' => 'tele_pass',
] ],
]); ]);
Http::fake([ Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([ 'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok', 'status' => 'ok',
'token' => 'mock-jwt-token-123', 'token' => 'mock-jwt-token-123',
'expires_at' => now()->addHour()->toDateTimeString() 'expires_at' => now()->addHour()->toDateTimeString(),
], 200), ], 200),
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([ 'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
'status' => 'error', 'status' => 'error',
'message' => 'Cashin no encontrado' 'message' => 'Cashin no encontrado',
], 404) ], 404),
]); ]);
\Illuminate\Support\Facades\Log::shouldReceive('error') Log::shouldReceive('error')
->once() ->once()
->with('Telepagos get cash-in details failed: Cashin no encontrado', \Mockery::on(function ($context) { ->with('Telepagos get cash-in details failed: Cashin no encontrado', \Mockery::on(function ($context) {
return $context['cashin_id'] === 6351 return $context['cashin_id'] === 6351
@@ -518,7 +567,7 @@ class IntegrationServiceTest extends TestCase
$service->forTenant($this->tenant->codigo); $service->forTenant($this->tenant->codigo);
$this->expectException(Exception::class); $this->expectException(Exception::class);
$this->expectExceptionMessage("Telepagos get cash-in details failed: Cashin no encontrado"); $this->expectExceptionMessage('Telepagos get cash-in details failed: Cashin no encontrado');
$service->getCashinDetails(6351); $service->getCashinDetails(6351);
} }

View File

@@ -4,10 +4,10 @@ namespace Tests\Feature\Integration;
use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration; use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\MailService; use App\Domains\Integration\Services\MailService;
use App\Domains\Integration\Services\TenantIntegrationService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
@@ -21,12 +21,12 @@ class MailServiceTest extends TestCase
{ {
use RefreshDatabase; use RefreshDatabase;
public function test_it_builds_an_isolated_smtp_mailer_from_the_tenant_integration(): void public function test_it_builds_an_isolated_smtp_mailer_from_the_client_integration(): void
{ {
$tenant = $this->createTenant(); $tenant = $this->createTenant();
$this->createEmailIntegration(); $this->createEmailIntegration();
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $tenant->codigo, 'client_id' => $tenant->client_id,
'integration_code' => 'email', 'integration_code' => 'email',
'integration_data' => $this->emailData(), 'integration_data' => $this->emailData(),
]); ]);
@@ -40,7 +40,7 @@ class MailServiceTest extends TestCase
$manager->shouldReceive('build') $manager->shouldReceive('build')
->once() ->once()
->with(Mockery::on(fn (array $config): bool => $config === [ ->with(Mockery::on(fn (array $config): bool => $config === [
'name' => 'tenant-smtp-acme', 'name' => 'client-smtp-'.$tenant->client_id,
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => 'smtp', 'scheme' => 'smtp',
'host' => 'smtp.example.com', 'host' => 'smtp.example.com',
@@ -54,10 +54,10 @@ class MailServiceTest extends TestCase
$service = (new MailService($manager))->forTenant($tenant->codigo); $service = (new MailService($manager))->forTenant($tenant->codigo);
$this->assertSame('tenant-smtp', $service->mailerName()); $this->assertSame('client-smtp', $service->mailerName());
} }
public function test_it_uses_the_default_mailer_when_tenant_configuration_is_not_required(): void public function test_it_uses_the_default_mailer_when_client_configuration_is_not_required(): void
{ {
Mail::fake(); Mail::fake();
config(['mail.default' => 'array']); config(['mail.default' => 'array']);
@@ -65,7 +65,7 @@ class MailServiceTest extends TestCase
Integration::create([ Integration::create([
'integration_code' => 'email', 'integration_code' => 'email',
'name' => 'Email', 'name' => 'Email',
'requires_tenant_configuration' => false, 'requires_client_configuration' => false,
]); ]);
$service = (new MailService)->forTenant($tenant->codigo); $service = (new MailService)->forTenant($tenant->codigo);
@@ -84,8 +84,8 @@ class MailServiceTest extends TestCase
Mail::fake(); Mail::fake();
$tenant = $this->createTenant(); $tenant = $this->createTenant();
$this->createEmailIntegration(); $this->createEmailIntegration();
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $tenant->codigo, 'client_id' => $tenant->client_id,
'integration_code' => 'email', 'integration_code' => 'email',
'integration_data' => $this->emailData(), 'integration_data' => $this->emailData(),
]); ]);
@@ -109,8 +109,8 @@ class MailServiceTest extends TestCase
$tenant = $this->createTenant(); $tenant = $this->createTenant();
$integration = $this->createEmailIntegration(); $integration = $this->createEmailIntegration();
app(TenantIntegrationService::class)->updateOrCreateIntegration( app(ClientIntegrationService::class)->updateOrCreateIntegration(
$tenant->codigo, $tenant->client,
$integration, $integration,
$this->emailData(), $this->emailData(),
); );

View File

@@ -11,8 +11,8 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory; use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
@@ -357,8 +357,8 @@ class TelepagosWebhookTest extends TestCase
], ],
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $tenant->codigo, 'client_id' => $tenant->client_id,
'integration_code' => 'telepagos_homo', 'integration_code' => 'telepagos_homo',
'integration_data' => [ 'integration_data' => [
'username' => 'user123', 'username' => 'user123',

View File

@@ -4,8 +4,8 @@ namespace Tests\Feature\MailTest;
use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use App\Domains\MailTest\Mailables\TestMail; use App\Domains\MailTest\Mailables\TestMail;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -171,8 +171,8 @@ class MailTestControllerTest extends TestCase
'integration_code' => 'email', 'integration_code' => 'email',
'name' => 'Email', 'name' => 'Email',
]); ]);
TenantIntegration::create([ ClientIntegration::create([
'tenant_code' => $tenant->codigo, 'client_id' => $tenant->client_id,
'integration_code' => 'email', 'integration_code' => 'email',
'integration_data' => [ 'integration_data' => [
'MAIL_SCHEME' => 'smtp', 'MAIL_SCHEME' => 'smtp',

View File

@@ -35,7 +35,7 @@ class NotificationMailServiceTest extends TestCase
'integration_code' => 'email', 'integration_code' => 'email',
'name' => 'Email', 'name' => 'Email',
'url' => null, 'url' => null,
'requires_tenant_configuration' => false, 'requires_client_configuration' => false,
'integration_data_schema' => [], 'integration_data_schema' => [],
]); ]);
$header = Attachment::query()->create([ $header = Attachment::query()->create([