feat(ticket): implement validity time management for tickets and catalog items

- Added ValidityTime model and migration to manage ticket validity periods.
- Updated TicketGeneratorService to resolve and assign validity times to tickets.
- Refactored ticket generation logic to remove legacy date fields and use validity time.
- Introduced timezone support for tenants to handle service dates correctly.
- Updated migrations to remove deprecated columns and add foreign keys for validity times.
- Modified seeders and tests to accommodate new validity time structure.
- Enhanced tests to validate ticket generation and validity time behavior.
This commit is contained in:
2026-08-06 15:52:19 -03:00
parent 7f01adab73
commit 448ffb4102
30 changed files with 556 additions and 381 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models; namespace App\Domains\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
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;
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'attribute_id', 'attribute_id',
'validity_time_id',
'value', 'value',
'label', 'label',
'sort_order', 'sort_order',
@@ -26,6 +28,7 @@ class AttributeOption extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'validity_time_id' => 'integer',
'sort_order' => 'integer', 'sort_order' => 'integer',
'metadata' => 'array', 'metadata' => 'array',
]; ];
@@ -38,4 +41,10 @@ class AttributeOption extends Model
{ {
return $this->belongsTo(Attribute::class, 'attribute_id'); return $this->belongsTo(Attribute::class, 'attribute_id');
} }
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
} }

View File

@@ -10,7 +10,7 @@ use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Event\Models\Event; use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; 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\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -32,8 +32,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'precio', 'precio',
'inventory_policy', 'inventory_policy',
'has_tickets', 'has_tickets',
'maximum_use_date', 'validity_time_id',
'minimum_use_date',
])] ])]
class CatalogItem extends Model class CatalogItem extends Model
{ {
@@ -61,8 +60,7 @@ class CatalogItem extends Model
'precio' => 'decimal:2', 'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class, 'inventory_policy' => InventoryPolicy::class,
'has_tickets' => 'boolean', 'has_tickets' => 'boolean',
'maximum_use_date' => 'datetime', 'validity_time_id' => 'integer',
'minimum_use_date' => 'datetime',
]; ];
} }
@@ -120,6 +118,12 @@ class CatalogItem extends Model
return $this->hasMany(Ticket::class, 'source_catalog_item_id'); return $this->hasMany(Ticket::class, 'source_catalog_item_id');
} }
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsToMany<Attribute, $this> */ /** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany public function attributes(): BelongsToMany
{ {
@@ -183,16 +187,6 @@ class CatalogItem extends Model
return $this->nombre; 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 public function isBundle(): bool
{ {
return $this->type === CatalogItemType::Bundle; return $this->type === CatalogItemType::Bundle;

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate; use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
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;
@@ -17,8 +16,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'catalog_item_id', 'catalog_item_id',
'event_date_id', 'event_date_id',
'inventory_id', 'inventory_id',
'minimum_use_date',
'maximum_use_date',
])] ])]
class Variant extends Model class Variant extends Model
{ {
@@ -34,8 +31,6 @@ class Variant extends Model
'catalog_item_id' => 'integer', 'catalog_item_id' => 'integer',
'event_date_id' => 'integer', 'event_date_id' => 'integer',
'inventory_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; 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();
}
} }

View File

@@ -69,8 +69,7 @@ class StoreCatalogItemRequest extends FormRequest
'precio' => ['required', 'numeric', 'min:0'], 'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)], 'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'], 'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'], 'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')],
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'], 'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'], 'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'], 'reserved_stock' => ['prohibited'],
@@ -99,13 +98,6 @@ class StoreCatalogItemRequest extends FormRequest
'variants.*.inventory_id' => ['prohibited'], 'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'], 'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['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' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable', 'string'], 'variants.*.values.*' => ['nullable', 'string'],
'variants.*.images' => ['sometimes', 'array'], 'variants.*.images' => ['sometimes', 'array'],

View File

@@ -34,8 +34,7 @@ class CatalogItemDetailResource extends JsonResource
'brand' => $this->brand?->nombre, 'brand' => $this->brand?->nombre,
'inventory_policy' => $this->inventory_policy?->value, 'inventory_policy' => $this->inventory_policy?->value,
'has_tickets' => $this->has_tickets, 'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date, 'validity_time_id' => $this->validity_time_id,
'maximum_use_date' => $this->maximum_use_date,
'attributes' => $this->itemAttributes 'attributes' => $this->itemAttributes
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute)) ->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values(), ->values(),
@@ -101,6 +100,7 @@ class CatalogItemDetailResource extends JsonResource
'value' => $option->value, 'value' => $option->value,
'label' => $option->label, 'label' => $option->label,
'sort_order' => $option->sort_order, 'sort_order' => $option->sort_order,
'validity_time_id' => $option->validity_time_id,
'metadata' => $option->metadata, 'metadata' => $option->metadata,
]) ])
->values(), ->values(),
@@ -115,14 +115,6 @@ class CatalogItemDetailResource extends JsonResource
'event_date_id' => $variant->event_date_id, 'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $this->variantStock($variant), '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 'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [ ->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value, $definition->itemAttribute?->attribute?->codigo => $definition->value,

View File

@@ -25,8 +25,7 @@ class CatalogItemResource extends JsonResource
'precio' => $this->precio, 'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value, 'inventory_policy' => $this->inventory_policy?->value,
'has_tickets' => $this->has_tickets, 'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date, 'validity_time_id' => $this->validity_time_id,
'maximum_use_date' => $this->maximum_use_date,
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock), 'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments 'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440)) ->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
@@ -37,14 +36,6 @@ class CatalogItemResource extends JsonResource
'event_date_id' => $variant->event_date_id, 'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'), 'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'real_stock' => $variant->inventory?->real_stock, '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 'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [ ->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value, $definition->itemAttribute?->attribute?->codigo => $definition->value,

View File

@@ -83,8 +83,6 @@ class CatalogService
$data['inventory_id'] = null; $data['inventory_id'] = null;
$data['inventory_policy'] = null; $data['inventory_policy'] = null;
$data['has_tickets'] = false; $data['has_tickets'] = false;
$data['minimum_use_date'] = null;
$data['maximum_use_date'] = null;
} elseif ($hasVariants) { } elseif ($hasVariants) {
$data['inventory_id'] = null; $data['inventory_id'] = null;
} else { } else {
@@ -381,8 +379,7 @@ class CatalogService
'attribute_codes', 'attribute_codes',
'variants', 'variants',
'has_tickets', 'has_tickets',
'minimum_use_date', 'validity_time_id',
'maximum_use_date',
] as $field) { ] as $field) {
if (array_key_exists($field, $data)) { if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -508,13 +505,9 @@ class CatalogService
$variant = $catalogItem->variants()->create([ $variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'event_date_id' => $eventDateId, 'event_date_id' => $eventDateId,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
]); ]);
$variant->setRelation('catalogItem', $catalogItem); $variant->setRelation('catalogItem', $catalogItem);
$this->validateVariantUseDates($variant, $index);
foreach ($data['values'] ?? [] as $attributeCode => $value) { foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null; $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<string, mixed> $data * @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $variants * @param array<int, array<string, mixed>> $variants

View File

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

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('sourceVariant.eventDate', 'sourceVariant.catalogItem') ->with('validityTime', 'tenant', '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('sourceVariant.eventDate', 'sourceVariant.catalogItem') ->with('validityTime', 'tenant', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id') ->orderByDesc('id')
->get(); ->get();

View File

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

View File

@@ -24,9 +24,9 @@ class TicketGenerationException extends RuntimeException
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id])); 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( public static function variantNotFound(

View File

@@ -21,8 +21,8 @@ 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',
'starts_at', 'validity_time_id',
'expires_at', 'service_date',
'used_at', 'used_at',
'scanner_user_id', 'scanner_user_id',
'user_id', 'user_id',
@@ -45,8 +45,8 @@ 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',
'starts_at' => 'datetime', 'validity_time_id' => 'integer',
'expires_at' => 'datetime', 'service_date' => 'date',
'used_at' => 'datetime', 'used_at' => 'datetime',
'scanner_user_id' => 'integer', 'scanner_user_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
@@ -89,6 +89,12 @@ class Ticket extends Model
return $this->belongsTo(Variant::class, 'source_variant_id'); return $this->belongsTo(Variant::class, 'source_variant_id');
} }
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
public function isValid(): bool public function isValid(): bool
{ {
$now = now(); $now = now();
@@ -121,13 +127,17 @@ class Ticket extends Model
public function getEffectiveStartsAt(): ?CarbonInterface public function getEffectiveStartsAt(): ?CarbonInterface
{ {
return $this->sourceVariant?->getMinimumUseDate() return $this->validityTime?->startsAt(
?? $this->starts_at; $this->service_date,
$this->tenant?->timezone ?? config('app.timezone'),
);
} }
public function getEffectiveExpiresAt(): ?CarbonInterface public function getEffectiveExpiresAt(): ?CarbonInterface
{ {
return $this->sourceVariant?->getMaximumUseDate() return $this->validityTime?->expiresAt(
?? $this->expires_at; $this->service_date,
$this->tenant?->timezone ?? config('app.timezone'),
);
} }
} }

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Ticket\Enums\ValidityTimeType;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'type',
'start_time',
'end_time',
'fixed_starts_at',
'fixed_expires_at',
'active',
])]
class ValidityTime extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'type' => ValidityTimeType::class,
'fixed_starts_at' => 'datetime',
'fixed_expires_at' => 'datetime',
'active' => 'boolean',
];
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<AttributeOption, $this> */
public function attributeOptions(): HasMany
{
return $this->hasMany(AttributeOption::class);
}
/** @return HasMany<Ticket, $this> */
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();
}
}

View File

@@ -5,8 +5,10 @@ namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User; 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\Ticket\Enums\ValidityTimeType;
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\ValidityTime;
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;
@@ -39,7 +41,8 @@ class TicketGeneratorService
$user, $user,
): Ticket { ): Ticket {
$item = $target['catalog_item']; $item = $target['catalog_item'];
$selectedItem = $target['variant'] ?? $item; $variant = $target['variant'];
$validityTime = $this->resolveValidityTime($item, $variant);
return Ticket::query()->create([ return Ticket::query()->create([
'tenant_code' => $item->tenant_code, 'tenant_code' => $item->tenant_code,
@@ -48,9 +51,11 @@ class TicketGeneratorService
'description' => (string) ($item->descripcion ?? ''), 'description' => (string) ($item->descripcion ?? ''),
'source_purchase_id' => $sourcePurchaseId, 'source_purchase_id' => $sourcePurchaseId,
'source_catalog_item_id' => $item->getKey(), 'source_catalog_item_id' => $item->getKey(),
'source_variant_id' => $target['variant']?->getKey(), 'source_variant_id' => $variant?->getKey(),
'starts_at' => $selectedItem->getMinimumUseDate(), 'validity_time_id' => $validityTime?->getKey(),
'expires_at' => $selectedItem->getMaximumUseDate(), 'service_date' => $validityTime?->type === ValidityTimeType::ServiceDateWindow
? ($variant?->eventDate?->date ?? now($item->tenant->timezone)->toDateString())
: null,
'used_at' => null, 'used_at' => null,
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
]); ]);
@@ -134,12 +139,37 @@ class TicketGeneratorService
throw TicketGenerationException::ticketsDisabled($catalogItem); 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; private function resolveValidityTime(
// CatalogItem $catalogItem,
// if ($selectedItem->getMaximumUseDate()?->lessThanOrEqualTo(now())) { ?Variant $variant,
// throw TicketGenerationException::maximumUseDateReached($catalogItem); ): ?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();
} }
} }

View File

@@ -0,0 +1,22 @@
<?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('tenants', function (Blueprint $table): void {
$table->string('timezone')->default('UTC')->after('dominio');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table): void {
$table->dropColumn('timezone');
});
}
};

View File

@@ -0,0 +1,27 @@
<?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::create('validity_times', function (Blueprint $table): void {
$table->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');
}
};

View File

@@ -0,0 +1,40 @@
<?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('catalog_items', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -0,0 +1,29 @@
<?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('tickets', function (Blueprint $table): void {
$table->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');
});
}
};

View File

@@ -0,0 +1,93 @@
<?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
{
$this->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]);
});
}
};

View File

@@ -14,6 +14,8 @@ use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Models\Event; use App\Domains\Event\Models\Event;
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 Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use RuntimeException; use RuntimeException;
@@ -79,6 +81,11 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$dates = $eventDates->keys()->all(); $dates = $eventDates->keys()->all();
$minimumUseDate = $dates[0].' 00:00:00'; $minimumUseDate = $dates[0].' 00:00:00';
$maximumUseDate = $dates[array_key_last($dates)].' 23:59:59'; $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([ $generalAdmission = $this->catalogService->create([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
@@ -91,8 +98,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'precio' => 10000, 'precio' => 10000,
'inventory_policy' => InventoryPolicy::Unlimited->value, 'inventory_policy' => InventoryPolicy::Unlimited->value,
'has_tickets' => true, 'has_tickets' => true,
'minimum_use_date' => $minimumUseDate, 'validity_time_id' => $eventValidityTime->id,
'maximum_use_date' => $maximumUseDate,
'variants' => array_map( 'variants' => array_map(
fn (string $date): array => [ fn (string $date): array => [
'real_stock' => 0, 'real_stock' => 0,
@@ -120,8 +126,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'descripcion' => $item['descripcion'] ?? $item['nombre'], 'descripcion' => $item['descripcion'] ?? $item['nombre'],
'inventory_policy' => InventoryPolicy::Unlimited->value, 'inventory_policy' => InventoryPolicy::Unlimited->value,
'real_stock' => 0, 'real_stock' => 0,
'minimum_use_date' => $minimumUseDate, 'validity_time_id' => $eventValidityTime->id,
'maximum_use_date' => $maximumUseDate,
...$item, ...$item,
]); ]);
} }

View File

@@ -32,14 +32,11 @@ class CatalogItemControllerTest extends TestCase
'slug' => 'shirt', 'slug' => 'shirt',
'nombre' => 'Shirt', 'nombre' => 'Shirt',
'precio' => 100, 'precio' => 100,
'minimum_use_date' => '2026-08-01 09:00:00',
'maximum_use_date' => '2026-08-31 18:00:00',
'attribute_codes' => [$attribute->codigo], 'attribute_codes' => [$attribute->codigo],
'images' => [$image, $image], 'images' => [$image, $image],
'variants' => [ 'variants' => [
[ [
'real_stock' => 5, 'real_stock' => 5,
'maximum_use_date' => '2026-08-15 18:00:00',
'values' => ['size' => 'M'], 'values' => ['size' => 'M'],
'images' => [$image], 'images' => [$image],
], ],
@@ -51,20 +48,7 @@ class CatalogItemControllerTest extends TestCase
->assertJsonPath('data.nombre', 'Shirt') ->assertJsonPath('data.nombre', 'Shirt')
->assertJsonCount(2, 'data.images') ->assertJsonCount(2, 'data.images')
->assertJsonCount(1, 'data.variants') ->assertJsonCount(1, 'data.variants')
->assertJsonCount(1, 'data.variants.0.images') ->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'),
);
$item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail(); $item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail();
$variant = $item->variants()->firstOrFail(); $variant = $item->variants()->firstOrFail();

View File

@@ -44,8 +44,7 @@ class CatalogSchemaTest extends TestCase
'precio', 'precio',
'inventory_policy', 'inventory_policy',
'has_tickets', 'has_tickets',
'maximum_use_date', 'validity_time_id',
'minimum_use_date',
], Schema::getColumnListing('catalog_items')); ], Schema::getColumnListing('catalog_items'));
} }
@@ -183,8 +182,6 @@ class CatalogSchemaTest extends TestCase
{ {
$this->assertTrue(Schema::hasColumns('variantes', [ $this->assertTrue(Schema::hasColumns('variantes', [
'event_date_id', 'event_date_id',
'minimum_use_date',
'maximum_use_date',
])); ]));
} }

View File

@@ -13,7 +13,6 @@ use App\Domains\Catalog\Services\CatalogService;
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 Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Tests\TestCase; 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 public function test_it_rejects_direct_inventory_together_with_variants(): void
{ {
$attribute = $this->createAttribute('size'); $attribute = $this->createAttribute('size');

View File

@@ -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'); $tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');
$this->configureTelepagosIntegration($tenant); $this->configureTelepagosIntegration($tenant);
@@ -267,7 +267,6 @@ class TelepagosWebhookTest extends TestCase
$variant = $this->createVariantForTenant('expired-ticket', 1, '50.00'); $variant = $this->createVariantForTenant('expired-ticket', 1, '50.00');
$variant->catalogItem->update([ $variant->catalogItem->update([
'has_tickets' => true, 'has_tickets' => true,
'maximum_use_date' => now()->subMinute(),
]); ]);
$purchase = $this->createPendingTransferPurchase( $purchase = $this->createPendingTransferPurchase(
$tenant, $tenant,

View File

@@ -95,22 +95,8 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d')) ->map(fn ($variant) => $variant->eventDate->date->format('Y-m-d'))
->all() ->all()
); );
$this->assertSame( $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'));
['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()
);
$standardItems = CatalogItem::query() $standardItems = CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
@@ -122,11 +108,11 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
$this->assertSame($event->id, $standardItem->event_id); $this->assertSame($event->id, $standardItem->event_id);
$this->assertSame( $this->assertSame(
'2026-10-09 00:00:00', '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( $this->assertSame(
'2026-10-12 23:59:59', '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'),
); );
} }

View File

@@ -51,11 +51,7 @@ class TicketGeneratorServiceTest extends TestCase
public function test_it_generates_tickets_from_a_standard_catalog_item(): void public function test_it_generates_tickets_from_a_standard_catalog_item(): void
{ {
$item = $this->createTicketableItem( $item = $this->createTicketableItem('single-day');
'single-day',
now()->subHour(),
now()->addDay(),
);
$tickets = $this->service->generate($item, $this->user, 2); $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->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->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); $this->assertNull($ticket->validity_time_id);
$this->assertTrue($ticket->expires_at->equalTo($item->maximum_use_date)); $this->assertNull($ticket->service_date);
} }
} }
@@ -84,41 +80,10 @@ class TicketGeneratorServiceTest extends TestCase
$this->service->generate($item->fresh(), $this->user); $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 public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void
{ {
$first = $this->createTicketableItem('first', maximumUseDate: now()->addDay()); $first = $this->createTicketableItem('first');
$second = $this->createTicketableItem('second', maximumUseDate: now()->addDays(2)); $second = $this->createTicketableItem('second');
$bundle = $this->createBundle('bundle'); $bundle = $this->createBundle('bundle');
$bundle->bundleComponents()->createMany([ $bundle->bundleComponents()->createMany([
['component_catalog_item_id' => $first->id, 'quantity' => 2], ['component_catalog_item_id' => $first->id, 'quantity' => 2],
@@ -134,18 +99,12 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertCount(2, $tickets->where('name', $second->nombre)); $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( $component = $this->createTicketableItem('variant-component');
'variant-component',
now()->subDay(),
now()->addMonth(),
);
$inventory = Inventory::query()->create(); $inventory = Inventory::query()->create();
$variant = $component->variants()->create([ $variant = $component->variants()->create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'minimum_use_date' => now()->addDay(),
'maximum_use_date' => now()->addWeek(),
]); ]);
$bundle = $this->createBundle('variant-bundle'); $bundle = $this->createBundle('variant-bundle');
$bundle->bundleComponents()->create([ $bundle->bundleComponents()->create([
@@ -160,8 +119,6 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame($component->id, $ticket->source_catalog_item_id); $this->assertSame($component->id, $ticket->source_catalog_item_id);
$this->assertSame($variant->id, $ticket->source_variant_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 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); ->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 = $this->createPurchase($item, 1);
$purchase->markAsPaid(); $purchase->markAsPaid();
@@ -258,11 +215,8 @@ class TicketGeneratorServiceTest extends TestCase
]); ]);
} }
private function createTicketableItem( private function createTicketableItem(string $slug): CatalogItem
string $slug, {
mixed $minimumUseDate = null,
mixed $maximumUseDate = null,
): CatalogItem {
return CatalogItem::query()->create([ return CatalogItem::query()->create([
'tenant_code' => $this->tenant->codigo, 'tenant_code' => $this->tenant->codigo,
'slug' => $slug, 'slug' => $slug,
@@ -270,8 +224,6 @@ class TicketGeneratorServiceTest extends TestCase
'descripcion' => "Descripción de {$slug}", 'descripcion' => "Descripción de {$slug}",
'precio' => 10, 'precio' => 10,
'has_tickets' => true, 'has_tickets' => true,
'minimum_use_date' => $minimumUseDate,
'maximum_use_date' => $maximumUseDate,
]); ]);
} }

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature\Ticket;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class TicketValiditySchemaTest extends TestCase
{
use RefreshDatabase;
public function test_ticket_validity_time_schema_is_available(): void
{
$this->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'));
}
}

View File

@@ -27,7 +27,6 @@ use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
class CatalogModelsTest extends TestCase class CatalogModelsTest extends TestCase
@@ -160,24 +159,6 @@ class CatalogModelsTest extends TestCase
$this->assertSame('catalog_items_attachments', $variant->attachments()->getTable()); $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 public function test_event_date_identifies_a_variant_without_catalog_attributes(): void
{ {
$item = new CatalogItem; $item = new CatalogItem;
@@ -189,15 +170,12 @@ class CatalogModelsTest extends TestCase
$eventDate->time_end = '18:00:00'; $eventDate->time_end = '18:00:00';
$variant = new Variant; $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('catalogItem', $item);
$variant->setRelation('eventDate', $eventDate); $variant->setRelation('eventDate', $eventDate);
$variant->setRelation('definitions', new EloquentCollection); $variant->setRelation('definitions', new EloquentCollection);
$this->assertSame('Entrada General', $variant->getName()); $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', $variant->eventDate->date->format('Y-m-d'));
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
} }
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void public function test_inventory_maps_stock_without_a_polymorphic_owner(): void

View File

@@ -5,9 +5,10 @@ namespace Tests\Unit\Ticket;
use App\Domains\Auth\Models\User; 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\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\Support\Carbon; use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
@@ -20,14 +21,14 @@ class TicketTest extends TestCase
parent::tearDown(); 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 = new Ticket;
$ticket->setRawAttributes([ $ticket->setRawAttributes([
'source_catalog_item_id' => '20', 'source_catalog_item_id' => '20',
'source_variant_id' => '30', 'source_variant_id' => '30',
'starts_at' => '2026-07-21 10:00:00', 'validity_time_id' => '40',
'expires_at' => '2026-07-22 10:00:00', 'service_date' => '2026-07-21',
'used_at' => null, 'used_at' => null,
'scanner_user_id' => '15', 'scanner_user_id' => '15',
'user_id' => '10', 'user_id' => '10',
@@ -37,8 +38,8 @@ 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->assertInstanceOf(Carbon::class, $ticket->starts_at); $this->assertSame(40, $ticket->validity_time_id);
$this->assertInstanceOf(Carbon::class, $ticket->expires_at); $this->assertInstanceOf(Carbon::class, $ticket->service_date);
$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);
@@ -47,105 +48,74 @@ 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());
} }
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()); $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'); 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'); Carbon::setTestNow('2026-07-21 14:00:00');
$ticket = new Ticket(['starts_at' => now()]); $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()); $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'); 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()); $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->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 = $this->ticketWithValidityTime(new ValidityTime([
$ticket = new Ticket([ 'type' => ValidityTimeType::FixedWindow,
'expires_at' => now()->subHour(), 'fixed_expires_at' => now()->subHour(),
'used_at' => now()->subDay(), ]));
]); $ticket->used_at = now()->subMinute();
$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);
} }
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; return $ticket;
$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);
} }
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace Tests\Unit\Ticket;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
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;
class ValidityTimeTest extends TestCase
{
public function test_it_casts_its_type_dates_and_flags(): void
{
$validityTime = new ValidityTime;
$validityTime->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());
}
}