Compare commits

..

9 Commits

Author SHA1 Message Date
1d7c484510 feat(scanner): implement scanner bootstrap functionality with controller, request, resource, and service; add routes and migration for scanner domain 2026-08-12 08:40:45 -03:00
cb98652e2f feat(ticket): add client and category fields to ticket resource and update related tests 2026-08-11 16:59:50 -03:00
622a3eef86 feat(scanner): enhance ticket scanning functionality with search and pagination support 2026-08-11 16:34:14 -03:00
498b4d9eac fix(auth): correct tenant code handling in login failure registration 2026-08-11 16:05:37 -03:00
7d9489169d feat(auth): implement permission-based access for scanner functionality and update related tests 2026-08-11 14:53:51 -03:00
3eaa3f8202 feat(scanner): add scanner authentication and context services with routes and tests 2026-08-11 14:53:51 -03:00
254faedf2c feat(scanner): implement ticket scanning functionality with controller, service, and routes 2026-08-11 14:53:51 -03:00
e0f0fb1a72 feat(seeder): enhance FiestaFutbolInfantilProductSeeder to manage event dates and validity times 2026-08-11 14:26:52 -03:00
02cf3f3773 Add tests for ticket validity and event date formatting
- Create TicketValiditySchemaTest to verify database schema for ticket validity.
- Update CatalogModelsTest to include tests for event date attributes and selection options.
- Introduce EventDateTextFormatterTest for formatting event dates in Spanish.
- Refactor EventModelsTest to include validity time relationships.
- Add SaleDetailResourceTest to ensure correct serialization of purchase items.
- Enhance TicketTest with validity time checks and status management.
- Implement ValidityTimeResourceTest to validate resource output for different validity types.
- Add ValidityTimeTest to verify casting and validity checks for validity time types.
2026-08-11 12:41:35 -03:00
90 changed files with 2590 additions and 134 deletions

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\ScannerLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ScannerLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(ScannerLoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticateScanner(
$credentials['email'],
$credentials['password'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'scanner-token',
['scanner'],
now()->addMinutes($expirationMinutes),
)->plainTextToken;
return response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\ScannerMeResource;
use App\Domains\Auth\Services\ScannerContextService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ScannerMeController extends Controller
{
public function __construct(
private readonly ScannerContextService $scannerContextService,
) {}
public function __invoke(Request $request): ScannerMeResource
{
/** @var User $user */
$user = $request->user();
return ScannerMeResource::make($this->scannerContextService->load($user));
}
}

View File

@@ -53,6 +53,16 @@ class User extends Authenticatable
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo'); return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
} }
public function hasPermission(string $permissionCode): bool
{
return $this->role()
->whereHas(
'permissions',
fn ($query) => $query->where('permisos.codigo', $permissionCode)
)
->exists();
}
/** /**
* @return BelongsTo<Tenant, $this> * @return BelongsTo<Tenant, $this>
*/ */

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Auth\Requests;
class ScannerLoginRequest extends AdminAppLoginRequest {}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Resources\TenantResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class ScannerMeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant),
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt; use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -62,14 +63,39 @@ class PasswordLoginService
); );
} }
/**
* Authenticate a tenant-bound user authorized to scan tickets.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateScanner(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
null,
true,
PermissionCode::ScanTickets->value,
);
}
private function authenticateUser( private function authenticateUser(
string $email, string $email,
string $password, string $password,
?string $tenantCode, ?string $tenantCode,
?string $ipAddress, ?string $ipAddress,
?string $userAgent, ?string $userAgent,
RoleCode $requiredRole = RoleCode::User, ?RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false, bool $requiresTenant = false,
?string $requiredPermission = null,
): User { ): User {
$normalizedEmail = mb_strtolower(trim($email)); $normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now(); $now = CarbonImmutable::now();
@@ -84,10 +110,21 @@ class PasswordLoginService
$now, $now,
$requiredRole, $requiredRole,
$requiresTenant, $requiresTenant,
$requiredPermission,
): array { ): array {
$user = User::query() $user = User::query()
->where('email', $normalizedEmail) ->where('email', $normalizedEmail)
->where('rol_codigo', $requiredRole->value) ->when(
$requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
)
->when(
$requiredPermission !== null,
fn ($query) => $query->whereHas(
'role.permissions',
fn ($query) => $query->where('permisos.codigo', $requiredPermission)
),
)
->when( ->when(
$requiresTenant, $requiresTenant,
fn ($query) => $query->whereNotNull('tenant_codigo'), fn ($query) => $query->whereNotNull('tenant_codigo'),
@@ -122,8 +159,8 @@ class PasswordLoginService
} }
if ($user === null || ! Hash::check($password, $user->password)) { if ($user === null || ! Hash::check($password, $user->password)) {
if ($user !== null) { if ($user !== null && $attemptTenantCode !== null) {
$this->registerFailure($user, $now, $tenantCode); $this->registerFailure($user, $now, $attemptTenantCode);
} }
$outcome = $user?->locked_until?->isFuture() $outcome = $user?->locked_until?->isFuture()
@@ -210,7 +247,7 @@ class PasswordLoginService
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error('Failed to trigger reset password on account lock', [ Log::error('Failed to trigger reset password on account lock', [
'user_id' => $user->id, 'user_id' => $user->id,
'exception' => $e 'exception' => $e,
]); ]);
} }
} }

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
class ScannerContextService
{
public function load(User $user): User
{
$tenant = $user->tenant()
->with([
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', $user->rol_codigo)
),
])
->firstOrFail();
$user->setRelation('tenant', $tenant);
return $user;
}
}

View File

@@ -25,3 +25,4 @@ Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class); Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
require __DIR__.'/adminapp.php'; require __DIR__.'/adminapp.php';
require __DIR__.'/scanner.php';

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Auth\Controllers\ScannerLoginController;
use App\Domains\Auth\Controllers\ScannerMeController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/scanner')->group(function (): void {
Route::post('login', ScannerLoginController::class)->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'scanner.tenant'])
->get('me', ScannerMeController::class);
});

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Domains\Authorization\Enums;
enum PermissionCode: string
{
case ScanTickets = 'tickets.escanear';
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\ScannerBootstrapRequest;
use App\Domains\Bootstrap\Resources\ScannerBootstrapResource;
use App\Domains\Bootstrap\Services\ScannerBootstrapService;
use App\Http\Controllers\Controller;
class ScannerBootstrapController extends Controller
{
public function __construct(protected ScannerBootstrapService $bootstrapService) {}
public function __invoke(ScannerBootstrapRequest $request): ScannerBootstrapResource
{
return ScannerBootstrapResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Bootstrap\Requests;
class ScannerBootstrapRequest extends TenantBootstrapRequest {}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Bootstrap\Resources;
class ScannerBootstrapResource extends AdminAppBootstrapResource {}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Tenant\Models\WebsiteType;
class ScannerBootstrapService
{
/** @return array{website_type: WebsiteType} */
public function get(string $domain): array
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('scanner_domain', $domain)
->firstOrFail(),
];
}
}

View File

@@ -7,3 +7,4 @@ Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
->where('dominio', '.*'); ->where('dominio', '.*');
require __DIR__.'/adminapp.php'; require __DIR__.'/adminapp.php';
require __DIR__.'/scanner.php';

View File

@@ -0,0 +1,9 @@
<?php
use App\Domains\Bootstrap\Controllers\ScannerBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/scanner/bootstrap/{dominio}',
ScannerBootstrapController::class
);

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -31,6 +32,7 @@ use Illuminate\Support\Collection;
'inventory_policy', 'inventory_policy',
'max_units_per_user', 'max_units_per_user',
'has_tickets', 'has_tickets',
'ticket_generation_policy',
'validity_time_id', 'validity_time_id',
])] ])]
class CatalogItem extends Model class CatalogItem extends Model
@@ -45,6 +47,7 @@ class CatalogItem extends Model
'type' => CatalogItemType::Standard->value, 'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false, 'has_tickets' => false,
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
]; ];
protected function casts(): array protected function casts(): array
@@ -58,6 +61,7 @@ class CatalogItem extends Model
'inventory_policy' => InventoryPolicy::class, 'inventory_policy' => InventoryPolicy::class,
'max_units_per_user' => 'integer', 'max_units_per_user' => 'integer',
'has_tickets' => 'boolean', 'has_tickets' => 'boolean',
'ticket_generation_policy' => TicketGenerationPolicy::class,
'validity_time_id' => 'integer', 'validity_time_id' => 'integer',
]; ];
} }

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule; use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@@ -55,6 +56,7 @@ class StoreCatalogItemRequest extends FormRequest
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'], 'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)],
'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')], 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'], 'inventory_id' => ['prohibited'],

View File

@@ -31,6 +31,7 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion, 'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
'validity_time_id' => $catalogItem->validity_time_id, 'validity_time_id' => $catalogItem->validity_time_id,
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
'stock_tecnico' => $catalogItem->availableStock(), 'stock_tecnico' => $catalogItem->availableStock(),
@@ -65,6 +66,7 @@ class CatalogFeaturedItemResource extends JsonResource
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
'validity_time_id' => $catalogItem->validity_time_id, 'validity_time_id' => $catalogItem->validity_time_id,
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime), 'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
'image' => $attachment?->getTemporaryUrl(1440), 'image' => $attachment?->getTemporaryUrl(1440),

View File

@@ -35,6 +35,7 @@ class CatalogItemDetailResource extends JsonResource
'inventory_policy' => $this->inventory_policy?->value, 'inventory_policy' => $this->inventory_policy?->value,
'max_units_per_user' => $this->max_units_per_user, 'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets, 'has_tickets' => $this->has_tickets,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id, 'validity_time_id' => $this->validity_time_id,
'validity_time' => $this->validityTime === null 'validity_time' => $this->validityTime === null
? null ? null
@@ -106,8 +107,8 @@ class CatalogItemDetailResource extends JsonResource
'value' => (string) $eventDate->id, 'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'), 'label' => $eventDate->date->format('d/m/Y'),
'sort_order' => $index, 'sort_order' => $index,
'validity_time_id' => null, 'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => null, 'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'metadata' => [ 'metadata' => [
'date' => $eventDate->date->format('Y-m-d'), 'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start, 'time_start' => $eventDate->time_start,

View File

@@ -25,6 +25,7 @@ class CatalogItemResource extends JsonResource
'inventory_policy' => $this->inventory_policy?->value, 'inventory_policy' => $this->inventory_policy?->value,
'max_units_per_user' => $this->max_units_per_user, 'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets, 'has_tickets' => $this->has_tickets,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id, 'validity_time_id' => $this->validity_time_id,
'validity_time' => $this->whenLoaded( 'validity_time' => $this->whenLoaded(
'validityTime', 'validityTime',

View File

@@ -26,6 +26,7 @@ class CatalogSearchItemResource extends JsonResource
'nombre' => $this->nombre, 'nombre' => $this->nombre,
'descripcion' => $this->descripcion, 'descripcion' => $this->descripcion,
'precio' => $this->precio, 'precio' => $this->precio,
'ticket_generation_policy' => $this->ticket_generation_policy->value,
'validity_time_id' => $this->validity_time_id, 'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->validityTime), 'validity_time' => ValidityTimeResource::make($this->validityTime),
'image' => $attachment?->getTemporaryUrl(1440), 'image' => $attachment?->getTemporaryUrl(1440),

View File

@@ -167,7 +167,7 @@ class CatalogService
'brand', 'brand',
'validityTime', 'validityTime',
'itemAttributes.attribute.options.validityTime', 'itemAttributes.attribute.options.validityTime',
'itemAttributes.attribute.eventDates', 'itemAttributes.attribute.eventDates.validityTime',
'variants' => fn ($query) => $query->orderBy('id'), 'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory', 'variants.inventory',
'variants.attachments', 'variants.attachments',
@@ -442,6 +442,7 @@ class CatalogService
'attribute_codes', 'attribute_codes',
'variants', 'variants',
'has_tickets', 'has_tickets',
'ticket_generation_policy',
'validity_time_id', 'validity_time_id',
] as $field) { ] as $field) {
if (array_key_exists($field, $data)) { if (array_key_exists($field, $data)) {

View File

@@ -5,6 +5,8 @@ namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -28,14 +30,29 @@ class EventDate extends Model
protected static function booted(): void protected static function booted(): void
{ {
static::saved(fn (self $eventDate) => $eventDate->syncTenantDateText()); static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
static::deleted(fn (self $eventDate) => $eventDate->syncTenantDateText()); static::created(fn (self $eventDate) => $eventDate->syncTenantDateText());
static::updated(function (self $eventDate): void {
if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) {
$eventDate->syncValidityTime();
}
$eventDate->syncTenantDateText();
});
static::deleted(function (self $eventDate): void {
$eventDate->syncTenantDateText();
ValidityTime::query()
->whereKey($eventDate->validity_time_id)
->whereDoesntHave('ticketValidityGroups')
->delete();
});
} }
protected function casts(): array protected function casts(): array
{ {
return [ return [
'date' => 'date:Y-m-d', 'date' => 'date:Y-m-d',
'validity_time_id' => 'integer',
]; ];
} }
@@ -45,6 +62,12 @@ class EventDate extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
} }
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return HasMany<Variant, $this> */ /** @return HasMany<Variant, $this> */
public function variants(): HasMany public function variants(): HasMany
{ {
@@ -86,4 +109,33 @@ class EventDate extends Model
), ),
]); ]);
} }
private function syncValidityTime(): void
{
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
$expiresAt = $expiresAt->addDay();
}
$attributes = [
'type' => ValidityTimeType::FixedWindow,
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
];
if ($this->validity_time_id === null) {
$validityTime = ValidityTime::query()->create($attributes);
$this->validity_time_id = $validityTime->getKey();
$this->setRelation('validityTime', $validityTime);
return;
}
$this->validityTime()->update($attributes);
$this->unsetRelation('validityTime');
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Event\Resources; namespace App\Domains\Event\Resources;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -20,6 +21,8 @@ class EventResource extends JsonResource
'location' => $this->event_location, 'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'dates' => $this->eventDates->map(fn ($eventDate): array => [
'id' => $eventDate->id, 'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'), 'date' => $eventDate->date->format('Y-m-d'),
'start_time' => substr($eventDate->time_start, 0, 5), 'start_time' => substr($eventDate->time_start, 0, 5),
'end_time' => substr($eventDate->time_end, 0, 5), 'end_time' => substr($eventDate->time_end, 0, 5),

View File

@@ -15,7 +15,7 @@ class EventService
public function forTenant(Tenant $tenant): Tenant public function forTenant(Tenant $tenant): Tenant
{ {
return $tenant->load(['eventDates', 'socialMedia']); return $tenant->load(['eventDates.validityTime', 'socialMedia']);
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
@@ -35,7 +35,7 @@ class EventService
$this->syncLegacyContact($tenant, $data['contact']); $this->syncLegacyContact($tenant, $data['contact']);
} }
return $tenant->load(['eventDates', 'socialMedia']); return $tenant->load(['eventDates.validityTime', 'socialMedia']);
}); });
} }

View File

@@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -136,6 +137,7 @@ class AccommodationService
'category_id' => $category->id, 'category_id' => $category->id,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]); ]);
return $accommodation; return $accommodation;
@@ -150,6 +152,7 @@ class AccommodationService
'precio' => collect($variants)->min('price') ?? 0, 'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null, 'inventory_id' => null,
]); ]);
} }

View File

@@ -9,6 +9,7 @@ use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -62,6 +63,7 @@ class EntryService
'category_id' => $category->id, 'category_id' => $category->id,
'precio' => $entry['price'], 'precio' => $entry['price'],
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'attribute_codes' => ['event_date'], 'attribute_codes' => ['event_date'],
'multi_select_attribute_codes' => ['event_date'], 'multi_select_attribute_codes' => ['event_date'],
@@ -133,6 +135,7 @@ class EntryService
'category_id' => $category->id, 'category_id' => $category->id,
'precio' => $entry['price'], 'precio' => $entry['price'],
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
]); ]);
$variant->update([ $variant->update([

View File

@@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -147,6 +148,7 @@ class FoodService
'category_id' => $category->id, 'category_id' => $category->id,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]); ]);
return $food; return $food;
@@ -161,6 +163,7 @@ class FoodService
'precio' => collect($variants)->min('price') ?? 0, 'precio' => collect($variants)->min('price') ?? 0,
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null, 'inventory_id' => null,
]); ]);
} }

View File

@@ -12,6 +12,7 @@ use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -74,6 +75,7 @@ class MerchandiseService
'max_units_per_user' => (int) $data['max_units_per_user'], 'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]); ]);
$itemAttributes = $this->itemAttributes($item, $attributes); $itemAttributes = $this->itemAttributes($item, $attributes);
@@ -198,6 +200,7 @@ class MerchandiseService
'max_units_per_user' => (int) $data['max_units_per_user'], 'max_units_per_user' => (int) $data['max_units_per_user'],
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
'inventory_id' => null, 'inventory_id' => null,
]); ]);
} }

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Forms\Resources;
use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -17,6 +18,8 @@ class FoodFormResource extends JsonResource
'event_dates' => $this->resource['event_dates']->map( 'event_dates' => $this->resource['event_dates']->map(
fn (EventDate $eventDate): array => [ fn (EventDate $eventDate): array => [
'id' => $eventDate->id, 'id' => $eventDate->id,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'date' => $eventDate->date->format('Y-m-d'), 'date' => $eventDate->date->format('Y-m-d'),
] ]
)->values(), )->values(),

View File

@@ -27,7 +27,7 @@ class FoodFormService
->keyBy('codigo'); ->keyBy('codigo');
return [ return [
'event_dates' => $tenant->eventDates()->get(), 'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
'schedules' => $attributes->get('horario')?->options ?? new Collection, 'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection, 'services' => $attributes->get('servicio')?->options ?? new Collection,
]; ];

View File

@@ -127,8 +127,7 @@ class TelepagosWebhookService
DB::transaction(function () use ($compra, $paymentData) { DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData); TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPurchase($compra); $this->checkoutService->confirmPaidPurchase($compra);
$compra->markAsPaid();
}); });
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}"); Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");

View File

@@ -18,12 +18,17 @@ class ReleaseCheckoutService
public function cancel(Purchase $purchase): Purchase public function cancel(Purchase $purchase): Purchase
{ {
return $this->release($purchase, Purchase::STATUS_CANCELLED); return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true);
}
public function cancelWithoutRestoringCart(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false);
} }
public function expire(Purchase $purchase): Purchase public function expire(Purchase $purchase): Purchase
{ {
return $this->release($purchase, Purchase::STATUS_EXPIRED); return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: true);
} }
public function expireOverdue(): int public function expireOverdue(): int
@@ -46,9 +51,9 @@ class ReleaseCheckoutService
return $expiredCount; return $expiredCount;
} }
private function release(Purchase $purchase, string $targetStatus): Purchase private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase
{ {
return DB::transaction(function () use ($purchase, $targetStatus): Purchase { return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_PAID) { if ($purchase->status === Purchase::STATUS_PAID) {
@@ -76,7 +81,7 @@ class ReleaseCheckoutService
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate() ->lockForUpdate()
->get(); ->get();
$reservationReturnedToCart = $this->sourceCart->restore($purchase); $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
foreach ($items as $item) { foreach ($items as $item) {
if (! $reservationReturnedToCart) { if (! $reservationReturnedToCart) {

View File

@@ -9,6 +9,7 @@ use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
/** /**
* Stable checkout API used by controllers, commands and integrations. * Stable checkout API used by controllers, commands and integrations.
@@ -64,11 +65,26 @@ class CheckoutService
$this->completer->confirm($purchase); $this->completer->confirm($purchase);
} }
public function confirmPaidPurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$this->completer->confirm($purchase);
$purchase->markAsPaid();
return $purchase->refresh()->load(['items.imageAttachment']);
});
}
public function cancelPurchase(Purchase $purchase): Purchase public function cancelPurchase(Purchase $purchase): Purchase
{ {
return $this->releaser->cancel($purchase); return $this->releaser->cancel($purchase);
} }
public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase
{
return $this->releaser->cancelWithoutRestoringCart($purchase);
}
public function expirePurchase(Purchase $purchase): Purchase public function expirePurchase(Purchase $purchase): Purchase
{ {
return $this->releaser->expire($purchase); return $this->releaser->expire($purchase);

View File

@@ -6,6 +6,7 @@ use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource; use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource; use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSalePdfService; use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService; use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@@ -38,6 +39,29 @@ class SaleController extends Controller
return new SaleDetailResource($this->saleService->detail($tenant, $sale)); return new SaleDetailResource($this->saleService->detail($tenant, $sale));
} }
public function tickets(Request $request, int $sale): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale)
);
}
public function confirm(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale));
}
public function cancel(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale));
}
public function modifications(Request $request): AnonymousResourceCollection public function modifications(Request $request): AnonymousResourceCollection
{ {
return SaleModificationResource::collection( return SaleModificationResource::collection(

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Ticket */
class SaleTicketResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'product' => $this->name,
'id' => $this->id,
'expires_at' => $this->getEffectiveExpiresAt(),
'status' => $this->status,
];
}
}

View File

@@ -4,13 +4,19 @@ namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange; use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class AdminAppSaleService class AdminAppSaleService
{ {
public function __construct(
protected CheckoutService $checkoutService,
) {}
public function confirmedSalesTotal(Tenant $tenant): string public function confirmedSalesTotal(Tenant $tenant): string
{ {
$total = Purchase::query() $total = Purchase::query()
@@ -47,6 +53,34 @@ class AdminAppSaleService
->findOrFail($saleId); ->findOrFail($saleId);
} }
/** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, int $saleId): Collection
{
return $this->findForTenant($tenant, $saleId)
->tickets()
->with('validityGroups.validityTimes')
->orderBy('id')
->get();
}
public function confirm(Tenant $tenant, int $saleId): Purchase
{
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->confirmPaidPurchase($sale)
);
}
public function cancel(Tenant $tenant, int $saleId): Purchase
{
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->cancelPurchaseWithoutRestoringCart($sale)
);
}
/** /**
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, Purchase> * @return Collection<int, Purchase>
@@ -125,4 +159,18 @@ class AdminAppSaleService
->orderByDesc('changed_at') ->orderByDesc('changed_at')
->orderByDesc('id'); ->orderByDesc('id');
} }
protected function findForTenant(Tenant $tenant, int $saleId): Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->findOrFail($saleId);
}
protected function saleForResponse(Purchase $sale): Purchase
{
return $sale->refresh()
->loadSum('items as quantity', 'cantidad')
->loadCount('tickets');
}
} }

View File

@@ -10,5 +10,8 @@ Route::prefix('v1/adminapp/tenant')
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/modifications', [SaleController::class, 'modifications']); Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']); Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale'); Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale');
}); });

View File

@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'codigo', 'codigo',
'nombre', 'nombre',
'dominio', 'dominio',
'scanner_domain',
'primary_color', 'primary_color',
'secondary_color', 'secondary_color',
'danger_color', 'danger_color',

View File

@@ -9,6 +9,7 @@ use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/** /**
* @mixin Tenant * @mixin Tenant
@@ -159,6 +160,10 @@ class TenantResource extends JsonResource
$formatted['submenues'] = $childrenByParent $formatted['submenues'] = $childrenByParent
->get($menu->code, collect()) ->get($menu->code, collect())
->sortBy(
fn (Menu $submenu) => Str::lower(Str::ascii($submenu->label)),
SORT_NATURAL
)
->map($formatMenu) ->map($formatMenu)
->values(); ->values();
@@ -168,6 +173,10 @@ class TenantResource extends JsonResource
return $menus return $menus
->filter(fn (Menu $menu) => $menu->parent_menu_code === null ->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|| ! $menuCodes->has($menu->parent_menu_code)) || ! $menuCodes->has($menu->parent_menu_code))
->sortBy(
fn (Menu $menu) => Str::lower(Str::ascii($menu->label)),
SORT_NATURAL
)
->map($formatMenu) ->map($formatMenu)
->values(); ->values();
} }

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Ticket\Controllers\Scanner;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Requests\ScannerTicketIndexRequest;
use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource;
use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\ScannerTicketService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class TicketController extends Controller
{
public function __construct(private readonly ScannerTicketService $ticketService) {}
public function index(ScannerTicketIndexRequest $request): AnonymousResourceCollection
{
/** @var User $scanner */
$scanner = $request->user();
return ScannedTicketResource::collection(
$this->ticketService->scannedBy($scanner, $request->validated())
);
}
public function show(Request $request, string $ticketUuid): TicketResource
{
/** @var User $scanner */
$scanner = $request->user();
return TicketResource::make(
$this->ticketService->detail($scanner, $ticketUuid)
);
}
public function scan(Request $request, string $ticketUuid): TicketResource
{
/** @var User $scanner */
$scanner = $request->user();
return TicketResource::make(
$this->ticketService->scan($scanner, $ticketUuid)
);
}
}

View File

@@ -22,7 +22,7 @@ class TicketController extends Controller
$tickets = Ticket::query() $tickets = Ticket::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey()) ->where('user_id', $request->user()->getKey())
->with('validityTime', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id') ->orderByDesc('id')
->get(); ->get();
@@ -36,7 +36,7 @@ class TicketController extends Controller
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey()) ->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds) ->whereIn('id', $ticketIds)
->with('validityTime', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->with('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id') ->orderByDesc('id')
->get(); ->get();

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Ticket\Enums;
enum TicketGenerationPolicy: string
{
case PerEventDate = 'per_event_date';
case OnePerUnit = 'one_per_unit';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -9,9 +9,12 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
@@ -21,7 +24,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'source_purchase_id', 'source_purchase_id',
'source_catalog_item_id', 'source_catalog_item_id',
'source_variant_id', 'source_variant_id',
'validity_time_id',
'used_at', 'used_at',
'scanner_user_id', 'scanner_user_id',
'user_id', 'user_id',
@@ -30,12 +32,19 @@ class Ticket extends Model
{ {
use HasFactory; use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_EXPIRED = 'expired';
public const STATUS_USED = 'used';
public $timestamps = false; public $timestamps = false;
protected $appends = [ protected $appends = [
'is_valid', 'is_valid',
'is_expired', 'is_expired',
'is_used', 'is_used',
'status',
]; ];
protected function casts(): array protected function casts(): array
@@ -44,7 +53,6 @@ class Ticket extends Model
'source_catalog_item_id' => 'integer', 'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer', 'source_variant_id' => 'integer',
'source_purchase_id' => 'integer', 'source_purchase_id' => 'integer',
'validity_time_id' => 'integer',
'used_at' => 'datetime', 'used_at' => 'datetime',
'scanner_user_id' => 'integer', 'scanner_user_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
@@ -87,18 +95,23 @@ class Ticket extends Model
return $this->belongsTo(Variant::class, 'source_variant_id'); return $this->belongsTo(Variant::class, 'source_variant_id');
} }
/** @return BelongsTo<ValidityTime, $this> */ /** @return HasMany<TicketValidityGroup, $this> */
public function validityTime(): BelongsTo public function validityGroups(): HasMany
{ {
return $this->belongsTo(ValidityTime::class); return $this->hasMany(TicketValidityGroup::class);
} }
public function isValid(): bool public function isValid(): bool
{ {
return $this->used_at === null if ($this->used_at !== null) {
&& ( return false;
$this->validityTime === null }
|| $this->validityTime->isValid()
$validityGroups = $this->resolvedValidityGroups();
return $validityGroups->isEmpty()
|| $validityGroups->contains(
fn (TicketValidityGroup $group): bool => $group->isValid()
); );
} }
@@ -109,11 +122,13 @@ class Ticket extends Model
public function getIsExpiredAttribute(): bool public function getIsExpiredAttribute(): bool
{ {
$expiresAt = $this->getEffectiveExpiresAt(); $validityGroups = $this->resolvedValidityGroups();
return $this->used_at === null return $this->used_at === null
&& $expiresAt !== null && $validityGroups->isNotEmpty()
&& $expiresAt->lessThanOrEqualTo(now()); && $validityGroups->every(
fn (TicketValidityGroup $group): bool => $group->isExpired()
);
} }
public function getIsUsedAttribute(): bool public function getIsUsedAttribute(): bool
@@ -121,13 +136,65 @@ class Ticket extends Model
return $this->used_at !== null; return $this->used_at !== null;
} }
public function getStatusAttribute(): string
{
if ($this->is_used) {
return self::STATUS_USED;
}
if ($this->is_expired) {
return self::STATUS_EXPIRED;
}
return self::STATUS_ACTIVE;
}
public function getEffectiveStartsAt(): ?CarbonInterface public function getEffectiveStartsAt(): ?CarbonInterface
{ {
return $this->validityTime?->startsAt(); return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveStartsAt())
->filter()
->sortBy(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
} }
public function getEffectiveExpiresAt(): ?CarbonInterface public function getEffectiveExpiresAt(): ?CarbonInterface
{ {
return $this->validityTime?->expiresAt(); return $this->resolvedValidityGroups()
->map(fn (TicketValidityGroup $group): ?CarbonInterface => $group->effectiveExpiresAt())
->filter()
->sortByDesc(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/** @return Collection<int, ValidityTime> */
public function allValidityTimes(): Collection
{
return $this->resolvedValidityGroups()
->flatMap(fn (TicketValidityGroup $group): EloquentCollection => $group->resolvedValidityTimes())
->unique(
fn (ValidityTime $validityTime): int => $validityTime->getKey()
?? spl_object_id($validityTime)
)
->values();
}
/** @return EloquentCollection<int, TicketValidityGroup> */
public function resolvedValidityGroups(): EloquentCollection
{
if ($this->relationLoaded('validityGroups')) {
return $this->getRelation('validityGroups');
}
if (! $this->exists) {
return new EloquentCollection;
}
$validityGroups = $this->validityGroups()
->with('validityTimes')
->get();
$this->setRelation('validityGroups', $validityGroups);
return $validityGroups;
} }
} }

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
#[Fillable(['ticket_id'])]
class TicketValidityGroup extends Model
{
public $timestamps = false;
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsToMany<ValidityTime, $this> */
public function validityTimes(): BelongsToMany
{
return $this->belongsToMany(
ValidityTime::class,
'ticket_validity_group_times',
'ticket_validity_group_id',
'validity_time_id',
);
}
public function isValid(?CarbonInterface $at = null): bool
{
$at ??= now();
$validityTimes = $this->resolvedValidityTimes();
return $validityTimes->isNotEmpty()
&& $validityTimes->every(
fn (ValidityTime $validityTime): bool => $validityTime->isValid($at)
);
}
public function isExpired(?CarbonInterface $at = null): bool
{
$at ??= now();
$expiresAt = $this->effectiveExpiresAt($at);
return $expiresAt !== null && $expiresAt->lessThanOrEqualTo($at);
}
public function effectiveStartsAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->resolvedValidityTimes()
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor))
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
public function effectiveExpiresAt(?CarbonInterface $at = null): ?CarbonInterface
{
$at ??= now();
$anchor = $this->dateAnchor() ?? $at;
return $this->resolvedValidityTimes()
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$startsAt = $validityTime->startsAt($anchor);
$expiresAt = $validityTime->expiresAt($anchor);
if (
$startsAt !== null
&& $expiresAt !== null
&& $expiresAt->lessThanOrEqualTo($startsAt)
) {
return $expiresAt->addDay();
}
return $expiresAt;
})
->filter()
->sortBy(fn (CarbonInterface $expiresAt): int => $expiresAt->getTimestamp())
->first();
}
/** @return EloquentCollection<int, ValidityTime> */
public function resolvedValidityTimes(): EloquentCollection
{
if ($this->relationLoaded('validityTimes')) {
return $this->getRelation('validityTimes');
}
if (! $this->exists) {
return new EloquentCollection;
}
$validityTimes = $this->validityTimes()->get();
$this->setRelation('validityTimes', $validityTimes);
return $validityTimes;
}
private function dateAnchor(): ?CarbonInterface
{
return $this->resolvedValidityTimes()
->filter(fn (ValidityTime $validityTime): bool => $validityTime->type === ValidityTimeType::FixedWindow)
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->fixed_starts_at)
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
}
}

View File

@@ -4,13 +4,16 @@ namespace App\Domains\Ticket\Models;
use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([ #[Fillable([
'type', 'type',
@@ -44,10 +47,21 @@ class ValidityTime extends Model
return $this->hasMany(AttributeOption::class); return $this->hasMany(AttributeOption::class);
} }
/** @return HasMany<Ticket, $this> */ /** @return HasOne<EventDate, $this> */
public function tickets(): HasMany public function eventDate(): HasOne
{ {
return $this->hasMany(Ticket::class); return $this->hasOne(EventDate::class);
}
/** @return BelongsToMany<TicketValidityGroup, $this> */
public function ticketValidityGroups(): BelongsToMany
{
return $this->belongsToMany(
TicketValidityGroup::class,
'ticket_validity_group_times',
'validity_time_id',
'ticket_validity_group_id',
);
} }
public function startsAt( public function startsAt(

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ScannerTicketIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Ticket\Resources\Scanner;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Ticket */
class ScannedTicketResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'product' => $this->name,
'id' => $this->id,
'ticket' => $this->ticket,
'used_at' => $this->used_at,
'expires_at' => $this->getEffectiveExpiresAt(),
'status' => $this->status,
];
}
}

View File

@@ -18,10 +18,14 @@ class TicketResource extends JsonResource
'ticket' => $this->ticket, 'ticket' => $this->ticket,
'name' => $this->name, 'name' => $this->name,
'description' => $this->description, 'description' => $this->description,
'client' => $this->user?->nombre_apellido,
'category' => $this->sourceCatalogItem?->category?->nombre,
'source_catalog_item_id' => $this->source_catalog_item_id, 'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id, 'source_variant_id' => $this->source_variant_id,
'validity_time_id' => $this->validity_time_id, 'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()),
'validity_time' => ValidityTimeResource::make($this->validityTime), 'validity_groups' => TicketValidityGroupResource::collection(
$this->resolvedValidityGroups()
),
'starts_at' => $this->getEffectiveStartsAt(), 'starts_at' => $this->getEffectiveStartsAt(),
'expires_at' => $this->getEffectiveExpiresAt(), 'expires_at' => $this->getEffectiveExpiresAt(),
'used_at' => $this->used_at, 'used_at' => $this->used_at,

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Models\TicketValidityGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin TicketValidityGroup */
class TicketValidityGroupResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_times' => ValidityTimeResource::collection($this->resolvedValidityTimes()),
'starts_at' => $this->effectiveStartsAt(),
'expires_at' => $this->effectiveExpiresAt(),
'is_valid' => $this->isValid(),
'is_expired' => $this->isExpired(),
];
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ScannerTicketService
{
/**
* @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<Ticket>
*/
public function scannedBy(User $scanner, array $filters = []): LengthAwarePaginator
{
$search = trim((string) ($filters['q'] ?? ''));
return $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey())
->when($search !== '', function (Builder $query) use ($search): void {
$usedAtDate = $this->parseSearchDate($search);
$query->where(function (Builder $searchQuery) use ($search, $usedAtDate): void {
$searchQuery->where('ticket', 'like', "%{$search}%");
if (ctype_digit($search)) {
$searchQuery->orWhere('id', (int) $search);
}
if ($usedAtDate !== null) {
$searchQuery->orWhereDate('used_at', $usedAtDate);
}
});
})
->orderByDesc('used_at')
->orderByDesc('id')
->paginateFromRequest()
->withQueryString();
}
private function parseSearchDate(string $search): ?string
{
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
[$year, $month, $day] = array_map('intval', array_slice($matches, 1));
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
if (preg_match('/^(\d{2})\/(\d{2})\/(\d{2}|\d{4})$/', $search, $matches) === 1) {
$day = (int) $matches[1];
$month = (int) $matches[2];
$year = (int) $matches[3];
$year = strlen($matches[3]) === 2 ? 2000 + $year : $year;
if (checkdate($month, $day, $year)) {
return sprintf('%04d-%02d-%02d', $year, $month, $day);
}
}
return null;
}
public function detail(User $scanner, string $ticketUuid): Ticket
{
$categoryIds = $this->scannerCategoryIds($scanner);
return $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid)
->where(function (Builder $query) use ($scanner, $categoryIds): void {
$query
->where('scanner_user_id', $scanner->getKey())
->orWhereHas(
'sourceCatalogItem',
fn (Builder $catalogItemQuery): Builder => $catalogItemQuery
->whereIn('category_id', $categoryIds)
);
})
->firstOrFail();
}
public function scan(User $scanner, string $ticketUuid): Ticket
{
return DB::transaction(function () use ($scanner, $ticketUuid): Ticket {
$ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid)
->lockForUpdate()
->firstOrFail();
if (! $this->scannerCanScan($scanner, $ticket)) {
throw ValidationException::withMessages([
'ticket' => __('api.ticket.scanner_category_forbidden'),
]);
}
if ($ticket->is_used) {
throw ValidationException::withMessages([
'ticket' => __('api.ticket.already_scanned'),
]);
}
if (! $ticket->is_valid) {
throw ValidationException::withMessages([
'ticket' => $ticket->is_expired
? __('api.ticket.expired_for_scan')
: __('api.ticket.not_valid_for_scan'),
]);
}
$ticket->forceFill([
'used_at' => now(),
'scanner_user_id' => $scanner->getKey(),
])->save();
return $ticket->refresh()->load($this->relations());
});
}
/** @return Builder<Ticket> */
private function baseQuery(): Builder
{
return Ticket::query()->with($this->relations());
}
/** @return array<int, string> */
private function relations(): array
{
return [
'validityGroups.validityTimes',
'sourceCatalogItem.category',
'sourceVariant.eventDate',
'sourceVariant.catalogItem',
'user',
];
}
/** @return array<int, int> */
private function scannerCategoryIds(User $scanner): array
{
return $scanner->scanCategories()
->pluck('categorias.id')
->map(fn (mixed $id): int => (int) $id)
->all();
}
private function scannerCanScan(User $scanner, Ticket $ticket): bool
{
$categoryId = $ticket->sourceCatalogItem?->category_id;
return $categoryId !== null
&& $scanner->scanCategories()
->where('categorias.id', $categoryId)
->exists();
}
}

View File

@@ -6,10 +6,12 @@ use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -36,25 +38,22 @@ class TicketGeneratorService
$quantity, $quantity,
$sourceVariantId, $sourceVariantId,
); );
$fixedValidityTimes = [];
return $targets->map(function (array $target) use ( return $targets->map(function (array $target) use (
&$fixedValidityTimes,
$sourcePurchaseId, $sourcePurchaseId,
$user, $user,
): Ticket { ): Ticket {
$item = $target['catalog_item']; $item = $target['catalog_item'];
$variant = $target['variant']; $variant = $target['variant'];
$eventDate = $target['event_date']; $eventDate = $target['event_date'];
$validityTime = $this->resolveValidityTime($item, $variant); $validityGroups = $this->buildTicketValidityGroups(
$validityTime = $this->materializeEventDateValidityTime( $item,
$variant, $variant,
$eventDate, $eventDate,
$validityTime, $this->resolveValidityTime($item, $variant),
$fixedValidityTimes,
); );
return Ticket::query()->create([ $ticket = Ticket::query()->create([
'tenant_code' => $item->tenant_code, 'tenant_code' => $item->tenant_code,
'ticket' => (string) Str::uuid(), 'ticket' => (string) Str::uuid(),
'name' => $this->ticketName($item, $variant, $eventDate), 'name' => $this->ticketName($item, $variant, $eventDate),
@@ -62,10 +61,28 @@ class TicketGeneratorService
'source_purchase_id' => $sourcePurchaseId, 'source_purchase_id' => $sourcePurchaseId,
'source_catalog_item_id' => $item->getKey(), 'source_catalog_item_id' => $item->getKey(),
'source_variant_id' => $variant?->getKey(), 'source_variant_id' => $variant?->getKey(),
'validity_time_id' => $validityTime?->getKey(),
'used_at' => null, 'used_at' => null,
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
]); ]);
$groups = $validityGroups->map(function (Collection $validityTimes) use ($ticket): TicketValidityGroup {
$group = $ticket->validityGroups()->create();
$group->validityTimes()->attach(
$validityTimes
->map(fn (ValidityTime $validityTime): int => $validityTime->getKey())
->all()
);
$group->setRelation(
'validityTimes',
new EloquentCollection($validityTimes->all()),
);
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
return $ticket;
}); });
}); });
} }
@@ -125,8 +142,21 @@ class TicketGeneratorService
]); ]);
} }
$variant->loadMissing(['eventDates', 'eventDate']); $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates(); $selectedEventDates = $variant->selectedEventDates();
if ($catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit) {
$eventDate = $selectedEventDates->count() === 1
? $selectedEventDates->first()
: null;
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
'event_date' => $eventDate,
]);
}
$eventDates = $selectedEventDates->isEmpty() $eventDates = $selectedEventDates->isEmpty()
? collect([null]) ? collect([null])
: $selectedEventDates; : $selectedEventDates;
@@ -246,52 +276,37 @@ class TicketGeneratorService
return $catalogItem->nombre.' ('.$properties->implode(', ').')'; return $catalogItem->nombre.' ('.$properties->implode(', ').')';
} }
/** /** @return Collection<int, Collection<int, ValidityTime>> */
* Convert a recurring time window into the fixed date and time purchased by private function buildTicketValidityGroups(
* the customer. Equal tickets generated together share the same snapshot. CatalogItem $catalogItem,
*
* @param array<string, ValidityTime> $fixedValidityTimes
*/
private function materializeEventDateValidityTime(
?Variant $variant, ?Variant $variant,
?EventDate $eventDate, ?EventDate $eventDate,
?ValidityTime $validityTime, ?ValidityTime $validityTime,
array &$fixedValidityTimes, ): Collection {
): ?ValidityTime { $eventDates = collect([$eventDate]);
if (
$variant === null
|| $eventDate === null
) {
return $validityTime;
}
if ($validityTime !== null && $validityTime->type !== ValidityTimeType::TimeWindow) {
return $validityTime;
}
$cacheKey = $eventDate->getKey().':'.($validityTime?->getKey() ?? 'event');
if (isset($fixedValidityTimes[$cacheKey])) {
return $fixedValidityTimes[$cacheKey];
}
$startsAt = $validityTime?->startsAt($eventDate->date) ?? $eventDate->startsAt();
$expiresAt = $validityTime?->expiresAt($eventDate->date) ?? $eventDate->endsAt();
if ( if (
$startsAt !== null $catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit
&& $expiresAt !== null && $variant !== null
&& $expiresAt->lessThanOrEqualTo($startsAt)
) { ) {
$expiresAt = $expiresAt->addDay(); $variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates();
if ($selectedEventDates->isNotEmpty()) {
$eventDates = $selectedEventDates;
}
} }
return $fixedValidityTimes[$cacheKey] = ValidityTime::query()->create([ return $eventDates
'type' => ValidityTimeType::FixedWindow, ->map(function (?EventDate $date) use ($validityTime): Collection {
'start_time' => null, $date?->loadMissing('validityTime');
'end_time' => null,
'fixed_starts_at' => $startsAt, return collect([$date?->validityTime, $validityTime])
'fixed_expires_at' => $expiresAt, ->filter()
]); ->unique(fn (ValidityTime $time): int => $time->getKey())
->values();
})
->filter(fn (Collection $group): bool => $group->isNotEmpty())
->values();
} }
} }

View File

@@ -9,3 +9,5 @@ Route::prefix('tenants/{tenant:codigo}')
Route::get('tickets', [TicketController::class, 'index']); Route::get('tickets', [TicketController::class, 'index']);
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']); Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
}); });
require __DIR__.'/scanner.php';

View File

@@ -0,0 +1,14 @@
<?php
use App\Domains\Ticket\Controllers\Scanner\TicketController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/scanner/tickets')
->middleware(['auth:sanctum', 'scanner.tenant'])
->group(function (): void {
Route::get('/', [TicketController::class, 'index']);
Route::get('{ticketUuid}', [TicketController::class, 'show'])
->whereUuid('ticketUuid');
Route::post('{ticketUuid}/scan', [TicketController::class, 'scan'])
->whereUuid('ticketUuid');
});

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Domains\Authorization\Enums\PermissionCode;
use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureScannerTenant
{
/**
* Ensure the authenticated user can scan tickets for a tenant.
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (
! $user
|| ! $user->tenant_codigo
|| ! $user->hasPermission(PermissionCode::ScanTickets->value)
) {
throw new AuthorizationException;
}
return $next($request);
}
}

View File

@@ -3,6 +3,7 @@
use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Http\Middleware\EnsureAdminAppTenant; use App\Http\Middleware\EnsureAdminAppTenant;
use App\Http\Middleware\EnsureScannerTenant;
use App\Http\Middleware\EnsureTenantHasMenu; use App\Http\Middleware\EnsureTenantHasMenu;
use App\Http\Middleware\SetApiLocale; use App\Http\Middleware\SetApiLocale;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -25,6 +26,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([ $middleware->alias([
'adminapp.tenant' => EnsureAdminAppTenant::class, 'adminapp.tenant' => EnsureAdminAppTenant::class,
'scanner.tenant' => EnsureScannerTenant::class,
'tenant.menu' => EnsureTenantHasMenu::class, 'tenant.menu' => EnsureTenantHasMenu::class,
]); ]);
$middleware->encryptCookies(except: [ $middleware->encryptCookies(except: [

View File

@@ -0,0 +1,32 @@
<?php
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->enum('ticket_generation_policy', TicketGenerationPolicy::values())
->default(TicketGenerationPolicy::PerEventDate->value)
->after('has_tickets');
});
DB::table('catalog_items')
->where('tenant_code', 'fiesta_futbol_infantil')
->update([
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]);
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('ticket_generation_policy');
});
}
};

View File

@@ -0,0 +1,63 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_validity_times', function (Blueprint $table): void {
$table->foreignId('ticket_id')
->constrained('tickets')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreignId('validity_time_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
$table->primary(['ticket_id', 'validity_time_id']);
});
DB::table('ticket_validity_times')->insertUsing(
['ticket_id', 'validity_time_id'],
DB::table('tickets')
->select(['id', 'validity_time_id'])
->whereNotNull('validity_time_id'),
);
Schema::table('tickets', function (Blueprint $table): void {
$table->dropConstrainedForeignId('validity_time_id');
});
}
public function down(): void
{
Schema::table('tickets', function (Blueprint $table): void {
$table->foreignId('validity_time_id')
->nullable()
->after('source_variant_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
});
DB::table('ticket_validity_times')
->orderBy('ticket_id')
->orderBy('validity_time_id')
->get()
->groupBy('ticket_id')
->each(function ($validityTimes, int|string $ticketId): void {
DB::table('tickets')
->where('id', $ticketId)
->update([
'validity_time_id' => $validityTimes->first()->validity_time_id,
]);
});
Schema::dropIfExists('ticket_validity_times');
}
};

View File

@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('event_dates', function (Blueprint $table): void {
$table->unsignedBigInteger('validity_time_id')
->nullable()
->unique()
->after('id');
});
DB::table('event_dates')
->orderBy('id')
->each(function (object $eventDate): void {
$startsAt = $eventDate->date.' '.$eventDate->time_start;
$expiresAt = $eventDate->date.' '.$eventDate->time_end;
if (strtotime($expiresAt) <= strtotime($startsAt)) {
$expiresAt = date('Y-m-d H:i:s', strtotime($expiresAt.' +1 day'));
}
$validityTimeId = DB::table('validity_times')->insertGetId([
'type' => 'fixed_window',
'start_time' => null,
'end_time' => null,
'fixed_starts_at' => $startsAt,
'fixed_expires_at' => $expiresAt,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('event_dates')
->where('id', $eventDate->id)
->update(['validity_time_id' => $validityTimeId]);
});
Schema::table('event_dates', function (Blueprint $table): void {
$table->unsignedBigInteger('validity_time_id')->nullable(false)->change();
$table->foreign('validity_time_id')
->references('id')
->on('validity_times')
->restrictOnDelete();
});
}
public function down(): void
{
$validityTimeIds = DB::table('event_dates')
->pluck('validity_time_id')
->filter()
->all();
Schema::table('event_dates', function (Blueprint $table): void {
$table->dropForeign(['validity_time_id']);
$table->dropUnique(['validity_time_id']);
$table->dropColumn('validity_time_id');
});
DB::table('validity_times')->whereIn('id', $validityTimeIds)->delete();
}
};

View File

@@ -0,0 +1,88 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('ticket_validity_groups', function (Blueprint $table): void {
$table->id();
$table->foreignId('ticket_id')
->constrained('tickets')
->cascadeOnUpdate()
->cascadeOnDelete();
});
Schema::create('ticket_validity_group_times', function (Blueprint $table): void {
$table->foreignId('ticket_validity_group_id')
->constrained('ticket_validity_groups')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreignId('validity_time_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
$table->primary(['ticket_validity_group_id', 'validity_time_id']);
});
// Every former pivot row was an OR alternative, so each one becomes
// an independent group to preserve existing ticket behavior.
DB::table('ticket_validity_times')
->orderBy('ticket_id')
->orderBy('validity_time_id')
->each(function (object $association): void {
$groupId = DB::table('ticket_validity_groups')->insertGetId([
'ticket_id' => $association->ticket_id,
]);
DB::table('ticket_validity_group_times')->insert([
'ticket_validity_group_id' => $groupId,
'validity_time_id' => $association->validity_time_id,
]);
});
Schema::dropIfExists('ticket_validity_times');
}
public function down(): void
{
Schema::create('ticket_validity_times', function (Blueprint $table): void {
$table->foreignId('ticket_id')
->constrained('tickets')
->cascadeOnUpdate()
->cascadeOnDelete();
$table->foreignId('validity_time_id')
->constrained('validity_times')
->cascadeOnUpdate()
->restrictOnDelete();
$table->primary(['ticket_id', 'validity_time_id']);
});
DB::table('ticket_validity_group_times')
->join(
'ticket_validity_groups',
'ticket_validity_groups.id',
'=',
'ticket_validity_group_times.ticket_validity_group_id',
)
->select([
'ticket_validity_groups.ticket_id',
'ticket_validity_group_times.validity_time_id',
])
->distinct()
->orderBy('ticket_validity_groups.ticket_id')
->each(fn (object $association) => DB::table('ticket_validity_times')->insert([
'ticket_id' => $association->ticket_id,
'validity_time_id' => $association->validity_time_id,
]));
Schema::dropIfExists('ticket_validity_group_times');
Schema::dropIfExists('ticket_validity_groups');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('website_type', function (Blueprint $table): void {
$table->string('scanner_domain')->nullable()->unique()->after('dominio');
});
}
public function down(): void
{
Schema::table('website_type', function (Blueprint $table): void {
$table->dropUnique(['scanner_domain']);
$table->dropColumn('scanner_domain');
});
}
};

View File

@@ -2,6 +2,7 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission; use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
@@ -81,7 +82,7 @@ class AuthorizationSeeder extends Seeder
'nombre' => 'Gestionar tickets', 'nombre' => 'Gestionar tickets',
'descripcion' => 'Permite emitir, invalidar o regenerar tickets.', 'descripcion' => 'Permite emitir, invalidar o regenerar tickets.',
], ],
'tickets.escanear' => [ PermissionCode::ScanTickets->value => [
'nombre' => 'Escanear tickets', 'nombre' => 'Escanear tickets',
'descripcion' => 'Permite validar y consumir tickets de las categorías asignadas al usuario.', 'descripcion' => 'Permite validar y consumir tickets de las categorías asignadas al usuario.',
], ],
@@ -119,12 +120,12 @@ class AuthorizationSeeder extends Seeder
RoleCode::AdminApp->value => [ RoleCode::AdminApp->value => [
'nombre' => 'Administrador de la aplicación', 'nombre' => 'Administrador de la aplicación',
'descripcion' => 'Accede a los menús administrativos de la aplicación.', 'descripcion' => 'Accede a los menús administrativos de la aplicación.',
'permisos' => [], 'permisos' => [PermissionCode::ScanTickets->value],
], ],
RoleCode::Scanner->value => [ RoleCode::Scanner->value => [
'nombre' => 'Scanner', 'nombre' => 'Scanner',
'descripcion' => 'Valida y consume tickets de las categorías que tiene asignadas.', 'descripcion' => 'Valida y consume tickets de las categorías que tiene asignadas.',
'permisos' => ['tickets.escanear'], 'permisos' => [PermissionCode::ScanTickets->value],
], ],
RoleCode::User->value => [ RoleCode::User->value => [
'nombre' => 'Usuario', 'nombre' => 'Usuario',

View File

@@ -10,7 +10,11 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use RuntimeException; use RuntimeException;
@@ -46,13 +50,30 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'event_location' => 'Sunchales, Santa Fe', 'event_location' => 'Sunchales, Santa Fe',
]); ]);
$existingValidityTimeIds = $tenant->eventDates()->pluck('validity_time_id');
$tenant->eventDates()->delete(); $tenant->eventDates()->delete();
ValidityTime::query()->whereKey($existingValidityTimeIds)->delete();
$eventDates = collect(['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12']) $eventDates = collect(['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'])
->map(fn (string $date) => $tenant->eventDates()->create([ ->map(function (string $date) use ($tenant): EventDate {
'date' => $date, $validityTime = ValidityTime::query()->create([
'time_start' => '00:00:00', 'type' => ValidityTimeType::FixedWindow,
'time_end' => '23:59:59', 'start_time' => null,
])); 'end_time' => null,
'fixed_starts_at' => $date.' 00:00:00',
'fixed_expires_at' => $date.' 23:59:59',
]);
$eventDate = new EventDate;
$eventDate->forceFill([
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
'validity_time_id' => $validityTime->id,
]);
return $tenant->eventDates()->save($eventDate);
});
$dateIds = $eventDates->pluck('id')->map(fn ($id): int => (int) $id)->values(); $dateIds = $eventDates->pluck('id')->map(fn ($id): int => (int) $id)->values();
$this->createProduct($tenant, [ $this->createProduct($tenant, [
@@ -145,6 +166,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
...$data, ...$data,
'has_tickets' => true, 'has_tickets' => true,
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
]); ]);
} }

View File

@@ -29,6 +29,13 @@ class MenuSeeder extends Seeder
'content_type' => Menu::CONTENT_TYPE_DYNAMIC, 'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
'route' => '/', 'route' => '/',
], ],
['code' => 'scanner.inicio', 'label' => 'Inicio', 'route' => '/scanner/inicio'],
['code' => 'scanner.scan', 'label' => 'Escanear', 'route' => '/scanner/scan'],
[
'code' => 'scanner.detail',
'label' => 'Detalle',
'route' => '/scanner/detail/:id',
],
[ [
'code' => 'adminapp.event', 'code' => 'adminapp.event',
'label' => 'Eventos', 'label' => 'Eventos',
@@ -219,8 +226,11 @@ class MenuSeeder extends Seeder
->where('code', 'main.adminapp') ->where('code', 'main.adminapp')
->orWhere('parent_menu_code', 'main.adminapp') ->orWhere('parent_menu_code', 'main.adminapp')
->pluck('code'); ->pluck('code');
$scannerMenuCodes = Menu::query()
->where('code', 'like', 'scanner.%')
->pluck('code');
$userMenuCodes = Menu::query() $userMenuCodes = Menu::query()
->whereNotIn('code', $adminAppMenuCodes) ->whereNotIn('code', $adminAppMenuCodes->merge($scannerMenuCodes))
->pluck('code'); ->pluck('code');
Role::query() Role::query()
@@ -231,6 +241,10 @@ class MenuSeeder extends Seeder
->where('codigo', RoleCode::User->value) ->where('codigo', RoleCode::User->value)
->each(fn (Role $role) => $role->menus()->sync($userMenuCodes)); ->each(fn (Role $role) => $role->menus()->sync($userMenuCodes));
Role::query()
->where('codigo', RoleCode::Scanner->value)
->each(fn (Role $role) => $role->menus()->sync($scannerMenuCodes));
$tenants = Tenant::all(); $tenants = Tenant::all();
$allMenus = Menu::pluck('code')->toArray(); $allMenus = Menu::pluck('code')->toArray();
@@ -252,6 +266,12 @@ class MenuSeeder extends Seeder
'adminapp.fiesta-futbol-infantil.merchandising', 'adminapp.fiesta-futbol-infantil.merchandising',
'adminapp.fiesta-futbol-infantil.comida', 'adminapp.fiesta-futbol-infantil.comida',
]; ];
$fiestaExcludedAdminMenuCodes = [
'adminapp.inicio',
'adminapp.catalog',
'adminapp.categories',
'adminapp.combos',
];
$frequentlyAskedQuestions = [ $frequentlyAskedQuestions = [
[ [
'pregunta' => '¿Hay algún límite de compra?', 'pregunta' => '¿Hay algún límite de compra?',
@@ -311,6 +331,8 @@ class MenuSeeder extends Seeder
if ($tenant->codigo !== 'fiesta_futbol_infantil') { if ($tenant->codigo !== 'fiesta_futbol_infantil') {
$menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes); $menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes);
} else {
$menuCodes = array_diff($menuCodes, $fiestaExcludedAdminMenuCodes);
} }
// Usar sync para asociar los menues al tenant // Usar sync para asociar los menues al tenant

View File

@@ -128,8 +128,8 @@ class TenantSeeder extends Seeder
'button_text' => 'Comprar entradas', 'button_text' => 'Comprar entradas',
'button_href' => '/tickets', 'button_href' => '/tickets',
'background_image_id' => $this->uploadedImage( 'background_image_id' => $this->uploadedImage(
'images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.jpg', 'images/tennants/fiesta_futbol_infantil/futbol_infantil_hero.png',
'futbol_infantil_hero.jpg', 'futbol_infantil_hero.png',
), ),
], ],
'eventConfig' => [ 'eventConfig' => [

View File

@@ -31,6 +31,7 @@ class WebsiteTypeSeeder extends Seeder
[ [
'nombre' => 'ShopIt', 'nombre' => 'ShopIt',
'dominio' => 'localhost', 'dominio' => 'localhost',
'scanner_domain' => 'scanner.localhost',
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(), 'footer_logo' => $this->onTicketFooterLogo(),
@@ -63,6 +64,7 @@ class WebsiteTypeSeeder extends Seeder
[ [
'nombre' => 'OnTicket', 'nombre' => 'OnTicket',
'dominio' => 'onticket.localhost', 'dominio' => 'onticket.localhost',
'scanner_domain' => 'scanner.onticket.localhost',
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(), 'footer_logo' => $this->onTicketFooterLogo(),

View File

@@ -62,6 +62,10 @@ return [
'invalid_variant' => 'Variant :variant does not belong to product :product.', 'invalid_variant' => 'Variant :variant does not belong to product :product.',
'purchase_without_user' => 'Purchase :purchase does not have an associated user.', 'purchase_without_user' => 'Purchase :purchase does not have an associated user.',
'product_not_found' => 'The product for purchase item :purchase_item was not found.', 'product_not_found' => 'The product for purchase item :purchase_item was not found.',
'already_scanned' => 'The ticket has already been scanned.',
'expired_for_scan' => 'The ticket has expired.',
'not_valid_for_scan' => 'The ticket is not currently valid.',
'scanner_category_forbidden' => 'The scanner is not assigned to the ticket category.',
], ],
'integration' => [ 'integration' => [
'not_configured' => 'The integration is not configured for this tenant.', 'not_configured' => 'The integration is not configured for this tenant.',

View File

@@ -62,6 +62,10 @@ return [
'invalid_variant' => 'La variante :variant no pertenece al producto :product.', 'invalid_variant' => 'La variante :variant no pertenece al producto :product.',
'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.', 'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.',
'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.', 'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.',
'already_scanned' => 'El ticket ya fue escaneado.',
'expired_for_scan' => 'El ticket está vencido.',
'not_valid_for_scan' => 'El ticket no es válido en este momento.',
'scanner_category_forbidden' => 'El scanner no está asignado a la categoría del ticket.',
], ],
'integration' => [ 'integration' => [
'not_configured' => 'La integración no está configurada para este tenant.', 'not_configured' => 'La integración no está configurada para este tenant.',

View File

@@ -0,0 +1,116 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Tests\TestCase;
class ScannerLoginControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_logs_in_a_tenant_bound_user_with_scan_permission(): void
{
$role = Role::query()->create([
'codigo' => RoleCode::AdminApp->value,
'nombre' => 'Operador',
]);
$permission = Permission::query()->create([
'codigo' => PermissionCode::ScanTickets->value,
'nombre' => 'Escanear tickets',
]);
$role->permissions()->attach($permission->codigo);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
]);
$user = User::factory()->create([
'email' => 'scanner@example.com',
'password' => Hash::make('secret123'),
'rol_codigo' => $role->codigo,
'tenant_codigo' => $tenant->codigo,
]);
$response = $this->postJson('/api/v1/scanner/login', [
'email' => ' SCANNER@EXAMPLE.COM ',
'password' => 'secret123',
]);
$response
->assertOk()
->assertJsonPath('user.id', $user->id)
->assertJsonPath('user.rol_codigo', RoleCode::AdminApp->value)
->assertJsonPath('token_type', 'Bearer');
$this->assertSame(['scanner'], $user->tokens()->sole()->abilities);
}
public function test_it_rejects_a_user_without_scan_permission(): void
{
$role = Role::query()->create([
'codigo' => RoleCode::Scanner->value,
'nombre' => 'Scanner sin permiso',
]);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
]);
$user = User::factory()->create([
'email' => 'customer@example.com',
'password' => Hash::make('secret123'),
'rol_codigo' => $role->codigo,
'tenant_codigo' => $tenant->codigo,
]);
$this->postJson('/api/v1/scanner/login', [
'email' => $user->email,
'password' => 'secret123',
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
}
public function test_an_invalid_password_is_recorded_with_the_users_tenant(): void
{
$role = Role::query()->create([
'codigo' => RoleCode::Scanner->value,
'nombre' => 'Scanner',
]);
$permission = Permission::query()->create([
'codigo' => PermissionCode::ScanTickets->value,
'nombre' => 'Escanear tickets',
]);
$role->permissions()->attach($permission->codigo);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
]);
$user = User::factory()->create([
'email' => 'scanner@example.com',
'password' => Hash::make('correct-password'),
'rol_codigo' => $role->codigo,
'tenant_codigo' => $tenant->codigo,
]);
$this->postJson('/api/v1/scanner/login', [
'email' => $user->email,
'password' => 'wrong-password',
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
$this->assertSame(1, $user->refresh()->failed_login_attempts);
$this->assertDatabaseHas('login_attempts', [
'user_id' => $user->id,
'tenant_codigo' => $tenant->codigo,
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Tests\Feature\Auth;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role;
use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ScannerMeControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_returns_only_scanner_menus_assigned_to_the_tenant(): void
{
$scannerRole = Role::query()->create([
'codigo' => RoleCode::Scanner->value,
'nombre' => 'Scanner',
]);
$permission = Permission::query()->create([
'codigo' => PermissionCode::ScanTickets->value,
'nombre' => 'Escanear tickets',
]);
$scannerRole->permissions()->attach($permission->codigo);
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
]);
$home = Menu::query()->create([
'code' => 'scanner.inicio',
'label' => 'Inicio',
'route' => '/scanner/inicio',
]);
$scan = Menu::query()->create([
'code' => 'scanner.scan',
'label' => 'Escanear',
'route' => '/scanner/scan',
]);
$foreign = Menu::query()->create([
'code' => 'adminapp.inicio',
'label' => 'Administración',
'route' => '/admin/inicio',
]);
$scannerRole->menus()->sync([$home->code, $scan->code]);
$tenant->menues()->sync([$home->code, $scan->code, $foreign->code]);
$user = User::factory()->create([
'rol_codigo' => $scannerRole->codigo,
'tenant_codigo' => $tenant->codigo,
]);
Sanctum::actingAs($user);
$this->getJson('/api/v1/scanner/me')
->assertOk()
->assertJsonPath('data.user.id', $user->id)
->assertJsonPath('data.tenant.codigo', $tenant->codigo)
->assertJsonCount(2, 'data.tenant.menues')
->assertJsonMissing(['code' => $foreign->code]);
}
}

View File

@@ -218,6 +218,8 @@ class CatalogItemDetailControllerTest extends TestCase
->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id) ->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id) ->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id)
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026') ->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026')
->assertJsonPath('data.attributes.0.options.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.attributes.0.options.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id) ->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id)
->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id) ->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id)
->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id) ->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id)

View File

@@ -32,6 +32,7 @@ class CatalogSchemaTest extends TestCase
{ {
$this->assertEqualsCanonicalizing([ $this->assertEqualsCanonicalizing([
'id', 'id',
'validity_time_id',
'tenant_code', 'tenant_code',
'category_id', 'category_id',
'brand_id', 'brand_id',
@@ -44,6 +45,7 @@ class CatalogSchemaTest extends TestCase
'inventory_policy', 'inventory_policy',
'max_units_per_user', 'max_units_per_user',
'has_tickets', 'has_tickets',
'ticket_generation_policy',
'validity_time_id', 'validity_time_id',
], Schema::getColumnListing('catalog_items')); ], Schema::getColumnListing('catalog_items'));
} }

View File

@@ -8,6 +8,7 @@ use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Database\Seeders\SocialMediaSeeder; use Database\Seeders\SocialMediaSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -45,6 +46,7 @@ class AdminAppEventControllerTest extends TestCase
->assertJsonPath('data.title', 'Festival Acme') ->assertJsonPath('data.title', 'Festival Acme')
->assertJsonPath('data.location', 'Predio Ferial, Rosario') ->assertJsonPath('data.location', 'Predio Ferial, Rosario')
->assertJsonPath('data.dates.0.date', '2026-10-09') ->assertJsonPath('data.dates.0.date', '2026-10-09')
->assertJsonPath('data.dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.dates.0.start_time', '09:00') ->assertJsonPath('data.dates.0.start_time', '09:00')
->assertJsonPath('data.dates.0.end_time', '18:30') ->assertJsonPath('data.dates.0.end_time', '18:30')
->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101') ->assertJsonPath('data.contact.whatsapp_url', 'https://wa.me/5493415550101')
@@ -64,6 +66,17 @@ class AdminAppEventControllerTest extends TestCase
'time_start' => '09:00:00', 'time_start' => '09:00:00',
'time_end' => '18:30:00', 'time_end' => '18:30:00',
]); ]);
$eventDate = $tenant->eventDates()->with('validityTime')->sole();
$this->assertSame($eventDate->validity_time_id, $response->json('data.dates.0.validity_time_id'));
$this->assertSame(ValidityTimeType::FixedWindow, $eventDate->validityTime->type);
$this->assertSame(
'2026-10-09 09:00:00',
$eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-10-09 18:30:00',
$eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
);
$this->assertDatabaseHas('tenant_social_media', [ $this->assertDatabaseHas('tenant_social_media', [
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'social_media_code' => 'whatsapp', 'social_media_code' => 'whatsapp',
@@ -110,6 +123,8 @@ class AdminAppEventControllerTest extends TestCase
'time_start' => '08:00', 'time_start' => '08:00',
'time_end' => '12:00', 'time_end' => '12:00',
]); ]);
$firstValidityTimeId = $firstDate->validity_time_id;
$removedValidityTimeId = $removedDate->validity_time_id;
$tenant->socialMedia()->attach('facebook', [ $tenant->socialMedia()->attach('facebook', [
'url' => 'https://facebook.com/old', 'url' => 'https://facebook.com/old',
'orden' => 2, 'orden' => 2,
@@ -135,9 +150,17 @@ class AdminAppEventControllerTest extends TestCase
$this->assertDatabaseHas('event_dates', [ $this->assertDatabaseHas('event_dates', [
'id' => $firstDate->id, 'id' => $firstDate->id,
'validity_time_id' => $firstValidityTimeId,
'date' => '2026-11-15', 'date' => '2026-11-15',
]); ]);
$this->assertDatabaseHas('validity_times', [
'id' => $firstValidityTimeId,
'type' => ValidityTimeType::FixedWindow->value,
'fixed_starts_at' => '2026-11-15 10:00:00',
'fixed_expires_at' => '2026-11-15 20:00:00',
]);
$this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]); $this->assertDatabaseMissing('event_dates', ['id' => $removedDate->id]);
$this->assertDatabaseMissing('validity_times', ['id' => $removedValidityTimeId]);
$this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text); $this->assertSame('15 de Noviembre 2026', $tenant->fresh()->event_date_text);
$this->assertDatabaseMissing('tenant_social_media', [ $this->assertDatabaseMissing('tenant_social_media', [
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,

View File

@@ -38,7 +38,7 @@ class AdminAppFoodFormControllerTest extends TestCase
'dominio' => 'fiesta.test', 'dominio' => 'fiesta.test',
'website_type_code' => 'onticket', 'website_type_code' => 'onticket',
]); ]);
$tenant->eventDates()->create([ $eventDate = $tenant->eventDates()->create([
'date' => '2026-10-09', 'date' => '2026-10-09',
'time_start' => '00:00', 'time_start' => '00:00',
'time_end' => '23:59', 'time_end' => '23:59',
@@ -60,6 +60,8 @@ class AdminAppFoodFormControllerTest extends TestCase
->assertOk() ->assertOk()
->assertJsonCount(1, 'data.event_dates') ->assertJsonCount(1, 'data.event_dates')
->assertJsonPath('data.event_dates.0.date', '2026-10-09') ->assertJsonPath('data.event_dates.0.date', '2026-10-09')
->assertJsonPath('data.event_dates.0.validity_time_id', $eventDate->validity_time_id)
->assertJsonPath('data.event_dates.0.validity_time.type', 'fixed_window')
->assertJsonPath('data.schedules.0.value', 'Almuerzo') ->assertJsonPath('data.schedules.0.value', 'Almuerzo')
->assertJsonPath('data.schedules.1.value', 'Cena') ->assertJsonPath('data.schedules.1.value', 'Cena')
->assertJsonPath('data.services.0.value', 'Comedor'); ->assertJsonPath('data.services.0.value', 'Comedor');

View File

@@ -4,12 +4,16 @@ namespace Tests\Feature\Sale;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Database\Seeders\AuthorizationSeeder; use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
use Tests\TestCase; use Tests\TestCase;
@@ -82,6 +86,165 @@ class AdminAppSaleControllerTest extends TestCase
->assertNotFound(); ->assertNotFound();
} }
public function test_an_adminapp_user_can_read_all_tickets_from_a_sale_in_its_tenant(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '20000.00',
]);
$firstTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => '11111111-1111-4111-8111-111111111111',
'name' => 'Abono general',
'description' => 'Acceso general',
'source_purchase_id' => $purchase->id,
'user_id' => $admin->id,
]);
$usedTicket = Ticket::query()->create([
'tenant_code' => $tenant->codigo,
'ticket' => '22222222-2222-4222-8222-222222222222',
'name' => 'Abono general',
'description' => 'Acceso general',
'source_purchase_id' => $purchase->id,
'used_at' => now()->subMinute(),
'user_id' => $admin->id,
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/tickets")
->assertOk()
->assertExactJson([
'data' => [
[
'product' => 'Abono general',
'id' => $firstTicket->id,
'expires_at' => null,
'status' => Ticket::STATUS_ACTIVE,
],
[
'product' => 'Abono general',
'id' => $usedTicket->id,
'expires_at' => null,
'status' => Ticket::STATUS_USED,
],
],
]);
}
public function test_an_adminapp_user_cannot_read_tickets_from_another_tenant_sale(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/tickets")
->assertNotFound();
}
public function test_an_adminapp_user_can_confirm_a_pending_sale(): void
{
Event::fake([PurchasePaid::class]);
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID);
$this->assertSame(Purchase::STATUS_PAID, $purchase->fresh()->status);
$this->assertDatabaseHas('value_changes', [
'trackable_id' => $purchase->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'user_id' => $admin->id,
]);
Event::assertDispatchedTimes(PurchasePaid::class, 1);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PAID);
Event::assertDispatchedTimes(PurchasePaid::class, 1);
}
public function test_adminapp_cancellation_does_not_restore_the_source_cart(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $admin->id,
'status' => 'converted',
]);
$cart->delete();
$purchase = Purchase::query()->create([
'cart_id' => $cart->id,
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertSame(Purchase::STATUS_CANCELLED, $purchase->fresh()->status);
$this->assertSoftDeleted('carritos', ['id' => $cart->id]);
$this->assertSame('converted', Cart::withTrashed()->findOrFail($cart->id)->status);
}
public function test_an_adminapp_user_cannot_change_a_sale_from_another_tenant(): void
{
$tenant = $this->createTenant('acme');
$otherTenant = $this->createTenant('other');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$foreignPurchase = Purchase::query()->create([
'tenant_codigo' => $otherTenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/confirm")
->assertNotFound();
$this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/cancel")
->assertNotFound();
}
public function test_a_paid_sale_cannot_be_cancelled_from_adminapp(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
'total' => '10000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertUnprocessable()
->assertJsonValidationErrors('purchase');
}
private function createTenant(string $code): Tenant private function createTenant(string $code): Tenant
{ {
return Tenant::query()->create([ return Tenant::query()->create([

View File

@@ -2,6 +2,7 @@
namespace Tests\Feature\Seeders; namespace Tests\Feature\Seeders;
use App\Domains\Authorization\Enums\PermissionCode;
use App\Domains\Authorization\Enums\RoleCode; use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Permission; use App\Domains\Authorization\Models\Permission;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
@@ -21,6 +22,7 @@ class AuthorizationSeederTest extends TestCase
[ [
RoleCode::Admin->value, RoleCode::Admin->value,
RoleCode::AdminApp->value, RoleCode::AdminApp->value,
RoleCode::Scanner->value,
RoleCode::User->value, RoleCode::User->value,
], ],
Role::query()->orderBy('codigo')->pluck('codigo')->all() Role::query()->orderBy('codigo')->pluck('codigo')->all()
@@ -34,10 +36,15 @@ class AuthorizationSeederTest extends TestCase
$admin = Role::query()->where('codigo', RoleCode::Admin->value)->firstOrFail(); $admin = Role::query()->where('codigo', RoleCode::Admin->value)->firstOrFail();
$appAdmin = Role::query()->where('codigo', RoleCode::AdminApp->value)->firstOrFail(); $appAdmin = Role::query()->where('codigo', RoleCode::AdminApp->value)->firstOrFail();
$scanner = Role::query()->where('codigo', RoleCode::Scanner->value)->firstOrFail();
$user = Role::query()->where('codigo', RoleCode::User->value)->firstOrFail(); $user = Role::query()->where('codigo', RoleCode::User->value)->firstOrFail();
$this->assertCount(22, $admin->permissions); $this->assertCount(22, $admin->permissions);
$this->assertCount(0, $appAdmin->permissions); $this->assertCount(0, $appAdmin->permissions);
$this->assertSame(
[PermissionCode::ScanTickets->value],
$scanner->permissions->pluck('codigo')->all()
);
$this->assertCount(0, $user->permissions); $this->assertCount(0, $user->permissions);
} }
@@ -46,8 +53,8 @@ class AuthorizationSeederTest extends TestCase
$this->seed(AuthorizationSeeder::class); $this->seed(AuthorizationSeeder::class);
$this->seed(AuthorizationSeeder::class); $this->seed(AuthorizationSeeder::class);
$this->assertCount(3, Role::query()->get()); $this->assertCount(4, Role::query()->get());
$this->assertCount(22, Permission::query()->get()); $this->assertCount(22, Permission::query()->get());
$this->assertDatabaseCount('roles_permisos', 22); $this->assertDatabaseCount('roles_permisos', 23);
} }
} }

View File

@@ -12,6 +12,9 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Database\Seeders\AttributeSeeder; use Database\Seeders\AttributeSeeder;
use Database\Seeders\FiestaFutbolInfantilProductSeeder; use Database\Seeders\FiestaFutbolInfantilProductSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -43,6 +46,14 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]); $this->seed([AttributeSeeder::class, FiestaFutbolInfantilProductSeeder::class]);
$this->assertSame('9, 10, 11 y 12 de Octubre 2026', $tenant->fresh()->event_date_text); $this->assertSame('9, 10, 11 y 12 de Octubre 2026', $tenant->fresh()->event_date_text);
$this->assertCount(4, $tenant->eventDates);
$this->assertCount(4, ValidityTime::query()->whereHas('eventDate', fn ($query) => $query
->where('tenant_code', $tenant->codigo))->get());
$this->assertTrue($tenant->eventDates()->with('validityTime')->get()->every(
fn ($eventDate): bool => $eventDate->validityTime->type === ValidityTimeType::FixedWindow
&& $eventDate->validityTime->fixed_starts_at->format('Y-m-d H:i:s') === $eventDate->date->format('Y-m-d').' 00:00:00'
&& $eventDate->validityTime->fixed_expires_at->format('Y-m-d H:i:s') === $eventDate->date->format('Y-m-d').' 23:59:59'
));
$this->assertSame( $this->assertSame(
['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'], ['color', 'event_date', 'horario', 'servicio', 'talle', 'tipo_alojamiento'],
@@ -66,6 +77,12 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->get() ->get()
->every(fn (CatalogItem $item): bool => $item->has_tickets), ->every(fn (CatalogItem $item): bool => $item->has_tickets),
); );
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->get()
->every(fn (CatalogItem $item): bool => $item->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit),
);
$expectedVariantCounts = [ $expectedVariantCounts = [
'camiseta' => 12, 'camiseta' => 12,

View File

@@ -25,6 +25,7 @@ class MenuSeederTest extends TestCase
$this->seed(MenuSeeder::class); $this->seed(MenuSeeder::class);
$expectedMenus = [ $expectedMenus = [
'adminapp.inicio' => ['Inicio', '/admin/inicio'],
'adminapp.catalog' => ['Catálogo', '/admin/catalog'], 'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
'adminapp.categories' => ['Categorías', '/admin/categories'], 'adminapp.categories' => ['Categorías', '/admin/categories'],
'adminapp.combos' => ['Combos', '/admin/combos'], 'adminapp.combos' => ['Combos', '/admin/combos'],
@@ -81,6 +82,17 @@ class MenuSeederTest extends TestCase
$this->assertTrue($fiestaTenant->menues()->where('menues.code', $code)->exists()); $this->assertTrue($fiestaTenant->menues()->where('menues.code', $code)->exists());
} }
foreach ([
'adminapp.inicio',
'adminapp.catalog',
'adminapp.categories',
'adminapp.combos',
] as $code) {
$this->assertFalse(
$fiestaTenant->menues()->where('menues.code', $code)->exists()
);
}
$this->assertFalse( $this->assertFalse(
Menu::query()->whereIn('code', [ Menu::query()->whereIn('code', [
'admin.event', 'admin.event',
@@ -112,8 +124,13 @@ class MenuSeederTest extends TestCase
->where('code', 'main.adminapp') ->where('code', 'main.adminapp')
->orWhere('parent_menu_code', 'main.adminapp') ->orWhere('parent_menu_code', 'main.adminapp')
->pluck('code'); ->pluck('code');
$scannerMenuCodes = Menu::query()
->where('code', 'like', 'scanner.%')
->orderBy('code')
->pluck('code')
->all();
$userMenuCodes = Menu::query() $userMenuCodes = Menu::query()
->whereNotIn('code', $adminAppMenuCodes) ->whereNotIn('code', [...$adminAppMenuCodes, ...$scannerMenuCodes])
->orderBy('code') ->orderBy('code')
->pluck('code') ->pluck('code')
->all(); ->all();
@@ -141,9 +158,23 @@ class MenuSeederTest extends TestCase
$this->assertSame($userMenuCodes, $userRoleMenuCodes); $this->assertSame($userMenuCodes, $userRoleMenuCodes);
$this->assertNotContains('main.adminapp', $userRoleMenuCodes); $this->assertNotContains('main.adminapp', $userRoleMenuCodes);
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes); $this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
$scannerRoleMenuCodes = Role::query()
->where('codigo', RoleCode::Scanner->value)
->firstOrFail()
->menus()
->orderBy('menues.code')
->pluck('menues.code')
->all();
$this->assertSame([
'scanner.detail',
'scanner.inicio',
'scanner.scan',
], $scannerRoleMenuCodes);
$this->assertDatabaseCount( $this->assertDatabaseCount(
'roles_menues', 'roles_menues',
(count($allMenuCodes) * 2) + count($userMenuCodes) (count($allMenuCodes) * 2) + count($userMenuCodes) + count($scannerMenuCodes)
); );
} }

View File

@@ -43,6 +43,7 @@ class WebsiteTypeSeederTest extends TestCase
$this->assertSame('ShopIt', $shopIt->nombre); $this->assertSame('ShopIt', $shopIt->nombre);
$this->assertSame('localhost', $shopIt->dominio); $this->assertSame('localhost', $shopIt->dominio);
$this->assertSame('scanner.localhost', $shopIt->scanner_domain);
$this->assertSame($expectedPresentation, $shopIt->only(array_keys($expectedPresentation))); $this->assertSame($expectedPresentation, $shopIt->only(array_keys($expectedPresentation)));
$this->assertSame('onticket_logo.png', $shopIt->siteLogo->filename); $this->assertSame('onticket_logo.png', $shopIt->siteLogo->filename);
Storage::disk('s3')->assertExists($shopIt->siteLogo->path); Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
@@ -70,6 +71,7 @@ class WebsiteTypeSeederTest extends TestCase
$this->assertSame('OnTicket', $onTicket->nombre); $this->assertSame('OnTicket', $onTicket->nombre);
$this->assertSame('onticket.localhost', $onTicket->dominio); $this->assertSame('onticket.localhost', $onTicket->dominio);
$this->assertSame('scanner.onticket.localhost', $onTicket->scanner_domain);
$this->assertSame($expectedPresentation, $onTicket->only(array_keys($expectedPresentation))); $this->assertSame($expectedPresentation, $onTicket->only(array_keys($expectedPresentation)));
$this->assertSame('onticket_logo.png', $onTicket->siteLogo->filename); $this->assertSame('onticket_logo.png', $onTicket->siteLogo->filename);
Storage::disk('s3')->assertExists($onTicket->siteLogo->path); Storage::disk('s3')->assertExists($onTicket->siteLogo->path);

View File

@@ -0,0 +1,76 @@
<?php
namespace Tests\Feature\Tenant;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class BootstrapScannerControllerTest extends TestCase
{
use RefreshDatabase;
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
{
$siteLogo = Attachment::factory()->create();
WebsiteType::query()->create([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'dominio' => 'admin.shopit.test',
'scanner_domain' => 'scanner.shopit.test',
'primary_color' => '#112233',
'secondary_color' => '#445566',
'danger_color' => '#aa0000',
'success_color' => '#00aa00',
'warning_color' => '#ffaa00',
'body_color' => '#666666',
'darker_body_color' => '#333333',
'surface_color' => '#ffffff',
'background_color' => '#f8f8f8',
'border_color' => '#eaeaea',
'login_header_footer_color' => '#313131',
'site_logo' => $siteLogo->id,
]);
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
->assertOk()
->assertJsonPath('data.website_type_code', 'shopit')
->assertJsonPath('data.primary_color', '#112233')
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
->assertJsonPath('data.footer_logo', null)
->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre')
->assertJsonMissingPath('data.dominio')
->assertJsonMissingPath('data.scanner_domain');
}
public function test_it_does_not_match_the_admin_app_domain(): void
{
WebsiteType::query()->create([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'dominio' => 'admin.shopit.test',
'scanner_domain' => 'scanner.shopit.test',
]);
$this->getJson('/api/v1/scanner/bootstrap/admin.shopit.test')
->assertNotFound();
}
public function test_it_returns_not_found_for_an_unknown_scanner_domain(): void
{
$this->getJson('/api/v1/scanner/bootstrap/unknown.test')
->assertNotFound();
}
public function test_it_rejects_an_invalid_domain(): void
{
$invalidDomain = str_repeat('a', 256);
$this->getJson("/api/v1/scanner/bootstrap/{$invalidDomain}")
->assertUnprocessable()
->assertJsonValidationErrors(['dominio']);
}
}

View File

@@ -248,10 +248,10 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.menues.0.code', 'help') ->assertJsonPath('data.menues.0.code', 'help')
->assertJsonPath('data.menues.0.label', 'Ayuda') ->assertJsonPath('data.menues.0.label', 'Ayuda')
->assertJsonPath('data.menues.0.parent_menu_code', null) ->assertJsonPath('data.menues.0.parent_menu_code', null)
->assertJsonPath('data.menues.0.submenues.0.code', 'help.faq') ->assertJsonPath('data.menues.0.submenues.0.code', 'help.shipping')
->assertJsonPath('data.menues.0.submenues.0.label', 'Preguntas frecuentes') ->assertJsonPath('data.menues.0.submenues.0.label', 'Envíos')
->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help') ->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help')
->assertJsonPath('data.menues.0.submenues.1.code', 'help.shipping'); ->assertJsonPath('data.menues.0.submenues.1.code', 'help.faq');
$assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void { $assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void {
$this->assertArrayNotHasKey('created_at', $menu); $this->assertArrayNotHasKey('created_at', $menu);
@@ -265,10 +265,10 @@ class BootstrapTenantControllerTest extends TestCase
}; };
$menus = $response->json('data.menues'); $menus = $response->json('data.menues');
$this->assertEquals($staticContent, $menus[0]['submenues'][0]['static_content']); $this->assertEquals($staticContent, $menus[0]['submenues'][1]['static_content']);
$assertCleanMenu($menus[0]); $assertCleanMenu($menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]); $this->assertArrayNotHasKey('static_content', $menus[0]);
$this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]); $this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][0]);
} }
public function test_it_returns_only_menus_assigned_to_the_user_role(): void public function test_it_returns_only_menus_assigned_to_the_user_role(): void
@@ -303,6 +303,49 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonMissing(['code' => 'admin.catalog']); ->assertJsonMissing(['code' => 'admin.catalog']);
} }
public function test_it_orders_parent_menus_and_their_submenus_by_label(): void
{
$tenant = $this->createTenant();
$userRole = Role::query()->create([
'codigo' => RoleCode::User->value,
'nombre' => 'Usuario',
]);
$zeta = Menu::query()->create([
'code' => 'zeta',
'label' => 'Zeta',
'route' => '/zeta',
]);
$tree = Menu::query()->create([
'code' => 'tree',
'label' => 'Árbol',
'route' => '/arbol',
]);
$fox = Menu::query()->create([
'code' => 'tree.fox',
'label' => 'Zorro',
'parent_menu_code' => $tree->code,
'route' => '/arbol/zorro',
]);
$eagle = Menu::query()->create([
'code' => 'tree.eagle',
'label' => 'Águila',
'parent_menu_code' => $tree->code,
'route' => '/arbol/aguila',
]);
$menuCodes = [$zeta->code, $tree->code, $fox->code, $eagle->code];
$tenant->menues()->sync($menuCodes);
$userRole->menus()->sync($menuCodes);
$this->getJson('/api/tenants/bootstrap/acme.com')
->assertOk()
->assertJsonPath('data.menues.0.code', 'tree')
->assertJsonPath('data.menues.1.code', 'zeta')
->assertJsonPath('data.menues.0.submenues.0.code', 'tree.eagle')
->assertJsonPath('data.menues.0.submenues.1.code', 'tree.fox');
}
public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void
{ {
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';

View File

@@ -20,6 +20,7 @@ class WebsiteTypeServiceTest extends TestCase
'codigo' => 'marketplace', 'codigo' => 'marketplace',
'nombre' => 'Marketplace', 'nombre' => 'Marketplace',
'dominio' => 'marketplace.test', 'dominio' => 'marketplace.test',
'scanner_domain' => 'scanner.marketplace.test',
'primary_color' => '#111111', 'primary_color' => '#111111',
'secondary_color' => '#222222', 'secondary_color' => '#222222',
'danger_color' => '#ff0000', 'danger_color' => '#ff0000',
@@ -48,6 +49,7 @@ class WebsiteTypeServiceTest extends TestCase
$this->assertDatabaseHas('website_type', [ $this->assertDatabaseHas('website_type', [
'codigo' => 'marketplace', 'codigo' => 'marketplace',
'dominio' => 'marketplace.test', 'dominio' => 'marketplace.test',
'scanner_domain' => 'scanner.marketplace.test',
'warning_color' => '#ffaa00', 'warning_color' => '#ffaa00',
'body_color' => '#666666', 'body_color' => '#666666',
'darker_body_color' => '#333333', 'darker_body_color' => '#333333',

View File

@@ -0,0 +1,313 @@
<?php
namespace Tests\Feature\Ticket;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class ScannerTicketControllerTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
private User $scanner;
private User $ticketOwner;
private Category $category;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
$this->tenant = $this->createTenant('acme');
$this->scanner = User::factory()->create([
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $this->tenant->codigo,
]);
$this->ticketOwner = User::factory()->create();
$this->category = Category::query()->create([
'tenant_code' => $this->tenant->codigo,
'nombre' => 'Entradas',
]);
$this->scanner->scanCategories()->attach($this->category);
}
public function test_scanner_routes_require_authentication_and_scan_permission(): void
{
$this->getJson('/api/v1/scanner/tickets')->assertUnauthorized();
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $this->tenant->codigo,
]));
$this->getJson('/api/v1/scanner/tickets')->assertForbidden();
}
public function test_scanner_can_list_only_its_scanned_tickets_using_adminapp_format(): void
{
$older = $this->createTicket('11111111-1111-4111-8111-111111111111', [
'used_at' => now()->subMinutes(2),
'scanner_user_id' => $this->scanner->id,
]);
$newer = $this->createTicket('22222222-2222-4222-8222-222222222222', [
'used_at' => now()->subMinute(),
'scanner_user_id' => $this->scanner->id,
]);
$otherScanner = User::factory()->create([
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $this->tenant->codigo,
]);
$this->createTicket('33333333-3333-4333-8333-333333333333', [
'used_at' => now(),
'scanner_user_id' => $otherScanner->id,
]);
Sanctum::actingAs($this->scanner);
$this->getJson('/api/v1/scanner/tickets')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $newer->id)
->assertJsonPath('data.0.ticket', $newer->ticket)
->assertJsonPath('data.0.status', Ticket::STATUS_USED)
->assertJsonPath('data.1.id', $older->id)
->assertJsonPath('meta.current_page', 1)
->assertJsonPath('meta.total', 2);
}
public function test_scanner_ticket_history_supports_id_search_and_pagination(): void
{
$matching = $this->createTicket('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', [
'used_at' => now(),
'scanner_user_id' => $this->scanner->id,
]);
$this->createTicket('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', [
'used_at' => now()->subMinute(),
'scanner_user_id' => $this->scanner->id,
]);
Sanctum::actingAs($this->scanner);
$this->getJson('/api/v1/scanner/tickets?q=aaaaaaaa&per_page=1')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.ticket', $matching->ticket)
->assertJsonPath('meta.current_page', 1)
->assertJsonPath('meta.per_page', 1)
->assertJsonPath('meta.total', 1);
}
public function test_scanner_ticket_history_can_be_searched_by_database_id(): void
{
$matching = $this->createTicket('cccccccc-cccc-4ccc-8ccc-cccccccccccc', [
'used_at' => now(),
'scanner_user_id' => $this->scanner->id,
]);
$this->createTicket('dddddddd-dddd-4ddd-8ddd-dddddddddddd', [
'used_at' => now()->subMinute(),
'scanner_user_id' => $this->scanner->id,
]);
Sanctum::actingAs($this->scanner);
$this->getJson("/api/v1/scanner/tickets?q={$matching->id}")
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $matching->id);
}
public function test_scanner_ticket_history_can_be_searched_by_used_date(): void
{
$matching = $this->createTicket('eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee', [
'used_at' => '2026-08-11 14:30:00',
'scanner_user_id' => $this->scanner->id,
]);
$this->createTicket('ffffffff-ffff-4fff-8fff-ffffffffffff', [
'used_at' => '2026-08-10 14:30:00',
'scanner_user_id' => $this->scanner->id,
]);
Sanctum::actingAs($this->scanner);
$this->getJson('/api/v1/scanner/tickets?q=11%2F08%2F26')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $matching->id);
}
public function test_scanner_can_read_an_authorized_ticket_detail_by_uuid(): void
{
$ticket = $this->createTicket('44444444-4444-4444-8444-444444444444');
Sanctum::actingAs($this->scanner);
$this->getJson("/api/v1/scanner/tickets/{$ticket->ticket}")
->assertOk()
->assertJsonPath('data.id', $ticket->id)
->assertJsonPath('data.ticket', $ticket->ticket)
->assertJsonPath('data.name', $ticket->name)
->assertJsonPath('data.client', $this->ticketOwner->nombre_apellido)
->assertJsonPath('data.category', $this->category->nombre)
->assertJsonPath('data.is_valid', true)
->assertJsonPath('data.is_used', false);
}
public function test_scanner_cannot_read_a_ticket_from_an_unassigned_category_or_tenant(): void
{
$otherCategory = Category::query()->create([
'tenant_code' => $this->tenant->codigo,
'nombre' => 'Comidas',
]);
$unassignedTicket = $this->createTicket(
'55555555-5555-4555-8555-555555555555',
[],
$otherCategory,
);
$otherTenant = $this->createTenant('other');
$foreignCategory = Category::query()->create([
'tenant_code' => $otherTenant->codigo,
'nombre' => 'Externas',
]);
$foreignTicket = $this->createTicket(
'66666666-6666-4666-8666-666666666666',
[],
$foreignCategory,
$otherTenant,
);
Sanctum::actingAs($this->scanner);
$this->getJson("/api/v1/scanner/tickets/{$unassignedTicket->ticket}")
->assertNotFound();
$this->getJson("/api/v1/scanner/tickets/{$foreignTicket->ticket}")
->assertNotFound();
}
public function test_scanner_can_scan_an_authorized_ticket_by_uuid(): void
{
$ticket = $this->createTicket('77777777-7777-4777-8777-777777777777');
Sanctum::actingAs($this->scanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
->assertOk()
->assertJsonPath('data.ticket', $ticket->ticket)
->assertJsonPath('data.scanner_user_id', $this->scanner->id)
->assertJsonPath('data.is_valid', false)
->assertJsonPath('data.is_used', true);
$this->assertDatabaseHas('tickets', [
'id' => $ticket->id,
'scanner_user_id' => $this->scanner->id,
]);
$this->assertNotNull($ticket->fresh()->used_at);
}
public function test_ticket_cannot_be_scanned_twice(): void
{
$ticket = $this->createTicket('88888888-8888-4888-8888-888888888888');
Sanctum::actingAs($this->scanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")->assertOk();
$otherScanner = User::factory()->create([
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $this->tenant->codigo,
]);
$otherScanner->scanCategories()->attach($this->category);
Sanctum::actingAs($otherScanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
->assertUnprocessable()
->assertJsonValidationErrors('ticket');
$this->assertSame($this->scanner->id, $ticket->fresh()->scanner_user_id);
}
public function test_scanner_cannot_scan_a_ticket_from_an_unassigned_category(): void
{
$otherCategory = Category::query()->create([
'tenant_code' => $this->tenant->codigo,
'nombre' => 'Comidas',
]);
$ticket = $this->createTicket(
'99999999-9999-4999-8999-999999999999',
[],
$otherCategory,
);
Sanctum::actingAs($this->scanner);
$this->postJson("/api/v1/scanner/tickets/{$ticket->ticket}/scan")
->assertUnprocessable()
->assertJsonValidationErrors('ticket');
$this->assertNull($ticket->fresh()->used_at);
}
/** @param array<string, mixed> $attributes */
private function createTicket(
string $uuid,
array $attributes = [],
?Category $category = null,
?Tenant $tenant = null,
): Ticket {
$tenant ??= $this->tenant;
$category ??= $this->category;
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'category_id' => $category->id,
'slug' => "ticket-{$uuid}",
'nombre' => 'Entrada general',
'descripcion' => 'Acceso general',
'precio' => 100,
'has_tickets' => true,
]);
return Ticket::query()->create(array_merge([
'tenant_code' => $tenant->codigo,
'ticket' => $uuid,
'name' => 'Entrada general',
'description' => 'Acceso general',
'source_catalog_item_id' => $catalogItem->id,
'user_id' => $this->ticketOwner->id,
], $attributes));
}
private function createTenant(string $code): Tenant
{
$logo = Attachment::query()->create([
'key' => (string) Str::uuid(),
'path' => "tests/{$code}-logo.png",
'filename' => "{$code}-logo.png",
'type' => 'image',
'mime_type' => 'image/png',
'extension' => 'png',
'size' => 1,
]);
return Tenant::query()->create([
'codigo' => $code,
'nombre' => ucfirst($code),
'dominio' => "{$code}.test",
'primary_color' => '#ff7006',
'secondary_color' => '#777777',
'danger_color' => '#e04a4a',
'success_color' => '#81bc73',
'header_bg_color' => '#313131',
'footer_bg_color' => '#313131',
'header_logo_id' => $logo->id,
'footer_logo_id' => $logo->id,
'website_type_code' => 'onticket',
]);
}
}

View File

@@ -6,7 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\TestCase; use Tests\TestCase;
@@ -21,6 +23,22 @@ class TicketControllerTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$olderTicket = $this->createTicket($tenant, $user, 'Older ticket'); $olderTicket = $this->createTicket($tenant, $user, 'Older ticket');
$newerTicket = $this->createTicket($tenant, $user, 'Newer ticket'); $newerTicket = $this->createTicket($tenant, $user, 'Newer ticket');
$validityTimes = collect([
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
]),
ValidityTime::query()->create([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
]),
]);
$validityTimes->each(function (ValidityTime $validityTime) use ($newerTicket): void {
$group = $newerTicket->validityGroups()->create();
$group->validityTimes()->attach($validityTime);
});
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/tickets") ->getJson("/api/tenants/{$tenant->codigo}/tickets")
@@ -31,6 +49,10 @@ class TicketControllerTest extends TestCase
->assertJsonPath('data.0.is_valid', true) ->assertJsonPath('data.0.is_valid', true)
->assertJsonPath('data.0.is_expired', false) ->assertJsonPath('data.0.is_expired', false)
->assertJsonPath('data.0.is_used', false) ->assertJsonPath('data.0.is_used', false)
->assertJsonCount(2, 'data.0.validity_times')
->assertJsonCount(2, 'data.0.validity_groups')
->assertJsonCount(1, 'data.0.validity_groups.0.validity_times')
->assertJsonMissingPath('data.0.validity_time')
->assertJsonPath('data.1.id', $olderTicket->id) ->assertJsonPath('data.1.id', $olderTicket->id)
->assertJsonMissingPath('meta') ->assertJsonMissingPath('meta')
->assertJsonMissingPath('links'); ->assertJsonMissingPath('links');

View File

@@ -14,6 +14,7 @@ use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Models\ValidityTime;
@@ -69,7 +70,7 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame($item->descripcion, $ticket->description); $this->assertSame($item->descripcion, $ticket->description);
$this->assertSame($item->id, $ticket->source_catalog_item_id); $this->assertSame($item->id, $ticket->source_catalog_item_id);
$this->assertNull($ticket->source_variant_id); $this->assertNull($ticket->source_variant_id);
$this->assertNull($ticket->validity_time_id); $this->assertTrue($ticket->validityGroups->isEmpty());
} }
} }
@@ -231,7 +232,7 @@ class TicketGeneratorServiceTest extends TestCase
]); ]);
} }
public function test_event_date_and_time_window_are_snapshotted_as_a_fixed_window(): void public function test_event_date_and_time_window_are_combined_in_the_same_and_group(): void
{ {
$item = $this->createTicketableItem('scheduled-meal'); $item = $this->createTicketableItem('scheduled-meal');
$eventDate = EventDate::query()->create([ $eventDate = EventDate::query()->create([
@@ -272,13 +273,22 @@ class TicketGeneratorServiceTest extends TestCase
$tickets = $this->service->generate($item, $this->user, 2, $variant->id); $tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$this->assertCount(2, $tickets); $this->assertCount(2, $tickets);
$this->assertSame(1, $tickets->pluck('validity_time_id')->unique()->count()); $this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 1));
$this->assertNotSame($timeWindow->id, $tickets->first()->validity_time_id); $this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->sole()->validityTimes
$fixedWindow = $tickets->first()->validityTime; ->pluck('id')
$this->assertSame(ValidityTimeType::FixedWindow, $fixedWindow->type); ->sort()
$this->assertSame('2026-08-20 22:00:00', $fixedWindow->fixed_starts_at->format('Y-m-d H:i:s')); ->values()
$this->assertSame('2026-08-21 02:00:00', $fixedWindow->fixed_expires_at->format('Y-m-d H:i:s')); ->all() === collect([$eventDate->validity_time_id, $timeWindow->id])->sort()->values()->all()
));
$this->assertSame(
'2026-08-20 22:00:00',
$tickets->first()->getEffectiveStartsAt()->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-08-20 23:59:59',
$tickets->first()->getEffectiveExpiresAt()->format('Y-m-d H:i:s'),
);
} }
public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void public function test_a_multi_date_variant_generates_one_ticket_for_each_selected_date(): void
@@ -296,12 +306,16 @@ class TicketGeneratorServiceTest extends TestCase
$variant->eventDates()->sync($dates->pluck('id')); $variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 1, $variant->id); $tickets = $this->service->generate($item, $this->user, 1, $variant->id);
$tickets->each->loadMissing('validityTime'); $tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets); $this->assertCount(2, $tickets);
$this->assertSame(
$dates->pluck('validity_time_id')->all(),
$tickets->map(fn ($ticket): int => $ticket->validityGroups->sole()->validityTimes->sole()->id)->all(),
);
$this->assertSame( $this->assertSame(
['2026-08-20 00:00:00', '2026-08-21 00:00:00'], ['2026-08-20 00:00:00', '2026-08-21 00:00:00'],
$tickets->map(fn ($ticket): string => $ticket->validityTime->fixed_starts_at->format('Y-m-d H:i:s'))->all(), $tickets->map(fn ($ticket): string => $ticket->validityGroups->sole()->validityTimes->sole()->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
); );
$this->assertSame( $this->assertSame(
['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'], ['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'],
@@ -310,6 +324,116 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all()); $this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all());
} }
public function test_one_per_unit_policy_generates_one_ticket_covering_all_selected_dates(): void
{
$item = $this->createTicketableItem('multi-date-pass');
$item->update([
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit,
]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '00:00:00',
'time_end' => '23:59:59',
]));
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$tickets = $this->service->generate($item, $this->user, 2, $variant->id);
$tickets->each->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $tickets);
$this->assertTrue($tickets->every(fn ($ticket): bool => $ticket->validityGroups->count() === 2));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->validityGroups->every(
fn ($group): bool => $group->validityTimes->count() === 1
)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$tickets->flatMap(fn ($ticket) => $ticket->allValidityTimes())->pluck('id')->unique()->all(),
);
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->name === 'Multi-date-pass (20/08/2026, 21/08/2026)'
));
$this->assertTrue($tickets->every(
fn ($ticket): bool => $ticket->allValidityTimes()
->map(fn (ValidityTime $validityTime): array => [
$validityTime->fixed_starts_at->format('Y-m-d H:i:s'),
$validityTime->fixed_expires_at->format('Y-m-d H:i:s'),
])
->all() === [
['2026-08-20 00:00:00', '2026-08-20 23:59:59'],
['2026-08-21 00:00:00', '2026-08-21 23:59:59'],
]
));
}
public function test_common_schedule_is_anded_into_each_alternative_event_date_group(): void
{
$item = $this->createTicketableItem('multi-date-lunch');
$item->update(['ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit]);
$dates = collect(['2026-08-20', '2026-08-21'])->map(fn (string $date) => EventDate::query()->create([
'tenant_code' => $this->tenant->codigo,
'date' => $date,
'time_start' => '09:00:00',
'time_end' => '18:00:00',
]));
$lunch = ValidityTime::query()->create([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]);
$schedule = Attribute::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'codigo' => 'schedule',
'nombre' => 'Schedule',
'type' => FieldType::Select,
]);
$schedule->options()->create([
'value' => 'lunch',
'label' => 'Lunch',
'validity_time_id' => $lunch->id,
]);
$itemSchedule = $item->itemAttributes()->create(['attribute_id' => $schedule->id]);
$variant = $item->variants()->create([
'inventory_id' => Inventory::query()->create()->id,
]);
$variant->eventDates()->sync($dates->pluck('id'));
$variant->definitions()->create([
'item_attribute_id' => $itemSchedule->id,
'value' => 'lunch',
]);
$ticket = $this->service->generate($item, $this->user, 1, $variant->id)->sole();
$ticket->loadMissing('validityGroups.validityTimes');
$this->assertCount(2, $ticket->validityGroups);
$this->assertTrue($ticket->validityGroups->every(
fn ($group): bool => $group->validityTimes->count() === 2
&& $group->validityTimes->contains($lunch)
));
$this->assertEqualsCanonicalizing(
$dates->pluck('validity_time_id')->all(),
$ticket->validityGroups
->flatMap->validityTimes
->reject(fn (ValidityTime $validityTime): bool => $validityTime->is($lunch))
->pluck('id')
->all(),
);
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
Carbon::setTestNow('2026-08-20 13:00:00');
$this->assertTrue($ticket->isValid());
Carbon::setTestNow('2026-08-20 16:00:00');
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
}
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
{ {
Event::fake([TicketsAvailable::class]); Event::fake([TicketsAvailable::class]);

View File

@@ -23,9 +23,16 @@ class TicketValiditySchemaTest extends TestCase
'updated_at', 'updated_at',
], Schema::getColumnListing('validity_times')); ], Schema::getColumnListing('validity_times'));
$this->assertTrue(Schema::hasColumns('tickets', [ $this->assertFalse(Schema::hasColumn('tickets', 'validity_time_id'));
$this->assertFalse(Schema::hasTable('ticket_validity_times'));
$this->assertEqualsCanonicalizing([
'id',
'ticket_id',
], Schema::getColumnListing('ticket_validity_groups'));
$this->assertEqualsCanonicalizing([
'ticket_validity_group_id',
'validity_time_id', 'validity_time_id',
])); ], Schema::getColumnListing('ticket_validity_group_times'));
$this->assertFalse(Schema::hasColumn('tickets', 'service_date')); $this->assertFalse(Schema::hasColumn('tickets', 'service_date'));
$this->assertFalse(Schema::hasColumn('tickets', 'starts_at')); $this->assertFalse(Schema::hasColumn('tickets', 'starts_at'));
$this->assertFalse(Schema::hasColumn('tickets', 'expires_at')); $this->assertFalse(Schema::hasColumn('tickets', 'expires_at'));
@@ -34,6 +41,7 @@ class TicketValiditySchemaTest extends TestCase
$this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date')); $this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date')); $this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date'));
$this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id')); $this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id'));
$this->assertTrue(Schema::hasColumn('event_dates', 'validity_time_id'));
$this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date')); $this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date'));
$this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date')); $this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date'));

View File

@@ -5,6 +5,7 @@ namespace Tests\Unit\Event;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ValidityTime;
use Tests\TestCase; use Tests\TestCase;
class EventModelsTest extends TestCase class EventModelsTest extends TestCase
@@ -14,6 +15,7 @@ class EventModelsTest extends TestCase
$eventDate = new EventDate; $eventDate = new EventDate;
$eventDate->setRawAttributes([ $eventDate->setRawAttributes([
'tenant_code' => 'acme', 'tenant_code' => 'acme',
'validity_time_id' => '12',
'date' => '2026-10-09', 'date' => '2026-10-09',
'time_start' => '09:00:00', 'time_start' => '09:00:00',
'time_end' => '18:30:00', 'time_end' => '18:30:00',
@@ -21,9 +23,12 @@ class EventModelsTest extends TestCase
$this->assertFalse($eventDate->usesTimestamps()); $this->assertFalse($eventDate->usesTimestamps());
$this->assertSame('acme', $eventDate->tenant_code); $this->assertSame('acme', $eventDate->tenant_code);
$this->assertSame(12, $eventDate->validity_time_id);
$this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-10-09 09:00:00', $eventDate->startsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-10-09 18:30:00', $eventDate->endsAt()->format('Y-m-d H:i:s'));
$this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated()); $this->assertInstanceOf(Tenant::class, $eventDate->tenant()->getRelated());
$this->assertInstanceOf(ValidityTime::class, $eventDate->validityTime()->getRelated());
$this->assertInstanceOf(EventDate::class, (new ValidityTime)->eventDate()->getRelated());
$this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated()); $this->assertInstanceOf(Variant::class, $eventDate->variants()->getRelated());
} }

View File

@@ -8,7 +8,9 @@ use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
@@ -27,7 +29,6 @@ class TicketTest extends TestCase
$ticket->setRawAttributes([ $ticket->setRawAttributes([
'source_catalog_item_id' => '20', 'source_catalog_item_id' => '20',
'source_variant_id' => '30', 'source_variant_id' => '30',
'validity_time_id' => '40',
'used_at' => null, 'used_at' => null,
'scanner_user_id' => '15', 'scanner_user_id' => '15',
'user_id' => '10', 'user_id' => '10',
@@ -37,7 +38,6 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->usesTimestamps()); $this->assertFalse($ticket->usesTimestamps());
$this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(20, $ticket->source_catalog_item_id);
$this->assertSame(30, $ticket->source_variant_id); $this->assertSame(30, $ticket->source_variant_id);
$this->assertSame(40, $ticket->validity_time_id);
$this->assertNull($ticket->used_at); $this->assertNull($ticket->used_at);
$this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(15, $ticket->scanner_user_id);
$this->assertSame(10, $ticket->user_id); $this->assertSame(10, $ticket->user_id);
@@ -46,12 +46,16 @@ class TicketTest extends TestCase
$this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated()); $this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated());
$this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated());
$this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated()); $this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated());
$this->assertInstanceOf(ValidityTime::class, $ticket->validityTime()->getRelated()); $this->assertInstanceOf(TicketValidityGroup::class, $ticket->validityGroups()->getRelated());
$this->assertInstanceOf(ValidityTime::class, (new TicketValidityGroup)->validityTimes()->getRelated());
} }
public function test_unused_ticket_without_validity_time_is_valid(): void public function test_unused_ticket_without_validity_time_is_valid(): void
{ {
$this->assertTrue((new Ticket)->isValid()); $ticket = new Ticket;
$this->assertTrue($ticket->isValid());
$this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status);
} }
public function test_fixed_window_controls_ticket_validity(): void public function test_fixed_window_controls_ticket_validity(): void
@@ -70,12 +74,11 @@ class TicketTest extends TestCase
public function test_time_window_is_resolved_for_current_date(): void public function test_time_window_is_resolved_for_current_date(): void
{ {
Carbon::setTestNow('2026-07-21 12:00:00'); Carbon::setTestNow('2026-07-21 12:00:00');
$ticket = new Ticket; $ticket = $this->ticketWithValidityGroups([[new ValidityTime([
$ticket->setRelation('validityTime', new ValidityTime([
'type' => ValidityTimeType::TimeWindow, 'type' => ValidityTimeType::TimeWindow,
'start_time' => '10:00:00', 'start_time' => '10:00:00',
'end_time' => '14:00:00', 'end_time' => '14:00:00',
])); ])]]);
$this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-07-21 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); $this->assertSame('2026-07-21 14:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
@@ -92,6 +95,50 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->isValid()); $this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->is_expired); $this->assertTrue($ticket->is_expired);
$this->assertSame(Ticket::STATUS_EXPIRED, $ticket->status);
}
public function test_ticket_is_valid_when_any_validity_time_is_active(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = $this->ticketWithValidityGroups([
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subDays(2),
'fixed_expires_at' => now()->subDay(),
])],
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subHour(),
'fixed_expires_at' => now()->addHour(),
])],
]);
$this->assertTrue($ticket->isValid());
$this->assertFalse($ticket->is_expired);
$this->assertSame('2026-07-19 10:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-07-21 11:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
}
public function test_ticket_is_invalid_but_not_expired_between_validity_times(): void
{
Carbon::setTestNow('2026-07-21 10:00:00');
$ticket = $this->ticketWithValidityGroups([
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->subDays(2),
'fixed_expires_at' => now()->subDay(),
])],
[new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => now()->addDay(),
'fixed_expires_at' => now()->addDays(2),
])],
]);
$this->assertFalse($ticket->isValid());
$this->assertFalse($ticket->is_expired);
$this->assertSame(Ticket::STATUS_ACTIVE, $ticket->status);
} }
public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void
@@ -105,12 +152,51 @@ class TicketTest extends TestCase
$this->assertFalse($ticket->is_valid); $this->assertFalse($ticket->is_valid);
$this->assertFalse($ticket->is_expired); $this->assertFalse($ticket->is_expired);
$this->assertTrue($ticket->is_used); $this->assertTrue($ticket->is_used);
$this->assertSame(Ticket::STATUS_USED, $ticket->status);
}
public function test_all_validity_times_in_the_same_group_must_be_active(): void
{
Carbon::setTestNow('2026-08-20 13:00:00');
$ticket = $this->ticketWithValidityGroups([[
new ValidityTime([
'type' => ValidityTimeType::FixedWindow,
'fixed_starts_at' => '2026-08-20 09:00:00',
'fixed_expires_at' => '2026-08-20 18:00:00',
]),
new ValidityTime([
'type' => ValidityTimeType::TimeWindow,
'start_time' => '12:00:00',
'end_time' => '15:00:00',
]),
]]);
$this->assertTrue($ticket->isValid());
$this->assertSame('2026-08-20 12:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-20 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s'));
Carbon::setTestNow('2026-08-20 16:00:00');
$this->assertFalse($ticket->isValid());
$this->assertTrue($ticket->is_expired);
} }
private function ticketWithValidityTime(ValidityTime $validityTime): Ticket private function ticketWithValidityTime(ValidityTime $validityTime): Ticket
{
return $this->ticketWithValidityGroups([[$validityTime]]);
}
/** @param array<int, array<int, ValidityTime>> $validityGroups */
private function ticketWithValidityGroups(array $validityGroups): Ticket
{ {
$ticket = new Ticket; $ticket = new Ticket;
$ticket->setRelation('validityTime', $validityTime); $groups = collect($validityGroups)->map(function (array $validityTimes): TicketValidityGroup {
$group = new TicketValidityGroup;
$group->setRelation('validityTimes', new EloquentCollection($validityTimes));
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
return $ticket; return $ticket;
} }

View File

@@ -5,7 +5,7 @@ namespace Tests\Unit\Ticket;
use App\Domains\Catalog\Models\AttributeOption; use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
@@ -26,7 +26,10 @@ class ValidityTimeTest extends TestCase
$this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at); $this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at);
$this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated());
$this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated()); $this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated());
$this->assertInstanceOf(Ticket::class, $validityTime->tickets()->getRelated()); $this->assertInstanceOf(
TicketValidityGroup::class,
$validityTime->ticketValidityGroups()->getRelated(),
);
} }
public function test_it_exposes_supported_type_values(): void public function test_it_exposes_supported_type_values(): void