Compare commits
32 Commits
feature/pu
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d123cd403 | |||
| 3164a00e6c | |||
| 36cbc8da29 | |||
| 58e027cad5 | |||
| 971c5f6cd9 | |||
| 2524f00dfd | |||
| 045fcfab72 | |||
| cfe29b41a9 | |||
| bd600649b6 | |||
| 7685089540 | |||
| 889e188deb | |||
| 4a166e3cbf | |||
| d8b3a354d9 | |||
| 44f75185a1 | |||
| 3adef9341e | |||
| 3b1698ffd1 | |||
| c436302d7a | |||
| 899bf12457 | |||
| a460743ae0 | |||
| a4d7b1b789 | |||
| 10655b8c07 | |||
| adc7b21ab8 | |||
| ba1355448a | |||
| 1c76e56f2d | |||
| daa74845b7 | |||
| 05944cec9c | |||
| c820741e5f | |||
| adc4cc9595 | |||
| c67dfcc1a1 | |||
| f61b7e4dee | |||
| e53fa949cf | |||
| c51e24311f |
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
|
||||
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
|
||||
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class OnTicketFeaturedGroupController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OnTicketFeaturedGroupService $featuredGroupService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
return OnTicketFeaturedGroupResource::collection(
|
||||
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
|
||||
);
|
||||
}
|
||||
|
||||
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
|
||||
{
|
||||
$featuredGroup = $this->featuredGroupService->create(
|
||||
$this->onTicketTenant($request),
|
||||
$request->validated(),
|
||||
);
|
||||
|
||||
return OnTicketFeaturedGroupResource::make($featuredGroup)
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function update(
|
||||
UpsertOnTicketFeaturedGroupRequest $request,
|
||||
FeaturedGroup $featuredGroup,
|
||||
): OnTicketFeaturedGroupResource {
|
||||
$tenant = $this->onTicketTenant($request);
|
||||
|
||||
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
return OnTicketFeaturedGroupResource::make(
|
||||
$this->featuredGroupService->update(
|
||||
$tenant,
|
||||
$featuredGroup,
|
||||
$request->validated(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private function onTicketTenant(Request $request): Tenant
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
abort_unless($tenant->website_type_code === 'onticket', 404);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests\AdminApp;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'category_name' => ['required', 'string', 'max:255'],
|
||||
'is_featured' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin FeaturedGroup */
|
||||
class OnTicketFeaturedGroupResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'code' => $this->code,
|
||||
'category_id' => $this->category_id,
|
||||
'category_name' => $this->category->nombre,
|
||||
'group_name' => $this->group_name,
|
||||
'is_featured' => $this->product_layout === ProductLayout::Row,
|
||||
'type' => $this->source_type->value,
|
||||
'product_layout' => $this->product_layout->value,
|
||||
'group_layout' => $this->group_layout->value,
|
||||
'order' => $this->group_order,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class OnTicketFeaturedGroupService
|
||||
{
|
||||
/** @return Collection<int, FeaturedGroup> */
|
||||
public function forTenant(Tenant $tenant): Collection
|
||||
{
|
||||
return FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('source_type', FeaturedGroupSource::Category)
|
||||
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
||||
->with('category')
|
||||
->orderBy('group_order')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @param array{category_name: string, is_featured: bool} $data */
|
||||
public function create(Tenant $tenant, array $data): FeaturedGroup
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
||||
$category = $tenant->categories()->create([
|
||||
'nombre' => $data['category_name'],
|
||||
]);
|
||||
|
||||
$featuredGroup = FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'source_type' => FeaturedGroupSource::Category,
|
||||
'category_id' => $category->id,
|
||||
'product_layout' => $this->productLayout($data['is_featured']),
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
'group_name' => $data['category_name'],
|
||||
'group_order' => $this->nextOrder($tenant),
|
||||
]);
|
||||
|
||||
return $featuredGroup->setRelation('category', $category);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{category_name: string, is_featured: bool} $data */
|
||||
public function update(
|
||||
Tenant $tenant,
|
||||
FeaturedGroup $featuredGroup,
|
||||
array $data,
|
||||
): FeaturedGroup {
|
||||
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
||||
$featuredGroup = FeaturedGroup::query()
|
||||
->whereKey($featuredGroup->getKey())
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('source_type', FeaturedGroupSource::Category)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$category = Category::query()
|
||||
->whereKey($featuredGroup->category_id)
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$category->update(['nombre' => $data['category_name']]);
|
||||
$featuredGroup->update([
|
||||
'group_name' => $data['category_name'],
|
||||
'product_layout' => $this->productLayout($data['is_featured']),
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
]);
|
||||
|
||||
return $featuredGroup->setRelation('category', $category);
|
||||
});
|
||||
}
|
||||
|
||||
private function productLayout(bool $isFeatured): ProductLayout
|
||||
{
|
||||
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
||||
}
|
||||
|
||||
private function nextOrder(Tenant $tenant): int
|
||||
{
|
||||
$maximumOrder = FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->max('group_order');
|
||||
|
||||
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
||||
}
|
||||
}
|
||||
@@ -21,16 +21,11 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
||||
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||
|
||||
## Endpoints de tienda
|
||||
|
||||
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
||||
|
||||
## Endpoints administrativos
|
||||
|
||||
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
||||
|
||||
## Dependencias y reglas
|
||||
|
||||
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||
->name('adminapp.featured-groups.index');
|
||||
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||
->name('adminapp.featured-groups.store');
|
||||
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
|
||||
->name('adminapp.featured-groups.update');
|
||||
});
|
||||
@@ -15,5 +15,3 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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,
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Client\Controllers\ClientController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('clients', ClientController::class);
|
||||
@@ -10,7 +10,6 @@ use App\Domains\Desfile\Services\EntryService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class EntryController extends Controller
|
||||
{
|
||||
@@ -48,10 +47,4 @@ class EntryController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function destroyImage(Request $request): Response
|
||||
{
|
||||
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,19 +166,6 @@ class EntryService
|
||||
return $this->current($tenant);
|
||||
}
|
||||
|
||||
public function deleteImage(Tenant $tenant): void
|
||||
{
|
||||
$attachment = DB::transaction(function () use ($tenant): Attachment {
|
||||
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
|
||||
$entry->allAttachments()->detach($attachment->id);
|
||||
|
||||
return $attachment;
|
||||
});
|
||||
|
||||
$this->deleteIfUnused($attachment);
|
||||
}
|
||||
|
||||
/** @return Collection<string, ItemAttribute> */
|
||||
private function itemAttributes(CatalogItem $entry): Collection
|
||||
{
|
||||
|
||||
@@ -14,6 +14,4 @@ Route::prefix('v1/adminapp/tenant/desfile')
|
||||
->name('adminapp.desfile.entries.image.replace');
|
||||
Route::patch('entries/image', [EntryController::class, 'updateImage'])
|
||||
->name('adminapp.desfile.entries.image.update');
|
||||
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
|
||||
->name('adminapp.desfile.entries.image.destroy');
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use App\Domains\Integration\Requests\StoreIntegrationRequest;
|
||||
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
|
||||
use Illuminate\Routing\Controller;
|
||||
|
||||
class IntegrationController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return response()->json(Integration::all());
|
||||
}
|
||||
|
||||
public function store(StoreIntegrationRequest $request)
|
||||
{
|
||||
$integration = Integration::create($request->validated());
|
||||
|
||||
return response()->json($integration, 201);
|
||||
}
|
||||
|
||||
public function show(Integration $integration)
|
||||
{
|
||||
return response()->json($integration);
|
||||
}
|
||||
|
||||
public function update(UpdateIntegrationRequest $request, Integration $integration)
|
||||
{
|
||||
$integration->update($request->validated());
|
||||
|
||||
return response()->json($integration->fresh());
|
||||
}
|
||||
|
||||
public function destroy(Integration $integration)
|
||||
{
|
||||
$integration->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
$service->handleWebhook($client, $cashinId);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'code' => 'integration.webhook_failed',
|
||||
'message' => __('api.integration.webhook_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TelepagosWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'id' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateIntegrationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$integration = $this->route('integration');
|
||||
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_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 ?? '')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkoutService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(Client $client, string $cashinId): void
|
||||
{
|
||||
Log::channel('telepagos')->info('Telepagos webhook received.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forClient($client);
|
||||
|
||||
try {
|
||||
$details = $telepagosService->getCashinDetails($cashinId);
|
||||
|
||||
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
||||
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||
|
||||
$paymentData = [
|
||||
'compra_id' => null,
|
||||
'matched_purchase_ids' => null,
|
||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||
'amount' => $amount,
|
||||
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
||||
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
||||
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
|
||||
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
$transferenciaOperationIds = [1, 3, 11];
|
||||
$qrOperationIds = [31, 37, 47];
|
||||
|
||||
$compra = null;
|
||||
|
||||
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
||||
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
||||
|
||||
if (! $cuit) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$tenantCodes = $client->tenants()->pluck('codigo');
|
||||
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$paymentData['matched_purchase_ids'] = $purchases->pluck('id')->all();
|
||||
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
||||
|
||||
if (! $compra) {
|
||||
if ($purchases->count() > 1) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
}
|
||||
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'amount' => $amount,
|
||||
'matches' => $purchases->count(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$compra = $telepagosQr->compra;
|
||||
|
||||
if (! $compra) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'qr_order_id' => $qrOrderId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
'purchase_status' => $compra->status,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
'cashin_amount' => $amount,
|
||||
'purchase_amount' => $totalAmount,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'operation_id' => $operationId,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentData['compra_id'] = $compra->id;
|
||||
|
||||
DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPaidPurchase($compra);
|
||||
});
|
||||
|
||||
Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'purchase_id' => $compra->id,
|
||||
'transaction_id' => $paymentData['transaction_id'],
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
protected function normalizeAmount(mixed $amount): string
|
||||
{
|
||||
return number_format((float) $amount, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,15 @@ Gestiona integraciones externas disponibles y su configuración por cliente. Un
|
||||
- `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.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- CRUD global bajo `/integrations`.
|
||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
||||
|
||||
## Logging de Telepagos
|
||||
|
||||
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
||||
Los eventos de autenticación, QR y consultas de cuenta se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
||||
|
||||
## Dependencias y reglas
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||
use App\Domains\Integration\Controllers\IntegrationController;
|
||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::group(['prefix' => 'integrations'], function () {
|
||||
Route::get('/', [IntegrationController::class, 'index']);
|
||||
Route::post('/', [IntegrationController::class, 'store']);
|
||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::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/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\MailTest\Controllers;
|
||||
|
||||
use App\Domains\MailTest\Requests\SendTestMailRequest;
|
||||
use App\Domains\MailTest\Services\MailTestService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class MailTestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected MailTestService $mailTestService,
|
||||
) {}
|
||||
|
||||
public function __invoke(SendTestMailRequest $request, string $tenantCode): JsonResponse
|
||||
{
|
||||
$tenant = Tenant::query()
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
|
||||
return response()->json(
|
||||
$this->mailTestService->send(
|
||||
$tenant,
|
||||
$request->validated('to'),
|
||||
$request->validated('subject'),
|
||||
$request->validated('message'),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\MailTest\Mailables;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TestMail extends Mailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $mailSubject,
|
||||
public readonly string $mailMessage,
|
||||
public readonly Tenant $tenant,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(subject: $this->mailSubject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
$branding = [
|
||||
'name' => $this->tenant->nombre,
|
||||
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
|
||||
'body_color' => '#334155',
|
||||
'background_color' => '#f1f5f9',
|
||||
'surface_color' => '#ffffff',
|
||||
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
|
||||
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
|
||||
];
|
||||
|
||||
return new Content(
|
||||
view: 'mail.test',
|
||||
with: [
|
||||
'tenant' => $this->tenant,
|
||||
'branding' => $branding,
|
||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\MailTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class SendTestMailRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'to' => ['required', 'string', 'email', 'max:255'],
|
||||
'subject' => ['nullable', 'string', 'max:255'],
|
||||
'message' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\MailTest\Services;
|
||||
|
||||
use App\Domains\Integration\Services\MailService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
|
||||
class MailTestService
|
||||
{
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function send(Tenant $tenant, string $recipient, ?string $subject = null, ?string $message = null): array
|
||||
{
|
||||
$subject ??= 'Prueba de correo de Shopit';
|
||||
$message ??= 'Este es un correo de prueba enviado desde Shopit.';
|
||||
|
||||
$mailService = (new MailService)->forTenant($tenant->codigo);
|
||||
$mailService->send(
|
||||
$recipient,
|
||||
$subject,
|
||||
'<h1 style="margin: 0 0 20px;">'.e($subject).'</h1>'
|
||||
.'<p>'.nl2br(e($message)).'</p>',
|
||||
);
|
||||
|
||||
return [
|
||||
'code' => 'mail.test_sent',
|
||||
'message' => __('api.mail.test_sent'),
|
||||
'recipient' => $recipient,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'mailer' => $mailService->mailerName(),
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# Dominio MailTest
|
||||
|
||||
## Propósito
|
||||
|
||||
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
|
||||
|
||||
## Componentes
|
||||
|
||||
- `MailTestController`: endpoint invocable de envío.
|
||||
- `SendTestMailRequest`: valida destinatario y contenido requerido.
|
||||
- `MailTestService`: coordina el envío de prueba.
|
||||
- `TestMail`: mailable utilizado para construir el mensaje.
|
||||
|
||||
## Endpoint
|
||||
|
||||
- `POST /{tenant_code}/mail-test/send`.
|
||||
|
||||
## Dependencias
|
||||
|
||||
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
|
||||
|
||||
## Consideraciones
|
||||
|
||||
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\MailTest\Controllers\MailTestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('{tenant_code}/mail-test/send', MailTestController::class);
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Controllers;
|
||||
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class MenuController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$menues = Menu::all();
|
||||
|
||||
return response()->json($menues);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|unique:menues,code',
|
||||
'label' => 'required|string|max:255',
|
||||
'parent_menu_code' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::exists('menues', 'code'),
|
||||
'different:code',
|
||||
],
|
||||
'content_type' => [
|
||||
'sometimes',
|
||||
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
||||
],
|
||||
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
||||
'route' => 'required|string',
|
||||
]);
|
||||
|
||||
$menu = Menu::create($validated);
|
||||
|
||||
return response()->json($menu, 201);
|
||||
}
|
||||
|
||||
public function show(Menu $menu): JsonResponse
|
||||
{
|
||||
return response()->json($menu);
|
||||
}
|
||||
|
||||
public function update(Request $request, Menu $menu): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
|
||||
'label' => 'sometimes|required|string|max:255',
|
||||
'parent_menu_code' => [
|
||||
'nullable',
|
||||
'string',
|
||||
Rule::exists('menues', 'code'),
|
||||
Rule::notIn([$menu->code]),
|
||||
],
|
||||
'content_type' => [
|
||||
'sometimes',
|
||||
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
||||
],
|
||||
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
||||
'route' => 'sometimes|required|string',
|
||||
]);
|
||||
|
||||
$menu->update($validated);
|
||||
|
||||
return response()->json($menu);
|
||||
}
|
||||
|
||||
public function destroy(Menu $menu): JsonResponse
|
||||
{
|
||||
$menu->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Controllers;
|
||||
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
|
||||
use App\Domains\Menu\Services\TenantMenuService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TenantMenuController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
|
||||
|
||||
public function store(
|
||||
StoreTenantMenuRequest $request,
|
||||
string $tenantCode,
|
||||
string $menuCode,
|
||||
): JsonResponse {
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
|
||||
|
||||
$tenantMenu = $this->tenantMenuService->configure(
|
||||
$tenant,
|
||||
$menu,
|
||||
$request->validated('static_content'),
|
||||
);
|
||||
|
||||
return response()->json($tenantMenu);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Requests;
|
||||
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class StoreTenantMenuRequest extends FormRequest
|
||||
{
|
||||
private ?Menu $menuModel = null;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->menuModel = Menu::query()
|
||||
->where('code', $this->route('menu_code'))
|
||||
->first();
|
||||
|
||||
if (! $this->menuModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'menu_code' => __('api.menu.not_found'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
|
||||
return [
|
||||
'static_content' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'static_content' => ['required', 'array'],
|
||||
];
|
||||
|
||||
foreach ($this->menuModel->static_content_schema as $field => $rule) {
|
||||
$rules["static_content.{$field}"] = $rule;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Services;
|
||||
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantMenuService
|
||||
{
|
||||
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
|
||||
{
|
||||
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
|
||||
[
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'menu_code' => $menu->code,
|
||||
],
|
||||
[
|
||||
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
|
||||
? $staticContent
|
||||
: null,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,6 @@ Define menús disponibles y permite configurar su contenido para cada tenant y r
|
||||
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
|
||||
- `MenuRole`: asociación entre menú y rol autorizado.
|
||||
|
||||
## Servicios
|
||||
|
||||
`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático.
|
||||
|
||||
## Endpoints
|
||||
|
||||
- Recurso REST `/menues` mediante `MenuController`.
|
||||
- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant.
|
||||
|
||||
## Dependencias y reglas
|
||||
|
||||
Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`.
|
||||
Depende de `Tenant` y `Authorization`. Los menús se configuran mediante seeders y relaciones internas; no se exponen endpoints públicos de administración.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Menu\Controllers\MenuController;
|
||||
use App\Domains\Menu\Controllers\TenantMenuController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('menues', MenuController::class);
|
||||
|
||||
Route::post(
|
||||
'{tenant_code}/menues/{menu_code}',
|
||||
[TenantMenuController::class, 'store']
|
||||
);
|
||||
@@ -243,15 +243,6 @@ class PurchaseController extends Controller
|
||||
], 400);
|
||||
}
|
||||
|
||||
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$checkoutService->completePurchase($compra)
|
||||
);
|
||||
}
|
||||
|
||||
public function submitForReview(
|
||||
Request $request,
|
||||
Tenant $tenant,
|
||||
|
||||
@@ -18,33 +18,6 @@ class CompleteCheckoutService
|
||||
private readonly PurchaseStateGuard $purchaseState,
|
||||
) {}
|
||||
|
||||
public function complete(Purchase $purchase): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($purchase): Purchase {
|
||||
$purchase = $this->lockPurchase($purchase);
|
||||
$this->purchaseState->assertNotExpired($purchase);
|
||||
|
||||
if ($purchase->payment_method === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'payment_method' => __('api.purchase.payment_method_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->isTerminal($purchase)) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
public function submitForReview(Purchase $purchase): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($purchase): Purchase {
|
||||
@@ -171,18 +144,6 @@ class CompleteCheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
private function isTerminal(Purchase $purchase): bool
|
||||
{
|
||||
return in_array($purchase->status, [
|
||||
Purchase::STATUS_PAID,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
Purchase::STATUS_SUPERSEDED,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function lockPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
/** @var Purchase */
|
||||
|
||||
@@ -30,11 +30,6 @@ class CheckoutService
|
||||
return $this->starter->start($tenant, $userId, $purchaseData);
|
||||
}
|
||||
|
||||
public function completePurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $this->completer->complete($purchase);
|
||||
}
|
||||
|
||||
public function submitForReview(Purchase $purchase): Purchase
|
||||
{
|
||||
return $this->completer->submitForReview($purchase);
|
||||
|
||||
@@ -9,7 +9,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
||||
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||
});
|
||||
|
||||
@@ -6,5 +6,8 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
||||
Route::get('staff', [AdminAppStaffController::class, 'index']);
|
||||
Route::post('staff', [AdminAppStaffController::class, 'store']);
|
||||
Route::put('staff/{staff}', [AdminAppStaffController::class, 'update']);
|
||||
Route::delete('staff/{staff}', [AdminAppStaffController::class, 'destroy']);
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Controllers;
|
||||
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
|
||||
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
|
||||
use App\Domains\StorageTest\Services\S3TestService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class S3TestController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected S3TestService $s3TestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public function store(StoreS3TestFileRequest $request): JsonResponse
|
||||
{
|
||||
$attachment = $this->attachmentService->store(
|
||||
$request->file('file') ?? (string) $request->validated('file_base64'),
|
||||
$request->validated('path'),
|
||||
);
|
||||
|
||||
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
|
||||
$attachment->path,
|
||||
(int) $request->validated('expires_in_minutes', 10),
|
||||
);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'id' => $attachment->id,
|
||||
'key' => $attachment->key,
|
||||
'path' => $attachment->path,
|
||||
'filename' => $attachment->filename,
|
||||
'type' => $attachment->type->value,
|
||||
'mime_type' => $attachment->mime_type,
|
||||
'extension' => $attachment->extension,
|
||||
'size' => $attachment->size,
|
||||
'temporary_url' => $temporaryUrl['temporary_url'],
|
||||
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
|
||||
],
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
|
||||
{
|
||||
return response()->json(
|
||||
$this->s3TestService->generateTemporaryUrl(
|
||||
$request->validated('path'),
|
||||
(int) $request->validated('expires_in_minutes', 10),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class GenerateS3TemporaryUrlRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreS3TestFileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
|
||||
'file_base64' => ['nullable', 'string', 'required_without:file'],
|
||||
'path' => ['required', 'string', 'max:2048'],
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class S3TestService
|
||||
{
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function storeTestFile(
|
||||
UploadedFile $file,
|
||||
?string $directory = null,
|
||||
int $expiresInMinutes = 10,
|
||||
): array {
|
||||
$directory = $this->normalizeDirectory($directory);
|
||||
$disk = Storage::disk('s3');
|
||||
$path = $disk->putFile($directory, $file);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
Log::error('S3 upload returned an empty path.', [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'filename' => basename($path),
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'extension' => $file->extension(),
|
||||
'size' => $file->getSize(),
|
||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
||||
{
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(?string $directory): string
|
||||
{
|
||||
$directory = trim((string) $directory, '/');
|
||||
|
||||
if ($directory !== '') {
|
||||
return $directory;
|
||||
}
|
||||
|
||||
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Dominio StorageTest
|
||||
|
||||
## Propósito
|
||||
|
||||
Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales.
|
||||
|
||||
## Componentes
|
||||
|
||||
- `S3TestController`: recibe solicitudes de carga y URL temporal.
|
||||
- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado.
|
||||
- `StoreS3TestFileRequest`: valida la carga.
|
||||
- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Bajo `/storage-test/s3`:
|
||||
|
||||
- `POST /upload`.
|
||||
- `GET /temporary-url`.
|
||||
|
||||
## Consideraciones
|
||||
|
||||
Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`.
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\StorageTest\Controllers\S3TestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('storage-test/s3')->group(function (): void {
|
||||
Route::post('upload', [S3TestController::class, 'store']);
|
||||
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
||||
});
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Tenant\Controllers\AdminApp;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
|
||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtraResource;
|
||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
@@ -26,20 +25,6 @@ class WebsiteExtraController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function showExtra(Request $request, string $websiteExtraCode): WebsiteExtraResource
|
||||
{
|
||||
$tenant = $this->loadTenant($request->user());
|
||||
$definition = $this->websiteExtraService->definitionForTenant($tenant, $websiteExtraCode);
|
||||
$websiteExtra = $tenant->websiteExtras
|
||||
->firstWhere('website_type_extra_id', $definition->id);
|
||||
|
||||
if (! $websiteExtra) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return WebsiteExtraResource::make($websiteExtra);
|
||||
}
|
||||
|
||||
public function update(
|
||||
UpdateWebsiteExtraRequest $request,
|
||||
string $websiteExtraCode
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Controllers;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TenantService $tenantService,
|
||||
protected TenantInformationService $tenantInformationService,
|
||||
) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$tenants = Tenant::query()
|
||||
->latest()
|
||||
->paginateFromRequest();
|
||||
|
||||
$this->tenantInformationService->loadMany($tenants->getCollection());
|
||||
|
||||
return TenantResource::collection($tenants)->response();
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->tenantService->create($request->validated());
|
||||
|
||||
return TenantResource::make(
|
||||
$this->tenantInformationService->load($tenant)
|
||||
)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant): TenantResource
|
||||
{
|
||||
return TenantResource::make(
|
||||
$this->tenantInformationService->load($tenant)
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||
{
|
||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||
|
||||
return TenantResource::make(
|
||||
$this->tenantInformationService->load($tenant)
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): Response
|
||||
{
|
||||
$tenant->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
protected bool $hasInvalidDomain = false;
|
||||
|
||||
protected bool $hasInvalidBasePath = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$rawDomain = $this->input('dominio');
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$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' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidDomain) {
|
||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||
}
|
||||
},
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
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'],
|
||||
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'phone' => ['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})$/'],
|
||||
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'social_media' => ['sometimes', 'array'],
|
||||
'social_media.*.code' => [
|
||||
'required',
|
||||
'string',
|
||||
'distinct',
|
||||
Rule::exists('social_media', 'code'),
|
||||
],
|
||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
||||
'checkout_editing_policy' => [
|
||||
'sometimes',
|
||||
Rule::in([CartEditingPolicy::Disabled->value]),
|
||||
],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
'website_type_code' => [
|
||||
'required_with:extras',
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::exists('website_type', 'codigo'),
|
||||
],
|
||||
], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code')));
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
protected bool $hasInvalidDomain = false;
|
||||
|
||||
protected bool $hasInvalidBasePath = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('dominio')) {
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& ($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]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @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',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
||||
],
|
||||
'nombre' => ['nullable', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidDomain) {
|
||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||
}
|
||||
},
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
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'],
|
||||
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'phone' => ['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})$/'],
|
||||
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'social_media' => ['sometimes', 'array'],
|
||||
'social_media.*.code' => [
|
||||
'required',
|
||||
'string',
|
||||
'distinct',
|
||||
Rule::exists('social_media', 'code'),
|
||||
],
|
||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
||||
'checkout_editing_policy' => [
|
||||
'sometimes',
|
||||
Rule::in([CartEditingPolicy::Disabled->value]),
|
||||
],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Models\AttachmentCrop;
|
||||
use App\Domains\Tenant\Models\WebsiteExtra;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin WebsiteExtra
|
||||
*/
|
||||
class WebsiteExtraResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'codigo' => $this->websiteTypeExtra->codigo,
|
||||
'nombre' => $this->websiteTypeExtra->nombre,
|
||||
'descripcion' => $this->websiteTypeExtra->descripcion,
|
||||
'is_required' => $this->websiteTypeExtra->is_required,
|
||||
'is_enabled' => $this->is_enabled,
|
||||
'request_rules' => $this->websiteTypeExtra->config_schema['request_rules'] ?? [],
|
||||
'config' => $this->formatConfig(
|
||||
$this->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->key
|
||||
),
|
||||
'resolved_config' => $this->formatConfig(
|
||||
$this->resolvedAdminConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvedAdminConfig(): mixed
|
||||
{
|
||||
$config = $this->resolvedConfig();
|
||||
|
||||
if (
|
||||
$this->websiteTypeExtra->codigo !== 'heroConfig'
|
||||
|| ! is_array($config)
|
||||
|| ! ($config['background_image_id'] ?? null) instanceof Attachment
|
||||
) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
$attachment = $config['background_image_id'];
|
||||
$fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0];
|
||||
$crops = $attachment->cropVariants->keyBy('variant');
|
||||
$config['background_image_id'] = [
|
||||
'url' => $attachment->getTemporaryUrl(1440),
|
||||
'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys(
|
||||
function (string $variant) use ($crops, $fullRange): array {
|
||||
$crop = $crops->get($variant);
|
||||
|
||||
return [$variant => [
|
||||
'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange,
|
||||
'crop_vertical' => $crop?->crop_vertical ?? $fullRange,
|
||||
]];
|
||||
}
|
||||
)->all(),
|
||||
];
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function formatConfig(mixed $value, callable $formatAttachment): mixed
|
||||
{
|
||||
if ($value instanceof Attachment) {
|
||||
return $formatAttachment($value);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
||||
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
||||
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
||||
Route::patch('website-extras/{websiteExtraCode}/toggle', [WebsiteExtraController::class, 'toggle']);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Tenant\Controllers\TenantController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('tenants', TenantController::class);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "8faefdb8-734a-4262-a3b6-4d49e56ea901",
|
||||
"name": "Storage Test S3",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"variable": [
|
||||
{
|
||||
"key": "base_url",
|
||||
"value": "http://127.0.0.1:8000"
|
||||
},
|
||||
{
|
||||
"key": "expires_in_minutes",
|
||||
"value": "10"
|
||||
},
|
||||
{
|
||||
"key": "path",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"item": [
|
||||
{
|
||||
"name": "Upload Test File",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "file",
|
||||
"type": "file",
|
||||
"src": []
|
||||
},
|
||||
{
|
||||
"key": "directory",
|
||||
"value": "testing/manual",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "expires_in_minutes",
|
||||
"value": "{{expires_in_minutes}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/storage-test/s3/upload",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"storage-test",
|
||||
"s3",
|
||||
"upload"
|
||||
]
|
||||
},
|
||||
"description": "Sube un archivo al disco s3 y devuelve el path junto con una temporary_url."
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Generate Temporary URL",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/storage-test/s3/temporary-url?path={{path}}&expires_in_minutes={{expires_in_minutes}}",
|
||||
"host": [
|
||||
"{{base_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"storage-test",
|
||||
"s3",
|
||||
"temporary-url"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "path",
|
||||
"value": "{{path}}"
|
||||
},
|
||||
{
|
||||
"key": "expires_in_minutes",
|
||||
"value": "{{expires_in_minutes}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Genera una URL temporal para un path ya existente en S3."
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -67,18 +67,7 @@ function bodyFor(string $method, string $uri): ?array
|
||||
'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}}'],
|
||||
@@ -92,11 +81,8 @@ function bodyFor(string $method, string $uri): ?array
|
||||
'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]]],
|
||||
@@ -162,10 +148,6 @@ function bodyFor(string $method, string $uri): ?array
|
||||
]);
|
||||
}
|
||||
|
||||
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']);
|
||||
}
|
||||
@@ -194,7 +176,6 @@ function queryFor(string $uri): array
|
||||
['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 => [],
|
||||
};
|
||||
}
|
||||
@@ -213,14 +194,6 @@ function folderFor(string $uri, string $action): array
|
||||
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];
|
||||
}
|
||||
@@ -268,8 +241,7 @@ 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',
|
||||
'integration_code' => 'integration_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',
|
||||
@@ -420,14 +392,13 @@ $variables = [
|
||||
'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',
|
||||
'client_id' => '1', 'integration_code' => 'telepagos',
|
||||
'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 = [
|
||||
|
||||
@@ -3,15 +3,11 @@
|
||||
require __DIR__.'/../app/Domains/Auth/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Catalog/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Cart/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/MailTest/routes/api.php';
|
||||
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';
|
||||
require __DIR__.'/../app/Domains/Event/routes/api.php';
|
||||
require __DIR__.'/../app/Domains/Forms/routes/api.php';
|
||||
|
||||
@@ -199,7 +199,7 @@ class BundleCatalogItemTest extends TestCase
|
||||
'email' => 'bundle@example.com',
|
||||
]);
|
||||
$purchase->update(['payment_method' => 'transfer']);
|
||||
$checkoutService->confirmPurchase($checkoutService->completePurchase($purchase));
|
||||
$checkoutService->confirmPurchase($purchase);
|
||||
|
||||
$this->assertDatabaseCount('compra_items', 1);
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Catalog;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Database\Seeders\AuthorizationSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OnTicketFeaturedGroupControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'onticket',
|
||||
'nombre' => 'OnTicket',
|
||||
]);
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'shopit',
|
||||
'nombre' => 'Shopit',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_authentication_is_required(): void
|
||||
{
|
||||
$this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertUnauthorized();
|
||||
$this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload())
|
||||
->assertUnauthorized();
|
||||
$this->putJson('/api/v1/adminapp/tenant/featured-groups/1', $this->payload())
|
||||
->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_index_returns_only_category_groups_for_the_onticket_tenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$otherTenant = $this->createTenant('other');
|
||||
$first = $this->createCategoryGroup($tenant, 'Food', order: 2);
|
||||
$second = $this->createCategoryGroup($tenant, 'Tickets', order: 1);
|
||||
$this->createCategoryGroup($otherTenant, 'Other tenant');
|
||||
FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'product_layout' => ProductLayout::Row,
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
'group_name' => 'All products',
|
||||
'group_order' => 0,
|
||||
]);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/featured-groups')
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $second->id)
|
||||
->assertJsonPath('data.0.category_name', 'Tickets')
|
||||
->assertJsonPath('data.1.id', $first->id)
|
||||
->assertJsonMissing(['category_name' => 'Other tenant'])
|
||||
->assertJsonMissing(['group_name' => 'All products']);
|
||||
}
|
||||
|
||||
public function test_store_creates_a_category_and_a_featured_horizontal_group(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$response = $this->postJson('/api/v1/adminapp/tenant/featured-groups', [
|
||||
'category_name' => 'Food',
|
||||
'is_featured' => true,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.code', 'food')
|
||||
->assertJsonPath('data.category_name', 'Food')
|
||||
->assertJsonPath('data.group_name', 'Food')
|
||||
->assertJsonPath('data.is_featured', true)
|
||||
->assertJsonPath('data.type', 'category')
|
||||
->assertJsonPath('data.product_layout', 'row')
|
||||
->assertJsonPath('data.group_layout', 'paginated');
|
||||
|
||||
$categoryId = $response->json('data.category_id');
|
||||
$this->assertDatabaseHas('categorias', [
|
||||
'id' => $categoryId,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => 'Food',
|
||||
]);
|
||||
$this->assertDatabaseHas('featured_groups', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'code' => 'food',
|
||||
'source_type' => 'category',
|
||||
'category_id' => $categoryId,
|
||||
'product_layout' => 'row',
|
||||
'group_layout' => 'paginated',
|
||||
'group_name' => 'Food',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_store_uses_column_with_cart_when_the_category_is_not_featured(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->postJson('/api/v1/adminapp/tenant/featured-groups', [
|
||||
'category_name' => 'Parking',
|
||||
'is_featured' => false,
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.is_featured', false)
|
||||
->assertJsonPath('data.product_layout', 'column_with_cart')
|
||||
->assertJsonPath('data.group_layout', 'paginated');
|
||||
}
|
||||
|
||||
public function test_update_changes_the_category_and_group_together(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$group = $this->createCategoryGroup($tenant, 'Old name', ProductLayout::ColumnWithCart);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->putJson("/api/v1/adminapp/tenant/featured-groups/{$group->id}", [
|
||||
'category_name' => 'New name',
|
||||
'is_featured' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.code', 'old-name')
|
||||
->assertJsonPath('data.category_name', 'New name')
|
||||
->assertJsonPath('data.group_name', 'New name')
|
||||
->assertJsonPath('data.is_featured', true)
|
||||
->assertJsonPath('data.product_layout', 'row')
|
||||
->assertJsonPath('data.group_layout', 'paginated');
|
||||
|
||||
$this->assertDatabaseHas('categorias', [
|
||||
'id' => $group->category_id,
|
||||
'nombre' => 'New name',
|
||||
]);
|
||||
$this->assertDatabaseHas('featured_groups', [
|
||||
'id' => $group->id,
|
||||
'code' => 'old-name',
|
||||
'group_name' => 'New name',
|
||||
'product_layout' => 'row',
|
||||
'group_layout' => 'paginated',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_update_rejects_groups_from_another_tenant_or_source(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$otherTenant = $this->createTenant('other');
|
||||
$otherGroup = $this->createCategoryGroup($otherTenant, 'Other');
|
||||
$allGroup = FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'product_layout' => ProductLayout::Row,
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
'group_name' => 'All products',
|
||||
'group_order' => 0,
|
||||
]);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->putJson(
|
||||
"/api/v1/adminapp/tenant/featured-groups/{$otherGroup->id}",
|
||||
$this->payload(),
|
||||
)->assertNotFound();
|
||||
$this->putJson(
|
||||
"/api/v1/adminapp/tenant/featured-groups/{$allGroup->id}",
|
||||
$this->payload(),
|
||||
)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_name_and_featured_flag_are_required(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->postJson('/api/v1/adminapp/tenant/featured-groups', [
|
||||
'category_name' => '',
|
||||
'is_featured' => 'yes',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['category_name', 'is_featured']);
|
||||
|
||||
$this->assertDatabaseCount('categorias', 0);
|
||||
$this->assertDatabaseCount('featured_groups', 0);
|
||||
}
|
||||
|
||||
public function test_the_controller_is_not_available_for_non_onticket_tenants(): void
|
||||
{
|
||||
$tenant = $this->createTenant('store', 'shopit');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertNotFound();
|
||||
$this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload())
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_a_customer_cannot_manage_onticket_featured_groups(): void
|
||||
{
|
||||
Sanctum::actingAs(User::factory()->create([
|
||||
'rol_codigo' => RoleCode::User->value,
|
||||
'tenant_codigo' => null,
|
||||
]));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/featured-groups')->assertForbidden();
|
||||
$this->postJson('/api/v1/adminapp/tenant/featured-groups', $this->payload())
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
/** @return array{category_name: string, is_featured: bool} */
|
||||
private function payload(): array
|
||||
{
|
||||
return [
|
||||
'category_name' => 'Food',
|
||||
'is_featured' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function createTenant(string $code, string $websiteType = 'onticket'): Tenant
|
||||
{
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.test",
|
||||
'website_type_code' => $websiteType,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAdminAppUser(Tenant $tenant): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'rol_codigo' => RoleCode::AdminApp->value,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCategoryGroup(
|
||||
Tenant $tenant,
|
||||
string $name,
|
||||
ProductLayout $productLayout = ProductLayout::Row,
|
||||
int $order = 0,
|
||||
): FeaturedGroup {
|
||||
$category = Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => $name,
|
||||
]);
|
||||
|
||||
return FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'source_type' => FeaturedGroupSource::Category,
|
||||
'category_id' => $category->id,
|
||||
'product_layout' => $productLayout,
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
'group_name' => $name,
|
||||
'group_order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,531 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Integration;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
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\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TelepagosWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['services.integrations.secret' => 'base64:'.base64_encode(random_bytes(32))]);
|
||||
Cache::flush();
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
public function test_transfer_payment_intent_requires_a_valid_transfer_payer_dni(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createPendingTransferPurchase($tenant, $user->id, $variant->id, 1, '12345678');
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
|
||||
'method' => 'transfer',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['transfer_payer_dni']);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
|
||||
'method' => 'transfer',
|
||||
'transfer_payer_dni' => '12.345.678',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['transfer_payer_dni']);
|
||||
}
|
||||
|
||||
public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
|
||||
{
|
||||
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||
$now = now()->startOfSecond();
|
||||
$this->travelTo($now);
|
||||
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createPendingTransferPurchase($tenant, $user->id, $variant->id, 1, '12345678');
|
||||
$purchase->update(['status' => Purchase::STATUS_CREATED]);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'test-token',
|
||||
'expires_at' => now()->addHour()->toIso8601String(),
|
||||
]),
|
||||
'https://api.telepagos.com.ar/v2/account/info' => Http::response([
|
||||
'status' => 'ok',
|
||||
'holder' => 'Telepagos Test',
|
||||
'cvu' => '0000003100000000000001',
|
||||
'alias' => 'telepagos.test',
|
||||
'entity' => 'Telepagos S.A.',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
|
||||
'method' => 'transfer',
|
||||
'transfer_payer_dni' => '23456789',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('transfer_data.alias', 'telepagos.test');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'dni' => '87654321',
|
||||
'transfer_payer_dni' => '23456789',
|
||||
'payment_method' => 'transfer',
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $purchase->stock_reservation_id,
|
||||
'status' => 'active',
|
||||
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
|
||||
]);
|
||||
|
||||
$this->travelBack();
|
||||
}
|
||||
|
||||
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
|
||||
$matchingUser = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$newerUser = User::factory()->create([
|
||||
'email' => 'buyer-2@example.com',
|
||||
]);
|
||||
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
|
||||
$matchingPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
$matchingUser->id,
|
||||
$variant->id,
|
||||
1,
|
||||
'12345678'
|
||||
);
|
||||
|
||||
$newerPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
$newerUser->id,
|
||||
$variant->id,
|
||||
2,
|
||||
'12345678'
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'test-token',
|
||||
'expires_at' => now()->addHour()->toIso8601String(),
|
||||
]),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/6351' => Http::response([
|
||||
'status' => 'ok',
|
||||
'data' => [
|
||||
'amount' => 50,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-123',
|
||||
'buyer' => [
|
||||
'cuit' => '20123456789',
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/telepagos/sonder', [
|
||||
'id' => '6351',
|
||||
])->assertOk()->assertJsonPath('status', 'success');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $matchingPurchase->id,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
'payment_method' => 'transfer',
|
||||
'total' => 50,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $newerPurchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'payment_method' => 'transfer',
|
||||
'total' => 100,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('telepagos_payments', [
|
||||
'compra_id' => $matchingPurchase->id,
|
||||
'amount' => 50,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-123',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('telepagos_payments', [
|
||||
'compra_id' => $newerPurchase->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $matchingPurchase->id,
|
||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
||||
'source_variant_id' => $variant->id,
|
||||
'cantidad' => 1,
|
||||
'total' => 50,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'real_stock' => 9,
|
||||
'reserved_stock' => 2,
|
||||
'sold_units' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $newerPurchase->id,
|
||||
'cantidad' => 2,
|
||||
]);
|
||||
$newerReservationId = $newerPurchase->fresh()->stock_reservation_id;
|
||||
$this->assertNotNull($newerReservationId);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $newerReservationId,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||
'stock_reservation_id' => $newerReservationId,
|
||||
'inventory_id' => $variant->inventory_id,
|
||||
'quantity' => 2,
|
||||
]);
|
||||
|
||||
$this->assertSoftDeleted('carritos', [
|
||||
'id' => $matchingPurchase->cart_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_webhook_buys_unlimited_inventory_without_reducing_real_stock(): void
|
||||
{
|
||||
$tenant = $this->createTenant('unlimited', 'Unlimited', 'unlimited.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant(
|
||||
'unlimited',
|
||||
0,
|
||||
'50.00',
|
||||
'service',
|
||||
InventoryPolicy::Unlimited,
|
||||
);
|
||||
$purchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
$user->id,
|
||||
$variant->id,
|
||||
3,
|
||||
'87654321',
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'test-token',
|
||||
'expires_at' => now()->addHour()->toIso8601String(),
|
||||
]),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/7000' => Http::response([
|
||||
'status' => 'ok',
|
||||
'data' => [
|
||||
'amount' => 150,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-unlimited',
|
||||
'buyer' => ['cuit' => '20876543219'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/telepagos/unlimited', ['id' => '7000'])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'real_stock' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'sold_units' => 3,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_webhook_records_unmatched_payment_when_multiple_purchases_match(): void
|
||||
{
|
||||
$tenant = $this->createTenant('ambiguous', 'Ambiguous', 'ambiguous.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
|
||||
$variant = $this->createVariantForTenant('ambiguous', 10, '50.00');
|
||||
$firstPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$variant->id,
|
||||
1,
|
||||
'12345678',
|
||||
);
|
||||
$secondPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$variant->id,
|
||||
1,
|
||||
'12345678',
|
||||
);
|
||||
$thirdPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$variant->id,
|
||||
1,
|
||||
'12345678',
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'test-token',
|
||||
'expires_at' => now()->addHour()->toIso8601String(),
|
||||
]),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/ambiguous' => Http::response([
|
||||
'status' => 'ok',
|
||||
'data' => [
|
||||
'amount' => 50,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-ambiguous',
|
||||
'buyer' => [
|
||||
'cuit' => '20123456789',
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/telepagos/ambiguous', [
|
||||
'id' => 'ambiguous',
|
||||
])->assertOk()->assertJsonPath('status', 'success');
|
||||
|
||||
$this->assertDatabaseHas('telepagos_payments', [
|
||||
'compra_id' => null,
|
||||
'amount' => 50,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-ambiguous',
|
||||
]);
|
||||
$payment = TelepagosPayment::query()
|
||||
->where('transaction_id', 'tx-ambiguous')
|
||||
->firstOrFail();
|
||||
$this->assertEqualsCanonicalizing(
|
||||
[$firstPurchase->id, $secondPurchase->id, $thirdPurchase->id],
|
||||
$payment->matched_purchase_ids,
|
||||
);
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $firstPurchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
]);
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $secondPurchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
]);
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $thirdPurchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void
|
||||
{
|
||||
$tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('expired-ticket', 1, '50.00');
|
||||
$variant->catalogItem->update([
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
$purchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
$user->id,
|
||||
$variant->id,
|
||||
1,
|
||||
'87654321',
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'test-token',
|
||||
'expires_at' => now()->addHour()->toIso8601String(),
|
||||
]),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/7001' => Http::response([
|
||||
'status' => 'ok',
|
||||
'data' => [
|
||||
'amount' => 50,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-expired-ticket',
|
||||
'buyer' => ['cuit' => '20876543219'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/telepagos/expired-ticket', ['id' => '7001'])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
]);
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
||||
'source_variant_id' => $variant->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createPendingTransferPurchase(
|
||||
Tenant $tenant,
|
||||
int $userId,
|
||||
int $variantId,
|
||||
int $quantity,
|
||||
string $dni,
|
||||
): Purchase {
|
||||
$cart = Cart::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'user_id' => $userId,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$variant = Variant::query()->findOrFail($variantId);
|
||||
$cart->addItem($variant->catalog_item_id, $variant->id, $quantity);
|
||||
|
||||
/** @var CheckoutService $checkoutService */
|
||||
$checkoutService = app(CheckoutService::class);
|
||||
|
||||
$purchase = $checkoutService->startCheckout($tenant, $userId, [
|
||||
'cart_id' => $cart->id,
|
||||
'dni' => '87654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
]);
|
||||
|
||||
$purchase->update([
|
||||
'payment_method' => 'transfer',
|
||||
'transfer_payer_dni' => $dni,
|
||||
]);
|
||||
|
||||
$checkoutService->completePurchase($purchase);
|
||||
|
||||
return $purchase->fresh();
|
||||
}
|
||||
|
||||
private function configureTelepagosIntegration(Tenant $tenant): 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' => $tenant->client_id,
|
||||
'integration_code' => 'telepagos_homo',
|
||||
'integration_data' => [
|
||||
'username' => 'user123',
|
||||
'password' => 'pass123',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function createVariantForTenant(
|
||||
string $tenantCode,
|
||||
int $stock,
|
||||
string $price,
|
||||
string $slugPrefix = 'shirt',
|
||||
InventoryPolicy $inventoryPolicy = InventoryPolicy::Tracked,
|
||||
): Variant {
|
||||
$category = Category::query()->create([
|
||||
'tenant_code' => $tenantCode,
|
||||
'nombre' => "{$slugPrefix} category {$tenantCode}",
|
||||
]);
|
||||
|
||||
$inventory = Inventory::query()->create(['real_stock' => $stock]);
|
||||
$catalogItem = CatalogItem::query()->create([
|
||||
'tenant_code' => $tenantCode,
|
||||
'category_id' => $category->id,
|
||||
'slug' => "{$slugPrefix}-{$tenantCode}-".CatalogItem::query()->count(),
|
||||
'nombre' => ucfirst($slugPrefix)." {$tenantCode}",
|
||||
'descripcion' => 'Test product',
|
||||
'precio' => $price,
|
||||
'inventory_policy' => $inventoryPolicy,
|
||||
]);
|
||||
|
||||
return Variant::query()->create([
|
||||
'catalog_item_id' => $catalogItem->id,
|
||||
'inventory_id' => $inventory->id,
|
||||
])->load(['catalogItem', 'inventory']);
|
||||
}
|
||||
|
||||
private function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
||||
{
|
||||
$hdrKey = (string) Str::uuid();
|
||||
$ftrKey = (string) Str::uuid();
|
||||
|
||||
$headerAttachment = Attachment::create([
|
||||
'key' => $hdrKey,
|
||||
'path' => 'tenants/'.$hdrKey.'.png',
|
||||
'filename' => 'logo_header.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
$footerAttachment = Attachment::create([
|
||||
'key' => $ftrKey,
|
||||
'path' => 'tenants/'.$ftrKey.'.png',
|
||||
'filename' => 'logo_footer.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
return Tenant::create([
|
||||
'codigo' => $codigo,
|
||||
'nombre' => $nombre,
|
||||
'dominio' => $dominio,
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#28a745',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo_id' => $headerAttachment->id,
|
||||
'footer_logo_id' => $footerAttachment->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
<?php
|
||||
|
||||
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\MailTest\Mailables\TestMail;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MailTestControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_sends_a_test_email(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$tenant = $this->createTenant();
|
||||
|
||||
$response = $this->postJson('/api/acme/mail-test/send', [
|
||||
'to' => 'recipient@example.com',
|
||||
'subject' => 'SMTP test',
|
||||
'message' => 'Test message',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('message', 'Correo de prueba enviado correctamente.')
|
||||
->assertJsonPath('recipient', 'recipient@example.com')
|
||||
->assertJsonPath('tenant_code', 'acme')
|
||||
->assertJsonPath('mailer', 'tenant-smtp')
|
||||
->assertJsonStructure(['sent_at']);
|
||||
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($tenant): bool {
|
||||
return $mail->hasTo('recipient@example.com')
|
||||
&& $mail->subject === 'SMTP test'
|
||||
&& str_contains($mail->render(), 'Test message')
|
||||
&& str_contains($mail->render(), $tenant->nombre);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_uses_default_content_when_optional_fields_are_omitted(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->createTenant();
|
||||
|
||||
$this->postJson('/api/acme/mail-test/send', [
|
||||
'to' => 'recipient@example.com',
|
||||
])->assertOk();
|
||||
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||
return $mail->subject === 'Prueba de correo de Shopit'
|
||||
&& str_contains($mail->render(), 'Este es un correo de prueba enviado desde Shopit.');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_validates_the_recipient(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->createTenant();
|
||||
|
||||
$this->postJson('/api/acme/mail-test/send', [
|
||||
'to' => 'invalid-email',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['to']);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_the_mail_template_uses_the_tenant_branding(): void
|
||||
{
|
||||
$tenant = new Tenant([
|
||||
'codigo' => 'tenant-store',
|
||||
'nombre' => 'Tenant Store',
|
||||
'primary_color' => '#778899',
|
||||
'header_bg_color' => '#112233',
|
||||
'footer_bg_color' => '#445566',
|
||||
]);
|
||||
|
||||
$tenant->setRelation('headerLogo', new class extends Attachment
|
||||
{
|
||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||
{
|
||||
return 'https://example.com/header-logo.png';
|
||||
}
|
||||
});
|
||||
$tenant->setRelation('footerLogo', new class extends Attachment
|
||||
{
|
||||
public function getTemporaryUrl(int $expiresInMinutes = 10): string
|
||||
{
|
||||
return 'https://example.com/footer-logo.png';
|
||||
}
|
||||
});
|
||||
|
||||
$mail = new TestMail(
|
||||
'Branded email',
|
||||
'Tenant message',
|
||||
$tenant,
|
||||
);
|
||||
|
||||
$html = $mail->render();
|
||||
|
||||
$this->assertStringContainsString('https://example.com/header-logo.png', $html);
|
||||
$this->assertStringContainsString('https://example.com/footer-logo.png', $html);
|
||||
$this->assertStringContainsString('background-color: #112233', $html);
|
||||
$this->assertStringContainsString('background-color: #445566', $html);
|
||||
$this->assertStringContainsString('color: #778899', $html);
|
||||
$this->assertStringContainsString('Tenant Store', $html);
|
||||
$this->assertStringContainsString('Tenant message', $html);
|
||||
}
|
||||
|
||||
public function test_it_returns_not_found_for_an_unknown_tenant(): void
|
||||
{
|
||||
Mail::fake();
|
||||
|
||||
$this->postJson('/api/unknown/mail-test/send', [
|
||||
'to' => 'recipient@example.com',
|
||||
])->assertNotFound();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_it_does_not_resolve_the_tenant_by_id(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$tenant = $this->createTenant();
|
||||
|
||||
$this->postJson("/api/{$tenant->id}/mail-test/send", [
|
||||
'to' => 'recipient@example.com',
|
||||
])->assertNotFound();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
}
|
||||
|
||||
private function createTenant(): Tenant
|
||||
{
|
||||
$headerLogo = Attachment::create([
|
||||
'path' => 'tenants/header.png',
|
||||
'filename' => 'header.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
'extension' => 'png',
|
||||
]);
|
||||
$footerLogo = Attachment::create([
|
||||
'path' => 'tenants/footer.png',
|
||||
'filename' => 'footer.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
'extension' => 'png',
|
||||
]);
|
||||
|
||||
$tenant = Tenant::create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme Store',
|
||||
'dominio' => 'acme.example.com',
|
||||
'primary_color' => '#778899',
|
||||
'secondary_color' => '#64748b',
|
||||
'danger_color' => '#dc2626',
|
||||
'success_color' => '#16a34a',
|
||||
'header_bg_color' => '#112233',
|
||||
'footer_bg_color' => '#445566',
|
||||
'header_logo_id' => $headerLogo->id,
|
||||
'footer_logo_id' => $footerLogo->id,
|
||||
]);
|
||||
|
||||
Integration::create([
|
||||
'integration_code' => 'email',
|
||||
'name' => 'Email',
|
||||
]);
|
||||
ClientIntegration::create([
|
||||
'client_id' => $tenant->client_id,
|
||||
'integration_code' => 'email',
|
||||
'integration_data' => [
|
||||
'MAIL_SCHEME' => 'smtp',
|
||||
'MAIL_HOST' => 'smtp.example.com',
|
||||
'MAIL_PORT' => 587,
|
||||
'MAIL_USERNAME' => 'mailer@example.com',
|
||||
'MAIL_PASSWORD' => 'secret',
|
||||
'MAIL_FROM_ADDRESS' => 'store@example.com',
|
||||
],
|
||||
]);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Menu;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Database\Seeders\MenuSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TenantMenuControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_validates_static_content_with_the_menu_schema(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$menu = Menu::query()->create([
|
||||
'code' => 'about',
|
||||
'label' => 'Nosotros',
|
||||
'content_type' => Menu::CONTENT_TYPE_STATIC,
|
||||
'static_content_schema' => [
|
||||
'title' => 'required|string|max:20',
|
||||
'sections' => 'required|array|min:1',
|
||||
'sections.*.body' => 'required|string',
|
||||
],
|
||||
'route' => '/about',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/{$tenant->codigo}/menues/{$menu->code}", [
|
||||
'static_content' => [
|
||||
'title' => 123,
|
||||
'sections' => [],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'static_content.title',
|
||||
'static_content.sections',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('tenants_menues', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'menu_code' => $menu->code,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_configures_a_static_menu_when_content_matches_the_schema(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$menu = Menu::query()->create([
|
||||
'code' => 'about',
|
||||
'label' => 'Nosotros',
|
||||
'content_type' => Menu::CONTENT_TYPE_STATIC,
|
||||
'static_content_schema' => [
|
||||
'title' => 'required|string|max:20',
|
||||
'sections' => 'required|array|min:1',
|
||||
'sections.*.body' => 'required|string',
|
||||
],
|
||||
'route' => '/about',
|
||||
]);
|
||||
|
||||
$content = [
|
||||
'title' => 'Nosotros',
|
||||
'sections' => [
|
||||
['body' => 'Nuestra historia'],
|
||||
],
|
||||
];
|
||||
|
||||
$this->postJson("/api/{$tenant->codigo}/menues/{$menu->code}", [
|
||||
'static_content' => $content,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('static_content.title', 'Nosotros')
|
||||
->assertJsonPath('static_content.sections.0.body', 'Nuestra historia');
|
||||
|
||||
$this->assertSame(
|
||||
$content,
|
||||
$tenant->menues()->where('menues.code', $menu->code)->firstOrFail()->pivot->static_content
|
||||
);
|
||||
}
|
||||
|
||||
public function test_dynamic_menus_do_not_require_static_content(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$menu = Menu::query()->create([
|
||||
'code' => 'catalog',
|
||||
'label' => 'Catálogo',
|
||||
'route' => '/catalog',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/{$tenant->codigo}/menues/{$menu->code}")
|
||||
->assertOk()
|
||||
->assertJsonPath('static_content', null);
|
||||
|
||||
$this->assertDatabaseHas('tenants_menues', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'menu_code' => $menu->code,
|
||||
'static_content' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_contact_content_requires_coordinates_inside_each_address(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$this->seed(MenuSeeder::class);
|
||||
|
||||
$this->postJson("/api/{$tenant->codigo}/menues/help.contact", [
|
||||
'static_content' => [
|
||||
'whatsapp' => [
|
||||
'whatsapp_url' => 'https://wa.me/543412602222',
|
||||
'whatsapp_label' => 'Chatea con nosotros',
|
||||
],
|
||||
'phone' => '+54 9 (0341) 6658247',
|
||||
'locations' => [
|
||||
'rosario' => [
|
||||
'label' => 'Rosario',
|
||||
'addresses' => [
|
||||
[
|
||||
'label' => 'Av. San Lorenzo 1542',
|
||||
'address' => 'Av. San Lorenzo 1542, Rosario',
|
||||
'coordinates' => [-91, -181],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'map_locations' => [
|
||||
'legacy_point' => [-32.946820, -60.639320],
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'static_content.locations.rosario.addresses.0.coordinates.0',
|
||||
'static_content.locations.rosario.addresses.0.coordinates.1',
|
||||
'static_content.map_locations',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createTenant(): Tenant
|
||||
{
|
||||
$headerLogo = $this->createAttachment('header.png');
|
||||
$footerLogo = $this->createAttachment('footer.png');
|
||||
|
||||
return Tenant::query()->create([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#444444',
|
||||
'header_bg_color' => '#ffffff',
|
||||
'footer_bg_color' => '#ffffff',
|
||||
'header_logo_id' => $headerLogo->id,
|
||||
'footer_logo_id' => $footerLogo->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAttachment(string $filename): Attachment
|
||||
{
|
||||
return Attachment::query()->create([
|
||||
'path' => "test/{$filename}",
|
||||
'filename' => $filename,
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -898,57 +898,6 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_moves_a_created_purchase_to_pending_payment_when_finalized(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'buyer@example.com',
|
||||
]);
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
|
||||
$cartId = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/cart/items', [
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 2,
|
||||
])
|
||||
->assertOk()
|
||||
->json('data.id');
|
||||
|
||||
$purchaseId = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cartId,
|
||||
])
|
||||
->assertCreated()
|
||||
->json('data.id');
|
||||
|
||||
Purchase::query()
|
||||
->whereKey($purchaseId)
|
||||
->update([
|
||||
'payment_method' => 'transfer',
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchaseId}/complete")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchaseId,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'payment_method' => 'transfer',
|
||||
]);
|
||||
$this->assertDatabaseHas('value_changes', [
|
||||
'trackable_type' => (new Purchase)->getMorphClass(),
|
||||
'trackable_id' => $purchaseId,
|
||||
'attribute' => 'status',
|
||||
'old_value' => Purchase::STATUS_CREATED,
|
||||
'new_value' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'actor_type' => 'user',
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_moves_a_submitted_purchase_to_review_idempotently(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
@@ -984,11 +933,6 @@ class StorePurchaseTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [
|
||||
'method' => 'qr',
|
||||
@@ -1204,10 +1148,9 @@ class StorePurchaseTest extends TestCase
|
||||
|
||||
$purchase->update([
|
||||
'payment_method' => 'transfer',
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
]);
|
||||
|
||||
app(CheckoutService::class)->completePurchase($purchase);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
@@ -1260,7 +1203,6 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
|
||||
$checkoutService = app(CheckoutService::class);
|
||||
$purchase = $checkoutService->completePurchase($purchase);
|
||||
$checkoutService->confirmPurchase($purchase);
|
||||
$purchase->refresh()->markAsPaid();
|
||||
|
||||
@@ -1478,7 +1420,6 @@ class StorePurchaseTest extends TestCase
|
||||
$purchase->update(['payment_method' => 'transfer']);
|
||||
|
||||
$checkoutService = app(CheckoutService::class);
|
||||
$purchase = $checkoutService->completePurchase($purchase);
|
||||
$checkoutService->confirmPurchase($purchase);
|
||||
$checkoutService->confirmPurchase($purchase);
|
||||
|
||||
|
||||
@@ -88,36 +88,6 @@ class AdminAppWebsiteExtraControllerTest extends TestCase
|
||||
->assertJsonPath('data.resolved_extras.contactConfig.phone', '+54 341 555 0101');
|
||||
}
|
||||
|
||||
public function test_adminapp_user_can_read_one_website_extra(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$definition = $this->websiteType->extras()->firstOrFail();
|
||||
$tenant->websiteExtras()->create([
|
||||
'website_type_extra_id' => $definition->id,
|
||||
'config' => ['phone' => '+54 341 555 0101'],
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/website-extras/contactConfig')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.codigo', 'contactConfig')
|
||||
->assertJsonPath('data.nombre', 'Configuración de contacto')
|
||||
->assertJsonPath('data.is_enabled', true)
|
||||
->assertJsonPath('data.config.phone', '+54 341 555 0101')
|
||||
->assertJsonPath('data.resolved_config.phone', '+54 341 555 0101');
|
||||
}
|
||||
|
||||
public function test_reading_an_unconfigured_website_extra_returns_not_found(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/website-extras/contactConfig')
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_adminapp_user_updates_one_extra_without_touching_other_tenants(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
|
||||
@@ -573,367 +573,6 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.menues.0.submenues.1.code', 'tree.fox');
|
||||
}
|
||||
|
||||
public function test_it_allows_different_domain_paths_and_rejects_duplicate_tenant_keys(): void
|
||||
{
|
||||
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
$firstResponse = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'https://ACME.com/puratendencia/',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => $base64Image,
|
||||
'footer_logo' => $base64Image,
|
||||
]);
|
||||
|
||||
$firstResponse
|
||||
->assertCreated()
|
||||
->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')
|
||||
->assertJsonPath('data.success_color', '#555555')
|
||||
->assertJsonPath('data.header_bg_color', '#444444')->assertJsonPath('data.footer_bg_color', '#444444');
|
||||
|
||||
$tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail();
|
||||
|
||||
$this->assertNotNull($tenant->header_logo_id);
|
||||
$this->assertNotNull($tenant->footer_logo_id);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'codigo' => 'acme',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo_id' => $tenant->header_logo_id,
|
||||
'footer_logo_id' => $tenant->footer_logo_id,
|
||||
]);
|
||||
|
||||
$headerUrl = $firstResponse->json('data.header_logo');
|
||||
$footerUrl = $firstResponse->json('data.footer_logo');
|
||||
|
||||
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
|
||||
$this->assertStringContainsString($tenant->footerLogo->key, $footerUrl);
|
||||
$this->assertTrue(
|
||||
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
$this->assertTrue(
|
||||
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
|
||||
$differentPathResponse = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'pura-tendencia',
|
||||
'nombre' => 'Pura Tendencia',
|
||||
'dominio' => 'acme.com',
|
||||
'base_path' => '/sonder/',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => $base64Image,
|
||||
'footer_logo' => $base64Image,
|
||||
]);
|
||||
|
||||
$differentPathResponse
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.dominio', 'acme.com')
|
||||
->assertJsonPath('data.base_path', '/sonder');
|
||||
|
||||
$secondResponse = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'acme.com',
|
||||
'base_path' => '/puratendencia',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$secondResponse
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['dominio']);
|
||||
}
|
||||
|
||||
public function test_it_allows_keeping_the_same_domain_on_update_but_rejects_collisions(): void
|
||||
{
|
||||
$tenant = $this->createTenant([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
]);
|
||||
|
||||
$otherTenant = $this->createTenant([
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'globex.com',
|
||||
]);
|
||||
|
||||
$hdrUuid = (string) Str::uuid();
|
||||
$ftrUuid = (string) Str::uuid();
|
||||
|
||||
$hdrAttachment = Attachment::create([
|
||||
'key' => $hdrUuid,
|
||||
'path' => 'tenants/'.$hdrUuid.'.png',
|
||||
'filename' => 'hdr.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
$ftrAttachment = Attachment::create([
|
||||
'key' => $ftrUuid,
|
||||
'path' => 'tenants/'.$ftrUuid.'.png',
|
||||
'filename' => 'ftr.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
$successfulResponse = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme Updated',
|
||||
'dominio' => 'https://ACME.com:443/',
|
||||
'primary_color' => '#555555',
|
||||
'secondary_color' => '#666666',
|
||||
'danger_color' => '#777777',
|
||||
'success_color' => '#999999',
|
||||
'header_bg_color' => '#888888',
|
||||
'footer_bg_color' => '#888888',
|
||||
'header_logo' => $hdrUuid,
|
||||
'footer_logo' => $ftrUuid,
|
||||
]);
|
||||
|
||||
$successfulResponse
|
||||
->assertOk()
|
||||
->assertJsonPath('data.nombre', 'Acme Updated')
|
||||
->assertJsonPath('data.dominio', 'acme.com')
|
||||
->assertJsonPath('data.primary_color', '#555555')
|
||||
->assertJsonPath('data.secondary_color', '#666666')
|
||||
->assertJsonPath('data.danger_color', '#777777')
|
||||
->assertJsonPath('data.success_color', '#999999')
|
||||
->assertJsonPath('data.header_bg_color', '#888888')->assertJsonPath('data.footer_bg_color', '#888888');
|
||||
|
||||
$headerUrl = $successfulResponse->json('data.header_logo');
|
||||
$footerUrl = $successfulResponse->json('data.footer_logo');
|
||||
|
||||
$this->assertStringContainsString($hdrUuid, $headerUrl);
|
||||
$this->assertStringContainsString($ftrUuid, $footerUrl);
|
||||
$this->assertTrue(
|
||||
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
$this->assertTrue(
|
||||
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'id' => $tenant->id,
|
||||
'primary_color' => '#555555',
|
||||
'secondary_color' => '#666666',
|
||||
'danger_color' => '#777777',
|
||||
'success_color' => '#999999',
|
||||
'header_bg_color' => '#888888',
|
||||
'footer_bg_color' => '#888888',
|
||||
'header_logo_id' => $hdrAttachment->id,
|
||||
'footer_logo_id' => $ftrAttachment->id,
|
||||
]);
|
||||
|
||||
$failingResponse = $this->putJson("/api/tenants/{$otherTenant->codigo}", [
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'https://ACME.com/',
|
||||
]);
|
||||
|
||||
$failingResponse
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['dominio']);
|
||||
}
|
||||
|
||||
public function test_it_allows_partial_update_without_required_fields(): void
|
||||
{
|
||||
$tenant = $this->createTenant([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#ffffff',
|
||||
]);
|
||||
|
||||
$response = $this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'primary_color' => '#000000',
|
||||
'cart_editing_policy' => 'quantity_and_remove',
|
||||
'checkout_editing_policy' => 'disabled',
|
||||
'display_cart_item_images' => false,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.codigo', 'acme')
|
||||
->assertJsonPath('data.nombre', 'Acme')
|
||||
->assertJsonPath('data.primary_color', '#000000')
|
||||
->assertJsonPath('data.cart_editing_policy.code', 'quantity_and_remove')
|
||||
->assertJsonPath('data.cart_editing_policy.allow_modify', true)
|
||||
->assertJsonPath('data.cart_editing_policy.allow_delete', true)
|
||||
->assertJsonPath('data.cart_editing_policy.allow_update_quantity', true)
|
||||
->assertJsonPath('data.cart_editing_policy.allow_update_variant', false)
|
||||
->assertJsonPath('data.checkout_editing_policy.code', 'disabled')
|
||||
->assertJsonPath('data.checkout_editing_policy.allow_modify', false)
|
||||
->assertJsonPath('data.display_cart_item_images', false);
|
||||
|
||||
$this->assertDatabaseHas('tenants', [
|
||||
'id' => $tenant->id,
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'primary_color' => '#000000',
|
||||
'cart_editing_policy' => 'quantity_and_remove',
|
||||
'checkout_editing_policy' => 'disabled',
|
||||
'display_cart_item_images' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_validates_aesthetic_colors(): void
|
||||
{
|
||||
$response = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => 'invalid-color',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response->assertJsonValidationErrors(['primary_color']);
|
||||
|
||||
$response2 = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#12345',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response2->assertJsonValidationErrors(['primary_color']);
|
||||
|
||||
$response3 = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => 'invalid-color',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => (string) Str::uuid(),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response3->assertJsonValidationErrors(['success_color']);
|
||||
}
|
||||
|
||||
public function test_it_validates_logo_must_be_image_or_svg(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
|
||||
$response = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => UploadedFile::fake()->create('document.pdf', 10, 'application/pdf'),
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response->assertJsonValidationErrors(['header_logo']);
|
||||
|
||||
$response2 = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => 'data:application/pdf;base64,JVBERi0xLjQKJdcfqksKMSAwIG9iagogIDw8IC9UeXBlIC9DYXRhbG9nCiAgICAvUGFnZXMgMiAwIFI...',
|
||||
'footer_logo' => (string) Str::uuid(),
|
||||
]);
|
||||
|
||||
$response2->assertJsonValidationErrors(['header_logo']);
|
||||
}
|
||||
|
||||
public function test_it_stores_uploaded_file_logos_in_tenants_directory(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
|
||||
$header = UploadedFile::fake()->image('header.png');
|
||||
$footer = UploadedFile::fake()->image('footer.svg', 100, 100);
|
||||
|
||||
$response = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => $header,
|
||||
'footer_logo' => $footer,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$tenant = Tenant::query()->with(['headerLogo', 'footerLogo'])->where('codigo', 'acme')->firstOrFail();
|
||||
|
||||
$this->assertNotNull($tenant->header_logo_id);
|
||||
$this->assertNotNull($tenant->footer_logo_id);
|
||||
|
||||
$headerUrl = $response->json('data.header_logo');
|
||||
$footerUrl = $response->json('data.footer_logo');
|
||||
|
||||
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
|
||||
$this->assertStringContainsString($tenant->footerLogo->key, $footerUrl);
|
||||
$this->assertTrue(
|
||||
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
$this->assertTrue(
|
||||
str_contains($footerUrl, 'Expires=') || str_contains($footerUrl, 'expiration=') || str_contains($footerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]);
|
||||
$this->assertDatabaseHas('attachments', ['key' => $tenant->footerLogo->key]);
|
||||
}
|
||||
|
||||
private function createTenant(array $attributes = []): Tenant
|
||||
{
|
||||
$hdrKey = (string) Str::uuid();
|
||||
@@ -969,55 +608,4 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
public function test_it_stores_base64_logos_in_tenants_directory(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
|
||||
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
$ftrUuid = (string) Str::uuid();
|
||||
$footerAttachment = Attachment::create([
|
||||
'key' => $ftrUuid,
|
||||
'path' => 'tenants/'.$ftrUuid.'.png',
|
||||
'filename' => 'logo_footer.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/tenants', [
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme',
|
||||
'dominio' => 'acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#555555',
|
||||
'header_bg_color' => '#444444',
|
||||
'footer_bg_color' => '#444444',
|
||||
'header_logo' => $base64Image,
|
||||
'footer_logo' => $ftrUuid,
|
||||
'site_title' => 'Acme Store',
|
||||
'favicon' => $base64Image,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$tenant = Tenant::query()->with(['headerLogo', 'favicon'])->where('codigo', 'acme')->firstOrFail();
|
||||
|
||||
$this->assertNotNull($tenant->header_logo_id);
|
||||
$this->assertNotNull($tenant->favicon_id);
|
||||
|
||||
$headerUrl = $response->json('data.header_logo');
|
||||
$faviconUrl = $response->json('data.favicon');
|
||||
|
||||
$response->assertJsonPath('data.site_title', 'Acme Store');
|
||||
$this->assertStringContainsString($tenant->headerLogo->key, $headerUrl);
|
||||
$this->assertStringContainsString($tenant->favicon->key, $faviconUrl);
|
||||
$this->assertTrue(
|
||||
str_contains($headerUrl, 'Expires=') || str_contains($headerUrl, 'expiration=') || str_contains($headerUrl, 'X-Amz-Expires=')
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('attachments', ['key' => $tenant->headerLogo->key]);
|
||||
$this->assertDatabaseHas('attachments', ['key' => $tenant->favicon->key]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use Database\Seeders\WebsiteTypeSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StoreTenantWithExtrasTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('s3');
|
||||
$this->seed(WebsiteTypeSeeder::class);
|
||||
}
|
||||
|
||||
public function test_it_creates_a_tenant_and_validates_and_stores_its_website_extras(): void
|
||||
{
|
||||
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
|
||||
'website_type_code' => 'onticket',
|
||||
'extras' => [
|
||||
'eventConfig' => [
|
||||
'title' => 'Festival',
|
||||
'location' => 'Buenos Aires',
|
||||
'dates_text' => '10 y 11 de octubre de 2026',
|
||||
'dates' => [
|
||||
[
|
||||
'date' => '2026-10-10',
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '18:00',
|
||||
],
|
||||
[
|
||||
'date' => '2026-10-11',
|
||||
'start_time' => '10:00',
|
||||
'end_time' => '17:00',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$response
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.website_type_code', 'onticket')
|
||||
->assertJsonMissingPath('data.website_type')
|
||||
->assertJsonPath('data.extras.eventConfig.title', 'Festival');
|
||||
|
||||
$tenant = Tenant::query()->where('codigo', 'festival')->sole();
|
||||
|
||||
$this->assertDatabaseHas('websites_extras', [
|
||||
'website_code' => $tenant->codigo,
|
||||
]);
|
||||
$this->assertSame(
|
||||
[
|
||||
'title' => 'Festival',
|
||||
'location' => 'Buenos Aires',
|
||||
'dates_text' => '10 y 11 de octubre de 2026',
|
||||
'dates' => [
|
||||
[
|
||||
'date' => '2026-10-10',
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '18:00',
|
||||
],
|
||||
[
|
||||
'date' => '2026-10-11',
|
||||
'start_time' => '10:00',
|
||||
'end_time' => '17:00',
|
||||
],
|
||||
],
|
||||
],
|
||||
$tenant->websiteExtras()->sole()->config
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_extra_not_supported_by_the_selected_website_type(): void
|
||||
{
|
||||
$this->postJson('/api/tenants', array_merge($this->tenantData(), [
|
||||
'website_type_code' => 'shopit',
|
||||
'extras' => [
|
||||
'eventConfig' => [
|
||||
'title' => 'Not supported',
|
||||
],
|
||||
],
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['extras']);
|
||||
|
||||
$this->assertDatabaseMissing('tenants', ['codigo' => 'festival']);
|
||||
}
|
||||
|
||||
public function test_it_applies_the_extra_schema_rules(): void
|
||||
{
|
||||
$this->postJson('/api/tenants', array_merge($this->tenantData(), [
|
||||
'website_type_code' => 'onticket',
|
||||
'extras' => [
|
||||
'eventConfig' => [
|
||||
'dates' => [[
|
||||
'date' => 'not-a-date',
|
||||
'start_time' => '09:00',
|
||||
'end_time' => '18:00',
|
||||
]],
|
||||
],
|
||||
],
|
||||
]))
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['extras.eventConfig.dates.0.date']);
|
||||
|
||||
$this->assertDatabaseMissing('tenants', ['codigo' => 'festival']);
|
||||
}
|
||||
|
||||
public function test_it_transforms_extra_images_to_attachment_ids(): void
|
||||
{
|
||||
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
|
||||
'website_type_code' => 'shopit',
|
||||
'extras' => [
|
||||
'carousel' => [$image],
|
||||
],
|
||||
]));
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->where('codigo', 'festival')
|
||||
->sole();
|
||||
$config = $tenant->websiteExtras()
|
||||
->sole()
|
||||
->config;
|
||||
|
||||
$this->assertCount(1, $config);
|
||||
$this->assertIsInt($config[0]);
|
||||
$this->assertDatabaseHas('attachments', [
|
||||
'id' => $config[0],
|
||||
'type' => 'image',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::query()->findOrFail($config[0]);
|
||||
$carouselUrl = $response->json('data.extras.carousel.0');
|
||||
|
||||
$this->assertIsString($carouselUrl);
|
||||
$this->assertStringContainsString($attachment->key, $carouselUrl);
|
||||
|
||||
app(TenantInformationService::class)->load($tenant);
|
||||
|
||||
$this->assertInstanceOf(
|
||||
Attachment::class,
|
||||
$tenant->websiteExtras->sole()->resolvedConfig()[0]
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_returns_scalar_attachment_fields_as_temporary_urls(): void
|
||||
{
|
||||
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
$response = $this->postJson('/api/tenants', array_merge($this->tenantData(), [
|
||||
'website_type_code' => 'onticket',
|
||||
'extras' => [
|
||||
'heroConfig' => [
|
||||
'title_html' => '<h1>Festival</h1>',
|
||||
'background_image_id' => $image,
|
||||
],
|
||||
],
|
||||
]));
|
||||
|
||||
$response->assertCreated();
|
||||
|
||||
$heroConfig = Tenant::query()
|
||||
->where('codigo', 'festival')
|
||||
->sole()
|
||||
->websiteExtras()
|
||||
->whereHas(
|
||||
'websiteTypeExtra',
|
||||
fn ($query) => $query->where('codigo', 'heroConfig')
|
||||
)
|
||||
->sole()
|
||||
->config;
|
||||
|
||||
$attachment = Attachment::query()->findOrFail($heroConfig['background_image_id']);
|
||||
$backgroundUrl = $response->json('data.extras.heroConfig.background_image_id');
|
||||
|
||||
$this->assertIsString($backgroundUrl);
|
||||
$this->assertStringContainsString($attachment->key, $backgroundUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function tenantData(): array
|
||||
{
|
||||
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||
|
||||
return [
|
||||
'codigo' => 'festival',
|
||||
'nombre' => 'Festival',
|
||||
'dominio' => 'festival.test',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#444444',
|
||||
'header_bg_color' => '#ffffff',
|
||||
'footer_bg_color' => '#ffffff',
|
||||
'header_logo' => $image,
|
||||
'footer_logo' => $image,
|
||||
'search_product_layout' => 'column_with_image',
|
||||
'search_group_layout' => 'paginated',
|
||||
'search_items_per_page' => 12,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -79,145 +79,6 @@ class TenantSocialMediaTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_tenant_update_synchronizes_social_media_and_returns_pivot_urls(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$instagram = $this->createSocialMedia('instagram', 'Instagram');
|
||||
$facebook = $this->createSocialMedia('facebook', 'Facebook');
|
||||
|
||||
$tenant->socialMedia()->attach($facebook->code, [
|
||||
'url' => 'https://facebook.com/old-acme',
|
||||
]);
|
||||
|
||||
$this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'social_media' => [
|
||||
[
|
||||
'code' => $instagram->code,
|
||||
'url' => 'https://instagram.com/acme',
|
||||
'orden' => 4,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.social_media.0.code', 'instagram')
|
||||
->assertJsonPath('data.social_media.0.icon', 'instagram')
|
||||
->assertJsonPath('data.social_media.0.name', 'Instagram')
|
||||
->assertJsonPath('data.social_media.0.url', 'https://instagram.com/acme')
|
||||
->assertJsonCount(1, 'data.social_media');
|
||||
|
||||
$this->assertDatabaseHas('tenant_social_media', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'social_media_code' => $instagram->code,
|
||||
'url' => 'https://instagram.com/acme',
|
||||
'orden' => 4,
|
||||
]);
|
||||
$this->assertDatabaseMissing('tenant_social_media', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'social_media_code' => $facebook->code,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_tenant_social_media_are_returned_in_configured_order(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$instagram = $this->createSocialMedia('instagram', 'Instagram');
|
||||
$facebook = $this->createSocialMedia('facebook', 'Facebook');
|
||||
|
||||
$this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'social_media' => [
|
||||
[
|
||||
'code' => $instagram->code,
|
||||
'url' => 'https://instagram.com/acme',
|
||||
'orden' => 20,
|
||||
],
|
||||
[
|
||||
'code' => $facebook->code,
|
||||
'url' => 'https://facebook.com/acme',
|
||||
'orden' => 10,
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.social_media.0.code', 'facebook')
|
||||
->assertJsonPath('data.social_media.1.code', 'instagram')
|
||||
->assertJsonMissingPath('data.social_media.0.orden')
|
||||
->assertJsonMissingPath('data.social_media.1.orden');
|
||||
}
|
||||
|
||||
public function test_tenant_store_synchronizes_social_media(): void
|
||||
{
|
||||
$instagram = $this->createSocialMedia('instagram', 'Instagram');
|
||||
$headerLogo = $this->createAttachment('new-header.png');
|
||||
$footerLogo = $this->createAttachment('new-footer.png');
|
||||
|
||||
$this->postJson('/api/tenants', [
|
||||
'codigo' => 'new-acme',
|
||||
'nombre' => 'New Acme',
|
||||
'dominio' => 'new-acme.com',
|
||||
'primary_color' => '#111111',
|
||||
'secondary_color' => '#222222',
|
||||
'danger_color' => '#333333',
|
||||
'success_color' => '#444444',
|
||||
'header_bg_color' => '#ffffff',
|
||||
'footer_bg_color' => '#ffffff',
|
||||
'header_logo' => $headerLogo->key,
|
||||
'footer_logo' => $footerLogo->key,
|
||||
'social_media' => [
|
||||
[
|
||||
'code' => $instagram->code,
|
||||
'url' => 'https://instagram.com/new-acme',
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.social_media.0.code', 'instagram')
|
||||
->assertJsonPath('data.social_media.0.url', 'https://instagram.com/new-acme');
|
||||
|
||||
$this->assertDatabaseHas('tenant_social_media', [
|
||||
'tenant_code' => 'new-acme',
|
||||
'social_media_code' => $instagram->code,
|
||||
'url' => 'https://instagram.com/new-acme',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_tenant_update_can_clear_social_media(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$instagram = $this->createSocialMedia('instagram', 'Instagram');
|
||||
$tenant->socialMedia()->attach($instagram->code, [
|
||||
'url' => 'https://instagram.com/acme',
|
||||
]);
|
||||
|
||||
$this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'social_media' => [],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.social_media', []);
|
||||
|
||||
$this->assertDatabaseMissing('tenant_social_media', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_tenant_update_validates_social_media_codes_and_urls(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
|
||||
$this->putJson("/api/tenants/{$tenant->codigo}", [
|
||||
'social_media' => [
|
||||
[
|
||||
'code' => 'unknown',
|
||||
'url' => 'not-a-url',
|
||||
],
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'social_media.0.code',
|
||||
'social_media.0.url',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createTenant(): Tenant
|
||||
{
|
||||
$headerLogo = $this->createAttachment('header.png');
|
||||
|
||||
Reference in New Issue
Block a user