12 Commits

Author SHA1 Message Date
67e6211857 merge: client-owned integrations into dev 2026-08-18 17:34:31 -03:00
58471cb13d fix(seed): assign Sonder tenant to its client 2026-08-18 17:33:05 -03:00
864bed0113 feat(onticket): group event tenants under shared client 2026-08-18 17:33:05 -03:00
ced7d594e8 refactor(integrations): move configuration ownership to clients 2026-08-18 17:32:44 -03:00
38e87fff6c feat(clients): add client ownership for tenants 2026-08-18 17:32:27 -03:00
a8973f6171 feat: separate tenant domain and base path, update related models, requests, and tests 2026-08-18 17:23:01 -03:00
8e26611097 feat: add display_cart_item_images feature to tenants and update related resources 2026-08-18 16:46:45 -03:00
817d0de6d2 feat(migration): move integrations from tenants to clients and update schema 2026-08-18 16:33:05 -03:00
a2298c9de2 feat: add cart editing feature to tenants
- Added 'cart_editing_enabled' attribute to Tenant model with default value true.
- Updated StoreTenantRequest and UpdateTenantRequest to include validation for 'cart_editing_enabled'.
- Modified TenantResource to expose 'cart_editing_enabled' in API responses.
- Created migration to add 'cart_editing_enabled' column to tenants table, defaulting to true, and set to false for specific tenant.
- Updated DesfilePuraTendenciaSeeder to set 'cart_editing_enabled' to false for the desfile tenant.
- Enhanced Postman collection generation script to include 'cart_editing_enabled' in tenant creation and update requests.
- Added tests to verify the correct behavior of 'cart_editing_enabled' during migrations and tenant provisioning.
2026-08-18 16:32:17 -03:00
d73b4d1daf feat: Introduce client management and integration updates
- Added client_id field to StoreTenantRequest and UpdateTenantRequest for tenant management.
- Updated TenantResource to include client_id in the response.
- Created migration to establish clients table and link tenants to clients.
- Migrated existing tenant integrations to client_integrations table.
- Grouped specific tenants under a shared client (OnTicket) in a new migration.
- Updated seeders to reflect new client structure and relationships.
- Adjusted integration configurations to require client instead of tenant.
- Added tests for client integration functionality and ensured existing tests reflect the new client structure.
2026-08-18 16:18:34 -03:00
e6785eb5af feat(seeder): add Desfile Pura Tendencia seeder and corresponding test 2026-08-18 15:44:20 -03:00
1a3f564afd fix(seeder): update catalog path to reflect new directory structure 2026-08-18 15:27:53 -03:00
64 changed files with 7564 additions and 1898 deletions

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Integration\Controllers;
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) {

View File

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

View File

@@ -2,16 +2,18 @@
namespace App\Domains\Integration\Models;
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');
}

View File

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

View File

@@ -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;

View File

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

View File

@@ -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 ?? '')],
];
}
}

View File

@@ -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
{

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ClientIntegrationService
{
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{
return $client->integrations()
->where('integration_code', $integrationCode)
->first();
}
/** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection
{
return $client->integrations()->with('integration')->get();
}
public function updateOrCreateIntegration(
Client $client,
Integration $integration,
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();
return $clientIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
return match ($integrationCode) {
'email' => new MailService,
'telepagos', 'telepagos_homo' => new TelepagosIntegrationService($integrationCode),
default => null,
};
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Integration\Services;
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;

View File

@@ -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

View File

@@ -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)) {

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use Illuminate\Support\Facades\DB;
class TenantIntegrationService
{
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
{
return TenantIntegration::where('tenant_code', $tenantCode)
->where('integration_code', $integrationCode)
->first();
}
public function getAllForTenant(string $tenantCode)
{
return TenantIntegration::with('integration')
->where('tenant_code', $tenantCode)
->get();
}
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
{
return DB::transaction(function () use ($tenantCode, $integration, $data) {
$tenantIntegration = TenantIntegration::updateOrCreate(
[
'tenant_code' => $tenantCode,
'integration_code' => $integration->integration_code,
],
[
'integration_data' => $data,
]
);
$service = $this->resolveService($integration->integration_code);
if ($service) {
$service->forTenant($tenantCode)->onSetup();
}
return $tenantIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
switch ($integrationCode) {
case 'email':
return new MailService;
case 'telepagos':
case 'telepagos_homo':
return new TelepagosIntegrationService($integrationCode);
default:
return null;
}
}
}

View File

@@ -2,18 +2,18 @@
## Propósito
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.

View File

@@ -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']);

View File

@@ -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)

View File

@@ -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,

View File

@@ -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',
];
}

View File

@@ -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',

View File

@@ -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'],
];
}

View File

@@ -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',

View File

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

View File

@@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
];
}
};

View File

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

View File

@@ -27,6 +27,7 @@ class DatabaseSeeder extends Seeder
AuthorizationSeeder::class,
SocialMediaSeeder::class,
TenantSeeder::class,
DesfilePuraTendenciaSeeder::class,
AttributeSeeder::class,
CategorySeeder::class,
BrandSeeder::class,

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

View File

@@ -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',

View File

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

View File

@@ -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',
]
],
]
);
}

View File

@@ -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,

View File

@@ -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.',

View File

@@ -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.',

View 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");

View File

@@ -9,6 +9,7 @@ require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Sale/routes/api.php';
require __DIR__.'/../app/Domains/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';

View File

@@ -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');

View File

@@ -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',
],

View File

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

View File

@@ -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(),
);

View File

@@ -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',

View File

@@ -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',

View File

@@ -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,
]);
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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',
]);
}
}

View File

@@ -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([

View File

@@ -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');

View File

@@ -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',
]);

View File

@@ -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,
]);
}

View File

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