diff --git a/app/Domains/Catalog/Models/AttributeOption.php b/app/Domains/Catalog/Models/AttributeOption.php index fe9811c..233e4bc 100644 --- a/app/Domains/Catalog/Models/AttributeOption.php +++ b/app/Domains/Catalog/Models/AttributeOption.php @@ -2,6 +2,7 @@ namespace App\Domains\Catalog\Models; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; #[Fillable([ 'attribute_id', + 'validity_time_id', 'value', 'label', 'sort_order', @@ -26,6 +28,7 @@ class AttributeOption extends Model protected function casts(): array { return [ + 'validity_time_id' => 'integer', 'sort_order' => 'integer', 'metadata' => 'array', ]; @@ -38,4 +41,10 @@ class AttributeOption extends Model { return $this->belongsTo(Attribute::class, 'attribute_id'); } + + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); + } } diff --git a/app/Domains/Catalog/Models/CatalogItem.php b/app/Domains/Catalog/Models/CatalogItem.php index 2fd62f0..4fb0fc3 100644 --- a/app/Domains/Catalog/Models/CatalogItem.php +++ b/app/Domains/Catalog/Models/CatalogItem.php @@ -10,7 +10,7 @@ use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Event\Models\Event; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; -use Carbon\CarbonInterface; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -32,8 +32,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'precio', 'inventory_policy', 'has_tickets', - 'maximum_use_date', - 'minimum_use_date', + 'validity_time_id', ])] class CatalogItem extends Model { @@ -61,8 +60,7 @@ class CatalogItem extends Model 'precio' => 'decimal:2', 'inventory_policy' => InventoryPolicy::class, 'has_tickets' => 'boolean', - 'maximum_use_date' => 'datetime', - 'minimum_use_date' => 'datetime', + 'validity_time_id' => 'integer', ]; } @@ -120,6 +118,12 @@ class CatalogItem extends Model return $this->hasMany(Ticket::class, 'source_catalog_item_id'); } + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); + } + /** @return BelongsToMany */ public function attributes(): BelongsToMany { @@ -183,16 +187,6 @@ class CatalogItem extends Model return $this->nombre; } - public function getMinimumUseDate(): ?CarbonInterface - { - return $this->minimum_use_date; - } - - public function getMaximumUseDate(): ?CarbonInterface - { - return $this->maximum_use_date; - } - public function isBundle(): bool { return $this->type === CatalogItemType::Bundle; diff --git a/app/Domains/Catalog/Models/Variant.php b/app/Domains/Catalog/Models/Variant.php index fe3b13e..bddd47c 100644 --- a/app/Domains/Catalog/Models/Variant.php +++ b/app/Domains/Catalog/Models/Variant.php @@ -5,7 +5,6 @@ namespace App\Domains\Catalog\Models; use App\Domains\Attachable\Models\Attachment; use App\Domains\Event\Models\EventDate; use App\Domains\Ticket\Models\Ticket; -use Carbon\CarbonInterface; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -17,8 +16,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'catalog_item_id', 'event_date_id', 'inventory_id', - 'minimum_use_date', - 'maximum_use_date', ])] class Variant extends Model { @@ -34,8 +31,6 @@ class Variant extends Model 'catalog_item_id' => 'integer', 'event_date_id' => 'integer', 'inventory_id' => 'integer', - 'minimum_use_date' => 'datetime', - 'maximum_use_date' => 'datetime', ]; } @@ -97,18 +92,4 @@ class Variant extends Model { return $this->catalogItem->nombre; } - - public function getMinimumUseDate(): ?CarbonInterface - { - return $this->eventDate?->startsAt() - ?? $this->minimum_use_date - ?? $this->catalogItem->getMinimumUseDate(); - } - - public function getMaximumUseDate(): ?CarbonInterface - { - return $this->eventDate?->endsAt() - ?? $this->maximum_use_date - ?? $this->catalogItem->getMaximumUseDate(); - } } diff --git a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php index 4ccb4c0..6c5eff1 100644 --- a/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php +++ b/app/Domains/Catalog/Requests/StoreCatalogItemRequest.php @@ -69,8 +69,7 @@ class StoreCatalogItemRequest extends FormRequest 'precio' => ['required', 'numeric', 'min:0'], 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], - 'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'], - 'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'], + 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], 'inventory_id' => ['prohibited'], 'reserved_stock' => ['prohibited'], @@ -99,13 +98,6 @@ class StoreCatalogItemRequest extends FormRequest 'variants.*.inventory_id' => ['prohibited'], 'variants.*.reserved_stock' => ['prohibited'], 'variants.*.sold_units' => ['prohibited'], - 'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'], - 'variants.*.maximum_use_date' => [ - 'sometimes', - 'nullable', - 'date', - 'after_or_equal:variants.*.minimum_use_date', - ], 'variants.*.values' => ['sometimes', 'array'], 'variants.*.values.*' => ['nullable', 'string'], 'variants.*.images' => ['sometimes', 'array'], diff --git a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php index 00fefe7..4e6acaa 100644 --- a/app/Domains/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemDetailResource.php @@ -34,8 +34,7 @@ class CatalogItemDetailResource extends JsonResource 'brand' => $this->brand?->nombre, 'inventory_policy' => $this->inventory_policy?->value, 'has_tickets' => $this->has_tickets, - 'minimum_use_date' => $this->minimum_use_date, - 'maximum_use_date' => $this->maximum_use_date, + 'validity_time_id' => $this->validity_time_id, 'attributes' => $this->itemAttributes ->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute)) ->values(), @@ -101,6 +100,7 @@ class CatalogItemDetailResource extends JsonResource 'value' => $option->value, 'label' => $option->label, 'sort_order' => $option->sort_order, + 'validity_time_id' => $option->validity_time_id, 'metadata' => $option->metadata, ]) ->values(), @@ -115,14 +115,6 @@ class CatalogItemDetailResource extends JsonResource 'event_date_id' => $variant->event_date_id, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'stock_tecnico' => $this->variantStock($variant), - 'minimum_use_date' => $variant->minimum_use_date, - 'maximum_use_date' => $variant->maximum_use_date, - 'effective_minimum_use_date' => $variant->eventDate?->startsAt() - ?? $variant->minimum_use_date - ?? $this->minimum_use_date, - 'effective_maximum_use_date' => $variant->eventDate?->endsAt() - ?? $variant->maximum_use_date - ?? $this->maximum_use_date, 'values' => $variant->definitions ->mapWithKeys(fn ($definition) => [ $definition->itemAttribute?->attribute?->codigo => $definition->value, diff --git a/app/Domains/Catalog/Resources/CatalogItemResource.php b/app/Domains/Catalog/Resources/CatalogItemResource.php index 363a73c..25c3f7b 100644 --- a/app/Domains/Catalog/Resources/CatalogItemResource.php +++ b/app/Domains/Catalog/Resources/CatalogItemResource.php @@ -25,8 +25,7 @@ class CatalogItemResource extends JsonResource 'precio' => $this->precio, 'inventory_policy' => $this->inventory_policy?->value, 'has_tickets' => $this->has_tickets, - 'minimum_use_date' => $this->minimum_use_date, - 'maximum_use_date' => $this->maximum_use_date, + 'validity_time_id' => $this->validity_time_id, 'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock), 'images' => $this->whenLoaded('attachments', fn () => $this->attachments ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) @@ -37,14 +36,6 @@ class CatalogItemResource extends JsonResource 'event_date_id' => $variant->event_date_id, 'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'real_stock' => $variant->inventory?->real_stock, - 'minimum_use_date' => $variant->minimum_use_date, - 'maximum_use_date' => $variant->maximum_use_date, - 'effective_minimum_use_date' => $variant->eventDate?->startsAt() - ?? $variant->minimum_use_date - ?? $this->minimum_use_date, - 'effective_maximum_use_date' => $variant->eventDate?->endsAt() - ?? $variant->maximum_use_date - ?? $this->maximum_use_date, 'values' => $variant->definitions ->mapWithKeys(fn ($definition) => [ $definition->itemAttribute?->attribute?->codigo => $definition->value, diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index 3896ca8..deac1e5 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -83,8 +83,6 @@ class CatalogService $data['inventory_id'] = null; $data['inventory_policy'] = null; $data['has_tickets'] = false; - $data['minimum_use_date'] = null; - $data['maximum_use_date'] = null; } elseif ($hasVariants) { $data['inventory_id'] = null; } else { @@ -381,8 +379,7 @@ class CatalogService 'attribute_codes', 'variants', 'has_tickets', - 'minimum_use_date', - 'maximum_use_date', + 'validity_time_id', ] as $field) { if (array_key_exists($field, $data)) { throw ValidationException::withMessages([ @@ -508,13 +505,9 @@ class CatalogService $variant = $catalogItem->variants()->create([ 'inventory_id' => $inventory->id, 'event_date_id' => $eventDateId, - 'minimum_use_date' => $data['minimum_use_date'] ?? null, - 'maximum_use_date' => $data['maximum_use_date'] ?? null, ]); $variant->setRelation('catalogItem', $catalogItem); - $this->validateVariantUseDates($variant, $index); - foreach ($data['values'] ?? [] as $attributeCode => $value) { $itemAttribute = $itemAttributes[$attributeCode] ?? null; @@ -567,24 +560,6 @@ class CatalogService } } - private function validateVariantUseDates(Variant $variant, int $index): void - { - $minimumUseDate = $variant->getMinimumUseDate(); - $maximumUseDate = $variant->getMaximumUseDate(); - - if ( - $minimumUseDate !== null - && $maximumUseDate !== null - && $maximumUseDate->lessThan($minimumUseDate) - ) { - throw ValidationException::withMessages([ - "variants.{$index}.maximum_use_date" => [ - __('api.catalog.invalid_effective_date_range'), - ], - ]); - } - } - /** * @param array $data * @param array> $variants diff --git a/app/Domains/Tenant/Models/Tenant.php b/app/Domains/Tenant/Models/Tenant.php index cd0c4d8..58f4ead 100644 --- a/app/Domains/Tenant/Models/Tenant.php +++ b/app/Domains/Tenant/Models/Tenant.php @@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'codigo', 'nombre', 'dominio', + 'timezone', 'primary_color', 'secondary_color', 'danger_color', diff --git a/app/Domains/Ticket/Controllers/TicketController.php b/app/Domains/Ticket/Controllers/TicketController.php index 7c8fc2a..f6bccf2 100644 --- a/app/Domains/Ticket/Controllers/TicketController.php +++ b/app/Domains/Ticket/Controllers/TicketController.php @@ -22,7 +22,7 @@ class TicketController extends Controller $tickets = Ticket::query() ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) - ->with('sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with('validityTime', 'tenant', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->orderByDesc('id') ->get(); @@ -36,7 +36,7 @@ class TicketController extends Controller ->where('tenant_code', $tenant->codigo) ->where('user_id', $request->user()->getKey()) ->whereIn('id', $ticketIds) - ->with('sourceVariant.eventDate', 'sourceVariant.catalogItem') + ->with('validityTime', 'tenant', 'sourceVariant.eventDate', 'sourceVariant.catalogItem') ->orderByDesc('id') ->get(); diff --git a/app/Domains/Ticket/Enums/ValidityTimeType.php b/app/Domains/Ticket/Enums/ValidityTimeType.php new file mode 100644 index 0000000..ecad553 --- /dev/null +++ b/app/Domains/Ticket/Enums/ValidityTimeType.php @@ -0,0 +1,15 @@ + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Domains/Ticket/Exceptions/TicketGenerationException.php b/app/Domains/Ticket/Exceptions/TicketGenerationException.php index e9fa303..bc543cc 100644 --- a/app/Domains/Ticket/Exceptions/TicketGenerationException.php +++ b/app/Domains/Ticket/Exceptions/TicketGenerationException.php @@ -24,9 +24,9 @@ class TicketGenerationException extends RuntimeException return new self(__('api.ticket.disabled', ['product' => $catalogItem->id])); } - public static function maximumUseDateReached(CatalogItem $catalogItem): self + public static function ambiguousValidityTime(CatalogItem $catalogItem): self { - return new self(__('api.ticket.expired', ['product' => $catalogItem->id])); + return new self("Catalog item {$catalogItem->id} resolves more than one validity time."); } public static function variantNotFound( diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 7d937bc..a76a49c 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -21,8 +21,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; 'source_purchase_id', 'source_catalog_item_id', 'source_variant_id', - 'starts_at', - 'expires_at', + 'validity_time_id', + 'service_date', 'used_at', 'scanner_user_id', 'user_id', @@ -45,8 +45,8 @@ class Ticket extends Model 'source_catalog_item_id' => 'integer', 'source_variant_id' => 'integer', 'source_purchase_id' => 'integer', - 'starts_at' => 'datetime', - 'expires_at' => 'datetime', + 'validity_time_id' => 'integer', + 'service_date' => 'date', 'used_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', @@ -89,6 +89,12 @@ class Ticket extends Model return $this->belongsTo(Variant::class, 'source_variant_id'); } + /** @return BelongsTo */ + public function validityTime(): BelongsTo + { + return $this->belongsTo(ValidityTime::class); + } + public function isValid(): bool { $now = now(); @@ -121,13 +127,17 @@ class Ticket extends Model public function getEffectiveStartsAt(): ?CarbonInterface { - return $this->sourceVariant?->getMinimumUseDate() - ?? $this->starts_at; + return $this->validityTime?->startsAt( + $this->service_date, + $this->tenant?->timezone ?? config('app.timezone'), + ); } public function getEffectiveExpiresAt(): ?CarbonInterface { - return $this->sourceVariant?->getMaximumUseDate() - ?? $this->expires_at; + return $this->validityTime?->expiresAt( + $this->service_date, + $this->tenant?->timezone ?? config('app.timezone'), + ); } } diff --git a/app/Domains/Ticket/Models/ValidityTime.php b/app/Domains/Ticket/Models/ValidityTime.php new file mode 100644 index 0000000..58b2382 --- /dev/null +++ b/app/Domains/Ticket/Models/ValidityTime.php @@ -0,0 +1,87 @@ + ValidityTimeType::class, + 'fixed_starts_at' => 'datetime', + 'fixed_expires_at' => 'datetime', + 'active' => 'boolean', + ]; + } + + /** @return HasMany */ + public function catalogItems(): HasMany + { + return $this->hasMany(CatalogItem::class); + } + + /** @return HasMany */ + public function attributeOptions(): HasMany + { + return $this->hasMany(AttributeOption::class); + } + + /** @return HasMany */ + public function tickets(): HasMany + { + return $this->hasMany(Ticket::class); + } + + public function startsAt(?CarbonInterface $serviceDate, string $timezone): ?CarbonInterface + { + if ($this->type === ValidityTimeType::FixedWindow) { + return $this->fixed_starts_at; + } + + return $this->atServiceDate($serviceDate, $this->start_time, $timezone); + } + + public function expiresAt(?CarbonInterface $serviceDate, string $timezone): ?CarbonInterface + { + if ($this->type === ValidityTimeType::FixedWindow) { + return $this->fixed_expires_at; + } + + return $this->atServiceDate($serviceDate, $this->end_time, $timezone); + } + + private function atServiceDate( + ?CarbonInterface $serviceDate, + ?string $time, + string $timezone, + ): ?CarbonInterface { + if ($serviceDate === null || $time === null) { + return null; + } + + return CarbonImmutable::parse( + $serviceDate->format('Y-m-d').' '.$time, + $timezone, + )->utc(); + } +} diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php index 652c89d..d101119 100644 --- a/app/Domains/Ticket/Services/TicketGeneratorService.php +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -5,8 +5,10 @@ namespace App\Domains\Ticket\Services; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -39,7 +41,8 @@ class TicketGeneratorService $user, ): Ticket { $item = $target['catalog_item']; - $selectedItem = $target['variant'] ?? $item; + $variant = $target['variant']; + $validityTime = $this->resolveValidityTime($item, $variant); return Ticket::query()->create([ 'tenant_code' => $item->tenant_code, @@ -48,9 +51,11 @@ class TicketGeneratorService 'description' => (string) ($item->descripcion ?? ''), 'source_purchase_id' => $sourcePurchaseId, 'source_catalog_item_id' => $item->getKey(), - 'source_variant_id' => $target['variant']?->getKey(), - 'starts_at' => $selectedItem->getMinimumUseDate(), - 'expires_at' => $selectedItem->getMaximumUseDate(), + 'source_variant_id' => $variant?->getKey(), + 'validity_time_id' => $validityTime?->getKey(), + 'service_date' => $validityTime?->type === ValidityTimeType::ServiceDateWindow + ? ($variant?->eventDate?->date ?? now($item->tenant->timezone)->toDateString()) + : null, 'used_at' => null, 'user_id' => $user->getKey(), ]); @@ -134,12 +139,37 @@ class TicketGeneratorService throw TicketGenerationException::ticketsDisabled($catalogItem); } - // TODO: Reactivar esta validación cuando los pagos con productos vencidos - // deban rechazarse nuevamente. Se deja deshabilitada temporalmente. - // $selectedItem = $variant ?? $catalogItem; - // - // if ($selectedItem->getMaximumUseDate()?->lessThanOrEqualTo(now())) { - // throw TicketGenerationException::maximumUseDateReached($catalogItem); - // } + } + + private function resolveValidityTime( + CatalogItem $catalogItem, + ?Variant $variant, + ): ?ValidityTime { + if ($catalogItem->validity_time_id !== null) { + return $catalogItem->validityTime; + } + + if ($variant === null) { + return null; + } + + $variant->loadMissing('definitions.itemAttribute.attribute.options.validityTime'); + + $validityTimes = $variant->definitions + ->map(function ($definition): ?ValidityTime { + $option = $definition->itemAttribute?->attribute?->options + ->firstWhere('value', $definition->value); + + return $option?->validityTime; + }) + ->filter() + ->unique(fn (ValidityTime $validityTime): int => $validityTime->getKey()) + ->values(); + + if ($validityTimes->count() > 1) { + throw TicketGenerationException::ambiguousValidityTime($catalogItem); + } + + return $validityTimes->first(); } } diff --git a/database/migrations/2026_08_06_000000_add_timezone_to_tenants_table.php b/database/migrations/2026_08_06_000000_add_timezone_to_tenants_table.php new file mode 100644 index 0000000..90c2cea --- /dev/null +++ b/database/migrations/2026_08_06_000000_add_timezone_to_tenants_table.php @@ -0,0 +1,22 @@ +string('timezone')->default('UTC')->after('dominio'); + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('timezone'); + }); + } +}; diff --git a/database/migrations/2026_08_06_000100_create_validity_times_table.php b/database/migrations/2026_08_06_000100_create_validity_times_table.php new file mode 100644 index 0000000..9e23145 --- /dev/null +++ b/database/migrations/2026_08_06_000100_create_validity_times_table.php @@ -0,0 +1,27 @@ +id(); + $table->string('type'); + $table->time('start_time')->nullable(); + $table->time('end_time')->nullable(); + $table->dateTime('fixed_starts_at')->nullable(); + $table->dateTime('fixed_expires_at')->nullable(); + $table->boolean('active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('validity_times'); + } +}; diff --git a/database/migrations/2026_08_06_000200_add_validity_time_to_catalog_tables.php b/database/migrations/2026_08_06_000200_add_validity_time_to_catalog_tables.php new file mode 100644 index 0000000..36833c7 --- /dev/null +++ b/database/migrations/2026_08_06_000200_add_validity_time_to_catalog_tables.php @@ -0,0 +1,40 @@ +foreignId('validity_time_id') + ->nullable() + ->after('has_tickets') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + + Schema::table('attribute_options', function (Blueprint $table): void { + $table->foreignId('validity_time_id') + ->nullable() + ->after('attribute_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('attribute_options', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + }); + } +}; diff --git a/database/migrations/2026_08_06_000300_add_validity_time_to_tickets_table.php b/database/migrations/2026_08_06_000300_add_validity_time_to_tickets_table.php new file mode 100644 index 0000000..baefc37 --- /dev/null +++ b/database/migrations/2026_08_06_000300_add_validity_time_to_tickets_table.php @@ -0,0 +1,29 @@ +foreignId('validity_time_id') + ->nullable() + ->after('source_variant_id') + ->constrained('validity_times') + ->cascadeOnUpdate() + ->restrictOnDelete(); + $table->date('service_date')->nullable()->after('validity_time_id'); + }); + } + + public function down(): void + { + Schema::table('tickets', function (Blueprint $table): void { + $table->dropConstrainedForeignId('validity_time_id'); + $table->dropColumn('service_date'); + }); + } +}; diff --git a/database/migrations/2026_08_06_000400_remove_legacy_ticket_validity_columns.php b/database/migrations/2026_08_06_000400_remove_legacy_ticket_validity_columns.php new file mode 100644 index 0000000..c46b51f --- /dev/null +++ b/database/migrations/2026_08_06_000400_remove_legacy_ticket_validity_columns.php @@ -0,0 +1,93 @@ +preserveFixedWindows( + 'catalog_items', + 'minimum_use_date', + 'maximum_use_date', + ); + $this->preserveFixedWindows('tickets', 'starts_at', 'expires_at'); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn(['starts_at', 'expires_at']); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dropColumn(['minimum_use_date', 'maximum_use_date']); + }); + + Schema::table('variantes', function (Blueprint $table): void { + $table->dropColumn(['minimum_use_date', 'maximum_use_date']); + }); + } + + public function down(): void + { + Schema::table('variantes', function (Blueprint $table): void { + $table->dateTime('minimum_use_date')->nullable(); + $table->dateTime('maximum_use_date')->nullable(); + }); + + Schema::table('catalog_items', function (Blueprint $table): void { + $table->dateTime('minimum_use_date')->nullable(); + $table->dateTime('maximum_use_date')->nullable(); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dateTime('starts_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + private function preserveFixedWindows( + string $table, + string $startsAtColumn, + string $expiresAtColumn, + ): void { + DB::table($table) + ->whereNull('validity_time_id') + ->where(function ($query) use ($startsAtColumn, $expiresAtColumn): void { + $query->whereNotNull($startsAtColumn) + ->orWhereNotNull($expiresAtColumn); + }) + ->select([$startsAtColumn, $expiresAtColumn]) + ->distinct() + ->get() + ->each(function ($window) use ($table, $startsAtColumn, $expiresAtColumn): void { + $startsAt = $window->{$startsAtColumn}; + $expiresAt = $window->{$expiresAtColumn}; + $validityTimeId = DB::table('validity_times')->insertGetId([ + 'type' => 'fixed_window', + 'start_time' => null, + 'end_time' => null, + 'fixed_starts_at' => $startsAt, + 'fixed_expires_at' => $expiresAt, + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table($table) + ->whereNull('validity_time_id') + ->when( + $startsAt === null, + fn ($query) => $query->whereNull($startsAtColumn), + fn ($query) => $query->where($startsAtColumn, $startsAt), + ) + ->when( + $expiresAt === null, + fn ($query) => $query->whereNull($expiresAtColumn), + fn ($query) => $query->where($expiresAtColumn, $expiresAt), + ) + ->update(['validity_time_id' => $validityTimeId]); + }); + } +}; diff --git a/database/seeders/FiestaFutbolInfantilProductSeeder.php b/database/seeders/FiestaFutbolInfantilProductSeeder.php index 6a947ba..5070b91 100644 --- a/database/seeders/FiestaFutbolInfantilProductSeeder.php +++ b/database/seeders/FiestaFutbolInfantilProductSeeder.php @@ -14,6 +14,8 @@ use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Services\CatalogService; use App\Domains\Event\Models\Event; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Database\Seeder; use RuntimeException; @@ -79,6 +81,11 @@ class FiestaFutbolInfantilProductSeeder extends Seeder $dates = $eventDates->keys()->all(); $minimumUseDate = $dates[0].' 00:00:00'; $maximumUseDate = $dates[array_key_last($dates)].' 23:59:59'; + $eventValidityTime = ValidityTime::query()->firstOrCreate([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => $minimumUseDate, + 'fixed_expires_at' => $maximumUseDate, + ]); $generalAdmission = $this->catalogService->create([ 'tenant_code' => $tenant->codigo, @@ -91,8 +98,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder 'precio' => 10000, 'inventory_policy' => InventoryPolicy::Unlimited->value, 'has_tickets' => true, - 'minimum_use_date' => $minimumUseDate, - 'maximum_use_date' => $maximumUseDate, + 'validity_time_id' => $eventValidityTime->id, 'variants' => array_map( fn (string $date): array => [ 'real_stock' => 0, @@ -120,8 +126,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder 'descripcion' => $item['descripcion'] ?? $item['nombre'], 'inventory_policy' => InventoryPolicy::Unlimited->value, 'real_stock' => 0, - 'minimum_use_date' => $minimumUseDate, - 'maximum_use_date' => $maximumUseDate, + 'validity_time_id' => $eventValidityTime->id, ...$item, ]); } diff --git a/tests/Feature/Catalog/CatalogItemControllerTest.php b/tests/Feature/Catalog/CatalogItemControllerTest.php index 76d5190..48eb7d1 100644 --- a/tests/Feature/Catalog/CatalogItemControllerTest.php +++ b/tests/Feature/Catalog/CatalogItemControllerTest.php @@ -32,14 +32,11 @@ class CatalogItemControllerTest extends TestCase 'slug' => 'shirt', 'nombre' => 'Shirt', 'precio' => 100, - 'minimum_use_date' => '2026-08-01 09:00:00', - 'maximum_use_date' => '2026-08-31 18:00:00', 'attribute_codes' => [$attribute->codigo], 'images' => [$image, $image], 'variants' => [ [ 'real_stock' => 5, - 'maximum_use_date' => '2026-08-15 18:00:00', 'values' => ['size' => 'M'], 'images' => [$image], ], @@ -51,20 +48,7 @@ class CatalogItemControllerTest extends TestCase ->assertJsonPath('data.nombre', 'Shirt') ->assertJsonCount(2, 'data.images') ->assertJsonCount(1, 'data.variants') - ->assertJsonCount(1, 'data.variants.0.images') - ->assertJsonPath('data.variants.0.minimum_use_date', null) - ->assertJsonPath( - 'data.variants.0.maximum_use_date', - fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'), - ) - ->assertJsonPath( - 'data.variants.0.effective_minimum_use_date', - fn (string $value): bool => str_starts_with($value, '2026-08-01T09:00:00'), - ) - ->assertJsonPath( - 'data.variants.0.effective_maximum_use_date', - fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'), - ); + ->assertJsonCount(1, 'data.variants.0.images'); $item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail(); $variant = $item->variants()->firstOrFail(); diff --git a/tests/Feature/Catalog/CatalogSchemaTest.php b/tests/Feature/Catalog/CatalogSchemaTest.php index f561360..c888f6d 100644 --- a/tests/Feature/Catalog/CatalogSchemaTest.php +++ b/tests/Feature/Catalog/CatalogSchemaTest.php @@ -44,8 +44,7 @@ class CatalogSchemaTest extends TestCase 'precio', 'inventory_policy', 'has_tickets', - 'maximum_use_date', - 'minimum_use_date', + 'validity_time_id', ], Schema::getColumnListing('catalog_items')); } @@ -183,8 +182,6 @@ class CatalogSchemaTest extends TestCase { $this->assertTrue(Schema::hasColumns('variantes', [ 'event_date_id', - 'minimum_use_date', - 'maximum_use_date', ])); } diff --git a/tests/Feature/Catalog/CatalogServiceTest.php b/tests/Feature/Catalog/CatalogServiceTest.php index bc1c211..5215b8f 100644 --- a/tests/Feature/Catalog/CatalogServiceTest.php +++ b/tests/Feature/Catalog/CatalogServiceTest.php @@ -13,7 +13,6 @@ use App\Domains\Catalog\Services\CatalogService; use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Carbon; use Illuminate\Validation\ValidationException; use Tests\TestCase; @@ -94,73 +93,6 @@ class CatalogServiceTest extends TestCase } } - public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void - { - $attribute = $this->createAttribute('day'); - $itemMinimum = Carbon::parse('2026-08-01 09:00:00'); - $itemMaximum = Carbon::parse('2026-08-31 18:00:00'); - $variantMaximum = Carbon::parse('2026-08-15 18:00:00'); - - $item = $this->service->create([ - 'tenant_code' => $this->tenant->codigo, - 'slug' => 'dated-variants', - 'nombre' => 'Dated variants', - 'precio' => 100, - 'minimum_use_date' => $itemMinimum, - 'maximum_use_date' => $itemMaximum, - 'attribute_codes' => [$attribute->codigo], - 'variants' => [ - [ - 'real_stock' => 5, - 'maximum_use_date' => $variantMaximum, - 'values' => [$attribute->codigo => 'Saturday'], - ], - ], - ]); - - $variant = $item->variants->firstOrFail(); - - $this->assertNull($variant->minimum_use_date); - $this->assertTrue($variant->maximum_use_date->equalTo($variantMaximum)); - $this->assertTrue($variant->getMinimumUseDate()->equalTo($itemMinimum)); - $this->assertTrue($variant->getMaximumUseDate()->equalTo($variantMaximum)); - } - - public function test_it_rejects_an_invalid_effective_variant_use_date_range(): void - { - $attribute = $this->createAttribute('day'); - - try { - $this->service->create([ - 'tenant_code' => $this->tenant->codigo, - 'slug' => 'invalid-dated-variant', - 'nombre' => 'Invalid dated variant', - 'precio' => 100, - 'minimum_use_date' => '2026-08-10 09:00:00', - 'maximum_use_date' => '2026-08-31 18:00:00', - 'attribute_codes' => [$attribute->codigo], - 'variants' => [ - [ - 'real_stock' => 5, - 'maximum_use_date' => '2026-08-09 18:00:00', - 'values' => [$attribute->codigo => 'Saturday'], - ], - ], - ]); - - $this->fail('A validation exception was not thrown.'); - } catch (ValidationException $exception) { - $this->assertArrayHasKey( - 'variants.0.maximum_use_date', - $exception->errors(), - ); - } - - $this->assertDatabaseMissing('catalog_items', [ - 'slug' => 'invalid-dated-variant', - ]); - } - public function test_it_rejects_direct_inventory_together_with_variants(): void { $attribute = $this->createAttribute('size'); diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index ba3bcb5..7fd1162 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -259,7 +259,7 @@ class TelepagosWebhookTest extends TestCase ]); } - public function test_webhook_confirms_a_purchase_when_its_ticket_use_date_has_ended(): void + public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void { $tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar'); $this->configureTelepagosIntegration($tenant); @@ -267,7 +267,6 @@ class TelepagosWebhookTest extends TestCase $variant = $this->createVariantForTenant('expired-ticket', 1, '50.00'); $variant->catalogItem->update([ 'has_tickets' => true, - 'maximum_use_date' => now()->subMinute(), ]); $purchase = $this->createPendingTransferPurchase( $tenant, diff --git a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php index 8eb8c1e..57e71c9 100644 --- a/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php +++ b/tests/Feature/Seeders/FiestaFutbolInfantilProductSeederTest.php @@ -95,22 +95,8 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase ->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d')) ->all() ); - $this->assertSame( - [ - ['2026-10-09 00:00:00', '2026-10-09 23:59:59'], - ['2026-10-10 00:00:00', '2026-10-10 23:59:59'], - ['2026-10-11 00:00:00', '2026-10-11 23:59:59'], - ['2026-10-12 00:00:00', '2026-10-12 23:59:59'], - ], - $generalAdmission->variants - ->sortBy(fn ($variant) => $variant->eventDate->date) - ->map(fn ($variant): array => [ - $variant->getMinimumUseDate()->format('Y-m-d H:i:s'), - $variant->getMaximumUseDate()->format('Y-m-d H:i:s'), - ]) - ->values() - ->all() - ); + $this->assertSame('2026-10-09 00:00:00', $generalAdmission->validityTime->fixed_starts_at->format('Y-m-d H:i:s')); + $this->assertSame('2026-10-12 23:59:59', $generalAdmission->validityTime->fixed_expires_at->format('Y-m-d H:i:s')); $standardItems = CatalogItem::query() ->where('tenant_code', $tenant->codigo) @@ -122,11 +108,11 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase $this->assertSame($event->id, $standardItem->event_id); $this->assertSame( '2026-10-09 00:00:00', - $standardItem->minimum_use_date->format('Y-m-d H:i:s'), + $standardItem->validityTime->fixed_starts_at->format('Y-m-d H:i:s'), ); $this->assertSame( '2026-10-12 23:59:59', - $standardItem->maximum_use_date->format('Y-m-d H:i:s'), + $standardItem->validityTime->fixed_expires_at->format('Y-m-d H:i:s'), ); } diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php index af886ed..0afb3e2 100644 --- a/tests/Feature/Ticket/TicketGeneratorServiceTest.php +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -51,11 +51,7 @@ class TicketGeneratorServiceTest extends TestCase public function test_it_generates_tickets_from_a_standard_catalog_item(): void { - $item = $this->createTicketableItem( - 'single-day', - now()->subHour(), - now()->addDay(), - ); + $item = $this->createTicketableItem('single-day'); $tickets = $this->service->generate($item, $this->user, 2); @@ -68,8 +64,8 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame($item->descripcion, $ticket->description); $this->assertSame($item->id, $ticket->source_catalog_item_id); $this->assertNull($ticket->source_variant_id); - $this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); - $this->assertTrue($ticket->expires_at->equalTo($item->maximum_use_date)); + $this->assertNull($ticket->validity_time_id); + $this->assertNull($ticket->service_date); } } @@ -84,41 +80,10 @@ class TicketGeneratorServiceTest extends TestCase $this->service->generate($item->fresh(), $this->user); } - public function test_it_generates_a_ticket_when_its_maximum_use_date_was_reached(): void - { - $item = $this->createTicketableItem('expired', maximumUseDate: now()); - - $tickets = $this->service->generate($item, $this->user); - - $this->assertCount(1, $tickets); - $this->assertTrue($tickets->first()->expires_at->equalTo($item->maximum_use_date)); - } - - public function test_variant_dates_override_and_inherit_catalog_item_dates(): void - { - $item = $this->createTicketableItem( - 'variant-dates', - now()->subDay(), - now()->addMonth(), - ); - $inventory = Inventory::query()->create(); - $variant = $item->variants()->create([ - 'inventory_id' => $inventory->id, - 'maximum_use_date' => now()->addWeek(), - ]); - - $ticket = $this->service - ->generate($item, $this->user, sourceVariantId: $variant->id) - ->firstOrFail(); - - $this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); - $this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date)); - } - public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void { - $first = $this->createTicketableItem('first', maximumUseDate: now()->addDay()); - $second = $this->createTicketableItem('second', maximumUseDate: now()->addDays(2)); + $first = $this->createTicketableItem('first'); + $second = $this->createTicketableItem('second'); $bundle = $this->createBundle('bundle'); $bundle->bundleComponents()->createMany([ ['component_catalog_item_id' => $first->id, 'quantity' => 2], @@ -134,18 +99,12 @@ class TicketGeneratorServiceTest extends TestCase $this->assertCount(2, $tickets->where('name', $second->nombre)); } - public function test_bundle_component_uses_its_variant_dates(): void + public function test_bundle_component_preserves_its_source_variant(): void { - $component = $this->createTicketableItem( - 'variant-component', - now()->subDay(), - now()->addMonth(), - ); + $component = $this->createTicketableItem('variant-component'); $inventory = Inventory::query()->create(); $variant = $component->variants()->create([ 'inventory_id' => $inventory->id, - 'minimum_use_date' => now()->addDay(), - 'maximum_use_date' => now()->addWeek(), ]); $bundle = $this->createBundle('variant-bundle'); $bundle->bundleComponents()->create([ @@ -160,8 +119,6 @@ class TicketGeneratorServiceTest extends TestCase $this->assertSame($component->id, $ticket->source_catalog_item_id); $this->assertSame($variant->id, $ticket->source_variant_id); - $this->assertTrue($ticket->starts_at->equalTo($variant->minimum_use_date)); - $this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date)); } public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void @@ -244,9 +201,9 @@ class TicketGeneratorServiceTest extends TestCase ->assertJsonPath('data.has_generated_tickets', false); } - public function test_paid_status_is_confirmed_when_ticket_maximum_use_date_was_reached(): void + public function test_paid_status_is_confirmed_when_ticket_is_generated(): void { - $item = $this->createTicketableItem('expired-paid-ticket', maximumUseDate: now()); + $item = $this->createTicketableItem('paid-ticket'); $purchase = $this->createPurchase($item, 1); $purchase->markAsPaid(); @@ -258,11 +215,8 @@ class TicketGeneratorServiceTest extends TestCase ]); } - private function createTicketableItem( - string $slug, - mixed $minimumUseDate = null, - mixed $maximumUseDate = null, - ): CatalogItem { + private function createTicketableItem(string $slug): CatalogItem + { return CatalogItem::query()->create([ 'tenant_code' => $this->tenant->codigo, 'slug' => $slug, @@ -270,8 +224,6 @@ class TicketGeneratorServiceTest extends TestCase 'descripcion' => "Descripción de {$slug}", 'precio' => 10, 'has_tickets' => true, - 'minimum_use_date' => $minimumUseDate, - 'maximum_use_date' => $maximumUseDate, ]); } diff --git a/tests/Feature/Ticket/TicketValiditySchemaTest.php b/tests/Feature/Ticket/TicketValiditySchemaTest.php new file mode 100644 index 0000000..92ea84d --- /dev/null +++ b/tests/Feature/Ticket/TicketValiditySchemaTest.php @@ -0,0 +1,43 @@ +assertEqualsCanonicalizing([ + 'id', + 'type', + 'start_time', + 'end_time', + 'fixed_starts_at', + 'fixed_expires_at', + 'active', + 'created_at', + 'updated_at', + ], Schema::getColumnListing('validity_times')); + + $this->assertTrue(Schema::hasColumns('tickets', [ + 'validity_time_id', + 'service_date', + ])); + $this->assertFalse(Schema::hasColumn('tickets', 'starts_at')); + $this->assertFalse(Schema::hasColumn('tickets', 'expires_at')); + + $this->assertTrue(Schema::hasColumn('catalog_items', 'validity_time_id')); + $this->assertFalse(Schema::hasColumn('catalog_items', 'minimum_use_date')); + $this->assertFalse(Schema::hasColumn('catalog_items', 'maximum_use_date')); + $this->assertTrue(Schema::hasColumn('attribute_options', 'validity_time_id')); + $this->assertFalse(Schema::hasColumn('variantes', 'minimum_use_date')); + $this->assertFalse(Schema::hasColumn('variantes', 'maximum_use_date')); + + $this->assertTrue(Schema::hasColumn('tenants', 'timezone')); + } +} diff --git a/tests/Unit/Catalog/CatalogModelsTest.php b/tests/Unit/Catalog/CatalogModelsTest.php index 95f8cc7..5e51a85 100644 --- a/tests/Unit/Catalog/CatalogModelsTest.php +++ b/tests/Unit/Catalog/CatalogModelsTest.php @@ -27,7 +27,6 @@ use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Collection as EloquentCollection; -use Illuminate\Support\Carbon; use Tests\TestCase; class CatalogModelsTest extends TestCase @@ -160,24 +159,6 @@ class CatalogModelsTest extends TestCase $this->assertSame('catalog_items_attachments', $variant->attachments()->getTable()); } - public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void - { - $item = new CatalogItem; - $item->minimum_use_date = Carbon::parse('2026-08-01 09:00:00'); - $item->maximum_use_date = Carbon::parse('2026-08-31 18:00:00'); - - $variant = new Variant; - $variant->maximum_use_date = Carbon::parse('2026-08-15 18:00:00'); - $variant->setRelation('catalogItem', $item); - - $this->assertTrue( - $variant->getMinimumUseDate()->equalTo($item->minimum_use_date), - ); - $this->assertTrue( - $variant->getMaximumUseDate()->equalTo($variant->maximum_use_date), - ); - } - public function test_event_date_identifies_a_variant_without_catalog_attributes(): void { $item = new CatalogItem; @@ -189,15 +170,12 @@ class CatalogModelsTest extends TestCase $eventDate->time_end = '18:00:00'; $variant = new Variant; - $variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00'); - $variant->maximum_use_date = Carbon::parse('2026-10-09 20:00:00'); $variant->setRelation('catalogItem', $item); $variant->setRelation('eventDate', $eventDate); $variant->setRelation('definitions', new EloquentCollection); $this->assertSame('Entrada General', $variant->getName()); - $this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s')); - $this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s')); + $this->assertSame('2026-10-09', $variant->eventDate->date->format('Y-m-d')); } public function test_inventory_maps_stock_without_a_polymorphic_owner(): void diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index d4a3958..029d90f 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -5,9 +5,10 @@ namespace Tests\Unit\Ticket; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; -use App\Domains\Event\Models\EventDate; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\ValidityTime; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -20,14 +21,14 @@ class TicketTest extends TestCase parent::tearDown(); } - public function test_it_maps_its_dates_and_relations(): void + public function test_it_maps_its_fields_and_relations(): void { $ticket = new Ticket; $ticket->setRawAttributes([ 'source_catalog_item_id' => '20', 'source_variant_id' => '30', - 'starts_at' => '2026-07-21 10:00:00', - 'expires_at' => '2026-07-22 10:00:00', + 'validity_time_id' => '40', + 'service_date' => '2026-07-21', 'used_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', @@ -37,8 +38,8 @@ class TicketTest extends TestCase $this->assertFalse($ticket->usesTimestamps()); $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); - $this->assertInstanceOf(Carbon::class, $ticket->starts_at); - $this->assertInstanceOf(Carbon::class, $ticket->expires_at); + $this->assertSame(40, $ticket->validity_time_id); + $this->assertInstanceOf(Carbon::class, $ticket->service_date); $this->assertNull($ticket->used_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); @@ -47,105 +48,74 @@ class TicketTest extends TestCase $this->assertInstanceOf(User::class, $ticket->scannerUser()->getRelated()); $this->assertInstanceOf(CatalogItem::class, $ticket->sourceCatalogItem()->getRelated()); $this->assertInstanceOf(Variant::class, $ticket->sourceVariant()->getRelated()); + $this->assertInstanceOf(ValidityTime::class, $ticket->validityTime()->getRelated()); } - public function test_unused_ticket_without_date_restrictions_is_valid(): void + public function test_unused_ticket_without_validity_time_is_valid(): void { $this->assertTrue((new Ticket)->isValid()); } - public function test_ticket_is_invalid_before_its_start_date(): void + public function test_fixed_window_controls_ticket_validity(): void { Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['starts_at' => now()->addSecond()]); + $ticket = $this->ticketWithValidityTime(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => now()->subHour(), + 'fixed_expires_at' => now()->addHour(), + ])); - $this->assertFalse($ticket->isValid()); + $this->assertTrue($ticket->isValid()); + $this->assertFalse($ticket->is_expired); } - public function test_ticket_is_valid_when_its_start_date_is_reached(): void + public function test_service_date_window_is_resolved_in_tenant_timezone(): void { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['starts_at' => now()]); + Carbon::setTestNow('2026-07-21 14:00:00'); + $ticket = new Ticket(['service_date' => '2026-07-21']); + $ticket->setRelation('validityTime', new ValidityTime([ + 'type' => ValidityTimeType::ServiceDateWindow, + 'start_time' => '10:00:00', + 'end_time' => '12:00:00', + ])); + $ticket->setRelation('tenant', new Tenant(['timezone' => 'America/Argentina/Buenos_Aires'])); + $this->assertSame('2026-07-21 13:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-07-21 15:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); $this->assertTrue($ticket->isValid()); } - public function test_ticket_is_invalid_when_it_expires(): void + public function test_ticket_is_invalid_when_validity_time_expires(): void { Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['expires_at' => now()]); + $ticket = $this->ticketWithValidityTime(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_expires_at' => now(), + ])); $this->assertFalse($ticket->isValid()); - } - - public function test_used_ticket_is_invalid(): void - { - $ticket = new Ticket(['used_at' => now()->subSecond()]); - - $this->assertFalse($ticket->isValid()); - } - - public function test_it_appends_computed_status_fields(): void - { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket([ - 'starts_at' => now()->subHour(), - 'expires_at' => now()->addHour(), - ]); - - $attributes = $ticket->toArray(); - - $this->assertTrue($attributes['is_valid']); - $this->assertFalse($attributes['is_expired']); - $this->assertFalse($attributes['is_used']); - } - - public function test_unused_ticket_is_expired_when_its_expiration_date_is_reached(): void - { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket(['expires_at' => now()]); - - $this->assertFalse($ticket->is_valid); $this->assertTrue($ticket->is_expired); - $this->assertFalse($ticket->is_used); } - public function test_used_ticket_is_not_reported_as_expired(): void + public function test_used_ticket_is_invalid_and_not_reported_as_expired(): void { - Carbon::setTestNow('2026-07-21 10:00:00'); - $ticket = new Ticket([ - 'expires_at' => now()->subHour(), - 'used_at' => now()->subDay(), - ]); + $ticket = $this->ticketWithValidityTime(new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_expires_at' => now()->subHour(), + ])); + $ticket->used_at = now()->subMinute(); $this->assertFalse($ticket->is_valid); $this->assertFalse($ticket->is_expired); $this->assertTrue($ticket->is_used); } - public function test_event_date_has_priority_over_ticket_and_variant_dates(): void + private function ticketWithValidityTime(ValidityTime $validityTime): Ticket { - Carbon::setTestNow('2026-10-09 19:00:00'); + $ticket = new Ticket; + $ticket->setRelation('validityTime', $validityTime); + $ticket->setRelation('tenant', new Tenant(['timezone' => 'UTC'])); - $eventDate = new EventDate; - $eventDate->date = '2026-10-09'; - $eventDate->time_start = '09:00:00'; - $eventDate->time_end = '18:00:00'; - - $variant = new Variant; - $variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00'); - $variant->maximum_use_date = Carbon::parse('2026-10-09 22:00:00'); - $variant->setRelation('eventDate', $eventDate); - - $ticket = new Ticket([ - 'starts_at' => Carbon::parse('2026-10-09 07:00:00'), - 'expires_at' => Carbon::parse('2026-10-10 23:59:59'), - ]); - $ticket->setRelation('sourceVariant', $variant); - - $this->assertSame('2026-10-09 09:00:00', $ticket->getEffectiveStartsAt()->format('Y-m-d H:i:s')); - $this->assertSame('2026-10-09 18:00:00', $ticket->getEffectiveExpiresAt()->format('Y-m-d H:i:s')); - $this->assertFalse($ticket->isValid()); - $this->assertTrue($ticket->is_expired); + return $ticket; } } diff --git a/tests/Unit/Ticket/ValidityTimeTest.php b/tests/Unit/Ticket/ValidityTimeTest.php new file mode 100644 index 0000000..90cdf2b --- /dev/null +++ b/tests/Unit/Ticket/ValidityTimeTest.php @@ -0,0 +1,41 @@ +setRawAttributes([ + 'type' => ValidityTimeType::FixedWindow->value, + 'fixed_starts_at' => '2026-08-20 10:00:00', + 'fixed_expires_at' => '2026-08-21 02:00:00', + 'active' => '1', + ]); + + $this->assertSame(ValidityTimeType::FixedWindow, $validityTime->type); + $this->assertInstanceOf(Carbon::class, $validityTime->fixed_starts_at); + $this->assertInstanceOf(Carbon::class, $validityTime->fixed_expires_at); + $this->assertTrue($validityTime->active); + $this->assertInstanceOf(CatalogItem::class, $validityTime->catalogItems()->getRelated()); + $this->assertInstanceOf(AttributeOption::class, $validityTime->attributeOptions()->getRelated()); + $this->assertInstanceOf(Ticket::class, $validityTime->tickets()->getRelated()); + } + + public function test_it_exposes_supported_type_values(): void + { + $this->assertSame([ + 'service_date_window', + 'fixed_window', + ], ValidityTimeType::values()); + } +}