From 3e3b3bfac085974070d9e85fcd5d5b237e159125 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Mon, 21 Sep 2026 15:19:09 -0300 Subject: [PATCH] feat(ticketing): resolve validity in tenant timezone --- .../Resources/CatalogItemDetailResource.php | 4 +- app/Domains/Core/Tenant/Models/Tenant.php | 14 +-- .../Core/Tenant/Resources/TenantResource.php | 1 + .../Ticketing/Event/Models/EventDate.php | 10 +-- .../Event/Resources/PublicEventResource.php | 9 +- .../Ticketing/Ticket/Models/ValidityTime.php | 24 +++-- .../Ticket/Resources/ValidityTimeResource.php | 7 +- .../Ticket/Services/ResolvedValidityGroup.php | 22 +++-- .../Services/TicketValidityResolver.php | 4 +- .../Ticketing/Ticket/documentacion/README.md | 23 +++++ config/app.php | 3 - ...6_09_21_040000_add_timezone_to_tenants.php | 41 +++++++++ .../Event/AdminAppEventControllerTest.php | 20 ++--- .../Migrations/AddTenantTimezoneTest.php | 48 ++++++++++ tests/Feature/Tenant/TenantTimezoneTest.php | 76 ++++++++++++++++ .../Ticket/TenantTimezoneValidityTest.php | 90 +++++++++++++++++++ .../Ticket/TicketValidityResolverTest.php | 3 +- 17 files changed, 354 insertions(+), 45 deletions(-) create mode 100644 database/migrations/2026_09_21_040000_add_timezone_to_tenants.php create mode 100644 tests/Feature/Migrations/AddTenantTimezoneTest.php create mode 100644 tests/Feature/Tenant/TenantTimezoneTest.php create mode 100644 tests/Unit/Ticket/TenantTimezoneValidityTest.php diff --git a/app/Domains/Commerce/Catalog/Resources/CatalogItemDetailResource.php b/app/Domains/Commerce/Catalog/Resources/CatalogItemDetailResource.php index ef2dea0..e6a8599 100644 --- a/app/Domains/Commerce/Catalog/Resources/CatalogItemDetailResource.php +++ b/app/Domains/Commerce/Catalog/Resources/CatalogItemDetailResource.php @@ -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(); diff --git a/app/Domains/Core/Tenant/Models/Tenant.php b/app/Domains/Core/Tenant/Models/Tenant.php index d1420a5..c06ecc6 100644 --- a/app/Domains/Core/Tenant/Models/Tenant.php +++ b/app/Domains/Core/Tenant/Models/Tenant.php @@ -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, diff --git a/app/Domains/Core/Tenant/Resources/TenantResource.php b/app/Domains/Core/Tenant/Resources/TenantResource.php index 7094db4..8378c5d 100644 --- a/app/Domains/Core/Tenant/Resources/TenantResource.php +++ b/app/Domains/Core/Tenant/Resources/TenantResource.php @@ -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', diff --git a/app/Domains/Ticketing/Event/Models/EventDate.php b/app/Domains/Ticketing/Event/Models/EventDate.php index e9218f3..c91a5fa 100644 --- a/app/Domains/Ticketing/Event/Models/EventDate.php +++ b/app/Domains/Ticketing/Event/Models/EventDate.php @@ -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, diff --git a/app/Domains/Ticketing/Event/Resources/PublicEventResource.php b/app/Domains/Ticketing/Event/Resources/PublicEventResource.php index b6430aa..c7c4133 100644 --- a/app/Domains/Ticketing/Event/Resources/PublicEventResource.php +++ b/app/Domains/Ticketing/Event/Resources/PublicEventResource.php @@ -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(), diff --git a/app/Domains/Ticketing/Ticket/Models/ValidityTime.php b/app/Domains/Ticketing/Ticket/Models/ValidityTime.php index a971291..795dcf9 100644 --- a/app/Domains/Ticketing/Ticket/Models/ValidityTime.php +++ b/app/Domains/Ticketing/Ticket/Models/ValidityTime.php @@ -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); } } diff --git a/app/Domains/Ticketing/Ticket/Resources/ValidityTimeResource.php b/app/Domains/Ticketing/Ticket/Resources/ValidityTimeResource.php index 892073a..dd0da57 100644 --- a/app/Domains/Ticketing/Ticket/Resources/ValidityTimeResource.php +++ b/app/Domains/Ticketing/Ticket/Resources/ValidityTimeResource.php @@ -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 */ 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), ]; } diff --git a/app/Domains/Ticketing/Ticket/Services/ResolvedValidityGroup.php b/app/Domains/Ticketing/Ticket/Services/ResolvedValidityGroup.php index 8d63db7..f8a6ee7 100644 --- a/app/Domains/Ticketing/Ticket/Services/ResolvedValidityGroup.php +++ b/app/Domains/Ticketing/Ticket/Services/ResolvedValidityGroup.php @@ -17,7 +17,7 @@ use Illuminate\Support\Collection; final readonly class ResolvedValidityGroup { /** @param Collection $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; diff --git a/app/Domains/Ticketing/Ticket/Services/TicketValidityResolver.php b/app/Domains/Ticketing/Ticket/Services/TicketValidityResolver.php index c2942eb..436bfab 100644 --- a/app/Domains/Ticketing/Ticket/Services/TicketValidityResolver.php +++ b/app/Domains/Ticketing/Ticket/Services/TicketValidityResolver.php @@ -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')) ); } } diff --git a/app/Domains/Ticketing/Ticket/documentacion/README.md b/app/Domains/Ticketing/Ticket/documentacion/README.md index d4ced7f..4eb7a2c 100644 --- a/app/Domains/Ticketing/Ticket/documentacion/README.md +++ b/app/Domains/Ticketing/Ticket/documentacion/README.md @@ -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. diff --git a/config/app.php b/config/app.php index a621533..722f90d 100644 --- a/config/app.php +++ b/config/app.php @@ -67,9 +67,6 @@ return [ 'timezone' => 'UTC', - // Las fechas de los eventos se ingresan como horarios locales de Argentina. - 'event_timezone' => env('EVENT_TIMEZONE', 'America/Argentina/Buenos_Aires'), - /* |-------------------------------------------------------------------------- | Application Locale Configuration diff --git a/database/migrations/2026_09_21_040000_add_timezone_to_tenants.php b/database/migrations/2026_09_21_040000_add_timezone_to_tenants.php new file mode 100644 index 0000000..a046b2b --- /dev/null +++ b/database/migrations/2026_09_21_040000_add_timezone_to_tenants.php @@ -0,0 +1,41 @@ +string('timezone')->default('America/Argentina/Buenos_Aires'); + }); + + // Correct legacy event windows once, using their original local date and hours. + // Subsequent tenant timezone changes do not rewrite stored UTC instants. + DB::table('event_dates')->orderBy('id')->chunkById(200, function ($dates): void { + foreach ($dates as $date) { + $timezone = DB::table('tenants')->where('codigo', $date->tenant_code)->value('timezone'); + $start = CarbonImmutable::parse(substr($date->date, 0, 10).' '.$date->time_start, $timezone); + $end = CarbonImmutable::parse(substr($date->date, 0, 10).' '.$date->time_end, $timezone); + if ($end->lessThanOrEqualTo($start)) { + $end = $end->addDay(); + } + DB::table('validity_times')->where('id', $date->validity_time_id)->update([ + 'fixed_starts_at' => $start->utc()->format('Y-m-d H:i:s'), + 'fixed_expires_at' => $end->utc()->format('Y-m-d H:i:s'), + ]); + } + }); + } + + public function down(): void + { + Schema::table('tenants', function (Blueprint $table): void { + $table->dropColumn('timezone'); + }); + } +}; diff --git a/tests/Feature/Event/AdminAppEventControllerTest.php b/tests/Feature/Event/AdminAppEventControllerTest.php index 489b5fd..8152f62 100644 --- a/tests/Feature/Event/AdminAppEventControllerTest.php +++ b/tests/Feature/Event/AdminAppEventControllerTest.php @@ -2,23 +2,23 @@ namespace Tests\Feature\Event; -use App\Shared\Attachable\Enums\AttachmentType; -use App\Shared\Attachable\Models\Attachment; -use App\Domains\Core\Auth\Models\User; -use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\Inventory; use App\Domains\Commerce\Catalog\Models\StockReservation; use App\Domains\Commerce\Catalog\Models\StockReservationLine; use App\Domains\Commerce\Catalog\Models\Variant; +use App\Domains\Commerce\Purchase\Services\Checkout\CatalogSelectionResolver; +use App\Domains\Core\Auth\Models\User; +use App\Domains\Core\Authorization\Enums\RoleCode; +use App\Domains\Core\Tenant\Models\AdminWebsiteType; +use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Event\Events\EventDateRescheduled; use App\Domains\Ticketing\Event\Events\EventDateSuspended; -use App\Domains\Commerce\Purchase\Services\Checkout\CatalogSelectionResolver; -use App\Domains\Core\Tenant\Models\Tenant; -use App\Domains\Core\Tenant\Models\AdminWebsiteType; use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType; use App\Domains\Ticketing\Ticket\Models\Ticket; +use App\Shared\Attachable\Enums\AttachmentType; +use App\Shared\Attachable\Models\Attachment; use Database\Seeders\AuthorizationSeeder; use Database\Seeders\SocialMediaSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -249,8 +249,8 @@ class AdminAppEventControllerTest extends TestCase $this->assertDatabaseHas('validity_times', [ 'id' => $firstValidityTimeId, 'type' => ValidityTimeType::FixedWindow->value, - 'fixed_starts_at' => '2026-10-01 08:00:00', - 'fixed_expires_at' => '2026-10-01 12:00:00', + 'fixed_starts_at' => '2026-10-01 11:00:00', + 'fixed_expires_at' => '2026-10-01 15:00:00', ]); $this->assertDatabaseHas('event_dates', ['id' => $removedDate->id]); $this->assertSame('1 y 2 de Octubre 2026', $tenant->fresh()->event_date_text); @@ -385,7 +385,7 @@ class AdminAppEventControllerTest extends TestCase ); $this->assertDatabaseHas('validity_times', [ 'id' => $original->validity_time_id, - 'fixed_starts_at' => '2027-10-09 09:00:00', + 'fixed_starts_at' => '2027-10-09 12:00:00', ]); $this->postJson("/api/v1/adminapp/tenant/event-dates/{$destination->id}/reschedule", [ diff --git a/tests/Feature/Migrations/AddTenantTimezoneTest.php b/tests/Feature/Migrations/AddTenantTimezoneTest.php new file mode 100644 index 0000000..542d39f --- /dev/null +++ b/tests/Feature/Migrations/AddTenantTimezoneTest.php @@ -0,0 +1,48 @@ +id(); + $table->string('codigo'); + }); + Schema::create('event_dates', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->date('date'); + $table->time('time_start'); + $table->time('time_end'); + $table->integer('validity_time_id'); + }); + Schema::create('validity_times', function (Blueprint $table): void { + $table->id(); + $table->dateTime('fixed_starts_at'); + $table->dateTime('fixed_expires_at'); + }); + DB::table('tenants')->insert(['codigo' => 'football']); + foreach ([1, 2] as $id) { + DB::table('validity_times')->insert([ + 'id' => $id, 'fixed_starts_at' => '2026-09-21 22:00:00', 'fixed_expires_at' => '2026-09-22 02:00:00', + ]); + } + DB::table('event_dates')->insert([ + 'tenant_code' => 'football', 'date' => '2026-09-21', 'time_start' => '22:00:00', 'time_end' => '02:00:00', 'validity_time_id' => 1, + ]); + $migration = require database_path('migrations/2026_09_21_040000_add_timezone_to_tenants.php'); + $migration->up(); + $this->assertDatabaseHas('tenants', ['codigo' => 'football', 'timezone' => 'America/Argentina/Buenos_Aires']); + $this->assertDatabaseHas('validity_times', ['id' => 1, 'fixed_starts_at' => '2026-09-22 01:00:00', 'fixed_expires_at' => '2026-09-22 05:00:00']); + $this->assertDatabaseHas('validity_times', ['id' => 2, 'fixed_starts_at' => '2026-09-21 22:00:00']); + $migration->down(); + $this->assertFalse(Schema::hasColumn('tenants', 'timezone')); + } +} diff --git a/tests/Feature/Tenant/TenantTimezoneTest.php b/tests/Feature/Tenant/TenantTimezoneTest.php new file mode 100644 index 0000000..d01a432 --- /dev/null +++ b/tests/Feature/Tenant/TenantTimezoneTest.php @@ -0,0 +1,76 @@ +createTenant('timezone'); + $this->getJson("/api/tenants/{$tenant->codigo}")->assertOk() + ->assertJsonPath('data.timezone', 'America/Argentina/Buenos_Aires'); + + } + + public function test_event_stores_utc_and_timezone_changes_preserve_stored_instants(): void + { + $tenant = $this->createTenant('event-zone'); + $date = $tenant->eventDates()->create([ + 'date' => '2026-09-21', 'time_start' => '22:00', 'time_end' => '02:00', + ]); + $this->assertDatabaseHas('validity_times', [ + 'id' => $date->validity_time_id, + 'fixed_starts_at' => '2026-09-22 01:00:00', + 'fixed_expires_at' => '2026-09-22 05:00:00', + ]); + $tenant->update(['timezone' => 'Asia/Tokyo']); + $this->assertSame('2026-09-22 01:00:00', $date->validityTime->fresh()->fixed_starts_at->format('Y-m-d H:i:s')); + } + + private function createTenant(string $code): Tenant + { + $attachmentIds = collect(['header', 'footer'])->map(function (string $name): int { + $key = (string) Str::uuid(); + + return Attachment::query()->create([ + 'key' => $key, + 'path' => "tenants/{$key}.png", + 'filename' => "{$name}.png", + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ])->id; + }); + + $clientId = DB::table('clients')->insertGetId([ + 'code' => $code, + 'name' => Str::headline($code), + ]); + + $tenantId = DB::table('tenants')->insertGetId([ + 'client_id' => $clientId, + 'codigo' => $code, + 'nombre' => Str::headline($code), + 'dominio' => "{$code}.test", + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#ffffff', + 'footer_bg_color' => '#ffffff', + 'header_logo_id' => $attachmentIds[0], + 'footer_logo_id' => $attachmentIds[1], + ]); + + return Tenant::query()->findOrFail($tenantId); + } +} diff --git a/tests/Unit/Ticket/TenantTimezoneValidityTest.php b/tests/Unit/Ticket/TenantTimezoneValidityTest.php new file mode 100644 index 0000000..e449f14 --- /dev/null +++ b/tests/Unit/Ticket/TenantTimezoneValidityTest.php @@ -0,0 +1,90 @@ + ValidityTimeType::TimeWindow, 'start_time' => '22:00', 'end_time' => '02:00']); + $group = new ResolvedValidityGroup(collect([$window]), 'America/Argentina/Buenos_Aires'); + foreach (['2026-09-22 01:00' => true, '2026-09-22 04:59' => true, '2026-09-22 05:00' => false, '2026-09-22 18:00' => false] as $time => $expected) { + $at = CarbonImmutable::parse($time, 'UTC'); + $this->assertSame($expected, $group->isValid($at), $time); + $this->assertSame($expected, $window->isValid($at, 'America/Argentina/Buenos_Aires'), $time); + } + $this->assertFalse((new ResolvedValidityGroup(collect([$window]), 'Asia/Tokyo'))->isValid(CarbonImmutable::parse('2026-09-22 01:00', 'UTC'))); + } + + public function test_event_anchor_uses_local_date_when_utc_date_is_next_day(): void + { + $fixed = new ValidityTime(['type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => '2026-09-22 01:00', 'fixed_expires_at' => '2026-09-22 06:00']); + $window = new ValidityTime(['type' => ValidityTimeType::TimeWindow, 'start_time' => '22:00', 'end_time' => '02:00']); + $group = new ResolvedValidityGroup(collect([$fixed, $window]), 'America/Argentina/Buenos_Aires'); + $this->assertTrue($group->isValid(CarbonImmutable::parse('2026-09-22 04:00', 'UTC'))); + $this->assertFalse($group->isValid(CarbonImmutable::parse('2026-09-23 04:00', 'UTC'))); + $this->assertSame('2026-09-22 01:00', $group->effectiveStartsAt()->utc()->format('Y-m-d H:i')); + $this->assertSame('2026-09-22 05:00', $group->effectiveExpiresAt()->utc()->format('Y-m-d H:i')); + } + + public function test_local_window_compares_with_utc_now_in_resource(): void + { + $this->travelTo(CarbonImmutable::parse('2026-09-21 22:00', 'UTC')); + $window = new ValidityTime(['type' => ValidityTimeType::TimeWindow, 'start_time' => '18:00', 'end_time' => '20:00']); + $resource = ValidityTimeResource::make($window, 'America/Argentina/Buenos_Aires'); + $this->assertTrue($resource->resolve()['is_valid']); + $this->assertFalse($window->isValid(now(), 'Asia/Tokyo')); + $this->travelBack(); + } + + public function test_daylight_saving_uses_date_specific_offset(): void + { + $window = new ValidityTime(['type' => ValidityTimeType::TimeWindow, 'start_time' => '18:00', 'end_time' => '20:00']); + $this->assertSame('2026-01-21 23:00', $window->startsAt(CarbonImmutable::parse('2026-01-21 12:00', 'UTC'), 'America/New_York')->utc()->format('Y-m-d H:i')); + $this->assertSame('2026-07-21 22:00', $window->startsAt(CarbonImmutable::parse('2026-07-21 12:00', 'UTC'), 'America/New_York')->utc()->format('Y-m-d H:i')); + } + + public function test_resolver_passes_tenant_timezone_to_groups(): void + { + $tenant = new Tenant(['timezone' => 'Asia/Tokyo']); + $item = new CatalogItem; + $item->setRelation('tenant', $tenant); + $date = new EventDate; + $date->setRelation('validityTime', new ValidityTime([ + 'type' => ValidityTimeType::FixedWindow, + 'fixed_starts_at' => '2026-09-21 13:00', + 'fixed_expires_at' => '2026-09-21 17:00', + ])); + $variant = new Variant; + $variant->setRelation('catalogItem', $item); + $variant->setRelation('eventDates', new Collection([$date])); + $variant->setRelation('eventDate', null); + $variant->setRelation('definitions', new Collection); + $resolved = (new TicketValidityResolver)->resolveVariant($variant); + $this->assertSame('Asia/Tokyo', $resolved->groups->first()->timezone); + } + + public function test_event_hours_are_local_and_end_can_be_next_day(): void + { + $date = new EventDate(['date' => '2026-09-21', 'time_start' => '22:00', 'time_end' => '02:00']); + $date->setRelation('tenant', new Tenant(['timezone' => 'America/Argentina/Buenos_Aires'])); + $this->assertSame('2026-09-22 01:00', $date->startsAt()->utc()->format('Y-m-d H:i')); + $this->assertSame('2026-09-22 05:00', $date->endsAt()->utc()->format('Y-m-d H:i')); + $date->setRelation('tenant', new Tenant(['timezone' => 'Asia/Tokyo'])); + $this->assertSame('2026-09-21 13:00', $date->startsAt()->utc()->format('Y-m-d H:i')); + } +} diff --git a/tests/Unit/Ticket/TicketValidityResolverTest.php b/tests/Unit/Ticket/TicketValidityResolverTest.php index 7333c95..e68ac23 100644 --- a/tests/Unit/Ticket/TicketValidityResolverTest.php +++ b/tests/Unit/Ticket/TicketValidityResolverTest.php @@ -8,10 +8,10 @@ use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\VariantDefinition; use App\Domains\Ticketing\Event\Models\EventDate; -use App\Shared\Enums\FieldType; use App\Domains\Ticketing\Ticket\Enums\ValidityTimeType; use App\Domains\Ticketing\Ticket\Models\ValidityTime; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Enums\FieldType; use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Support\Carbon; use Tests\TestCase; @@ -200,6 +200,7 @@ class TicketValidityResolverTest extends TestCase private function variant(EloquentCollection $eventDates, EloquentCollection $definitions): Variant { $variant = new Variant; + $variant->setRelation('catalogItem', null); $variant->setRelation('eventDates', $eventDates); $variant->setRelation('eventDate', null); $variant->setRelation('definitions', $definitions);