Add tests for ticket validity and event date formatting

- Create TicketValiditySchemaTest to verify database schema for ticket validity.
- Update CatalogModelsTest to include tests for event date attributes and selection options.
- Introduce EventDateTextFormatterTest for formatting event dates in Spanish.
- Refactor EventModelsTest to include validity time relationships.
- Add SaleDetailResourceTest to ensure correct serialization of purchase items.
- Enhance TicketTest with validity time checks and status management.
- Implement ValidityTimeResourceTest to validate resource output for different validity types.
- Add ValidityTimeTest to verify casting and validity checks for validity time types.
This commit is contained in:
2026-08-11 12:41:35 -03:00
parent b294e5c46e
commit 02cf3f3773
166 changed files with 10203 additions and 1849 deletions

View File

@@ -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('validityGroups.validityTimes', '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('validityGroups.validityTimes', 'sourceVariant.eventDate', 'sourceVariant.catalogItem')
->orderByDesc('id')
->get();

View File

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

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Ticket\Enums;
enum ValidityTimeType: string
{
case TimeWindow = 'time_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]));
}
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(

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Event\Models\EventDate;
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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'type',
'start_time',
'end_time',
'fixed_starts_at',
'fixed_expires_at',
])]
class ValidityTime extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'type' => ValidityTimeType::class,
'fixed_starts_at' => 'datetime',
'fixed_expires_at' => 'datetime',
];
}
/** @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 HasOne<EventDate, $this> */
public function eventDate(): HasOne
{
return $this->hasOne(EventDate::class);
}
/** @return BelongsToMany<TicketValidityGroup, $this> */
public function ticketValidityGroups(): BelongsToMany
{
return $this->belongsToMany(
TicketValidityGroup::class,
'ticket_validity_group_times',
'validity_time_id',
'ticket_validity_group_id',
);
}
public function startsAt(
?CarbonInterface $at = null,
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_starts_at;
}
return $this->atCurrentDate($this->start_time, $at);
}
public function expiresAt(
?CarbonInterface $at = null,
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_expires_at;
}
return $this->atCurrentDate($this->end_time, $at);
}
public function isValid(
?CarbonInterface $at = null,
): bool {
$at ??= now();
$startsAt = $this->startsAt($at);
$expiresAt = $this->expiresAt($at);
return ($startsAt === null || $startsAt->lessThanOrEqualTo($at))
&& ($expiresAt === null || $expiresAt->greaterThan($at));
}
private function atCurrentDate(
?string $time,
?CarbonInterface $at,
): ?CarbonInterface {
if ($time === null) {
return null;
}
$at ??= now();
$localDate = CarbonImmutable::instance($at)
->format('Y-m-d');
return CarbonImmutable::parse($localDate.' '.$time);
}
}

View File

@@ -20,6 +20,10 @@ class TicketResource extends JsonResource
'description' => $this->description,
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'validity_times' => ValidityTimeResource::collection($this->allValidityTimes()),
'validity_groups' => TicketValidityGroupResource::collection(
$this->resolvedValidityGroups()
),
'starts_at' => $this->getEffectiveStartsAt(),
'expires_at' => $this->getEffectiveExpiresAt(),
'used_at' => $this->used_at,

View File

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

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Ticket\Resources;
use App\Domains\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValidityTime */
class ValidityTimeResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$fields = match ($this->type) {
ValidityTimeType::TimeWindow => [
'start_time' => $this->start_time,
'end_time' => $this->end_time,
],
ValidityTimeType::FixedWindow => [
'fixed_starts_at' => $this->fixed_starts_at,
'fixed_expires_at' => $this->fixed_expires_at,
],
};
return [
'id' => $this->id,
'type' => $this->type->value,
'is_valid' => $this->isValid(),
...array_filter($fields, fn (mixed $value): bool => $value !== null),
];
}
}

View File

@@ -5,8 +5,13 @@ 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\Event\Models\EventDate;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketValidityGroup;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
@@ -39,27 +44,51 @@ class TicketGeneratorService
$user,
): Ticket {
$item = $target['catalog_item'];
$selectedItem = $target['variant'] ?? $item;
$variant = $target['variant'];
$eventDate = $target['event_date'];
$validityGroups = $this->buildTicketValidityGroups(
$item,
$variant,
$eventDate,
$this->resolveValidityTime($item, $variant),
);
return Ticket::query()->create([
$ticket = Ticket::query()->create([
'tenant_code' => $item->tenant_code,
'ticket' => (string) Str::uuid(),
'name' => $item->nombre,
'name' => $this->ticketName($item, $variant, $eventDate),
'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(),
'used_at' => null,
'user_id' => $user->getKey(),
]);
$groups = $validityGroups->map(function (Collection $validityTimes) use ($ticket): TicketValidityGroup {
$group = $ticket->validityGroups()->create();
$group->validityTimes()->attach(
$validityTimes
->map(fn (ValidityTime $validityTime): int => $validityTime->getKey())
->all()
);
$group->setRelation(
'validityTimes',
new EloquentCollection($validityTimes->all()),
);
return $group;
});
$ticket->setRelation('validityGroups', new EloquentCollection($groups->all()));
return $ticket;
});
});
}
/**
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}>
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null, event_date: EventDate|null}>
*/
private function resolveTargets(
CatalogItem $catalogItem,
@@ -70,10 +99,7 @@ class TicketGeneratorService
$variant = $this->resolveVariant($catalogItem, $sourceVariantId);
$this->validateTarget($catalogItem, $variant);
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
]);
return $this->targetsForVariant($catalogItem, $variant, $quantity);
}
$catalogItem->loadMissing([
@@ -91,17 +117,59 @@ class TicketGeneratorService
$variant = $component->variant;
$this->validateTarget($componentItem, $variant);
return Collection::times(
return $this->targetsForVariant(
$componentItem,
$variant,
$quantity * $component->quantity,
fn (): array => [
'catalog_item' => $componentItem,
'variant' => $variant,
],
);
})
->values();
}
/**
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null, event_date: EventDate|null}>
*/
private function targetsForVariant(
CatalogItem $catalogItem,
?Variant $variant,
int $quantity,
): Collection {
if ($variant === null) {
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => null,
'event_date' => null,
]);
}
$variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates();
if ($catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit) {
$eventDate = $selectedEventDates->count() === 1
? $selectedEventDates->first()
: null;
return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
'event_date' => $eventDate,
]);
}
$eventDates = $selectedEventDates->isEmpty()
? collect([null])
: $selectedEventDates;
return Collection::times($quantity)
->flatMap(fn () => $eventDates->map(fn (?EventDate $eventDate): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
'event_date' => $eventDate,
]))
->values();
}
private function resolveVariant(
CatalogItem $catalogItem,
?int $sourceVariantId,
@@ -134,12 +202,111 @@ 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();
}
private function ticketName(
CatalogItem $catalogItem,
?Variant $variant,
?EventDate $eventDate,
): string {
if ($variant === null) {
return $catalogItem->nombre;
}
$options = $variant->selectionOptions();
if ($eventDate !== null) {
$options->put('event_date', [
'value' => (string) $eventDate->getKey(),
'label' => $eventDate->date->format('d/m/Y'),
]);
}
$properties = $options
->flatMap(function (array $option): array {
if (array_is_list($option)) {
return collect($option)
->pluck('label')
->filter(fn ($label): bool => is_string($label) && $label !== '')
->all();
}
$label = $option['label'] ?? null;
return is_string($label) && $label !== '' ? [$label] : [];
})
->values();
if ($properties->isEmpty()) {
return $catalogItem->nombre;
}
return $catalogItem->nombre.' ('.$properties->implode(', ').')';
}
/** @return Collection<int, Collection<int, ValidityTime>> */
private function buildTicketValidityGroups(
CatalogItem $catalogItem,
?Variant $variant,
?EventDate $eventDate,
?ValidityTime $validityTime,
): Collection {
$eventDates = collect([$eventDate]);
if (
$catalogItem->ticket_generation_policy === TicketGenerationPolicy::OnePerUnit
&& $variant !== null
) {
$variant->loadMissing(['eventDates.validityTime', 'eventDate.validityTime']);
$selectedEventDates = $variant->selectedEventDates();
if ($selectedEventDates->isNotEmpty()) {
$eventDates = $selectedEventDates;
}
}
return $eventDates
->map(function (?EventDate $date) use ($validityTime): Collection {
$date?->loadMissing('validityTime');
return collect([$date?->validityTime, $validityTime])
->filter()
->unique(fn (ValidityTime $time): int => $time->getKey())
->values();
})
->filter(fn (Collection $group): bool => $group->isNotEmpty())
->values();
}
}

View File

@@ -0,0 +1,33 @@
# Dominio Ticket
## Propósito
Genera, valida, consulta y exporta entradas asociadas a compras pagadas de productos o variantes ticketables.
## Modelo
- `Ticket`: pertenece a tenant y usuario, conserva referencias a compra, producto, variante, validez y usuario escáner.
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para productos, opciones y tickets.
- `ValidityTimeType`: enum de estrategias de vigencia.
El modelo calcula si un ticket está vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin.
## Flujo de generación
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
2. `GenerateTicketsForPaidPurchase` atiende el evento.
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
## Endpoints
Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
- `GET /tickets`.
- `POST /tickets/pdf`.
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
## Dependencias y reglas
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.