refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
66
app/Shared/Integration/Casts/EncryptedIntegrationData.php
Normal file
66
app/Shared/Integration/Casts/EncryptedIntegrationData.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Casts;
|
||||
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Exception;
|
||||
|
||||
class EncryptedIntegrationData implements CastsAttributes
|
||||
{
|
||||
protected function getEncrypter(): Encrypter
|
||||
{
|
||||
$secret = config('services.integrations.secret');
|
||||
|
||||
if (empty($secret)) {
|
||||
throw new Exception('The integrations secret is not configured.');
|
||||
}
|
||||
|
||||
// Laravel encrypter requires a key of exact length. Typically 32 bytes for AES-256-CBC.
|
||||
// If the secret is base64 encoded like the APP_KEY:
|
||||
if (str_starts_with($secret, 'base64:')) {
|
||||
$key = base64_decode(substr($secret, 7));
|
||||
} else {
|
||||
// Otherwise, we hash it to ensure 32 bytes for AES-256-CBC.
|
||||
$key = hash('sha256', $secret, true);
|
||||
}
|
||||
|
||||
return new Encrypter($key, config('app.cipher', 'AES-256-CBC'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the given value.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$decrypted = $this->getEncrypter()->decryptString($value);
|
||||
return json_decode($decrypted, true);
|
||||
} catch (Exception $e) {
|
||||
// Return null or throw depending on how strict we want to be.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the given value for storage.
|
||||
*
|
||||
* @param array<string, mixed> $attributes
|
||||
*/
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value);
|
||||
return $this->getEncrypter()->encryptString($json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||
use App\Domains\Integration\Resources\IntegrationAssociationResource;
|
||||
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||
use App\Domains\Tenant\Models\AdminWebsiteType;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class AdminWebsiteTypeIntegrationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly IntegrationAssociationService $service) {}
|
||||
|
||||
public function index(AdminWebsiteType $adminWebsiteType)
|
||||
{
|
||||
return IntegrationAssociationResource::collection($adminWebsiteType->integrations()->with('integrationInstance')->get());
|
||||
}
|
||||
|
||||
public function show(AdminWebsiteType $adminWebsiteType, string $integrationCode)
|
||||
{
|
||||
return new IntegrationAssociationResource($adminWebsiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
|
||||
}
|
||||
|
||||
public function store(ConfigureIntegrationRequest $request, AdminWebsiteType $adminWebsiteType, string $integrationCode)
|
||||
{
|
||||
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
|
||||
|
||||
return new IntegrationAssociationResource($this->service->configure(
|
||||
$adminWebsiteType,
|
||||
$integration,
|
||||
$request->validated('integration_data'),
|
||||
));
|
||||
}
|
||||
|
||||
public function destroy(AdminWebsiteType $adminWebsiteType, string $integrationCode)
|
||||
{
|
||||
$this->service->detach($adminWebsiteType, $integrationCode);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
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(
|
||||
ConfigureIntegrationRequest $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);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
|
||||
{
|
||||
$service->detach($client, $integrationCode);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
43
app/Shared/Integration/Controllers/IntegrationController.php
Normal file
43
app/Shared/Integration/Controllers/IntegrationController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\StoreIntegrationRequest;
|
||||
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class IntegrationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return response()->json(Integration::all());
|
||||
}
|
||||
|
||||
public function store(StoreIntegrationRequest $request)
|
||||
{
|
||||
$integration = Integration::create($request->validated());
|
||||
|
||||
return response()->json($integration, 201);
|
||||
}
|
||||
|
||||
public function show(Integration $integration)
|
||||
{
|
||||
return response()->json($integration);
|
||||
}
|
||||
|
||||
public function update(UpdateIntegrationRequest $request, Integration $integration)
|
||||
{
|
||||
$integration->update($request->validated());
|
||||
|
||||
return response()->json($integration->fresh());
|
||||
}
|
||||
|
||||
public function destroy(Integration $integration)
|
||||
{
|
||||
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
|
||||
$integration->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
$service->handleWebhook($client, $cashinId);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'code' => 'integration.webhook_failed',
|
||||
'message' => __('api.integration.webhook_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use App\Domains\Tenant\Models\AdminWebsiteType;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AdminWebsiteTypeIntegration extends Model
|
||||
{
|
||||
protected $table = 'admin_website_type_integrations';
|
||||
protected $fillable = ['admin_website_type_code', 'integration_code', 'integration_instance_id'];
|
||||
|
||||
/** @return BelongsTo<AdminWebsiteType, $this> */
|
||||
public function adminWebsiteType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AdminWebsiteType::class, 'admin_website_type_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Integration, $this> */
|
||||
public function integration(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||
public function integrationInstance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(IntegrationInstance::class);
|
||||
}
|
||||
}
|
||||
34
app/Shared/Integration/Models/ClientIntegration.php
Normal file
34
app/Shared/Integration/Models/ClientIntegration.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ClientIntegration extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'integration_code',
|
||||
'integration_instance_id',
|
||||
];
|
||||
|
||||
/** @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 BelongsTo<IntegrationInstance, $this> */
|
||||
public function integrationInstance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(IntegrationInstance::class);
|
||||
}
|
||||
}
|
||||
42
app/Shared/Integration/Models/Integration.php
Normal file
42
app/Shared/Integration/Models/Integration.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Integration extends Model
|
||||
{
|
||||
protected $table = 'integrations';
|
||||
|
||||
protected $fillable = [
|
||||
'integration_code',
|
||||
'name',
|
||||
'url',
|
||||
'integration_data_schema',
|
||||
'requires_configuration',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'integration_data_schema' => 'array',
|
||||
'requires_configuration' => 'boolean',
|
||||
];
|
||||
|
||||
/** @return HasMany<IntegrationInstance, $this> */
|
||||
public function instances(): HasMany
|
||||
{
|
||||
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
|
||||
/** @return HasMany<ClientIntegration, $this> */
|
||||
public function clientIntegrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
|
||||
/** @return HasMany<AdminWebsiteTypeIntegration, $this> */
|
||||
public function websiteTypeIntegrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(AdminWebsiteTypeIntegration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
}
|
||||
41
app/Shared/Integration/Models/IntegrationInstance.php
Normal file
41
app/Shared/Integration/Models/IntegrationInstance.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class IntegrationInstance extends Model
|
||||
{
|
||||
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
|
||||
public function tokenCacheKey(): string
|
||||
{
|
||||
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
|
||||
}
|
||||
|
||||
protected $fillable = ['integration_code', 'name', 'integration_data'];
|
||||
|
||||
protected $hidden = ['integration_data'];
|
||||
|
||||
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
|
||||
|
||||
/** @return BelongsTo<Integration, $this> */
|
||||
public function integration(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||
}
|
||||
|
||||
/** @return HasMany<ClientIntegration, $this> */
|
||||
public function clientIntegrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(ClientIntegration::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<AdminWebsiteTypeIntegration, $this> */
|
||||
public function websiteTypeIntegrations(): HasMany
|
||||
{
|
||||
return $this->hasMany(AdminWebsiteTypeIntegration::class);
|
||||
}
|
||||
}
|
||||
14
app/Shared/Integration/Policies/IntegrationPolicy.php
Normal file
14
app/Shared/Integration/Policies/IntegrationPolicy.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Policies;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
|
||||
class IntegrationPolicy
|
||||
{
|
||||
public function manage(User $user): bool
|
||||
{
|
||||
return $user->rol_codigo === RoleCode::Admin->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ConfigureIntegrationRequest extends FormRequest
|
||||
{
|
||||
protected ?Integration $integrationModel = null;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('manage', Integration::class) ?? false;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->integrationModel = Integration::query()
|
||||
->where('integration_code', $this->route('integration_code'))
|
||||
->first();
|
||||
|
||||
if (! $this->integrationModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'integration_code' => __('api.integration.not_configured'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = ['integration_data' => ['present', 'array']];
|
||||
|
||||
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
24
app/Shared/Integration/Requests/StoreIntegrationRequest.php
Normal file
24
app/Shared/Integration/Requests/StoreIntegrationRequest.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_configuration' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
app/Shared/Integration/Requests/TelepagosWebhookRequest.php
Normal file
28
app/Shared/Integration/Requests/TelepagosWebhookRequest.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TelepagosWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Shared/Integration/Requests/UpdateIntegrationRequest.php
Normal file
27
app/Shared/Integration/Requests/UpdateIntegrationRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$integration = $this->route('integration');
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_configuration' => ['sometimes', 'boolean'],
|
||||
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class IntegrationAssociationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'client_id' => $this->when(isset($this->client_id), $this->client_id),
|
||||
'admin_website_type_code' => $this->when(isset($this->admin_website_type_code), $this->admin_website_type_code),
|
||||
'integration_code' => $this->integration_code,
|
||||
'integration_instance_id' => $this->integration_instance_id,
|
||||
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class IntegrationInstanceResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'integration_code' => $this->integration_code,
|
||||
'name' => $this->name,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
180
app/Shared/Integration/Services/BaseIntegrationService.php
Normal file
180
app/Shared/Integration/Services/BaseIntegrationService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Models\ClientIntegration;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\IntegrationInstance;
|
||||
use App\Domains\Integration\Models\AdminWebsiteTypeIntegration;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
abstract class BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* The unique code of the integration.
|
||||
*/
|
||||
protected string $integrationCode;
|
||||
|
||||
/**
|
||||
* The current tenant code.
|
||||
*/
|
||||
protected string $tenantCode;
|
||||
|
||||
protected ?Tenant $tenant = null;
|
||||
|
||||
protected ?Client $clientContext = null;
|
||||
|
||||
/**
|
||||
* The integration model instance.
|
||||
*/
|
||||
protected ?Integration $integration = null;
|
||||
|
||||
/**
|
||||
* The effective integration instance.
|
||||
*/
|
||||
protected ?IntegrationInstance $integrationInstance = null;
|
||||
|
||||
/**
|
||||
* Set the integration code.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setIntegrationCode(string $integrationCode): self
|
||||
{
|
||||
$this->integrationCode = $integrationCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the integration code.
|
||||
*/
|
||||
public function getIntegrationCode(): string
|
||||
{
|
||||
return $this->integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tenant code and load the integration models.
|
||||
*
|
||||
* @return $this
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function forTenant(string $tenantCode): self
|
||||
{
|
||||
$this->tenantCode = $tenantCode;
|
||||
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
|
||||
$this->clientContext = $this->tenant->client;
|
||||
$this->loadIntegration();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function forClient(Client|string $client): self
|
||||
{
|
||||
$this->clientContext = $client instanceof Client
|
||||
? $client
|
||||
: Client::query()->where('code', $client)->firstOrFail();
|
||||
$this->tenant = null;
|
||||
$this->loadIntegration();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the integration definition and its effective instance configuration.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function loadIntegration(): void
|
||||
{
|
||||
if (empty($this->integrationCode)) {
|
||||
throw new Exception('Integration code is not set.');
|
||||
}
|
||||
|
||||
$this->integrationInstance = null;
|
||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||
if (! $this->integration) {
|
||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||
}
|
||||
|
||||
if (! $this->clientContext) {
|
||||
throw new Exception('Client context is not set.');
|
||||
}
|
||||
|
||||
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
|
||||
->where('integration_code', $this->integrationCode)
|
||||
->first()?->integrationInstance;
|
||||
|
||||
if (! $this->integrationInstance && $this->tenant?->admin_website_type_code) {
|
||||
$this->integrationInstance = AdminWebsiteTypeIntegration::with('integrationInstance')
|
||||
->where('admin_website_type_code', $this->tenant->admin_website_type_code)
|
||||
->where('integration_code', $this->integrationCode)
|
||||
->first()?->integrationInstance;
|
||||
}
|
||||
|
||||
if (! $this->integrationInstance && $this->integration->requires_configuration) {
|
||||
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request URL.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
if (! $this->integration) {
|
||||
throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->integration->url, '/');
|
||||
$path = ltrim($path, '/');
|
||||
|
||||
return $path !== '' ? "{$baseUrl}/{$path}" : $baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an integration setting from the effective instance configuration.
|
||||
*/
|
||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->integrationInstance->integration_data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-configured HTTP client builder.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function client(): PendingRequest
|
||||
{
|
||||
return Http::baseUrl($this->getUrl())
|
||||
->withHeaders($this->getHeaders());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for the integration.
|
||||
*/
|
||||
abstract public function getHeaders(): array;
|
||||
|
||||
/**
|
||||
* Hook called after the integration is configured for the client.
|
||||
* Can be used to validate credentials or perform initial setups.
|
||||
* Throw an Exception on failure.
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
// Override in child classes if needed
|
||||
}
|
||||
}
|
||||
50
app/Shared/Integration/Services/ClientIntegrationService.php
Normal file
50
app/Shared/Integration/Services/ClientIntegrationService.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?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()
|
||||
->with(['integration', 'integrationInstance'])
|
||||
->where('integration_code', $integrationCode)
|
||||
->first();
|
||||
}
|
||||
|
||||
/** @return Collection<int, ClientIntegration> */
|
||||
public function getAllForClient(Client $client): Collection
|
||||
{
|
||||
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
|
||||
}
|
||||
|
||||
public function updateOrCreateIntegration(
|
||||
Client $client,
|
||||
Integration $integration,
|
||||
array $data,
|
||||
): ClientIntegration {
|
||||
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
||||
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Models\ClientIntegration;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\IntegrationInstance;
|
||||
use App\Domains\Integration\Models\AdminWebsiteTypeIntegration;
|
||||
use App\Domains\Tenant\Models\AdminWebsiteType;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IntegrationAssociationService
|
||||
{
|
||||
public function configure(Client|AdminWebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|AdminWebsiteTypeIntegration
|
||||
{
|
||||
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|AdminWebsiteTypeIntegration {
|
||||
$instance = IntegrationInstance::create([
|
||||
'integration_code' => $integration->integration_code,
|
||||
'name' => $integration->name.' / '.$this->ownerName($owner),
|
||||
'integration_data' => $integrationData,
|
||||
]);
|
||||
|
||||
return $this->associate($owner, $integration->integration_code, $instance);
|
||||
});
|
||||
}
|
||||
|
||||
public function associate(Client|AdminWebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|AdminWebsiteTypeIntegration
|
||||
{
|
||||
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|AdminWebsiteTypeIntegration {
|
||||
$association = $owner->integrations()
|
||||
->where('integration_code', $code)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
$previousInstanceId = $association?->integration_instance_id;
|
||||
|
||||
$instanceIds = array_values(array_unique(array_filter([
|
||||
$previousInstanceId,
|
||||
$instance->id,
|
||||
])));
|
||||
sort($instanceIds);
|
||||
|
||||
$instances = IntegrationInstance::query()
|
||||
->whereKey($instanceIds)
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
|
||||
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
|
||||
|
||||
$association = $owner->integrations()->updateOrCreate(
|
||||
['integration_code' => $code],
|
||||
['integration_instance_id' => $instance->id],
|
||||
);
|
||||
|
||||
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
|
||||
$this->deleteIfUnused($previousInstanceId);
|
||||
}
|
||||
|
||||
return $association->load('integrationInstance');
|
||||
});
|
||||
}
|
||||
|
||||
public function detach(Client|AdminWebsiteType $owner, string $code): void
|
||||
{
|
||||
DB::transaction(function () use ($owner, $code): void {
|
||||
$association = $owner->integrations()
|
||||
->where('integration_code', $code)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $association) {
|
||||
return;
|
||||
}
|
||||
|
||||
$instanceId = $association->integration_instance_id;
|
||||
$association->delete();
|
||||
$this->deleteIfUnused($instanceId);
|
||||
});
|
||||
}
|
||||
|
||||
private function deleteIfUnused(int $instanceId): void
|
||||
{
|
||||
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
|
||||
|
||||
if ($instance
|
||||
&& ! $instance->clientIntegrations()->exists()
|
||||
&& ! $instance->websiteTypeIntegrations()->exists()) {
|
||||
$instance->delete();
|
||||
}
|
||||
}
|
||||
|
||||
private function ownerName(Client|AdminWebsiteType $owner): string
|
||||
{
|
||||
return $owner instanceof Client ? $owner->name : $owner->nombre;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Integration\Models\IntegrationInstance;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class IntegrationInstanceService
|
||||
{
|
||||
public function create(array $data): IntegrationInstance
|
||||
{
|
||||
return IntegrationInstance::create($data);
|
||||
}
|
||||
|
||||
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
|
||||
{
|
||||
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
|
||||
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||
$cacheKey = $instance->tokenCacheKey();
|
||||
$instance->update($data);
|
||||
if (array_key_exists('integration_data', $data)) {
|
||||
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||
}
|
||||
|
||||
return $instance;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(IntegrationInstance $instance): void
|
||||
{
|
||||
DB::transaction(function () use ($instance): void {
|
||||
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
|
||||
$cacheKey = $instance->tokenCacheKey();
|
||||
$instance->delete();
|
||||
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||
});
|
||||
}
|
||||
}
|
||||
226
app/Shared/Integration/Services/MailService.php
Normal file
226
app/Shared/Integration/Services/MailService.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\AdminWebsiteType;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\MailManager;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class MailService extends BaseIntegrationService
|
||||
{
|
||||
private const REQUIRED_SMTP_FIELDS = [
|
||||
'MAIL_HOST',
|
||||
'MAIL_PORT',
|
||||
'MAIL_USERNAME',
|
||||
'MAIL_PASSWORD',
|
||||
'MAIL_FROM_ADDRESS',
|
||||
];
|
||||
|
||||
protected string $integrationCode = 'email';
|
||||
|
||||
private readonly MailFactory $mailFactory;
|
||||
|
||||
private ?Mailer $mailer = null;
|
||||
|
||||
private bool $usesInstanceMailer = false;
|
||||
|
||||
public function __construct(?MailFactory $mailFactory = null)
|
||||
{
|
||||
$this->mailFactory = $mailFactory ?? app(MailFactory::class);
|
||||
}
|
||||
|
||||
public function forTenant(string $tenantCode): self
|
||||
{
|
||||
parent::forTenant($tenantCode);
|
||||
|
||||
if ($this->integrationInstance) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesInstanceMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesInstanceMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function forClient(Client|string $client): self
|
||||
{
|
||||
parent::forClient($client);
|
||||
$this->tenant = $this->clientContext?->tenants()->first();
|
||||
|
||||
if ($this->integrationInstance) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesInstanceMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesInstanceMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{data: string, name: string, mime: string}> $attachments
|
||||
*/
|
||||
public function send(
|
||||
string|array $recipient,
|
||||
string $subject,
|
||||
string $content,
|
||||
Tenant|AdminWebsiteType|null $brand = null,
|
||||
array $attachments = [],
|
||||
): void {
|
||||
if (! $this->mailer || ! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$brand ??= $this->tenant;
|
||||
$branding = $this->brandingFor($brand);
|
||||
|
||||
$html = Blade::render(
|
||||
<<<'BLADE'
|
||||
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
{!! $content !!}
|
||||
</x-mail.branded-layout>
|
||||
BLADE,
|
||||
[
|
||||
'branding' => $branding,
|
||||
'headerLogoUrl' => $brand instanceof AdminWebsiteType
|
||||
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||
'content' => $content,
|
||||
],
|
||||
);
|
||||
|
||||
$mail = (new Mailable)
|
||||
->subject($subject)
|
||||
->html($html);
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$mail->attachData(
|
||||
$attachment['data'],
|
||||
$attachment['name'],
|
||||
['mime' => $attachment['mime']],
|
||||
);
|
||||
}
|
||||
|
||||
$this->mailer->to($recipient)->send($mail);
|
||||
}
|
||||
|
||||
public function mailerName(): string
|
||||
{
|
||||
return $this->usesInstanceMailer
|
||||
? 'integration-smtp'
|
||||
: (string) config('mail.default');
|
||||
}
|
||||
|
||||
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
|
||||
private function brandingFor(Tenant|AdminWebsiteType $brand): array
|
||||
{
|
||||
if ($brand instanceof AdminWebsiteType) {
|
||||
$brand->loadMissing(['siteLogo', 'footerLogo']);
|
||||
|
||||
return [
|
||||
'name' => $brand->nombre,
|
||||
'primary_color' => $brand->primary_color ?? '#FF7006',
|
||||
'body_color' => $brand->body_color ?? '#666666',
|
||||
'background_color' => $brand->background_color ?? '#f8f8f8',
|
||||
'surface_color' => $brand->surface_color ?? '#ffffff',
|
||||
'header_bg_color' => $brand->surface_color ?? '#ffffff',
|
||||
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
|
||||
];
|
||||
}
|
||||
|
||||
$brand->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
return [
|
||||
'name' => $brand->nombre,
|
||||
'primary_color' => $brand->primary_color ?? '#6376f3',
|
||||
'body_color' => '#334155',
|
||||
'background_color' => '#f1f5f9',
|
||||
'surface_color' => '#ffffff',
|
||||
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
|
||||
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
|
||||
];
|
||||
}
|
||||
|
||||
public function onSetup(): void
|
||||
{
|
||||
if (! $this->mailer || ! $this->clientContext) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
|
||||
|
||||
if (! is_string($recipient) || $recipient === '') {
|
||||
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del cliente.');
|
||||
}
|
||||
|
||||
$subject = 'Configuración de correo validada';
|
||||
$content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
|
||||
.'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
|
||||
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
|
||||
|
||||
if ($this->tenant) {
|
||||
$this->send($recipient, $subject, $content);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mailer->to($recipient)->send(
|
||||
(new Mailable)->subject($subject)->html($content)
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveMailer(): Mailer
|
||||
{
|
||||
$data = $this->integrationInstance?->integration_data;
|
||||
|
||||
if (! is_array($data)) {
|
||||
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
|
||||
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
|
||||
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del cliente.");
|
||||
}
|
||||
}
|
||||
|
||||
// MailFake implements MailFactory but cannot build transports.
|
||||
if (! $this->mailFactory instanceof MailManager) {
|
||||
return $this->mailFactory->mailer();
|
||||
}
|
||||
|
||||
$mailer = $this->mailFactory->build([
|
||||
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
|
||||
'transport' => 'smtp',
|
||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||
'host' => $data['MAIL_HOST'],
|
||||
'port' => (int) $data['MAIL_PORT'],
|
||||
'username' => $data['MAIL_USERNAME'],
|
||||
'password' => $data['MAIL_PASSWORD'],
|
||||
'timeout' => isset($data['MAIL_TIMEOUT']) ? (int) $data['MAIL_TIMEOUT'] : null,
|
||||
'local_domain' => $data['MAIL_EHLO_DOMAIN'] ?? null,
|
||||
]);
|
||||
|
||||
$mailer->alwaysFrom(
|
||||
$data['MAIL_FROM_ADDRESS'],
|
||||
$data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
|
||||
);
|
||||
|
||||
return $mailer;
|
||||
}
|
||||
}
|
||||
241
app/Shared/Integration/Services/TelepagosIntegrationService.php
Normal file
241
app/Shared/Integration/Services/TelepagosIntegrationService.php
Normal file
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosIntegrationService extends BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* TelepagosIntegrationService constructor.
|
||||
*/
|
||||
public function __construct(string $integrationCode = 'telepagos')
|
||||
{
|
||||
// Force homologation code if not in production and using default
|
||||
if ($integrationCode === 'telepagos' && ! app()->environment('production')) {
|
||||
$integrationCode = 'telepagos_homo';
|
||||
}
|
||||
|
||||
$this->integrationCode = $integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for Telepagos integration.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$this->getToken(),
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid token, either from cache or by performing a login.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
if (! $this->integrationInstance) {
|
||||
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
||||
}
|
||||
|
||||
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||
|
||||
$token = Cache::get($cacheKey);
|
||||
|
||||
if ($token) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return $this->login();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with Telepagos and cache the returned token.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function login(): string
|
||||
{
|
||||
$username = $this->getIntegrationSetting('username');
|
||||
$password = $this->getIntegrationSetting('password');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
throw new Exception('Missing username or password in Telepagos integration settings.');
|
||||
}
|
||||
|
||||
$url = $this->getUrl('/v2/auth/token');
|
||||
|
||||
$response = Http::post($url, [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
]);
|
||||
|
||||
$data = $this->handleResponse($response, 'authentication');
|
||||
|
||||
$token = $data['token'] ?? null;
|
||||
$expiresAtStr = $data['expires_at'] ?? null;
|
||||
|
||||
if (! $token || ! $expiresAtStr) {
|
||||
throw new Exception('Telepagos authentication response is missing token or expires_at.');
|
||||
}
|
||||
|
||||
$expiresAt = Carbon::parse($expiresAtStr);
|
||||
// Calculate TTL and subtract a buffer of 60 seconds
|
||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||
|
||||
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
|
||||
*/
|
||||
protected function sendRequest(string $method, string $endpoint, array $data = []): Response
|
||||
{
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
Log::channel('telepagos')->info('Telepagos request returned 401; refreshing token and retrying.', [
|
||||
'method' => strtoupper($method),
|
||||
'endpoint' => $endpoint,
|
||||
'client_id' => $this->clientContext?->id,
|
||||
'integration_code' => $this->integrationCode,
|
||||
]);
|
||||
|
||||
$this->clearToken();
|
||||
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a QR code for cash-in.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function generateQr(float $amount, string $concept, string $description): array
|
||||
{
|
||||
$payload = [
|
||||
'amount' => $amount,
|
||||
'concept' => $concept,
|
||||
'description' => $description,
|
||||
];
|
||||
|
||||
$response = $this->sendRequest('post', '/v2/payment/cashin/qr/generate', $payload);
|
||||
|
||||
return $this->handleResponse($response, 'QR generation', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the details of a cash-in payment.
|
||||
*
|
||||
* @param int $cashinId
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getCashinDetails(string $cashinId): array
|
||||
{
|
||||
$response = $this->sendRequest('get', "/v2/payment/cashin/{$cashinId}");
|
||||
|
||||
return $this->handleResponse($response, 'get cash-in details', [
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the account info.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getAccountInfo(): array
|
||||
{
|
||||
$response = $this->sendRequest('get', '/v2/account/info');
|
||||
|
||||
return $this->handleResponse($response, 'get account info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function handleResponse(Response $response, string $actionDescription, array $context = []): array
|
||||
{
|
||||
if ($response->failed() || $response->json('status') !== 'ok') {
|
||||
$errorMessage = $response->json('message') ?? $response->body();
|
||||
Log::channel('telepagos')->error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
|
||||
'response_status' => $response->status(),
|
||||
'response_body' => $this->sanitizeForLog($response->json() ?? $response->body()),
|
||||
'client_id' => $this->clientContext?->id,
|
||||
'integration_code' => $this->integrationCode,
|
||||
], $context));
|
||||
|
||||
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
|
||||
}
|
||||
|
||||
return $response->json() ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove credentials and tokens before serializing provider responses.
|
||||
*/
|
||||
protected function sanitizeForLog(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$sensitiveKeys = ['authorization', 'password', 'token', 'access_token', 'refresh_token'];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
|
||||
$value[$key] = '[REDACTED]';
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$value[$key] = $this->sanitizeForLog($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cached token.
|
||||
*/
|
||||
public function clearToken(): void
|
||||
{
|
||||
if (! $this->integrationInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform initial setup validation for Telepagos.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
// Realiza un login de prueba para validar que las credenciales son correctas.
|
||||
$this->login();
|
||||
}
|
||||
}
|
||||
333
app/Shared/Integration/Services/TelepagosWebhookService.php
Normal file
333
app/Shared/Integration/Services/TelepagosWebhookService.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Purchase\Services\DniDistanceService;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkoutService,
|
||||
private readonly DniDistanceService $dniDistance,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(Client $client, string $cashinId): void
|
||||
{
|
||||
Log::channel('telepagos')->info('Telepagos webhook received.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forClient($client);
|
||||
|
||||
try {
|
||||
$details = $telepagosService->getCashinDetails($cashinId);
|
||||
|
||||
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
||||
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||
|
||||
$paymentData = [
|
||||
'compra_id' => null,
|
||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||
'amount' => $amount,
|
||||
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
||||
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
||||
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
|
||||
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
$transferenciaOperationIds = [1, 3, 11];
|
||||
$qrOperationIds = [31, 37, 47];
|
||||
|
||||
$compra = null;
|
||||
|
||||
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
||||
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
||||
|
||||
if (! $cuit) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$tenantCodes = $client->tenants()->pluck('codigo');
|
||||
$eligiblePurchases = Purchase::query()
|
||||
->whereIn('tenant_codigo', $tenantCodes)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer');
|
||||
|
||||
$purchases = (clone $eligiblePurchases)
|
||||
->where('total', $amount)
|
||||
->latest()
|
||||
->get()
|
||||
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
|
||||
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
|
||||
->values();
|
||||
|
||||
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
||||
|
||||
if (! $compra) {
|
||||
$candidatePurchases = $this->findTransferCandidates(
|
||||
$eligiblePurchases,
|
||||
$dni,
|
||||
$amount,
|
||||
);
|
||||
if ($candidatePurchases->isNotEmpty()) {
|
||||
$payment = $this->storeTransferCandidates(
|
||||
$paymentData,
|
||||
$candidatePurchases,
|
||||
$dni,
|
||||
$amount,
|
||||
);
|
||||
|
||||
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'amount' => $amount,
|
||||
'candidate_count' => $payment->candidates->count(),
|
||||
'candidates' => $payment->candidates
|
||||
->map(fn ($candidate): array => [
|
||||
'purchase_id' => $candidate->compra_id,
|
||||
'match_reason' => $candidate->match_reason,
|
||||
'dni_distance' => $candidate->dni_distance,
|
||||
'amount_difference' => $candidate->amount_difference,
|
||||
'confidence' => $candidate->confidence,
|
||||
])
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'amount' => $amount,
|
||||
'matches' => $purchases->count(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$compra = $telepagosQr->compra;
|
||||
|
||||
if (! $compra) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
'purchase_status' => $compra->status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
'cashin_amount' => $amount,
|
||||
'purchase_amount' => $totalAmount,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'operation_id' => $operationId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentData['compra_id'] = $compra->id;
|
||||
|
||||
DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPaidPurchase($compra);
|
||||
});
|
||||
|
||||
Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
'transaction_id' => $paymentData['transaction_id'],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeAmount(mixed $amount): string
|
||||
{
|
||||
return number_format((float) $amount, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Purchase> $eligiblePurchases
|
||||
* @return Collection<int, Purchase>
|
||||
*/
|
||||
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
|
||||
{
|
||||
$tolerancePercentage = max(
|
||||
0,
|
||||
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
|
||||
);
|
||||
$numericAmount = (float) $amount;
|
||||
$tolerance = $numericAmount * ($tolerancePercentage / 100);
|
||||
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
|
||||
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
|
||||
|
||||
return (clone $eligiblePurchases)
|
||||
->whereBetween('total', [$minimumAmount, $maximumAmount])
|
||||
->latest()
|
||||
->get()
|
||||
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
|
||||
if ($purchase->transfer_payer_dni === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||
$distance = $this->dniDistance->distance(
|
||||
$dni,
|
||||
(string) $purchase->transfer_payer_dni,
|
||||
);
|
||||
|
||||
return $distance === 0
|
||||
|| ($purchaseAmount === $amount && $distance <= 2);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $paymentData
|
||||
* @param Collection<int, Purchase> $candidatePurchases
|
||||
*/
|
||||
private function storeTransferCandidates(
|
||||
array $paymentData,
|
||||
Collection $candidatePurchases,
|
||||
string $dni,
|
||||
string $amount,
|
||||
): TelepagosPayment {
|
||||
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
|
||||
$payment = TelepagosPayment::create($paymentData);
|
||||
|
||||
$payment->candidates()->createMany(
|
||||
$candidatePurchases
|
||||
->map(function (Purchase $purchase) use ($dni, $amount): array {
|
||||
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||
$dniDistance = $this->dniDistance->distance(
|
||||
$dni,
|
||||
(string) $purchase->transfer_payer_dni,
|
||||
);
|
||||
$dniMatches = $dniDistance === 0;
|
||||
$amountMatches = $purchaseAmount === $amount;
|
||||
|
||||
return [
|
||||
'compra_id' => $purchase->id,
|
||||
'dni_matches' => $dniMatches,
|
||||
'dni_distance' => $dniDistance,
|
||||
'payment_dni' => $dni,
|
||||
'purchase_dni' => $purchase->transfer_payer_dni,
|
||||
'amount_matches' => $amountMatches,
|
||||
'payment_amount' => $amount,
|
||||
'purchase_amount' => $purchaseAmount,
|
||||
'amount_difference' => $this->normalizeAmount(
|
||||
abs((float) $purchaseAmount - (float) $amount),
|
||||
),
|
||||
'match_reason' => $amountMatches
|
||||
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
|
||||
: 'exact_dni_near_amount',
|
||||
'confidence' => $amountMatches
|
||||
? ($dniMatches ? 'exact' : 'medium')
|
||||
: 'high',
|
||||
];
|
||||
})
|
||||
->all(),
|
||||
);
|
||||
|
||||
return $payment->load('candidates');
|
||||
});
|
||||
}
|
||||
}
|
||||
48
app/Shared/Integration/documentacion/README.md
Normal file
48
app/Shared/Integration/documentacion/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Dominio Integration
|
||||
|
||||
## Modelo
|
||||
|
||||
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
|
||||
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
|
||||
- `ClientIntegration` y `AdminWebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
|
||||
|
||||
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
|
||||
|
||||
## Resolución
|
||||
|
||||
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de admin del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
|
||||
|
||||
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
|
||||
|
||||
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
|
||||
|
||||
## Administración
|
||||
|
||||
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
|
||||
|
||||
| Método | Ruta | Operación |
|
||||
| --- | --- | --- |
|
||||
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
|
||||
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
|
||||
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
|
||||
| GET | `/admin-website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de admin. |
|
||||
| PUT | `/admin-website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de admin. |
|
||||
| DELETE | `/admin-website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
|
||||
|
||||
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
|
||||
|
||||
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
|
||||
|
||||
## Webhooks y contexto operativo
|
||||
|
||||
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
|
||||
|
||||
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
|
||||
|
||||
## Despliegue
|
||||
|
||||
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
|
||||
|
||||
## Logging
|
||||
|
||||
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.
|
||||
25
app/Shared/Integration/routes/api.php
Normal file
25
app/Shared/Integration/routes/api.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||
use App\Domains\Integration\Controllers\AdminWebsiteTypeIntegrationController;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
|
||||
Route::prefix('admin-website-types/{adminWebsiteType:codigo}/integrations')->group(function (): void {
|
||||
Route::get('/', [AdminWebsiteTypeIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [AdminWebsiteTypeIntegrationController::class, 'show']);
|
||||
Route::put('/{integration_code}', [AdminWebsiteTypeIntegrationController::class, 'store']);
|
||||
Route::delete('/{integration_code}', [AdminWebsiteTypeIntegrationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
|
||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||
Reference in New Issue
Block a user