feat(ticketing): resolve validity in tenant timezone
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user