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:
@@ -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<ValidityTime, $this> */
|
||||
public function validityTime(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ValidityTime::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ValidityTime, $this> */
|
||||
public function validityTime(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ValidityTime::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attribute, $this> */
|
||||
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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, mixed> $data
|
||||
* @param array<int, array<string, mixed>> $variants
|
||||
|
||||
@@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'codigo',
|
||||
'nombre',
|
||||
'dominio',
|
||||
'timezone',
|
||||
'primary_color',
|
||||
'secondary_color',
|
||||
'danger_color',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
15
app/Domains/Ticket/Enums/ValidityTimeType.php
Normal file
15
app/Domains/Ticket/Enums/ValidityTimeType.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ValidityTime, $this> */
|
||||
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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
87
app/Domains/Ticket/Models/ValidityTime.php
Normal file
87
app/Domains/Ticket/Models/ValidityTime.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
43
tests/Feature/Ticket/TicketValiditySchemaTest.php
Normal file
43
tests/Feature/Ticket/TicketValiditySchemaTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
41
tests/Unit/Ticket/ValidityTimeTest.php
Normal file
41
tests/Unit/Ticket/ValidityTimeTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user