Compare commits
12 Commits
tenant/des
...
homo
| Author | SHA1 | Date | |
|---|---|---|---|
| 67e6211857 | |||
| 58471cb13d | |||
| 864bed0113 | |||
| ced7d594e8 | |||
| 38e87fff6c | |||
| a8973f6171 | |||
| 8e26611097 | |||
| 817d0de6d2 | |||
| a2298c9de2 | |||
| d73b4d1daf | |||
| e6785eb5af | |||
| 1a3f564afd |
File diff suppressed because it is too large
Load Diff
@@ -155,16 +155,19 @@ class GoogleAuthService
|
||||
$parts = parse_url($returnUrl);
|
||||
if (! is_array($parts)
|
||||
|| ! isset($parts['scheme'], $parts['host'])
|
||||
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|
||||
|| ($parts['path'] ?? '') !== '') {
|
||||
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower($parts['scheme']);
|
||||
$host = TenantDomainNormalizer::normalize($parts['host']);
|
||||
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
|
||||
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
|
||||
|
||||
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
|
||||
if ($host === null
|
||||
|| $tenantDomain === null
|
||||
|| $host !== $tenantDomain
|
||||
|| $returnPath !== $tenant->base_path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,15 @@ class TenantBootstrapService
|
||||
|
||||
public function get(string $domain, string $path = '/'): Tenant
|
||||
{
|
||||
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
|
||||
$tenantsByDomain = Tenant::query()
|
||||
->whereIn('dominio', $candidateKeys)
|
||||
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
|
||||
$tenantsByBasePath = Tenant::query()
|
||||
->where('dominio', $domain)
|
||||
->whereIn('base_path', $candidateBasePaths)
|
||||
->get()
|
||||
->keyBy('dominio');
|
||||
->keyBy('base_path');
|
||||
|
||||
$tenant = collect($candidateKeys)
|
||||
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
|
||||
$tenant = collect($candidateBasePaths)
|
||||
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
|
||||
->first(fn (?Tenant $candidate): bool => $candidate !== null);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -19,12 +20,14 @@ class CartItemResource extends JsonResource
|
||||
{
|
||||
$selectedItem = $this->selectedItem();
|
||||
$imageUrl = null;
|
||||
$tenant = $request->route('tenant');
|
||||
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
|
||||
|
||||
if ($selectedItem?->relationLoaded('attachments')) {
|
||||
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
|
||||
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
||||
}
|
||||
|
||||
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
||||
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
||||
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
||||
}
|
||||
|
||||
|
||||
47
app/Domains/Client/Controllers/ClientController.php
Normal file
47
app/Domains/Client/Controllers/ClientController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
30
app/Domains/Client/Models/Client.php
Normal file
30
app/Domains/Client/Models/Client.php
Normal 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);
|
||||
}
|
||||
}
|
||||
22
app/Domains/Client/Requests/StoreClientRequest.php
Normal file
22
app/Domains/Client/Requests/StoreClientRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/Client/Requests/UpdateClientRequest.php
Normal file
26
app/Domains/Client/Requests/UpdateClientRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/Client/Resources/ClientResource.php
Normal file
26
app/Domains/Client/Resources/ClientResource.php
Normal 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,
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
6
app/Domains/Client/routes/api.php
Normal file
6
app/Domains/Client/routes/api.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Client\Controllers\ClientController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('clients', ClientController::class);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
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;
|
||||
@@ -12,12 +13,12 @@ class TelepagosWebhookController extends Controller
|
||||
/**
|
||||
* 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 {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||
$service->handleWebhook($client, $cashinId);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,18 @@
|
||||
|
||||
namespace App\Domains\Integration\Models;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TenantIntegration extends Model
|
||||
class ClientIntegration extends Model
|
||||
{
|
||||
protected $table = 'tenant_integration';
|
||||
protected $hidden = ['integration_data'];
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'integration_code',
|
||||
'tenant_code',
|
||||
'integration_data',
|
||||
];
|
||||
|
||||
@@ -19,7 +21,14 @@ class TenantIntegration extends Model
|
||||
'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');
|
||||
}
|
||||
@@ -13,16 +13,16 @@ class Integration extends Model
|
||||
'name',
|
||||
'url',
|
||||
'integration_data_schema',
|
||||
'requires_tenant_configuration',
|
||||
'requires_client_configuration',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Domains\Integration\Models\Integration;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreTenantIntegrationRequest extends FormRequest
|
||||
class StoreClientIntegrationRequest extends FormRequest
|
||||
{
|
||||
protected ?Integration $integrationModel = null;
|
||||
|
||||
@@ -15,10 +15,12 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation()
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$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) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -31,11 +33,8 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
// Dynamic validation rules based on the integration data schema
|
||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
}
|
||||
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_tenant_configuration' => ['sometimes', 'boolean'],
|
||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@ class UpdateIntegrationRequest extends FormRequest
|
||||
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_tenant_configuration' => ['sometimes', 'boolean'],
|
||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||
// the code shouldn't ideally be updatable, but if it is:
|
||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
|
||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,58 +2,54 @@
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Models\ClientIntegration;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\TenantIntegration;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
abstract class BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* The unique code of the integration.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $integrationCode;
|
||||
|
||||
/**
|
||||
* The current tenant code.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected string $tenantCode;
|
||||
|
||||
protected ?Tenant $tenant = null;
|
||||
|
||||
protected ?Client $clientContext = null;
|
||||
|
||||
/**
|
||||
* The integration model instance.
|
||||
*
|
||||
* @var Integration|null
|
||||
*/
|
||||
protected ?Integration $integration = null;
|
||||
|
||||
/**
|
||||
* The tenant-specific integration model instance.
|
||||
*
|
||||
* @var TenantIntegration|null
|
||||
* The client-owned integration configuration.
|
||||
*/
|
||||
protected ?TenantIntegration $tenantIntegration = null;
|
||||
protected ?ClientIntegration $clientIntegration = null;
|
||||
|
||||
/**
|
||||
* Set the integration code.
|
||||
*
|
||||
* @param string $integrationCode
|
||||
* @return $this
|
||||
*/
|
||||
public function setIntegrationCode(string $integrationCode): self
|
||||
{
|
||||
$this->integrationCode = $integrationCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the integration code.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getIntegrationCode(): string
|
||||
{
|
||||
@@ -63,53 +59,69 @@ abstract class BaseIntegrationService
|
||||
/**
|
||||
* Set the tenant code and load the integration models.
|
||||
*
|
||||
* @param string $tenantCode
|
||||
* @return $this
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function forTenant(string $tenantCode): self
|
||||
{
|
||||
$this->tenantCode = $tenantCode;
|
||||
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
|
||||
$this->clientContext = $this->tenant->client;
|
||||
$this->loadIntegration();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function forClient(Client|string $client): self
|
||||
{
|
||||
$this->clientContext = $client instanceof Client
|
||||
? $client
|
||||
: Client::query()->where('code', $client)->firstOrFail();
|
||||
$this->tenant = null;
|
||||
$this->loadIntegration();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the Integration and TenantIntegration models.
|
||||
* Load the integration definition and its client-owned configuration.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function loadIntegration(): void
|
||||
{
|
||||
if (empty($this->integrationCode)) {
|
||||
throw new Exception("Integration code is not set.");
|
||||
throw new Exception('Integration code is not set.');
|
||||
}
|
||||
|
||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||
if (!$this->integration) {
|
||||
if (! $this->integration) {
|
||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||
}
|
||||
|
||||
$this->tenantIntegration = TenantIntegration::where('tenant_code', $this->tenantCode)
|
||||
if (! $this->clientContext) {
|
||||
throw new Exception('Client context is not set.');
|
||||
}
|
||||
|
||||
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
|
||||
->where('integration_code', $this->integrationCode)
|
||||
->first();
|
||||
|
||||
if (!$this->tenantIntegration && $this->integration->requires_tenant_configuration) {
|
||||
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured.");
|
||||
if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
|
||||
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request URL.
|
||||
*
|
||||
* @param string $path
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getUrl(string $path = ''): string
|
||||
{
|
||||
if (!$this->integration) {
|
||||
throw new Exception("Integration is not loaded. Call forTenant() first.");
|
||||
if (! $this->integration) {
|
||||
throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($this->integration->url, '/');
|
||||
@@ -119,25 +131,20 @@ abstract class BaseIntegrationService
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integration setting by key from tenant's integration data.
|
||||
*
|
||||
* @param string $key
|
||||
* @param mixed $default
|
||||
* @return mixed
|
||||
* Get an integration setting from the client-owned configuration.
|
||||
*/
|
||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||
{
|
||||
if (!$this->tenantIntegration || !$this->tenantIntegration->integration_data) {
|
||||
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->tenantIntegration->integration_data[$key] ?? $default;
|
||||
return $this->clientIntegration->integration_data[$key] ?? $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-configured HTTP client builder.
|
||||
*
|
||||
* @return PendingRequest
|
||||
* @throws Exception
|
||||
*/
|
||||
public function client(): PendingRequest
|
||||
@@ -148,17 +155,13 @@ abstract class BaseIntegrationService
|
||||
|
||||
/**
|
||||
* Get the headers for the integration.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
abstract public function getHeaders(): array;
|
||||
|
||||
/**
|
||||
* Hook called after the integration is configured for the tenant.
|
||||
* Hook called after the integration is configured for the client.
|
||||
* Can be used to validate credentials or perform initial setups.
|
||||
* Throw an Exception on failure.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function onSetup(): void
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Client\Models\Client;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
@@ -27,9 +27,7 @@ class MailService extends BaseIntegrationService
|
||||
|
||||
private ?Mailer $mailer = null;
|
||||
|
||||
private ?Tenant $tenant = null;
|
||||
|
||||
private bool $usesTenantMailer = false;
|
||||
private bool $usesClientMailer = false;
|
||||
|
||||
public function __construct(?MailFactory $mailFactory = null)
|
||||
{
|
||||
@@ -40,16 +38,28 @@ class MailService extends BaseIntegrationService
|
||||
{
|
||||
parent::forTenant($tenantCode);
|
||||
|
||||
$this->tenant = Tenant::query()
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
|
||||
if ($this->tenantIntegration) {
|
||||
if ($this->clientIntegration) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesTenantMailer = true;
|
||||
$this->usesClientMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesTenantMailer = false;
|
||||
$this->usesClientMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function forClient(Client|string $client): self
|
||||
{
|
||||
parent::forClient($client);
|
||||
$this->tenant = $this->clientContext?->tenants()->first();
|
||||
|
||||
if ($this->clientIntegration) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesClientMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesClientMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -63,7 +73,7 @@ class MailService extends BaseIntegrationService
|
||||
public function send(string|array $recipient, string $subject, string $content): void
|
||||
{
|
||||
if (! $this->mailer || ! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
@@ -91,43 +101,50 @@ class MailService extends BaseIntegrationService
|
||||
|
||||
public function mailerName(): string
|
||||
{
|
||||
return $this->usesTenantMailer
|
||||
? 'tenant-smtp'
|
||||
return $this->usesClientMailer
|
||||
? 'client-smtp'
|
||||
: (string) config('mail.default');
|
||||
}
|
||||
|
||||
public function onSetup(): void
|
||||
{
|
||||
if (! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
|
||||
if (! $this->mailer || ! $this->clientContext) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
|
||||
|
||||
if (! is_string($recipient) || $recipient === '') {
|
||||
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del tenant.');
|
||||
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del cliente.');
|
||||
}
|
||||
|
||||
$this->send(
|
||||
$recipient,
|
||||
'Configuración de correo validada',
|
||||
'<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
|
||||
.'<p>La integración SMTP de '.e($this->tenant->nombre).' fue configurada correctamente.</p>'
|
||||
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>',
|
||||
$subject = 'Configuración de correo validada';
|
||||
$content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
|
||||
.'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
|
||||
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
|
||||
|
||||
if ($this->tenant) {
|
||||
$this->send($recipient, $subject, $content);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mailer->to($recipient)->send(
|
||||
(new Mailable)->subject($subject)->html($content)
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveMailer(): Mailer
|
||||
{
|
||||
$data = $this->tenantIntegration?->integration_data;
|
||||
$data = $this->clientIntegration?->integration_data;
|
||||
|
||||
if (! is_array($data)) {
|
||||
throw new InvalidArgumentException('La configuración SMTP del tenant no es válida.');
|
||||
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
||||
}
|
||||
|
||||
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
|
||||
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
|
||||
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del tenant.");
|
||||
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del cliente.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +154,7 @@ class MailService extends BaseIntegrationService
|
||||
}
|
||||
|
||||
$mailer = $this->mailFactory->build([
|
||||
'name' => "tenant-smtp-{$this->tenantCode}",
|
||||
'name' => 'client-smtp-'.$this->clientContext?->id,
|
||||
'transport' => 'smtp',
|
||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||
'host' => $data['MAIL_HOST'],
|
||||
@@ -150,7 +167,7 @@ class MailService extends BaseIntegrationService
|
||||
|
||||
$mailer->alwaysFrom(
|
||||
$data['MAIL_FROM_ADDRESS'],
|
||||
$data['MAIL_FROM_NAME'] ?? $this->tenant?->nombre,
|
||||
$data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
|
||||
);
|
||||
|
||||
return $mailer;
|
||||
|
||||
@@ -2,39 +2,37 @@
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TelepagosIntegrationService extends BaseIntegrationService
|
||||
{
|
||||
/**
|
||||
* TelepagosIntegrationService constructor.
|
||||
*
|
||||
* @param string $integrationCode
|
||||
*/
|
||||
public function __construct(string $integrationCode = 'telepagos')
|
||||
{
|
||||
// Force homologation code if not in production and using default
|
||||
if ($integrationCode === 'telepagos' && !app()->environment('production')) {
|
||||
if ($integrationCode === 'telepagos' && ! app()->environment('production')) {
|
||||
$integrationCode = 'telepagos_homo';
|
||||
}
|
||||
|
||||
|
||||
$this->integrationCode = $integrationCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the headers for Telepagos integration.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getHeaders(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $this->getToken(),
|
||||
'Authorization' => 'Bearer '.$this->getToken(),
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
@@ -43,16 +41,15 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
/**
|
||||
* Get a valid token, either from cache or by performing a login.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
if (!$this->tenantIntegration) {
|
||||
throw new Exception("Tenant integration is not loaded. Call forTenant() first.");
|
||||
if (! $this->clientIntegration || ! $this->clientContext) {
|
||||
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
||||
}
|
||||
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
||||
|
||||
$token = Cache::get($cacheKey);
|
||||
|
||||
@@ -66,7 +63,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
/**
|
||||
* Authenticate with Telepagos and cache the returned token.
|
||||
*
|
||||
* @return string
|
||||
* @throws Exception
|
||||
*/
|
||||
public function login(): string
|
||||
@@ -75,7 +71,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
$password = $this->getIntegrationSetting('password');
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
throw new Exception("Missing username or password in Telepagos integration settings.");
|
||||
throw new Exception('Missing username or password in Telepagos integration settings.');
|
||||
}
|
||||
|
||||
$url = $this->getUrl('/v2/auth/token');
|
||||
@@ -92,15 +88,15 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
$token = $data['token'] ?? null;
|
||||
$expiresAtStr = $data['expires_at'] ?? null;
|
||||
|
||||
if (!$token || !$expiresAtStr) {
|
||||
throw new Exception("Telepagos authentication response is missing token or expires_at.");
|
||||
if (! $token || ! $expiresAtStr) {
|
||||
throw new Exception('Telepagos authentication response is missing token or expires_at.');
|
||||
}
|
||||
|
||||
$expiresAt = Carbon::parse($expiresAtStr);
|
||||
// Calculate TTL and subtract a buffer of 60 seconds
|
||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||
|
||||
return $token;
|
||||
@@ -108,21 +104,16 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
|
||||
/**
|
||||
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
|
||||
*
|
||||
* @param string $method
|
||||
* @param string $endpoint
|
||||
* @param array $data
|
||||
* @return \Illuminate\Http\Client\Response
|
||||
*/
|
||||
protected function sendRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
|
||||
protected function sendRequest(string $method, string $endpoint, array $data = []): Response
|
||||
{
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
|
||||
if ($response->status() === 401) {
|
||||
Log::info("Telepagos 401 Unauthorized. Refreshing token and retrying...");
|
||||
|
||||
Log::info('Telepagos 401 Unauthorized. Refreshing token and retrying...');
|
||||
|
||||
$this->clearToken();
|
||||
|
||||
|
||||
$response = $this->client()->$method($endpoint, $data);
|
||||
}
|
||||
|
||||
@@ -132,10 +123,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
/**
|
||||
* Generate a QR code for cash-in.
|
||||
*
|
||||
* @param float $amount
|
||||
* @param string $concept
|
||||
* @param string $description
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function generateQr(float $amount, string $concept, string $description): array
|
||||
@@ -154,8 +141,8 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
/**
|
||||
* Get the details of a cash-in payment.
|
||||
*
|
||||
* @param int $cashinId
|
||||
* @return array
|
||||
* @param int $cashinId
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getCashinDetails(string $cashinId): array
|
||||
@@ -170,25 +157,21 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
/**
|
||||
* Get the account info.
|
||||
*
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getAccountInfo(): array
|
||||
{
|
||||
$response = $this->sendRequest('get', '/v2/account/info');
|
||||
|
||||
return $this->handleResponse($response, 'get account info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
|
||||
*
|
||||
* @param \Illuminate\Http\Client\Response $response
|
||||
* @param string $actionDescription
|
||||
* @param array $context
|
||||
* @return array
|
||||
* @throws Exception
|
||||
*/
|
||||
protected function handleResponse(\Illuminate\Http\Client\Response $response, string $actionDescription, array $context = []): array
|
||||
protected function handleResponse(Response $response, string $actionDescription, array $context = []): array
|
||||
{
|
||||
if ($response->failed() || $response->json('status') !== 'ok') {
|
||||
$errorMessage = $response->json('message') ?? $response->body();
|
||||
@@ -205,19 +188,20 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
|
||||
/**
|
||||
* Clear the cached token.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearToken(): void
|
||||
{
|
||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
||||
if (! $this->clientContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform initial setup validation for Telepagos.
|
||||
*
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function onSetup(): void
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -22,12 +22,10 @@ class TelepagosWebhookService
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
public function handleWebhook(Client $client, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
$telepagosService->forClient($client);
|
||||
|
||||
try {
|
||||
$details = $telepagosService->getCashinDetails($cashinId);
|
||||
@@ -52,7 +50,8 @@ class TelepagosWebhookService
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
$tenantCodes = $client->tenants()->pluck('codigo');
|
||||
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
@@ -61,10 +60,13 @@ class TelepagosWebhookService
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->latest()
|
||||
->first();
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
Log::warning("Telepagos webhook: Expected one matching purchase for client {$client->code}, DNI {$dni}, amount {$amount} and cashin {$cashinId}; found {$purchases->count()}");
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -91,6 +93,12 @@ class TelepagosWebhookService
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} does not belong to client {$client->code}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
## 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
|
||||
|
||||
- `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.
|
||||
- `TenantIntegrationService`: consulta y configura integraciones del tenant.
|
||||
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
|
||||
|
||||
## 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.
|
||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
||||
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
||||
@@ -21,9 +21,9 @@ Gestiona integraciones externas disponibles y su configuración por tenant. Incl
|
||||
## Endpoints
|
||||
|
||||
- CRUD global bajo `/integrations`.
|
||||
- Consulta y configuración por tenant bajo `/{tenant_code}/integrations`.
|
||||
- `POST /webhooks/telepagos/{tenant_codigo}` para notificaciones del proveedor.
|
||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||
use App\Domains\Integration\Controllers\IntegrationController;
|
||||
use App\Domains\Integration\Controllers\TenantIntegrationController;
|
||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::group(['prefix' => 'integrations'], function () {
|
||||
@@ -12,10 +13,10 @@ Route::group(['prefix' => 'integrations'], function () {
|
||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::group(['prefix' => '{tenant_code}/integrations'], function () {
|
||||
Route::get('/', [TenantIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']);
|
||||
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']);
|
||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||
});
|
||||
|
||||
Route::post('webhooks/telepagos/{tenant_codigo}', [\App\Domains\Integration\Controllers\TelepagosWebhookController::class, 'handle']);
|
||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||
|
||||
@@ -61,6 +61,10 @@ class NotificationMailService
|
||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||
default => $tenant->dominio,
|
||||
};
|
||||
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
&& $tenant->base_path !== '/'
|
||||
? $tenant->base_path
|
||||
: '';
|
||||
$recoveryQuery = ['email' => $attempt->user->email];
|
||||
if (
|
||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||
@@ -70,7 +74,7 @@ class NotificationMailService
|
||||
}
|
||||
$recoveryUrl = $recoveryDomain === null
|
||||
? null
|
||||
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -15,8 +16,13 @@ class PurchaseItemResource extends JsonResource
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$tenant = $request->route('tenant');
|
||||
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
|
||||
|
||||
if ($this->resource instanceof PurchaseItem) {
|
||||
$imageUrl = $this->imageAttachment?->getTemporaryUrl(1440);
|
||||
$imageUrl = $displayImage
|
||||
? $this->imageAttachment?->getTemporaryUrl(1440)
|
||||
: null;
|
||||
$attributes = $this->variant_attributes ?? [];
|
||||
|
||||
return [
|
||||
@@ -42,7 +48,9 @@ class PurchaseItemResource extends JsonResource
|
||||
$quantity = (int) ($this->cantidad ?? 0);
|
||||
$unitPrice = $this->resolveUnitPrice($selectedItem);
|
||||
$lineTotal = $unitPrice * $quantity;
|
||||
$imageUrl = $this->resolveImageUrl($selectedItem, $catalogItem);
|
||||
$imageUrl = $displayImage
|
||||
? $this->resolveImageUrl($selectedItem, $catalogItem)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
@@ -16,11 +17,14 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
#[Fillable([
|
||||
'client_id',
|
||||
'codigo',
|
||||
'nombre',
|
||||
'dominio',
|
||||
'base_path',
|
||||
'site_title',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
@@ -40,6 +44,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'display_categories',
|
||||
'display_seach_bar',
|
||||
'display_cart',
|
||||
'cart_editing_enabled',
|
||||
'display_cart_item_images',
|
||||
'scanner_category_validation_enabled',
|
||||
'event_title',
|
||||
'event_location',
|
||||
@@ -50,12 +56,15 @@ class Tenant extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $attributes = [
|
||||
'base_path' => '/',
|
||||
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
||||
'search_group_layout' => GroupLayout::Paginated->value,
|
||||
'search_items_per_page' => 12,
|
||||
'display_categories' => true,
|
||||
'display_seach_bar' => true,
|
||||
'display_cart' => true,
|
||||
'cart_editing_enabled' => true,
|
||||
'display_cart_item_images' => true,
|
||||
'scanner_category_validation_enabled' => true,
|
||||
];
|
||||
|
||||
@@ -64,6 +73,28 @@ class Tenant extends Model
|
||||
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
|
||||
{
|
||||
return $this->scanner_category_validation_enabled;
|
||||
@@ -83,6 +114,8 @@ class Tenant extends Model
|
||||
'display_categories' => 'boolean',
|
||||
'display_seach_bar' => 'boolean',
|
||||
'display_cart' => 'boolean',
|
||||
'cart_editing_enabled' => 'boolean',
|
||||
'display_cart_item_images' => 'boolean',
|
||||
'scanner_category_validation_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
protected bool $hasInvalidDomain = false;
|
||||
|
||||
protected bool $hasInvalidBasePath = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
@@ -23,13 +25,21 @@ class StoreTenantRequest extends FormRequest
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
|
||||
$hasExplicitBasePath = $this->has('base_path');
|
||||
$rawBasePath = $hasExplicitBasePath
|
||||
? $this->input('base_path')
|
||||
: TenantDomainNormalizer::pathFromDomain($rawDomain);
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|
||||
|| $normalizedBasePath === null;
|
||||
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
'base_path' => $normalizedBasePath,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -41,6 +51,7 @@ class StoreTenantRequest extends FormRequest
|
||||
$logoRule = ['required', new ImageOrBase64Rule];
|
||||
|
||||
return array_merge([
|
||||
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
@@ -53,7 +64,21 @@ class StoreTenantRequest extends FormRequest
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'dominio'),
|
||||
Rule::unique('tenants', 'dominio')
|
||||
->where('base_path', $this->input('base_path')),
|
||||
],
|
||||
'base_path' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidBasePath) {
|
||||
$fail("The {$attribute} field must contain a valid URL path.");
|
||||
}
|
||||
},
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'base_path')
|
||||
->where('dominio', $this->input('dominio')),
|
||||
],
|
||||
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
@@ -82,6 +107,8 @@ class StoreTenantRequest extends FormRequest
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_enabled' => ['sometimes', 'boolean'],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
'website_type_code' => [
|
||||
'required_with:extras',
|
||||
|
||||
@@ -15,6 +15,8 @@ class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
protected bool $hasInvalidDomain = false;
|
||||
|
||||
protected bool $hasInvalidBasePath = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
@@ -24,14 +26,29 @@ class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
if ($this->has('dominio')) {
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalizeTenantKey($rawDomain);
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
&& ($normalizedDomain === null || $embeddedBasePath === null);
|
||||
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
]);
|
||||
|
||||
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
|
||||
$this->merge(['base_path' => $embeddedBasePath]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->has('base_path')) {
|
||||
$rawBasePath = $this->input('base_path');
|
||||
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
|
||||
|
||||
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|
||||
|| $normalizedBasePath === null;
|
||||
|
||||
$this->merge(['base_path' => $normalizedBasePath]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +59,13 @@ class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $this->route('tenant');
|
||||
$domain = $this->input('dominio', $tenant?->dominio);
|
||||
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
|
||||
|
||||
$logoRule = ['nullable', new ImageOrBase64Rule];
|
||||
|
||||
return [
|
||||
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
|
||||
'codigo' => [
|
||||
'nullable',
|
||||
'string',
|
||||
@@ -63,7 +83,23 @@ class UpdateTenantRequest extends FormRequest
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'dominio')->ignore($tenant?->id),
|
||||
Rule::unique('tenants', 'dominio')
|
||||
->where('base_path', $basePath)
|
||||
->ignore($tenant?->id),
|
||||
],
|
||||
'base_path' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidBasePath) {
|
||||
$fail("The {$attribute} field must contain a valid URL path.");
|
||||
}
|
||||
},
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'base_path')
|
||||
->where('dominio', $domain)
|
||||
->ignore($tenant?->id),
|
||||
],
|
||||
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
@@ -92,6 +128,8 @@ class UpdateTenantRequest extends FormRequest
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_enabled' => ['sometimes', 'boolean'],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -24,9 +24,11 @@ class TenantResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'client_id' => $this->client_id,
|
||||
'codigo' => $this->codigo,
|
||||
'nombre' => $this->nombre,
|
||||
'dominio' => $this->dominio,
|
||||
'base_path' => $this->base_path,
|
||||
'site_title' => $this->site_title
|
||||
?? $this->websiteType?->site_title
|
||||
?? 'ShopitFront',
|
||||
@@ -73,6 +75,8 @@ class TenantResource extends JsonResource
|
||||
'display_categories' => $this->display_categories,
|
||||
'display_seach_bar' => $this->display_seach_bar,
|
||||
'display_cart' => $this->display_cart,
|
||||
'cart_editing_enabled' => $this->cart_editing_enabled,
|
||||
'display_cart_item_images' => $this->display_cart_item_images,
|
||||
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
|
||||
'social_media' => $this->whenLoaded(
|
||||
'socialMedia',
|
||||
|
||||
@@ -42,13 +42,7 @@ class TenantDomainNormalizer
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($path === null && is_string($domain)) {
|
||||
$decodedDomain = trim(urldecode($domain));
|
||||
$candidate = str_contains($decodedDomain, '://')
|
||||
? $decodedDomain
|
||||
: "//{$decodedDomain}";
|
||||
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
|
||||
}
|
||||
$path ??= self::pathFromDomain($domain);
|
||||
|
||||
$normalizedPath = self::normalizePath($path);
|
||||
|
||||
@@ -59,6 +53,25 @@ class TenantDomainNormalizer
|
||||
return $host.($normalizedPath === '/' ? '' : $normalizedPath);
|
||||
}
|
||||
|
||||
public static function pathFromDomain(mixed $domain): ?string
|
||||
{
|
||||
if (! is_string($domain)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decodedDomain = trim(urldecode($domain));
|
||||
|
||||
if ($decodedDomain === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidate = str_contains($decodedDomain, '://')
|
||||
? $decodedDomain
|
||||
: "//{$decodedDomain}";
|
||||
|
||||
return self::normalizePath(parse_url($candidate, PHP_URL_PATH) ?: '/');
|
||||
}
|
||||
|
||||
public static function normalizePath(mixed $path): ?string
|
||||
{
|
||||
if (! is_string($path)) {
|
||||
@@ -98,9 +111,23 @@ class TenantDomainNormalizer
|
||||
public static function tenantKeyCandidates(mixed $domain, mixed $path): array
|
||||
{
|
||||
$host = self::normalize($domain);
|
||||
|
||||
if ($host === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static fn (string $basePath): string => $host.($basePath === '/' ? '' : $basePath),
|
||||
self::basePathCandidates($path),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function basePathCandidates(mixed $path): array
|
||||
{
|
||||
$normalizedPath = self::normalizePath($path);
|
||||
|
||||
if ($host === null || $normalizedPath === null) {
|
||||
if ($normalizedPath === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -111,11 +138,11 @@ class TenantDomainNormalizer
|
||||
$candidates = [];
|
||||
|
||||
while ($segments !== []) {
|
||||
$candidates[] = $host.'/'.implode('/', $segments);
|
||||
$candidates[] = '/'.implode('/', $segments);
|
||||
array_pop($segments);
|
||||
}
|
||||
|
||||
$candidates[] = $host;
|
||||
$candidates[] = '/';
|
||||
|
||||
return $candidates;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
|
||||
|
||||
## Modelo
|
||||
|
||||
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual.
|
||||
- `Tenant`: entidad principal, resuelta en rutas por `codigo`; relaciona catálogo, fechas, redes, menús y configuración visual. Su ubicación pública se representa con `dominio` y `base_path` (`/` para la raíz).
|
||||
- `WebsiteType`: plantilla o tipo de sitio disponible.
|
||||
- `WebsiteTypeExtra`: definición de un extra y su configuración admitida.
|
||||
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
|
||||
@@ -20,6 +20,8 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
|
||||
- `WebsiteExtraService`: construye reglas dinámicas, crea, actualiza y habilita/deshabilita extras.
|
||||
- `TenantDomainNormalizer`: normaliza dominios antes de resolver el tenant.
|
||||
|
||||
El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant raíz y otros tenants en prefijos diferentes. El bootstrap compara segmentos completos del path y selecciona el prefijo más específico.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- Recurso REST público/administrativo `/tenants`.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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
|
||||
{
|
||||
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table): void {
|
||||
$table->boolean('cart_editing_enabled')->default(true)->after('display_cart');
|
||||
});
|
||||
|
||||
DB::table('tenants')
|
||||
->where('codigo', self::DESFILE_TENANT_CODE)
|
||||
->update(['cart_editing_enabled' => false]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table): void {
|
||||
$table->dropColumn('cart_editing_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
<?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
|
||||
{
|
||||
$locations = DB::table('tenants')
|
||||
->select(['id', 'dominio'])
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->mapWithKeys(function (object $tenant): array {
|
||||
[$domain, $basePath] = $this->splitTenantLocation($tenant->dominio, $tenant->id);
|
||||
|
||||
return [$tenant->id => compact('domain', 'basePath')];
|
||||
});
|
||||
$duplicates = $locations
|
||||
->groupBy(fn (array $location): string => $location['domain'].'|'.$location['basePath'])
|
||||
->filter(fn ($matches): bool => $matches->count() > 1);
|
||||
|
||||
if ($duplicates->isNotEmpty()) {
|
||||
throw new RuntimeException(
|
||||
'Cannot create the tenant domain/base-path unique index; duplicates exist: '
|
||||
.$duplicates->keys()->implode(', ')
|
||||
);
|
||||
}
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->string('base_path')->default('/')->after('dominio');
|
||||
$table->dropUnique('tenants_dominio_unique');
|
||||
});
|
||||
|
||||
foreach ($locations as $tenantId => $location) {
|
||||
DB::table('tenants')
|
||||
->where('id', $tenantId)
|
||||
->update([
|
||||
'dominio' => $location['domain'],
|
||||
'base_path' => $location['basePath'],
|
||||
]);
|
||||
}
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->unique(
|
||||
['dominio', 'base_path'],
|
||||
'tenants_dominio_base_path_unique',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropUnique('tenants_dominio_base_path_unique');
|
||||
});
|
||||
|
||||
DB::table('tenants')
|
||||
->select(['id', 'dominio', 'base_path'])
|
||||
->orderBy('id')
|
||||
->each(function (object $tenant): void {
|
||||
$tenantKey = $tenant->dominio.($tenant->base_path === '/' ? '' : $tenant->base_path);
|
||||
|
||||
DB::table('tenants')
|
||||
->where('id', $tenant->id)
|
||||
->update(['dominio' => $tenantKey]);
|
||||
});
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn('base_path');
|
||||
$table->unique('dominio', 'tenants_dominio_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{string, string} */
|
||||
private function splitTenantLocation(mixed $value, int $tenantId): array
|
||||
{
|
||||
if (! is_string($value) || trim(urldecode($value)) === '') {
|
||||
throw new RuntimeException("Cannot migrate tenant location for tenant {$tenantId}.");
|
||||
}
|
||||
|
||||
$decodedValue = trim(urldecode($value));
|
||||
$candidate = str_contains($decodedValue, '://')
|
||||
? $decodedValue
|
||||
: "//{$decodedValue}";
|
||||
$host = parse_url($candidate, PHP_URL_HOST);
|
||||
$path = parse_url($candidate, PHP_URL_PATH) ?: '/';
|
||||
|
||||
if (! is_string($host) || $host === '') {
|
||||
throw new RuntimeException("Cannot migrate tenant location '{$value}' for tenant {$tenantId}.");
|
||||
}
|
||||
|
||||
$segments = array_values(array_filter(
|
||||
explode('/', preg_replace('#/+#', '/', $path) ?? ''),
|
||||
static fn (string $segment): bool => $segment !== '',
|
||||
));
|
||||
|
||||
if (array_intersect($segments, ['.', '..']) !== []) {
|
||||
throw new RuntimeException("Cannot migrate tenant base path '{$path}' for tenant {$tenantId}.");
|
||||
}
|
||||
|
||||
return [
|
||||
strtolower($host),
|
||||
$segments === [] ? '/' : '/'.implode('/', $segments),
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?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
|
||||
{
|
||||
private const DESFILE_TENANT_CODE = 'desfile_pura_tendencia';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table): void {
|
||||
$table->boolean('display_cart_item_images')->default(true)->after('cart_editing_enabled');
|
||||
});
|
||||
|
||||
DB::table('tenants')
|
||||
->where('codigo', self::DESFILE_TENANT_CODE)
|
||||
->update(['display_cart_item_images' => false]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table): void {
|
||||
$table->dropColumn('display_cart_item_images');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -27,6 +27,7 @@ class DatabaseSeeder extends Seeder
|
||||
AuthorizationSeeder::class,
|
||||
SocialMediaSeeder::class,
|
||||
TenantSeeder::class,
|
||||
DesfilePuraTendenciaSeeder::class,
|
||||
AttributeSeeder::class,
|
||||
CategorySeeder::class,
|
||||
BrandSeeder::class,
|
||||
|
||||
266
database/seeders/DesfilePuraTendenciaSeeder.php
Normal file
266
database/seeders/DesfilePuraTendenciaSeeder.php
Normal file
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Enums\InventorySubject;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class DesfilePuraTendenciaSeeder extends Seeder
|
||||
{
|
||||
private const ONTICKET_CLIENT_CODE = 'onticket';
|
||||
|
||||
private const TENANT_CODE = 'desfile_pura_tendencia';
|
||||
|
||||
public function __construct(
|
||||
private readonly TenantService $tenantService,
|
||||
private readonly CatalogService $catalogService,
|
||||
) {}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($client): void {
|
||||
$this->createTenant($client);
|
||||
$this->createEventDate();
|
||||
$this->createEntryCatalog();
|
||||
});
|
||||
}
|
||||
|
||||
private function createTenant(Client $client): void
|
||||
{
|
||||
$this->tenantService->create([
|
||||
'client_id' => $client->id,
|
||||
'codigo' => self::TENANT_CODE,
|
||||
'nombre' => 'Desfile Pura Tendencia',
|
||||
'dominio' => 'desfile-pura-tendencia.localhost',
|
||||
'site_title' => 'Desfile Pura Tendencia',
|
||||
'event_title' => 'Desfile Pura Tendencia',
|
||||
'event_location' => 'Salón Centro Recreativo Luz y Fuerza',
|
||||
'event_date_text' => '16 de Octubre 2026',
|
||||
'primary_color' => '#BA69A9',
|
||||
'secondary_color' => '#A0A0A0',
|
||||
'danger_color' => '#FF8888',
|
||||
'success_color' => '#198754',
|
||||
'header_bg_color' => '#ffffff',
|
||||
'footer_bg_color' => '#D4441C',
|
||||
'display_categories' => false,
|
||||
'display_seach_bar' => false,
|
||||
'display_cart' => true,
|
||||
'cart_editing_enabled' => false,
|
||||
'display_cart_item_images' => false,
|
||||
'scanner_category_validation_enabled' => false,
|
||||
'website_type_code' => 'onticket',
|
||||
'header_logo' => $this->uploadedImage(
|
||||
'images/tennants/desfile_pura_tendencia/desfile_pura_tendencia_header.png',
|
||||
'desfile_pura_tendencia_header.png',
|
||||
),
|
||||
'footer_logo' => $this->uploadedImage(
|
||||
'images/tennants/desfile_pura_tendencia/desfile_pura_tendencia_footer.png',
|
||||
'desfile_pura_tendencia_footer.png',
|
||||
),
|
||||
'favicon' => $this->uploadedImage(
|
||||
'images/tennants/desfile_pura_tendencia/pura_tendencia_favicon.png',
|
||||
'pura_tendencia_favicon.png',
|
||||
),
|
||||
'footer_bg_image' => $this->uploadedImage(
|
||||
'images/tennants/desfile_pura_tendencia/desfile_pura_tendencia_footer_background.png',
|
||||
'desfile_pura_tendencia_footer_background.png',
|
||||
),
|
||||
'extras' => [
|
||||
'heroConfig' => [
|
||||
'title_html' => '<h1>LA NOCHE DE LA MODA</h1>',
|
||||
'description_html' => 'Viví una experiencia de <b>Alta Costura con conducción exclusiva de Pampita</b> y las colecciones de Pucheta-Paz.',
|
||||
'background_image_id' => $this->uploadedImage(
|
||||
'images/tennants/desfile_pura_tendencia/desfile_pura_tendencia_hero.png',
|
||||
'desfile_pura_tendencia_hero.png',
|
||||
),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createEventDate(): void
|
||||
{
|
||||
$now = now();
|
||||
$validityTimeId = DB::table('validity_times')->insertGetId([
|
||||
'type' => 'fixed_window',
|
||||
'start_time' => null,
|
||||
'end_time' => null,
|
||||
'fixed_starts_at' => '2026-10-16 20:30:00',
|
||||
'fixed_expires_at' => '2026-10-16 23:59:00',
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
DB::table('event_dates')->insert([
|
||||
'tenant_code' => self::TENANT_CODE,
|
||||
'validity_time_id' => $validityTimeId,
|
||||
'date' => '2026-10-16',
|
||||
'time_start' => '20:30:00',
|
||||
'time_end' => '23:59:00',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createEntryCatalog(): void
|
||||
{
|
||||
$attributes = [
|
||||
'tipo' => ['name' => 'Tipo', 'options' => ['VIP + LUNCH', 'NORMAL']],
|
||||
'sector' => ['name' => 'Sector', 'options' => ['A', 'B', 'C', 'D']],
|
||||
'fila' => ['name' => 'Fila', 'options' => array_map('strval', range(1, 5))],
|
||||
'asiento' => ['name' => 'Asiento', 'options' => array_map('strval', range(1, 100))],
|
||||
];
|
||||
|
||||
foreach ($attributes as $code => $definition) {
|
||||
$attribute = Attribute::query()->create([
|
||||
'tenant_codigo' => self::TENANT_CODE,
|
||||
'codigo' => $code,
|
||||
'nombre' => $definition['name'],
|
||||
'is_required' => true,
|
||||
'metadata_schema' => null,
|
||||
'type' => 'select',
|
||||
]);
|
||||
|
||||
$attribute->options()->createMany(array_map(
|
||||
fn (string $option, int $index): array => [
|
||||
'value' => $option,
|
||||
'label' => $option,
|
||||
'sort_order' => $index + 1,
|
||||
'metadata' => null,
|
||||
],
|
||||
$definition['options'],
|
||||
array_keys($definition['options']),
|
||||
));
|
||||
}
|
||||
|
||||
$catalogItem = $this->catalogService->create([
|
||||
'tenant_code' => self::TENANT_CODE,
|
||||
'category_id' => null,
|
||||
'brand_id' => null,
|
||||
'slug' => 'entrada',
|
||||
'nombre' => 'Entrada',
|
||||
'descripcion' => 'Entrada para Desfile Pura Tendencia',
|
||||
'precio' => 40000,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'inventory_subject' => InventorySubject::Seat->value,
|
||||
'has_tickets' => true,
|
||||
'attribute_codes' => array_keys($attributes),
|
||||
'variants' => $this->entryVariants(),
|
||||
'images' => [
|
||||
$this->uploadedImage(
|
||||
'images/tennants/desfile_pura_tendencia/catalog/entrada_pasarela.png',
|
||||
'entrada_pasarela.png',
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->setAttributeOrder($catalogItem);
|
||||
|
||||
FeaturedGroup::query()->create([
|
||||
'tenant_code' => self::TENANT_CODE,
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'category_id' => null,
|
||||
'product_layout' => ProductLayout::TicketSelector,
|
||||
'group_layout' => GroupLayout::Single,
|
||||
'group_name' => 'Entradas',
|
||||
'group_order' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<array<string, mixed>> */
|
||||
private function entryVariants(): array
|
||||
{
|
||||
$variants = [];
|
||||
|
||||
foreach (['A', 'B', 'C', 'D'] as $sector) {
|
||||
$lastSeat = in_array($sector, ['B', 'D'], true) ? 16 : 17;
|
||||
|
||||
foreach (range(1, 5) as $row) {
|
||||
foreach (range(1, $lastSeat) as $seat) {
|
||||
[$type, $price] = $this->entryTypeAndPrice($sector, $row);
|
||||
$variants[] = [
|
||||
'real_stock' => 1,
|
||||
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
|
||||
'precio' => $price,
|
||||
'values' => [
|
||||
'tipo' => $type,
|
||||
'sector' => $sector,
|
||||
'fila' => (string) $row,
|
||||
'asiento' => (string) $seat,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
private function setAttributeOrder(CatalogItem $catalogItem): void
|
||||
{
|
||||
$sortOrders = ['tipo' => 1, 'sector' => 2, 'fila' => 3, 'asiento' => 4];
|
||||
|
||||
$catalogItem->itemAttributes()
|
||||
->with('attribute:id,codigo')
|
||||
->get()
|
||||
->each(function ($itemAttribute) use ($sortOrders): void {
|
||||
$itemAttribute->update([
|
||||
'sort_order' => $sortOrders[$itemAttribute->attribute->codigo],
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{string, int} */
|
||||
private function entryTypeAndPrice(string $sector, int $row): array
|
||||
{
|
||||
$prices = in_array($sector, ['A', 'C'], true)
|
||||
? [1 => 250000, 2 => 200000, 3 => 100000, 4 => 75000, 5 => 50000]
|
||||
: [1 => 240000, 2 => 190000, 3 => 90000, 4 => 65000, 5 => 40000];
|
||||
|
||||
return [$row <= 2 ? 'VIP + LUNCH' : 'NORMAL', $prices[$row]];
|
||||
}
|
||||
|
||||
private function uploadedImage(string $relativePath, string $filename): UploadedFile
|
||||
{
|
||||
$path = public_path($relativePath);
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException("Image not found at path: {$path}");
|
||||
}
|
||||
|
||||
$mimeType = mime_content_type($path);
|
||||
|
||||
return new UploadedFile(
|
||||
$path,
|
||||
$filename,
|
||||
is_string($mimeType) ? $mimeType : null,
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ class EmailIntegrationSeeder extends Seeder
|
||||
[
|
||||
'name' => 'Email',
|
||||
'url' => null,
|
||||
'requires_tenant_configuration' => false,
|
||||
'requires_client_configuration' => false,
|
||||
'integration_data_schema' => [
|
||||
'MAIL_MAILER' => 'required|string|in:smtp',
|
||||
'MAIL_SCHEME' => 'required|string|in:smtp',
|
||||
|
||||
@@ -81,7 +81,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
throw new RuntimeException("Tenant 'sonder' not found.");
|
||||
}
|
||||
|
||||
$catalogPath = public_path('images/catalog');
|
||||
$catalogPath = public_path('images/tennants/fiesta_futbol_infantil/catalog');
|
||||
|
||||
if (! is_dir($catalogPath)) {
|
||||
throw new RuntimeException("Catalog directory not found at path: {$catalogPath}");
|
||||
|
||||
@@ -17,11 +17,11 @@ class TelepagosIntegrationSeeder extends Seeder
|
||||
[
|
||||
'name' => 'Telepagos',
|
||||
'url' => 'https://api.telepagos.com.ar',
|
||||
'requires_tenant_configuration' => true,
|
||||
'requires_client_configuration' => true,
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
@@ -30,11 +30,11 @@ class TelepagosIntegrationSeeder extends Seeder
|
||||
[
|
||||
'name' => 'Telepagos Homologación',
|
||||
'url' => 'https://api.homo.telepagos.com.ar',
|
||||
'requires_tenant_configuration' => true,
|
||||
'requires_client_configuration' => true,
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -13,6 +14,10 @@ use Throwable;
|
||||
|
||||
class TenantSeeder extends Seeder
|
||||
{
|
||||
private const ONTICKET_CLIENT_CODE = 'onticket';
|
||||
|
||||
private const SONDER_CLIENT_CODE = 'sonder';
|
||||
|
||||
private const SOCIAL_MEDIA = [
|
||||
[
|
||||
'code' => 'instagram',
|
||||
@@ -40,6 +45,16 @@ class TenantSeeder extends Seeder
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$sonderClient = Client::query()->firstOrCreate(
|
||||
['code' => self::SONDER_CLIENT_CODE],
|
||||
['name' => 'Sonder'],
|
||||
);
|
||||
|
||||
$onTicketClient = Client::query()->firstOrCreate(
|
||||
['code' => self::ONTICKET_CLIENT_CODE],
|
||||
['name' => 'OnTicket'],
|
||||
);
|
||||
|
||||
$this->deleteTenant('sonder');
|
||||
|
||||
Tenant::query()
|
||||
@@ -47,6 +62,7 @@ class TenantSeeder extends Seeder
|
||||
->delete();
|
||||
|
||||
$this->tenantService->create([
|
||||
'client_id' => $sonderClient->id,
|
||||
'codigo' => 'sonder',
|
||||
'nombre' => 'Sonder',
|
||||
'dominio' => 'localhost',
|
||||
@@ -101,6 +117,7 @@ class TenantSeeder extends Seeder
|
||||
->delete();
|
||||
|
||||
$this->tenantService->create([
|
||||
'client_id' => $onTicketClient->id,
|
||||
'codigo' => 'fiesta_futbol_infantil',
|
||||
'nombre' => 'Fiesta Fútbol Infantil',
|
||||
'dominio' => $fiestaDomain,
|
||||
|
||||
@@ -73,7 +73,7 @@ return [
|
||||
'scanner_category_forbidden' => 'The scanner is not assigned to the ticket category.',
|
||||
],
|
||||
'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.',
|
||||
'validation_failed' => 'Configuration validation failed: :error',
|
||||
'invalid_payment_method' => 'Invalid payment method.',
|
||||
|
||||
@@ -73,7 +73,7 @@ return [
|
||||
'scanner_category_forbidden' => 'El scanner no está asignado a la categoría del ticket.',
|
||||
],
|
||||
'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.',
|
||||
'validation_failed' => 'Error validando la configuración: :error',
|
||||
'invalid_payment_method' => 'Método de pago inválido.',
|
||||
|
||||
451
postman/generate-shopit-collection.php
Normal file
451
postman/generate-shopit-collection.php
Normal file
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Regenerates the canonical Postman collection from Laravel's registered routes.
|
||||
*
|
||||
* Run from the backend root with:
|
||||
* php postman/generate-shopit-collection.php
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
chdir($root);
|
||||
|
||||
$command = escapeshellarg(PHP_BINARY).' artisan route:list --path=api --json';
|
||||
$routeJson = shell_exec($command);
|
||||
|
||||
if (! is_string($routeJson) || trim($routeJson) === '') {
|
||||
fwrite(STDERR, "Unable to read Laravel routes.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$routes = json_decode($routeJson, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
const TINY_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function jsonBody(array $payload): array
|
||||
{
|
||||
$raw = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$raw = preg_replace('/"\{\{([a-z_]+_id)\}\}"/', '{{$1}}', $raw);
|
||||
|
||||
return [
|
||||
'mode' => 'raw',
|
||||
'raw' => $raw,
|
||||
'options' => ['raw' => ['language' => 'json']],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, string> $fields
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function formDataBody(array $fields, array $fileFields = []): array
|
||||
{
|
||||
$formData = [];
|
||||
|
||||
foreach ($fields as $key => $value) {
|
||||
$formData[] = ['key' => $key, 'value' => $value, 'type' => 'text'];
|
||||
}
|
||||
|
||||
foreach ($fileFields as $key) {
|
||||
$formData[] = ['key' => $key, 'type' => 'file', 'src' => []];
|
||||
}
|
||||
|
||||
return ['mode' => 'formdata', 'formdata' => $formData];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
function bodyFor(string $method, string $uri): ?array
|
||||
{
|
||||
$key = $method.' '.$uri;
|
||||
|
||||
$exact = [
|
||||
'POST api/auth/google/exchange' => ['oauth_code' => '{{oauth_code}}', 'tenant_codigo' => '{{tenant_code}}'],
|
||||
'POST api/register' => ['tenant_codigo' => '{{tenant_code}}', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}', 'password' => '{{user_password}}', 'password_confirmation' => '{{user_password}}', 'dni' => '30123456', 'telefono' => '+5491112345678'],
|
||||
'POST api/login' => ['email' => '{{user_email}}', 'password' => '{{user_password}}', 'tenant_codigo' => '{{tenant_code}}'],
|
||||
'PUT api/me' => ['nombre_apellido' => 'Usuario Demo Actualizado', 'email' => '{{user_email}}', 'dni' => '30123456', 'telefono' => '+5491112345678'],
|
||||
'POST api/password/reset-attempts' => ['tenant_codigo' => '{{tenant_code}}', 'email' => '{{user_email}}'],
|
||||
'POST api/password/reset-attempts/validate' => ['email' => '{{user_email}}', 'codigo' => '{{reset_code}}'],
|
||||
'POST api/password/reset' => ['email' => '{{user_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{user_password}}', 'password_confirmation' => '{{user_password}}'],
|
||||
'POST api/clients' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo'],
|
||||
'PUT api/clients/{client}' => ['code' => 'cliente-demo', 'name' => 'Cliente Demo Actualizado'],
|
||||
'PATCH api/clients/{client}' => ['name' => 'Cliente Demo Actualizado'],
|
||||
'POST api/integrations' => ['integration_code' => 'telepagos', 'name' => 'Telepagos', 'url' => 'https://api.example.com', 'integration_data_schema' => ['api_key' => ['required', 'string']], 'requires_client_configuration' => true],
|
||||
'PUT api/integrations/{integration}' => ['name' => 'Telepagos', 'url' => 'https://api.example.com', 'requires_client_configuration' => true],
|
||||
'PUT api/clients/{client}/integrations/{integration_code}' => ['integration_data' => ['api_key' => 'replace-me']],
|
||||
'POST api/menues' => ['code' => 'demo', 'label' => 'Demo', 'parent_menu_code' => null, 'content_type' => 'static', 'static_content_schema' => ['title' => ['required', 'string']], 'route' => '/demo'],
|
||||
'PUT api/menues/{menue}' => ['label' => 'Demo actualizado', 'route' => '/demo'],
|
||||
'PATCH api/menues/{menue}' => ['label' => 'Demo actualizado'],
|
||||
'POST api/{tenant_code}/menues/{menu_code}' => ['static_content' => ['title' => 'Contenido demo']],
|
||||
'POST api/webhooks/telepagos/{client}' => ['id' => 'payment-id-demo'],
|
||||
'POST api/{tenant_code}/mail-test/send' => ['to' => 'destinatario@example.com', 'subject' => 'Prueba ShopIt', 'message' => 'Correo de prueba enviado desde Postman.'],
|
||||
'POST api/tenants/{tenant:codigo}/cart/items' => ['catalog_item_id' => '{{catalog_item_id}}', 'variant_id' => '{{variant_id}}', 'cantidad' => 1],
|
||||
'PATCH api/tenants/{tenant:codigo}/cart/items/{cartItem}' => ['cantidad' => 2, 'variant_id' => '{{variant_id}}'],
|
||||
'POST api/tenants/{tenant:codigo}/catalog-items/{catalogItem}/variant-options' => ['selected_values' => ['color' => 'azul'], 'cart_item_id' => '{{cart_item_id}}'],
|
||||
'POST api/tenants/{tenant:codigo}/compras/start-checkout' => ['cart_id' => '{{cart_id}}'],
|
||||
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/customer-data' => ['dni' => '30123456', 'telefono' => '+5491112345678', 'nombre_apellido' => 'Usuario Demo', 'email' => '{{user_email}}'],
|
||||
'PATCH api/tenants/{tenant:codigo}/compras/{compra}/items/{item}' => ['quantity' => 2],
|
||||
'POST api/tenants/{tenant:codigo}/compras/{compra}/payment-intent' => ['method' => 'transfer', 'transfer_payer_dni' => '30123456'],
|
||||
'POST api/tenants/{tenant:codigo}/tickets/pdf' => ['ticket_ids' => [1]],
|
||||
'POST api/v1/adminapp/login' => ['email' => '{{admin_email}}', 'password' => '{{admin_password}}'],
|
||||
'POST api/v1/adminapp/password/reset-attempts' => ['email' => '{{admin_email}}'],
|
||||
'POST api/v1/adminapp/password/reset-attempts/validate' => ['email' => '{{admin_email}}', 'codigo' => '{{reset_code}}'],
|
||||
'POST api/v1/adminapp/password/reset' => ['email' => '{{admin_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{admin_password}}', 'password_confirmation' => '{{admin_password}}'],
|
||||
'PUT api/v1/adminapp/tenant/event' => ['title' => 'Evento Demo', 'location' => 'Buenos Aires', 'dates' => [['date' => '2026-12-01', 'start_time' => '18:00', 'end_time' => '23:00']], 'social_media' => [['code' => 'instagram', 'url' => 'https://instagram.com/example', 'orden' => 0]]],
|
||||
'POST api/v1/adminapp/tenant/featured-groups' => ['category_name' => 'Destacados', 'is_featured' => true],
|
||||
'PUT api/v1/adminapp/tenant/featured-groups/{featuredGroup}' => ['category_name' => 'Destacados', 'is_featured' => true],
|
||||
'POST api/v1/adminapp/tenant/staff' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
|
||||
'PUT api/v1/adminapp/tenant/staff/{staff}' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
|
||||
'PATCH api/v1/adminapp/tenant/staff/{staff}' => ['nombre_apellido' => 'Operador Demo', 'dni' => '30123456', 'email' => 'operador@example.com', 'category_ids' => [1]],
|
||||
'PUT api/v1/adminapp/tenant/website-extras/{websiteExtraCode}' => ['enabled' => true, 'content' => ['title' => 'Contenido demo']],
|
||||
'PATCH api/v1/adminapp/tenant/website-extras/{websiteExtraCode}/toggle' => ['enabled' => true],
|
||||
'POST api/v1/adminapp/tenant/accommodations' => ['variants' => [['title' => 'Habitación doble', 'description' => 'Dos personas', 'stock' => 10, 'price' => 100000]]],
|
||||
'POST api/v1/adminapp/tenant/entries' => ['entries' => [['title' => 'Entrada general', 'description' => 'Acceso general', 'event_date_ids' => [1], 'stock' => 100, 'price' => 25000]]],
|
||||
'POST api/v1/adminapp/tenant/foods' => ['variants' => [['event_date_id' => 1, 'schedule' => '21:00', 'service' => 'Cena', 'description' => 'Menú completo', 'stock' => 100, 'price' => 15000]]],
|
||||
'POST api/v1/adminapp/tenant/merchandise' => ['items' => [['title' => 'Camiseta', 'description' => 'Camiseta oficial', 'max_units_per_user' => 2, 'variants' => [['color' => 'Azul', 'size' => 'M', 'stock' => 20, 'price' => 30000]]]]],
|
||||
'PUT api/v1/adminapp/tenant/desfile/entries' => ['rows' => [['type' => 'General', 'sector' => 'A', 'row' => 1, 'max_seat' => 20, 'price' => 25000]]],
|
||||
'PATCH api/v1/adminapp/tenant/desfile/entries/image' => ['is_enabled' => true],
|
||||
'POST api/v1/scanner/login' => ['email' => '{{scanner_email}}', 'password' => '{{scanner_password}}'],
|
||||
'POST api/v1/scanner/password/reset-attempts' => ['email' => '{{scanner_email}}'],
|
||||
'POST api/v1/scanner/password/reset-attempts/validate' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}'],
|
||||
'POST api/v1/scanner/password/reset' => ['email' => '{{scanner_email}}', 'codigo' => '{{reset_code}}', 'password' => '{{scanner_password}}', 'password_confirmation' => '{{scanner_password}}'],
|
||||
];
|
||||
|
||||
if (isset($exact[$key])) {
|
||||
return jsonBody($exact[$key]);
|
||||
}
|
||||
|
||||
if ($key === 'POST api/tenants') {
|
||||
return jsonBody([
|
||||
'client_id' => '{{client_id}}',
|
||||
'codigo' => '{{tenant_code}}',
|
||||
'nombre' => 'Tenant Demo',
|
||||
'dominio' => '{{tenant_domain}}',
|
||||
'site_title' => 'ShopIt Demo',
|
||||
'primary_color' => '#111827',
|
||||
'secondary_color' => '#2563EB',
|
||||
'danger_color' => '#DC2626',
|
||||
'success_color' => '#16A34A',
|
||||
'header_bg_color' => '#FFFFFF',
|
||||
'footer_bg_color' => '#111827',
|
||||
'header_logo' => TINY_PNG,
|
||||
'footer_logo' => TINY_PNG,
|
||||
'search_product_layout' => 'column_with_image',
|
||||
'search_group_layout' => 'paginated',
|
||||
'search_items_per_page' => 12,
|
||||
'display_categories' => true,
|
||||
'display_seach_bar' => true,
|
||||
'display_cart' => true,
|
||||
'cart_editing_enabled' => true,
|
||||
'website_type_code' => 'shopit',
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($key, ['PUT api/tenants/{tenant}', 'PATCH api/tenants/{tenant}'], true)) {
|
||||
return jsonBody(['nombre' => 'Tenant Demo Actualizado', 'site_title' => 'ShopIt Demo', 'primary_color' => '#111827', 'cart_editing_enabled' => true]);
|
||||
}
|
||||
|
||||
if ($key === 'POST api/tenants/{tenant:codigo}/catalog-items') {
|
||||
return jsonBody([
|
||||
'type' => 'standard',
|
||||
'category_id' => '{{category_id}}',
|
||||
'slug' => 'producto-demo',
|
||||
'nombre' => 'Producto Demo',
|
||||
'descripcion' => 'Producto creado desde Postman',
|
||||
'precio' => 10000,
|
||||
'inventory_policy' => 'tracked',
|
||||
'inventory_subject' => 'product',
|
||||
'max_units_per_user' => 5,
|
||||
'has_tickets' => false,
|
||||
'real_stock' => 100,
|
||||
'images' => [TINY_PNG],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($key === 'POST api/storage-test/s3/upload') {
|
||||
return formDataBody(['path' => 'postman/test-file.png', 'expires_in_minutes' => '60'], ['file']);
|
||||
}
|
||||
|
||||
if ($key === 'POST api/v1/adminapp/tenant/desfile/entries/image') {
|
||||
return formDataBody(['is_enabled' => '1'], ['image']);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return array<int, array{key: string, value: string, disabled?: bool}> */
|
||||
function queryFor(string $uri): array
|
||||
{
|
||||
return match ($uri) {
|
||||
'api/tenants/bootstrap' => [['key' => 'dominio', 'value' => '{{tenant_domain}}'], ['key' => 'path', 'value' => '/']],
|
||||
'api/v1/adminapp/bootstrap/{dominio}', 'api/v1/scanner/bootstrap/{dominio}' => [['key' => 'path', 'value' => '/']],
|
||||
'api/tenants/{tenant:codigo}/catalog-items' => [['key' => 'q', 'value' => 'demo'], ['key' => 'page', 'value' => '1']],
|
||||
'api/tenants/{tenant:codigo}/catalog-items/{catalogItem}' => [['key' => 'variant_id', 'value' => '{{variant_id}}', 'disabled' => true]],
|
||||
'api/tenants/{tenant:codigo}/catalog/featured-groups/{featuredGroup}/items',
|
||||
'api/tenants/{tenant:codigo}/categories/{category}' => [['key' => 'page', 'value' => '1']],
|
||||
'api/tenants/{tenant:codigo}/compras' => [['key' => 'status', 'value' => 'pending_payment', 'disabled' => true], ['key' => 'page', 'value' => '1']],
|
||||
'api/v1/adminapp/tenant/sales', 'api/v1/adminapp/tenant/sales/pdf' => [
|
||||
['key' => 'q', 'value' => '', 'disabled' => true],
|
||||
['key' => 'status', 'value' => 'confirmed', 'disabled' => true],
|
||||
['key' => 'sale_date', 'value' => '2026-12-01', 'disabled' => true],
|
||||
['key' => 'sort_by', 'value' => 'date'],
|
||||
['key' => 'sort_direction', 'value' => 'desc'],
|
||||
['key' => 'page', 'value' => '1'],
|
||||
['key' => 'per_page', 'value' => '20'],
|
||||
],
|
||||
'api/v1/scanner/tickets' => [['key' => 'q', 'value' => '', 'disabled' => true], ['key' => 'page', 'value' => '1'], ['key' => 'per_page', 'value' => '20']],
|
||||
'api/storage-test/s3/temporary-url' => [['key' => 'path', 'value' => '{{s3_path}}'], ['key' => 'expires_in_minutes', 'value' => '60']],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: string} */
|
||||
function folderFor(string $uri, string $action): array
|
||||
{
|
||||
preg_match('/App\\\\Domains\\\\([^\\\\]+)/', $action, $matches);
|
||||
$domain = $matches[1] ?? 'Other';
|
||||
|
||||
if (str_starts_with($uri, 'api/v1/adminapp/')) {
|
||||
return ['Admin App API', $domain];
|
||||
}
|
||||
|
||||
if (str_starts_with($uri, 'api/v1/scanner/')) {
|
||||
return ['Scanner API', $domain];
|
||||
}
|
||||
|
||||
if (str_starts_with($uri, 'api/webhooks/')) {
|
||||
return ['Webhooks', $domain];
|
||||
}
|
||||
|
||||
if (in_array($domain, ['StorageTest', 'MailTest'], true)) {
|
||||
return ['Developer Utilities', $domain];
|
||||
}
|
||||
|
||||
if (in_array($domain, ['Client', 'Integration', 'Menu', 'Tenant'], true) && ! str_contains($uri, '{tenant:codigo}')) {
|
||||
return ['Platform Management', $domain];
|
||||
}
|
||||
|
||||
return ['Storefront API', $domain];
|
||||
}
|
||||
|
||||
function humanize(string $value): string
|
||||
{
|
||||
$value = preg_replace('/Controller$/', '', $value);
|
||||
$value = preg_replace('/(?<!^)[A-Z]/', ' $0', (string) $value);
|
||||
|
||||
return trim(str_replace(['_', '-'], ' ', (string) $value));
|
||||
}
|
||||
|
||||
function requestName(string $method, string $action, bool $multiMethod): string
|
||||
{
|
||||
$parts = explode('\\', explode('@', $action)[0]);
|
||||
$controller = humanize(end($parts));
|
||||
$handler = str_contains($action, '@') ? explode('@', $action)[1] : '__invoke';
|
||||
$verbs = [
|
||||
'index' => 'List', 'store' => 'Create', 'show' => 'Get', 'update' => 'Update',
|
||||
'destroy' => 'Delete', 'addItem' => 'Add Item', 'updateItemQuantity' => 'Update Item Quantity',
|
||||
'removeItem' => 'Remove Item', 'search' => 'Search', 'category' => 'Get Category',
|
||||
'featuredGroupItems' => 'List Featured Group Items', 'variantOptions' => 'Get Variant Options',
|
||||
'startCheckout' => 'Start Checkout', 'updateCustomerData' => 'Update Customer Data',
|
||||
'prepareItemEditing' => 'Prepare Item Editing', 'paymentIntent' => 'Create Payment Intent',
|
||||
'submitForReview' => 'Submit for Review', 'cancel' => 'Cancel', 'complete' => 'Complete',
|
||||
'downloadPdf' => 'Download PDF', 'downloadModificationsPdf' => 'Download Modifications PDF',
|
||||
'modifications' => 'List Modifications', 'tickets' => 'List Tickets', 'confirm' => 'Confirm',
|
||||
'temporaryUrl' => 'Generate Temporary URL', 'replaceImage' => 'Replace Image',
|
||||
'updateImage' => 'Update Image', 'destroyImage' => 'Delete Image', 'showExtra' => 'Get Extra',
|
||||
'toggle' => 'Toggle', 'scan' => 'Scan', 'handle' => 'Receive',
|
||||
];
|
||||
|
||||
$name = $handler === '__invoke'
|
||||
? $controller
|
||||
: (($verbs[$handler] ?? humanize($handler)).' '.$controller);
|
||||
|
||||
return $multiMethod ? $name.' ('.$method.')' : $name;
|
||||
}
|
||||
|
||||
function pathFor(string $uri): string
|
||||
{
|
||||
$variables = [
|
||||
'tenant:codigo' => 'tenant_code', 'tenant' => 'tenant_id', 'client' => 'client_id',
|
||||
'integration_code' => 'integration_code', 'integration' => 'integration_id', 'menue' => 'menu_id',
|
||||
'menu_code' => 'menu_code', 'tenant_code' => 'tenant_code', 'catalogItem' => 'catalog_item_id',
|
||||
'featuredGroup' => 'featured_group_id', 'category' => 'category_id', 'cartItem' => 'cart_item_id',
|
||||
'compra' => 'purchase_id', 'item' => 'purchase_item_id', 'dominio' => 'tenant_domain',
|
||||
'sale' => 'sale_id', 'staff' => 'staff_id', 'websiteExtraCode' => 'website_extra_code',
|
||||
'accommodation' => 'accommodation_id', 'entry' => 'entry_id', 'food' => 'food_id',
|
||||
'merchandise' => 'merchandise_id', 'ticketUuid' => 'ticket_uuid',
|
||||
];
|
||||
|
||||
return preg_replace_callback('/\{([^}]+)\}/', function (array $matches) use ($variables): string {
|
||||
$variable = $variables[$matches[1]] ?? strtolower((string) preg_replace('/(?<!^)[A-Z]/', '_$0', $matches[1]));
|
||||
|
||||
return '{{'.$variable.'}}';
|
||||
}, $uri);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function authFor(string $uri, array $middleware): ?array
|
||||
{
|
||||
$protected = count(array_filter(
|
||||
$middleware,
|
||||
fn (string $item): bool => str_contains($item, 'Authenticate:sanctum'),
|
||||
)) > 0;
|
||||
|
||||
if (! $protected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$variable = str_starts_with($uri, 'api/v1/adminapp/')
|
||||
? 'admin_token'
|
||||
: (str_starts_with($uri, 'api/v1/scanner/') ? 'scanner_token' : 'token');
|
||||
|
||||
return ['type' => 'bearer', 'bearer' => [['key' => 'token', 'value' => '{{'.$variable.'}}', 'type' => 'string']]];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
function tokenCaptureEvent(string $uri): array
|
||||
{
|
||||
$variable = $uri === 'api/v1/adminapp/login' ? 'admin_token' : ($uri === 'api/v1/scanner/login' ? 'scanner_token' : 'token');
|
||||
|
||||
return [
|
||||
'listen' => 'test',
|
||||
'script' => [
|
||||
'type' => 'text/javascript',
|
||||
'exec' => [
|
||||
'if (pm.response.code >= 200 && pm.response.code < 300) {',
|
||||
' const payload = pm.response.json();',
|
||||
" if (payload.token) pm.collectionVariables.set('{$variable}', payload.token);",
|
||||
'}',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$tree = [];
|
||||
$registeredOperations = 0;
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$methods = array_values(array_filter(explode('|', $route['method']), fn (string $method): bool => $method !== 'HEAD'));
|
||||
$multiMethod = count($methods) > 1;
|
||||
|
||||
foreach ($methods as $method) {
|
||||
[$section, $folder] = folderFor($route['uri'], $route['action']);
|
||||
$path = pathFor($route['uri']);
|
||||
$query = queryFor($route['uri']);
|
||||
$enabledQuery = array_values(array_filter($query, fn (array $item): bool => ! ($item['disabled'] ?? false)));
|
||||
$queryString = $enabledQuery === [] ? '' : '?'.implode('&', array_map(
|
||||
fn (array $item): string => rawurlencode($item['key']).'='.$item['value'],
|
||||
$enabledQuery,
|
||||
));
|
||||
$body = bodyFor($method, $route['uri']);
|
||||
$auth = authFor($route['uri'], $route['middleware']);
|
||||
$headers = [['key' => 'Accept', 'value' => 'application/json', 'type' => 'text']];
|
||||
|
||||
if (($body['mode'] ?? null) === 'raw') {
|
||||
$headers[] = ['key' => 'Content-Type', 'value' => 'application/json', 'type' => 'text'];
|
||||
}
|
||||
|
||||
$request = [
|
||||
'method' => $method,
|
||||
'header' => $headers,
|
||||
'description' => sprintf(
|
||||
"Ruta Laravel: `%s /%s`\n\nControlador: `%s`%s",
|
||||
$method,
|
||||
$route['uri'],
|
||||
$route['action'],
|
||||
$auth === null ? '' : "\n\nRequiere autenticación Sanctum.",
|
||||
),
|
||||
'url' => [
|
||||
'raw' => '{{base_url}}/'.$path.$queryString,
|
||||
'host' => ['{{base_url}}'],
|
||||
'path' => explode('/', $path),
|
||||
],
|
||||
];
|
||||
|
||||
if ($query !== []) {
|
||||
$request['url']['query'] = $query;
|
||||
}
|
||||
|
||||
if ($body !== null) {
|
||||
$request['body'] = $body;
|
||||
}
|
||||
|
||||
if ($auth !== null) {
|
||||
$request['auth'] = $auth;
|
||||
}
|
||||
|
||||
$item = [
|
||||
'name' => requestName($method, $route['action'], $multiMethod),
|
||||
'request' => $request,
|
||||
'response' => [],
|
||||
];
|
||||
|
||||
if (in_array($route['uri'], ['api/login', 'api/v1/adminapp/login', 'api/v1/scanner/login'], true)) {
|
||||
$item['event'] = [tokenCaptureEvent($route['uri'])];
|
||||
}
|
||||
|
||||
$tree[$section][$folder][] = $item;
|
||||
$registeredOperations++;
|
||||
}
|
||||
}
|
||||
|
||||
$sectionDescriptions = [
|
||||
'Storefront API' => 'API pública y autenticada consumida por el storefront: autenticación, catálogo, carrito, compras y tickets.',
|
||||
'Admin App API' => 'Operaciones de administración del tenant. Ejecutá Login primero para completar `admin_token`.',
|
||||
'Scanner API' => 'Operaciones de la aplicación de escaneo. Ejecutá Login primero para completar `scanner_token`.',
|
||||
'Platform Management' => 'CRUD y configuración de tenants, clientes, menús e integraciones.',
|
||||
'Webhooks' => 'Entradas públicas para notificaciones de proveedores externos.',
|
||||
'Developer Utilities' => 'Endpoints de diagnóstico de correo y almacenamiento. No deberían exponerse en producción.',
|
||||
];
|
||||
|
||||
$items = [];
|
||||
foreach ($tree as $section => $folders) {
|
||||
$children = [];
|
||||
foreach ($folders as $folder => $requests) {
|
||||
$children[] = ['name' => humanize($folder), 'item' => $requests];
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'name' => $section,
|
||||
'description' => $sectionDescriptions[$section] ?? '',
|
||||
'item' => $children,
|
||||
];
|
||||
}
|
||||
|
||||
$variables = [
|
||||
'base_url' => 'http://localhost:8000',
|
||||
'token' => '', 'admin_token' => '', 'scanner_token' => '',
|
||||
'user_email' => 'usuario@example.com', 'user_password' => 'Password!123',
|
||||
'admin_email' => 'admin@example.com', 'admin_password' => 'Password!123',
|
||||
'scanner_email' => 'scanner@example.com', 'scanner_password' => 'Password!123',
|
||||
'tenant_code' => 'demo', 'tenant_id' => '1', 'tenant_domain' => 'demo.test',
|
||||
'client_id' => '1', 'integration_id' => '1', 'integration_code' => 'telepagos',
|
||||
'menu_id' => '1', 'menu_code' => 'demo', 'catalog_item_id' => '1', 'variant_id' => '1',
|
||||
'category_id' => '1', 'featured_group_id' => '1', 'cart_id' => '1', 'cart_item_id' => '1',
|
||||
'purchase_id' => '1', 'purchase_item_id' => '1', 'sale_id' => '1', 'staff_id' => '1',
|
||||
'website_extra_code' => 'hero', 'accommodation_id' => '1', 'entry_id' => '1', 'food_id' => '1',
|
||||
'merchandise_id' => '1', 'ticket_uuid' => '00000000-0000-0000-0000-000000000000',
|
||||
'oauth_code' => '00000000-0000-0000-0000-000000000000', 'reset_code' => '1234',
|
||||
's3_path' => 'postman/test-file.png',
|
||||
];
|
||||
|
||||
$collection = [
|
||||
'info' => [
|
||||
'_postman_id' => '76fd6fd2-53b9-4d92-8e02-1ddcc6207fa2',
|
||||
'name' => 'ShopIt API — Complete',
|
||||
'description' => "Colección canónica generada desde las rutas reales de Laravel. Incluye {$registeredOperations} operaciones HTTP, ejemplos de payload, filtros, archivos y tokens separados para Storefront, Admin App y Scanner.\n\nUso rápido:\n1. Ajustá `base_url` y las credenciales.\n2. Ejecutá el Login de la aplicación correspondiente; el token se guarda automáticamente.\n3. Ajustá los IDs y códigos de las variables de colección.\n\nRegeneración: `php postman/generate-shopit-collection.php`.",
|
||||
'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
|
||||
],
|
||||
'item' => $items,
|
||||
'variable' => array_map(
|
||||
fn (string $key, string $value): array => ['key' => $key, 'value' => $value, 'type' => 'string'],
|
||||
array_keys($variables),
|
||||
array_values($variables),
|
||||
),
|
||||
];
|
||||
|
||||
$output = $root.DIRECTORY_SEPARATOR.'ShopIt_API_Postman_Collection.json';
|
||||
file_put_contents($output, json_encode($collection, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).PHP_EOL);
|
||||
|
||||
fwrite(STDOUT, "Generated {$registeredOperations} operations in {$output}\n");
|
||||
@@ -9,6 +9,7 @@ require __DIR__.'/../app/Domains/Purchase/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Sale/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Bootstrap/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/Menu/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Ticket/routes/api.php';
|
||||
|
||||
@@ -75,6 +75,21 @@ class CartControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$tenant->update(['display_cart_item_images' => false]);
|
||||
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||
$item->attachments()->attach($this->createAttachment('cart-item'), ['orden' => 0]);
|
||||
|
||||
$this->postJson('/api/tenants/acme/cart/items', [
|
||||
'catalog_item_id' => $item->id,
|
||||
'cantidad' => 1,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.imagen', null);
|
||||
}
|
||||
|
||||
public function test_it_adds_a_specific_variant_and_merges_repeated_additions(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
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\TenantIntegration;
|
||||
use App\Domains\Integration\Services\TenantIntegrationService;
|
||||
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantIntegrationControllerTest extends TestCase
|
||||
class ClientIntegrationControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
@@ -22,19 +23,20 @@ class TenantIntegrationControllerTest extends TestCase
|
||||
'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')
|
||||
->once()
|
||||
->with(
|
||||
'test-tenant',
|
||||
Mockery::on(fn (Client $argument) => $argument->is($client)),
|
||||
Mockery::on(fn (Integration $argument) => $argument->is($integration)),
|
||||
['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' => [
|
||||
'api_key' => 'secret',
|
||||
],
|
||||
@@ -2,15 +2,20 @@
|
||||
|
||||
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\TenantIntegration;
|
||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||
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 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
|
||||
{
|
||||
@@ -23,26 +28,26 @@ class IntegrationServiceTest extends TestCase
|
||||
parent::setUp();
|
||||
|
||||
// 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
|
||||
Cache::flush();
|
||||
|
||||
$hdrKey = (string) \Illuminate\Support\Str::uuid();
|
||||
$ftrKey = (string) \Illuminate\Support\Str::uuid();
|
||||
$hdrKey = (string) Str::uuid();
|
||||
$ftrKey = (string) Str::uuid();
|
||||
|
||||
$headerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||
$headerAttachment = Attachment::create([
|
||||
'key' => $hdrKey,
|
||||
'path' => 'tenants/' . $hdrKey . '.png',
|
||||
'path' => 'tenants/'.$hdrKey.'.png',
|
||||
'filename' => 'logo_header.png',
|
||||
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
$footerAttachment = \App\Domains\Attachable\Models\Attachment::create([
|
||||
$footerAttachment = Attachment::create([
|
||||
'key' => $ftrKey,
|
||||
'path' => 'tenants/' . $ftrKey . '.png',
|
||||
'path' => 'tenants/'.$ftrKey.'.png',
|
||||
'filename' => 'logo_footer.png',
|
||||
'type' => \App\Domains\Attachable\Enums\AttachmentType::Image,
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
@@ -72,7 +77,7 @@ class IntegrationServiceTest extends TestCase
|
||||
$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
|
||||
Integration::create([
|
||||
@@ -82,13 +87,13 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
|
||||
$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);
|
||||
}
|
||||
@@ -102,16 +107,16 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'user123',
|
||||
'password' => 'pass123',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
@@ -131,16 +136,16 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
// Mock HTTP response sequence for authentication
|
||||
@@ -149,13 +154,13 @@ class IntegrationServiceTest extends TestCase
|
||||
->push([
|
||||
'status' => 'ok',
|
||||
'token' => 'mock-jwt-token-123',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200)
|
||||
->push([
|
||||
'status' => 'ok',
|
||||
'token' => 'new-mock-jwt-token',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
], 200)
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
@@ -181,6 +186,50 @@ class IntegrationServiceTest extends TestCase
|
||||
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
|
||||
{
|
||||
Integration::create([
|
||||
@@ -190,23 +239,23 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => '',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
$service->forTenant($this->tenant->codigo);
|
||||
|
||||
$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();
|
||||
}
|
||||
@@ -220,26 +269,26 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'error',
|
||||
'message' => 'Invalid credentials'
|
||||
], 401)
|
||||
'message' => 'Invalid credentials',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('error')
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with('Telepagos authentication failed: Invalid credentials', \Mockery::on(function ($context) {
|
||||
return $context['username'] === 'tele_user'
|
||||
@@ -251,7 +300,7 @@ class IntegrationServiceTest extends TestCase
|
||||
$service->forTenant($this->tenant->codigo);
|
||||
|
||||
$this->expectException(Exception::class);
|
||||
$this->expectExceptionMessage("Telepagos authentication failed: Invalid credentials");
|
||||
$this->expectExceptionMessage('Telepagos authentication failed: Invalid credentials');
|
||||
|
||||
$service->getToken();
|
||||
}
|
||||
@@ -265,28 +314,28 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'mock-jwt-token-123',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200),
|
||||
'https://api.telepagos.com.ar/v1/payments' => Http::response([
|
||||
'status' => 'success',
|
||||
'payment_id' => 999
|
||||
], 200)
|
||||
'payment_id' => 999,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
@@ -294,7 +343,7 @@ class IntegrationServiceTest extends TestCase
|
||||
|
||||
// Get configured client and perform GET request
|
||||
$client = $service->client();
|
||||
$this->assertInstanceOf(\Illuminate\Http\Client\PendingRequest::class, $client);
|
||||
$this->assertInstanceOf(PendingRequest::class, $client);
|
||||
|
||||
$response = $client->get('/v1/payments');
|
||||
|
||||
@@ -317,29 +366,29 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'mock-jwt-token-123',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([
|
||||
'status' => 'ok',
|
||||
'qr_code' => 'mock-qr-code-data',
|
||||
'qr_order_id' => 6353
|
||||
], 200)
|
||||
'qr_order_id' => 6353,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
@@ -369,31 +418,31 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'mock-jwt-token-123',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/qr/generate' => Http::response([
|
||||
'status' => 'error',
|
||||
'message' => 'Importe inválido'
|
||||
], 422)
|
||||
'message' => 'Importe inválido',
|
||||
], 422),
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('error')
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with('Telepagos QR generation failed: Importe inválido', \Mockery::on(function ($context) {
|
||||
return $context['amount'] === 1200.00
|
||||
@@ -407,7 +456,7 @@ class IntegrationServiceTest extends TestCase
|
||||
$service->forTenant($this->tenant->codigo);
|
||||
|
||||
$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');
|
||||
}
|
||||
@@ -421,29 +470,29 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'mock-jwt-token-123',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
|
||||
'status' => 'ok',
|
||||
'buyer' => [
|
||||
'cuit' => '20416561398',
|
||||
'cvu' => '0000124900000000011974'
|
||||
'cvu' => '0000124900000000011974',
|
||||
],
|
||||
'amount' => 1200,
|
||||
'concept' => 'VAR',
|
||||
@@ -452,8 +501,8 @@ class IntegrationServiceTest extends TestCase
|
||||
'description' => 'Pago prueba',
|
||||
'transaction_id' => '2026070364',
|
||||
'qr_order_id' => 6351,
|
||||
'link_id' => null
|
||||
], 200)
|
||||
'link_id' => null,
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new TelepagosIntegrationService('telepagos');
|
||||
@@ -482,31 +531,31 @@ class IntegrationServiceTest extends TestCase
|
||||
'integration_data_schema' => [
|
||||
'username' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $this->tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'tele_user',
|
||||
'password' => 'tele_pass',
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'mock-jwt-token-123',
|
||||
'expires_at' => now()->addHour()->toDateTimeString()
|
||||
'expires_at' => now()->addHour()->toDateTimeString(),
|
||||
], 200),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
|
||||
'status' => 'error',
|
||||
'message' => 'Cashin no encontrado'
|
||||
], 404)
|
||||
'message' => 'Cashin no encontrado',
|
||||
], 404),
|
||||
]);
|
||||
|
||||
\Illuminate\Support\Facades\Log::shouldReceive('error')
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with('Telepagos get cash-in details failed: Cashin no encontrado', \Mockery::on(function ($context) {
|
||||
return $context['cashin_id'] === 6351
|
||||
@@ -518,7 +567,7 @@ class IntegrationServiceTest extends TestCase
|
||||
$service->forTenant($this->tenant->codigo);
|
||||
|
||||
$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);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ 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\TenantIntegration;
|
||||
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||
use App\Domains\Integration\Services\MailService;
|
||||
use App\Domains\Integration\Services\TenantIntegrationService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Mail\Mailable;
|
||||
@@ -21,12 +21,12 @@ class MailServiceTest extends TestCase
|
||||
{
|
||||
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();
|
||||
$this->createEmailIntegration();
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $tenant->client_id,
|
||||
'integration_code' => 'email',
|
||||
'integration_data' => $this->emailData(),
|
||||
]);
|
||||
@@ -40,7 +40,7 @@ class MailServiceTest extends TestCase
|
||||
$manager->shouldReceive('build')
|
||||
->once()
|
||||
->with(Mockery::on(fn (array $config): bool => $config === [
|
||||
'name' => 'tenant-smtp-acme',
|
||||
'name' => 'client-smtp-'.$tenant->client_id,
|
||||
'transport' => 'smtp',
|
||||
'scheme' => 'smtp',
|
||||
'host' => 'smtp.example.com',
|
||||
@@ -54,10 +54,10 @@ class MailServiceTest extends TestCase
|
||||
|
||||
$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();
|
||||
config(['mail.default' => 'array']);
|
||||
@@ -65,7 +65,7 @@ class MailServiceTest extends TestCase
|
||||
Integration::create([
|
||||
'integration_code' => 'email',
|
||||
'name' => 'Email',
|
||||
'requires_tenant_configuration' => false,
|
||||
'requires_client_configuration' => false,
|
||||
]);
|
||||
|
||||
$service = (new MailService)->forTenant($tenant->codigo);
|
||||
@@ -84,8 +84,8 @@ class MailServiceTest extends TestCase
|
||||
Mail::fake();
|
||||
$tenant = $this->createTenant();
|
||||
$this->createEmailIntegration();
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $tenant->client_id,
|
||||
'integration_code' => 'email',
|
||||
'integration_data' => $this->emailData(),
|
||||
]);
|
||||
@@ -109,8 +109,8 @@ class MailServiceTest extends TestCase
|
||||
$tenant = $this->createTenant();
|
||||
$integration = $this->createEmailIntegration();
|
||||
|
||||
app(TenantIntegrationService::class)->updateOrCreateIntegration(
|
||||
$tenant->codigo,
|
||||
app(ClientIntegrationService::class)->updateOrCreateIntegration(
|
||||
$tenant->client,
|
||||
$integration,
|
||||
$this->emailData(),
|
||||
);
|
||||
|
||||
@@ -11,8 +11,8 @@ use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Integration\Models\ClientIntegration;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Models\TenantIntegration;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
@@ -357,8 +357,8 @@ class TelepagosWebhookTest extends TestCase
|
||||
],
|
||||
]);
|
||||
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'user123',
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace Tests\Feature\MailTest;
|
||||
|
||||
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\TenantIntegration;
|
||||
use App\Domains\MailTest\Mailables\TestMail;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -171,8 +171,8 @@ class MailTestControllerTest extends TestCase
|
||||
'integration_code' => 'email',
|
||||
'name' => 'Email',
|
||||
]);
|
||||
TenantIntegration::create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
ClientIntegration::create([
|
||||
'client_id' => $tenant->client_id,
|
||||
'integration_code' => 'email',
|
||||
'integration_data' => [
|
||||
'MAIL_SCHEME' => 'smtp',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AddCartEditingEnabledToTenantsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_disables_cart_editing_only_for_desfile(): void
|
||||
{
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_18_060000_add_cart_editing_enabled_to_tenants_table.php'
|
||||
);
|
||||
|
||||
$migration->down();
|
||||
$migration->up();
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => 'desfile_pura_tendencia',
|
||||
'cart_editing_enabled' => false,
|
||||
]);
|
||||
|
||||
DB::table('tenants')->insert([
|
||||
'codigo' => 'cart-editing-default',
|
||||
'nombre' => 'Cart editing default',
|
||||
'dominio' => 'cart-editing-default.test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => 'cart-editing-default',
|
||||
'cart_editing_enabled' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AddDisplayCartItemImagesToTenantsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_hides_cart_item_images_only_for_desfile(): void
|
||||
{
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_18_061000_add_display_cart_item_images_to_tenants_table.php'
|
||||
);
|
||||
|
||||
$migration->down();
|
||||
$migration->up();
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => 'desfile_pura_tendencia',
|
||||
'display_cart_item_images' => false,
|
||||
]);
|
||||
|
||||
DB::table('tenants')->insert([
|
||||
'codigo' => 'cart-images-default',
|
||||
'nombre' => 'Cart images default',
|
||||
'dominio' => 'cart-images-default.test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => 'cart-images-default',
|
||||
'display_cart_item_images' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SeparateTenantDomainAndBasePathTest extends TestCase
|
||||
{
|
||||
private string $originalConnection;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->originalConnection = DB::getDefaultConnection();
|
||||
config()->set('database.connections.tenant_path_test', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => true,
|
||||
]);
|
||||
DB::setDefaultConnection('tenant_path_test');
|
||||
|
||||
Schema::create('tenants', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('dominio');
|
||||
$table->unique('dominio', 'tenants_dominio_unique');
|
||||
});
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
DB::purge('tenant_path_test');
|
||||
DB::setDefaultConnection($this->originalConnection);
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_splits_existing_tenant_keys_and_enforces_composite_uniqueness(): void
|
||||
{
|
||||
DB::table('tenants')->insert([
|
||||
['dominio' => 'onticket.com.ar'],
|
||||
['dominio' => 'onticket.com.ar/desfile/'],
|
||||
['dominio' => 'https://ONTICKET.COM.AR/sonder'],
|
||||
]);
|
||||
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php'
|
||||
);
|
||||
$migration->up();
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'dominio' => 'onticket.com.ar',
|
||||
'base_path' => '/',
|
||||
]);
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'dominio' => 'onticket.com.ar',
|
||||
'base_path' => '/desfile',
|
||||
]);
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'dominio' => 'onticket.com.ar',
|
||||
'base_path' => '/sonder',
|
||||
]);
|
||||
|
||||
$this->expectException(QueryException::class);
|
||||
|
||||
DB::table('tenants')->insert([
|
||||
'dominio' => 'onticket.com.ar',
|
||||
'base_path' => '/desfile',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_recombines_tenant_locations_when_rolled_back(): void
|
||||
{
|
||||
DB::table('tenants')->insert(['dominio' => 'onticket.com.ar/desfile']);
|
||||
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_18_060000_separate_tenant_domain_and_base_path.php'
|
||||
);
|
||||
$migration->up();
|
||||
$migration->down();
|
||||
|
||||
$this->assertFalse(Schema::hasColumn('tenants', 'base_path'));
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'dominio' => 'onticket.com.ar/desfile',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class NotificationMailServiceTest extends TestCase
|
||||
'integration_code' => 'email',
|
||||
'name' => 'Email',
|
||||
'url' => null,
|
||||
'requires_tenant_configuration' => false,
|
||||
'requires_client_configuration' => false,
|
||||
'integration_data_schema' => [],
|
||||
]);
|
||||
$header = Attachment::query()->create([
|
||||
|
||||
@@ -847,6 +847,30 @@ class StorePurchaseTest extends TestCase
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_detail_filters_item_images_when_the_tenant_disables_them(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$tenant->update(['display_cart_item_images' => false]);
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
$image = Attachment::create([
|
||||
'key' => (string) Str::uuid(),
|
||||
'path' => 'catalog/purchase-item.png',
|
||||
'filename' => 'purchase-item.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
$purchase->items()->firstOrFail()->update(['image_attachment_id' => $image->id]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.item_details.imagen', null);
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
namespace Tests\Feature\Seeders;
|
||||
|
||||
use Database\Seeders\DesfilePuraTendenciaSeeder;
|
||||
use Database\Seeders\MenuSeeder;
|
||||
use Database\Seeders\SocialMediaSeeder;
|
||||
use Database\Seeders\TenantSeeder;
|
||||
@@ -11,7 +12,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
class DesfilePuraTendenciaSeederTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
@@ -23,34 +24,10 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
WebsiteTypeSeeder::class,
|
||||
SocialMediaSeeder::class,
|
||||
TenantSeeder::class,
|
||||
DesfilePuraTendenciaSeeder::class,
|
||||
MenuSeeder::class,
|
||||
]);
|
||||
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_12_020000_create_desfile_pura_tendencia_tenant.php'
|
||||
);
|
||||
$migration->up();
|
||||
|
||||
$rowAndSeatMigration = require database_path(
|
||||
'migrations/2026_08_14_030000_swap_desfile_row_and_seat_semantics.php'
|
||||
);
|
||||
$rowAndSeatMigration->up();
|
||||
|
||||
$seatOptionsMigration = require database_path(
|
||||
'migrations/2026_08_14_040000_expand_desfile_seat_options.php'
|
||||
);
|
||||
$seatOptionsMigration->up();
|
||||
|
||||
$footerBackgroundMigration = require database_path(
|
||||
'migrations/2026_08_12_060000_set_desfile_footer_background_image.php'
|
||||
);
|
||||
$footerBackgroundMigration->up();
|
||||
|
||||
$browserBrandingMigration = require database_path(
|
||||
'migrations/2026_08_14_010000_set_seeded_tenant_browser_branding.php'
|
||||
);
|
||||
$browserBrandingMigration->up();
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => 'desfile_pura_tendencia',
|
||||
'nombre' => 'Desfile Pura Tendencia',
|
||||
@@ -67,6 +44,8 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
'footer_bg_color' => '#D4441C',
|
||||
'display_categories' => false,
|
||||
'display_seach_bar' => false,
|
||||
'cart_editing_enabled' => false,
|
||||
'display_cart_item_images' => false,
|
||||
'site_title' => 'Desfile Pura Tendencia',
|
||||
]);
|
||||
|
||||
@@ -73,6 +73,8 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
'display_categories' => false,
|
||||
'display_seach_bar' => false,
|
||||
'display_cart' => false,
|
||||
'cart_editing_enabled' => false,
|
||||
'display_cart_item_images' => false,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/tenants/bootstrap?dominio=acme.com&path=%2F');
|
||||
@@ -89,6 +91,8 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.display_categories', false)
|
||||
->assertJsonPath('data.display_seach_bar', false)
|
||||
->assertJsonPath('data.display_cart', false)
|
||||
->assertJsonPath('data.cart_editing_enabled', false)
|
||||
->assertJsonPath('data.display_cart_item_images', false)
|
||||
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');
|
||||
|
||||
$headerUrl = $response->json('data.header_logo');
|
||||
@@ -138,23 +142,27 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
$this->createTenant([
|
||||
'codigo' => 'pura-tendencia',
|
||||
'nombre' => 'Pura Tendencia',
|
||||
'dominio' => 'qa.onticket.com.ar/puratendencia',
|
||||
'dominio' => 'qa.onticket.com.ar',
|
||||
'base_path' => '/puratendencia',
|
||||
]);
|
||||
$this->createTenant([
|
||||
'codigo' => 'pura-tendencia-vip',
|
||||
'nombre' => 'Pura Tendencia VIP',
|
||||
'dominio' => 'qa.onticket.com.ar/puratendencia/vip',
|
||||
'dominio' => 'qa.onticket.com.ar',
|
||||
'base_path' => '/puratendencia/vip',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fproductos%2F123')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.codigo', 'pura-tendencia')
|
||||
->assertJsonPath('data.dominio', 'qa.onticket.com.ar/puratendencia');
|
||||
->assertJsonPath('data.dominio', 'qa.onticket.com.ar')
|
||||
->assertJsonPath('data.base_path', '/puratendencia');
|
||||
|
||||
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fpuratendencia%2Fvip%2Fproductos%2F123')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.codigo', 'pura-tendencia-vip')
|
||||
->assertJsonPath('data.dominio', 'qa.onticket.com.ar/puratendencia/vip');
|
||||
->assertJsonPath('data.dominio', 'qa.onticket.com.ar')
|
||||
->assertJsonPath('data.base_path', '/puratendencia/vip');
|
||||
}
|
||||
|
||||
public function test_it_uses_the_root_tenant_for_spa_paths_without_a_tenant_prefix(): void
|
||||
@@ -167,7 +175,8 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
$this->createTenant([
|
||||
'codigo' => 'sonder',
|
||||
'nombre' => 'Sonder',
|
||||
'dominio' => 'qa.onticket.com.ar/sonder',
|
||||
'dominio' => 'qa.onticket.com.ar',
|
||||
'base_path' => '/sonder',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/tenants/bootstrap?dominio=qa.onticket.com.ar&path=%2Fproducto%2F123')
|
||||
@@ -570,7 +579,8 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
|
||||
$firstResponse
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.dominio', 'acme.com/puratendencia')
|
||||
->assertJsonPath('data.dominio', 'acme.com')
|
||||
->assertJsonPath('data.base_path', '/puratendencia')
|
||||
->assertJsonPath('data.primary_color', '#111111')
|
||||
->assertJsonPath('data.secondary_color', '#222222')
|
||||
->assertJsonPath('data.danger_color', '#333333')
|
||||
@@ -609,7 +619,8 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
$differentPathResponse = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'pura-tendencia',
|
||||
'nombre' => 'Pura Tendencia',
|
||||
'dominio' => 'acme.com/sonder',
|
||||
'dominio' => 'acme.com',
|
||||
'base_path' => '/sonder/',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
@@ -622,12 +633,14 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
|
||||
$differentPathResponse
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.dominio', 'acme.com/sonder');
|
||||
->assertJsonPath('data.dominio', 'acme.com')
|
||||
->assertJsonPath('data.base_path', '/sonder');
|
||||
|
||||
$secondResponse = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'acme.com/puratendencia',
|
||||
'dominio' => 'acme.com',
|
||||
'base_path' => '/puratendencia',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
@@ -745,19 +758,25 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
|
||||
$response = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'primary_color' => '#000000',
|
||||
'cart_editing_enabled' => false,
|
||||
'display_cart_item_images' => false,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.codigo', 'acme')
|
||||
->assertJsonPath('data.nombre', 'Acme')
|
||||
->assertJsonPath('data.primary_color', '#000000');
|
||||
->assertJsonPath('data.primary_color', '#000000')
|
||||
->assertJsonPath('data.cart_editing_enabled', false)
|
||||
->assertJsonPath('data.display_cart_item_images', false);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'id' => $tenant->id,
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'primary_color' => '#000000',
|
||||
'cart_editing_enabled' => false,
|
||||
'display_cart_item_images' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -73,4 +73,14 @@ class TenantDomainNormalizerTest extends TestCase
|
||||
'/sonder-shop/productos',
|
||||
));
|
||||
}
|
||||
|
||||
public function test_it_builds_base_path_candidates_from_the_longest_path_to_root(): void
|
||||
{
|
||||
$this->assertSame([
|
||||
'/desfile/productos/123',
|
||||
'/desfile/productos',
|
||||
'/desfile',
|
||||
'/',
|
||||
], TenantDomainNormalizer::basePathCandidates('/desfile/productos/123?ref=home'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user