feat(ticketing): resolve validity in tenant timezone

This commit is contained in:
2026-09-21 15:19:09 -03:00
parent f4de2ff5b2
commit 3e3b3bfac0
17 changed files with 354 additions and 45 deletions

View File

@@ -7,8 +7,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Shared\Enums\FieldType;
use App\Domains\Ticketing\Ticket\Resources\ValidityTimeResource;
use App\Shared\Enums\FieldType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
@@ -140,7 +140,7 @@ class CatalogItemDetailResource extends JsonResource
'validity_time_id' => $option->validity_time_id,
'validity_time' => $option->validityTime === null
? null
: ValidityTimeResource::make($option->validityTime),
: ValidityTimeResource::make($option->validityTime, $this->tenant->timezone),
'metadata' => $option->metadata,
])
->values();

View File

@@ -2,20 +2,20 @@
namespace App\Domains\Core\Tenant\Models;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventCategory;
use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\Menu\Models\TenantMenu;
use App\Domains\Core\Tenant\Enums\CartEditingPolicy;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventCategory;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use App\Shared\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -28,6 +28,7 @@ use Illuminate\Support\Facades\Schema;
'client_id',
'codigo',
'nombre',
'timezone',
'dominio',
'base_path',
'site_title',
@@ -66,7 +67,10 @@ class Tenant extends Model
{
use HasFactory;
public const DEFAULT_TIMEZONE = 'America/Argentina/Buenos_Aires';
protected $attributes = [
'timezone' => self::DEFAULT_TIMEZONE,
'base_path' => '/',
'search_product_layout' => ProductLayout::ColumnWithImage->value,
'search_group_layout' => GroupLayout::Paginated->value,

View File

@@ -35,6 +35,7 @@ class TenantResource extends JsonResource
'client_id' => $this->client_id,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'timezone' => $this->timezone,
'dominio' => $this->dominio,
'base_path' => $this->base_path,
'site_title' => $this->site_title ?? 'ShopitFront',

View File

@@ -3,10 +3,10 @@
namespace App\Domains\Ticketing\Event\Models;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateStatus;
use App\Domains\Ticketing\Event\Services\EffectiveEventDateResolver;
use App\Domains\Ticketing\Event\Services\EventDateTextFormatter;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType;
use App\Domains\Ticketing\Ticket\Models\ValidityTime;
use Carbon\CarbonInterface;
@@ -131,12 +131,12 @@ class EventDate extends Model
public function startsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start, $this->tenant->timezone);
}
public function endsAt(): CarbonInterface
{
$endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
$endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end, $this->tenant->timezone);
return $endsAt->lessThanOrEqualTo($this->startsAt())
? $endsAt->addDay()
@@ -184,8 +184,8 @@ class EventDate extends Model
private function syncValidityTime(): void
{
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
$startsAt = $this->startsAt()->utc();
$expiresAt = $this->endsAt()->utc();
$attributes = [
'type' => ValidityTimeType::FixedWindow,

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Ticketing\Event\Resources;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Carbon;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -29,10 +28,7 @@ class PublicEventResource extends JsonResource
return null;
}
return Carbon::parse(
$first->date->format('Y-m-d').' '.$first->time_start,
config('app.event_timezone'),
)->toISOString();
return $first->startsAt()->toISOString();
}),
'dates' => $this->whenLoaded('dates', fn () => $this->dates->map(fn ($date): array => [
'id' => $date->id,
@@ -52,8 +48,7 @@ class PublicEventResource extends JsonResource
'description' => $item->descripcion,
'price' => $item->precio,
'image' => $item->attachments->first()?->getTemporaryUrl(1440),
'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool =>
$attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'),
'requires_selection' => $item->itemAttributes->contains(fn ($attribute): bool => $attribute->show_in_selector && $attribute->attribute?->codigo !== 'event_date'),
'maximum_quantity' => $item->inventory_policy === InventoryPolicy::Unlimited
? null
: $item->availableStock(),

View File

@@ -47,30 +47,42 @@ class ValidityTime extends Model
public function startsAt(
?CarbonInterface $at = null,
string $timezone = 'UTC',
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_starts_at;
}
return $this->atCurrentDate($this->start_time, $at);
return $this->atCurrentDate($this->start_time, $at, $timezone);
}
public function expiresAt(
?CarbonInterface $at = null,
string $timezone = 'UTC',
): ?CarbonInterface {
if ($this->type === ValidityTimeType::FixedWindow) {
return $this->fixed_expires_at;
}
return $this->atCurrentDate($this->end_time, $at);
return $this->atCurrentDate($this->end_time, $at, $timezone);
}
public function isValid(
?CarbonInterface $at = null,
string $timezone = 'UTC',
): bool {
$at ??= now();
$startsAt = $this->startsAt($at);
$expiresAt = $this->expiresAt($at);
$startsAt = $this->startsAt($at, $timezone);
$expiresAt = $this->expiresAt($at, $timezone);
if ($this->type === ValidityTimeType::TimeWindow
&& $startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) {
if ($at->lessThan($expiresAt)) {
$startsAt = $startsAt->subDay();
} else {
$expiresAt = $expiresAt->addDay();
}
}
return ($startsAt === null || $startsAt->lessThanOrEqualTo($at))
&& ($expiresAt === null || $expiresAt->greaterThan($at));
@@ -79,6 +91,7 @@ class ValidityTime extends Model
private function atCurrentDate(
?string $time,
?CarbonInterface $at,
string $timezone,
): ?CarbonInterface {
if ($time === null) {
return null;
@@ -86,8 +99,9 @@ class ValidityTime extends Model
$at ??= now();
$localDate = CarbonImmutable::instance($at)
->setTimezone($timezone)
->format('Y-m-d');
return CarbonImmutable::parse($localDate.' '.$time);
return CarbonImmutable::parse($localDate.' '.$time, $timezone);
}
}

View File

@@ -10,6 +10,11 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValidityTime */
class ValidityTimeResource extends JsonResource
{
public function __construct($resource, private readonly string $timezone = 'UTC')
{
parent::__construct($resource);
}
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
@@ -27,7 +32,7 @@ class ValidityTimeResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'is_valid' => $this->isValid(),
'is_valid' => $this->isValid(timezone: $this->timezone),
...array_filter($fields, fn (mixed $value): bool => $value !== null),
];
}

View File

@@ -17,7 +17,7 @@ use Illuminate\Support\Collection;
final readonly class ResolvedValidityGroup
{
/** @param Collection<int, ValidityTime> $validityTimes */
public function __construct(public Collection $validityTimes) {}
public function __construct(public Collection $validityTimes, public string $timezone = 'UTC') {}
/** Comprueba si el instante pertenece a la intersección efectiva del grupo. */
public function isValid(?CarbonInterface $at = null): bool
@@ -50,7 +50,17 @@ final readonly class ResolvedValidityGroup
$anchor = $this->dateAnchor() ?? $at;
return $this->validityTimes
->map(fn (ValidityTime $validityTime): ?CarbonInterface => $validityTime->startsAt($anchor))
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$start = $validityTime->startsAt($anchor, $this->timezone);
$end = $validityTime->expiresAt($anchor, $this->timezone);
if ($this->dateAnchor() === null && $start !== null && $end !== null
&& $end->lessThanOrEqualTo($start) && $anchor->lessThan($end)) {
return $start->copy()->subDay();
}
return $start;
})
->filter()
->sortByDesc(fn (CarbonInterface $startsAt): int => $startsAt->getTimestamp())
->first();
@@ -67,11 +77,13 @@ final readonly class ResolvedValidityGroup
return $this->validityTimes
->map(function (ValidityTime $validityTime) use ($anchor): ?CarbonInterface {
$startsAt = $validityTime->startsAt($anchor);
$expiresAt = $validityTime->expiresAt($anchor);
$startsAt = $validityTime->startsAt($anchor, $this->timezone);
$expiresAt = $validityTime->expiresAt($anchor, $this->timezone);
if ($startsAt !== null && $expiresAt !== null && $expiresAt->lessThanOrEqualTo($startsAt)) {
return $expiresAt->addDay();
return $this->dateAnchor() === null && $anchor->lessThan($expiresAt)
? $expiresAt
: $expiresAt->copy()->addDay();
}
return $expiresAt;

View File

@@ -29,6 +29,7 @@ class TicketValidityResolver
/** Relaciones necesarias para resolver tickets sin consultas N+1. */
public const RELATIONS = [
'sourceVariant.catalogItem.tenant',
'sourceVariant.eventDates.validityTime',
'sourceVariant.eventDate.validityTime',
'sourceVariant.definitions.itemAttribute.attribute.options.validityTime',
@@ -60,6 +61,7 @@ class TicketValidityResolver
public function resolveVariant(Variant $variant): ResolvedTicketValidity
{
$variant->loadMissing([
'catalogItem.tenant',
'eventDates.validityTime',
'eventDate.validityTime',
'definitions.itemAttribute.attribute.options.validityTime',
@@ -149,7 +151,7 @@ class TicketValidityResolver
}
return new ResolvedTicketValidity(
$groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times))
$groups->map(fn (Collection $times): ResolvedValidityGroup => new ResolvedValidityGroup($times, $variant->catalogItem?->tenant?->timezone ?? 'UTC'))
);
}
}

View File

@@ -17,6 +17,29 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ
se combinan con OR y las dimensiones diferentes se combinan con AND. El modelo calcula si un ticket está
vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin persistir vigencias en el ticket.
## Zona horaria y vigencias
La zona se configura por tenant mediante `timezone` (identificador IANA, por ejemplo
`America/Argentina/Buenos_Aires`), configurado mediante seeders o base de datos y expuesto en la respuesta de tenants.
Ese es el valor por defecto. La aplicación mantiene su zona global en UTC.
Las fechas y horas de `EventDate` y los horarios de `time_window` son locales al tenant.
Las ventanas `fixed_window` se guardan en UTC. El resolver pasa la zona del tenant del producto
al grupo de vigencia: antes de combinar fecha y hora, convierte el ancla a esa zona.
Sin fecha de evento, utiliza el día local del instante consultado; contempla ventanas nocturnas.
El inicio es inclusivo y el vencimiento exclusivo.
Para validar una ventana horaria directamente, pasar la zona explícitamente:
`$validityTime->isValid(now(), $tenant->timezone)`. Un `ValidityTime` aislado no tiene tenant;
los métodos mantienen UTC como valor por defecto para usos sin contexto.
La migración `2026_09_21_040000_add_timezone_to_tenants.php` asigna la zona inicial y reconstruye
una sola vez las ventanas asociadas a fechas existentes, usando las fechas y horas locales originales.
No modifica ventanas independientes. Los cambios posteriores de zona son responsabilidad del tenant:
reinterpretan los horarios locales, pero no reescriben los instantes UTC guardados. Editar la fecha
u horas de un evento sí vuelve a calcular su ventana. El rollback elimina la columna, sin deshacer
la corrección de los instantes. Ya no se utiliza `EVENT_TIMEZONE`.
## Flujo de generación
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.