From a928a2e848398a90f81c512caff87f9879c028bc Mon Sep 17 00:00:00 2001 From: ncoronel Date: Tue, 21 Jul 2026 11:20:52 -0300 Subject: [PATCH] feat(ticket): implement ticket generation service, model, and exception handling with tests --- .../Exceptions/TicketGenerationException.php | 29 +++ app/Domains/Ticket/Models/Ticket.php | 81 ++++++++ .../Services/TicketGeneratorService.php | 86 ++++++++ ...2026_07_21_000000_create_tickets_table.php | 34 ++++ .../Ticket/TicketGeneratorServiceTest.php | 183 ++++++++++++++++++ tests/Unit/Ticket/TicketTest.php | 113 +++++++++++ 6 files changed, 526 insertions(+) create mode 100644 app/Domains/Ticket/Exceptions/TicketGenerationException.php create mode 100644 app/Domains/Ticket/Models/Ticket.php create mode 100644 app/Domains/Ticket/Services/TicketGeneratorService.php create mode 100644 database/migrations/2026_07_21_000000_create_tickets_table.php create mode 100644 tests/Feature/Ticket/TicketGeneratorServiceTest.php create mode 100644 tests/Unit/Ticket/TicketTest.php diff --git a/app/Domains/Ticket/Exceptions/TicketGenerationException.php b/app/Domains/Ticket/Exceptions/TicketGenerationException.php new file mode 100644 index 0000000..2a9c58e --- /dev/null +++ b/app/Domains/Ticket/Exceptions/TicketGenerationException.php @@ -0,0 +1,29 @@ +id} no tiene componentes."); + } + + public static function ticketsDisabled(CatalogItem $catalogItem): self + { + return new self("El producto {$catalogItem->id} no tiene tickets habilitados."); + } + + public static function maximumUseDateReached(CatalogItem $catalogItem): self + { + return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso."); + } +} diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php new file mode 100644 index 0000000..0b6d048 --- /dev/null +++ b/app/Domains/Ticket/Models/Ticket.php @@ -0,0 +1,81 @@ + 'datetime', + 'expires_at' => 'datetime', + 'used_at' => 'datetime', + 'user_id' => 'integer', + ]; + } + + /** @return BelongsTo */ + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); + } + + /** @return BelongsTo */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function isValid(): bool + { + $now = now(); + + return $this->used_at === null + && ($this->starts_at === null || $this->starts_at->lessThanOrEqualTo($now)) + && ($this->expires_at === null || $this->expires_at->greaterThan($now)); + } + + public function getIsValidAttribute(): bool + { + return $this->isValid(); + } + + public function getIsExpiredAttribute(): bool + { + return $this->used_at === null + && $this->expires_at !== null + && $this->expires_at->lessThanOrEqualTo(now()); + } + + public function getIsUsedAttribute(): bool + { + return $this->used_at !== null; + } +} diff --git a/app/Domains/Ticket/Services/TicketGeneratorService.php b/app/Domains/Ticket/Services/TicketGeneratorService.php new file mode 100644 index 0000000..614850a --- /dev/null +++ b/app/Domains/Ticket/Services/TicketGeneratorService.php @@ -0,0 +1,86 @@ + + */ + public function generate(CatalogItem $catalogItem, User $user, int $quantity = 1): Collection + { + if ($quantity < 1) { + throw TicketGenerationException::invalidQuantity(); + } + + $now = now(); + + return DB::transaction(function () use ($catalogItem, $user, $quantity, $now): Collection { + $catalogItems = $this->resolveCatalogItems($catalogItem, $quantity, $now); + + return $catalogItems->map(fn (CatalogItem $item): Ticket => Ticket::query()->create([ + 'tenant_code' => $item->tenant_code, + 'ticket' => (string) Str::uuid(), + 'name' => $item->nombre, + 'description' => (string) ($item->descripcion ?? ''), + 'starts_at' => $item->minimum_use_date, + 'expires_at' => $item->maximum_use_date, + 'used_at' => null, + 'user_id' => $user->getKey(), + ])); + }); + } + + /** + * @return Collection + */ + private function resolveCatalogItems( + CatalogItem $catalogItem, + int $quantity, + CarbonInterface $now, + ): Collection { + if (! $catalogItem->isBundle()) { + $this->validateCatalogItem($catalogItem, $now); + + return Collection::times($quantity, fn (): CatalogItem => $catalogItem); + } + + $catalogItem->loadMissing('bundleComponents.catalogItem'); + + if ($catalogItem->bundleComponents->isEmpty()) { + throw TicketGenerationException::emptyBundle($catalogItem); + } + + return $catalogItem->bundleComponents + ->flatMap(function ($component) use ($quantity, $now): Collection { + $componentItem = $component->catalogItem; + $this->validateCatalogItem($componentItem, $now); + + return Collection::times( + $quantity * $component->quantity, + fn (): CatalogItem => $componentItem, + ); + }) + ->values(); + } + + private function validateCatalogItem(CatalogItem $catalogItem, CarbonInterface $now): void + { + if (! $catalogItem->has_tickets) { + throw TicketGenerationException::ticketsDisabled($catalogItem); + } + + if ($catalogItem->maximum_use_date?->lessThanOrEqualTo($now)) { + throw TicketGenerationException::maximumUseDateReached($catalogItem); + } + } +} diff --git a/database/migrations/2026_07_21_000000_create_tickets_table.php b/database/migrations/2026_07_21_000000_create_tickets_table.php new file mode 100644 index 0000000..d6cee4e --- /dev/null +++ b/database/migrations/2026_07_21_000000_create_tickets_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('tenant_code'); + $table->uuid('ticket'); + $table->string('name'); + $table->text('description'); + $table->dateTime('starts_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + $table->dateTime('used_at')->nullable(); + $table->foreignId('user_id')->constrained()->cascadeOnUpdate()->restrictOnDelete(); + + $table->foreign('tenant_code') + ->references('codigo') + ->on('tenants') + ->cascadeOnUpdate() + ->restrictOnDelete(); + }); + } + + public function down(): void + { + Schema::dropIfExists('tickets'); + } +}; diff --git a/tests/Feature/Ticket/TicketGeneratorServiceTest.php b/tests/Feature/Ticket/TicketGeneratorServiceTest.php new file mode 100644 index 0000000..88496fe --- /dev/null +++ b/tests/Feature/Ticket/TicketGeneratorServiceTest.php @@ -0,0 +1,183 @@ +service = app(TicketGeneratorService::class); + $this->tenant = $this->createTenant(); + $this->user = User::factory()->create(); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function test_it_generates_tickets_from_a_standard_catalog_item(): void + { + $item = $this->createTicketableItem( + 'single-day', + now()->subHour(), + now()->addDay(), + ); + + $tickets = $this->service->generate($item, $this->user, 2); + + $this->assertCount(2, $tickets); + foreach ($tickets as $ticket) { + $this->assertTrue(Str::isUuid($ticket->ticket)); + $this->assertSame($this->tenant->codigo, $ticket->tenant_code); + $this->assertSame($this->user->id, $ticket->user_id); + $this->assertSame($item->nombre, $ticket->name); + $this->assertSame($item->descripcion, $ticket->description); + $this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date)); + $this->assertTrue($ticket->expires_at->equalTo($item->maximum_use_date)); + } + } + + public function test_it_rejects_an_item_without_tickets_enabled(): void + { + $item = $this->createTicketableItem('disabled'); + $item->update(['has_tickets' => false]); + + $this->expectException(TicketGenerationException::class); + $this->expectExceptionMessage('no tiene tickets habilitados'); + + $this->service->generate($item->fresh(), $this->user); + } + + public function test_it_rejects_an_item_when_its_maximum_use_date_was_reached(): void + { + $item = $this->createTicketableItem('expired', maximumUseDate: now()); + + $this->expectException(TicketGenerationException::class); + $this->expectExceptionMessage('alcanzó su fecha máxima de uso'); + + $this->service->generate($item, $this->user); + } + + 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)); + $bundle = $this->createBundle('bundle'); + $bundle->bundleComponents()->createMany([ + ['component_catalog_item_id' => $first->id, 'quantity' => 2], + ['component_catalog_item_id' => $second->id, 'quantity' => 1], + ]); + + $tickets = $this->service->generate($bundle, $this->user, 2); + + $this->assertCount(6, $tickets); + $this->assertCount(4, $tickets->where('name', $first->nombre)); + $this->assertCount(2, $tickets->where('name', $second->nombre)); + } + + public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void + { + $valid = $this->createTicketableItem('valid'); + $invalid = $this->createTicketableItem('invalid'); + $invalid->update(['has_tickets' => false]); + $bundle = $this->createBundle('invalid-bundle'); + $bundle->bundleComponents()->createMany([ + ['component_catalog_item_id' => $valid->id, 'quantity' => 1], + ['component_catalog_item_id' => $invalid->id, 'quantity' => 1], + ]); + + try { + $this->service->generate($bundle, $this->user); + $this->fail('La generación debería haber fallado.'); + } catch (TicketGenerationException) { + $this->assertDatabaseCount('tickets', 0); + } + } + + private function createTicketableItem( + string $slug, + mixed $minimumUseDate = null, + mixed $maximumUseDate = null, + ): CatalogItem { + return CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'slug' => $slug, + 'nombre' => ucfirst($slug), + 'descripcion' => "Descripción de {$slug}", + 'precio' => 10, + 'has_tickets' => true, + 'minimum_use_date' => $minimumUseDate, + 'maximum_use_date' => $maximumUseDate, + ]); + } + + private function createBundle(string $slug): CatalogItem + { + return CatalogItem::query()->create([ + 'tenant_code' => $this->tenant->codigo, + 'type' => CatalogItemType::Bundle, + 'inventory_policy' => null, + 'slug' => $slug, + 'nombre' => ucfirst($slug), + 'descripcion' => "Descripción de {$slug}", + 'precio' => 20, + ]); + } + + private function createTenant(): Tenant + { + $header = Attachment::query()->create([ + 'path' => 'test/ticket-header.png', + 'filename' => 'header.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + $footer = Attachment::query()->create([ + 'path' => 'test/ticket-footer.png', + 'filename' => 'footer.png', + 'type' => AttachmentType::Image, + 'mime_type' => 'image/png', + ]); + + return Tenant::query()->create([ + 'codigo' => 'ticket-tenant', + 'nombre' => 'Ticket Tenant', + 'dominio' => 'ticket.local', + 'primary_color' => '#000000', + 'secondary_color' => '#000000', + 'danger_color' => '#000000', + 'success_color' => '#000000', + 'header_bg_color' => '#000000', + 'footer_bg_color' => '#000000', + 'header_logo_id' => $header->id, + 'footer_logo_id' => $footer->id, + ]); + } +} diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php new file mode 100644 index 0000000..fdf4b2d --- /dev/null +++ b/tests/Unit/Ticket/TicketTest.php @@ -0,0 +1,113 @@ +setRawAttributes([ + 'starts_at' => '2026-07-21 10:00:00', + 'expires_at' => '2026-07-22 10:00:00', + 'used_at' => null, + 'user_id' => '10', + ]); + + $this->assertSame('tickets', $ticket->getTable()); + $this->assertFalse($ticket->usesTimestamps()); + $this->assertInstanceOf(Carbon::class, $ticket->starts_at); + $this->assertInstanceOf(Carbon::class, $ticket->expires_at); + $this->assertNull($ticket->used_at); + $this->assertSame(10, $ticket->user_id); + $this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated()); + $this->assertInstanceOf(User::class, $ticket->user()->getRelated()); + } + + public function test_unused_ticket_without_date_restrictions_is_valid(): void + { + $this->assertTrue((new Ticket)->isValid()); + } + + public function test_ticket_is_invalid_before_its_start_date(): void + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = new Ticket(['starts_at' => now()->addSecond()]); + + $this->assertFalse($ticket->isValid()); + } + + public function test_ticket_is_valid_when_its_start_date_is_reached(): void + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = new Ticket(['starts_at' => now()]); + + $this->assertTrue($ticket->isValid()); + } + + public function test_ticket_is_invalid_when_it_expires(): void + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = new Ticket(['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 + { + Carbon::setTestNow('2026-07-21 10:00:00'); + $ticket = new Ticket([ + 'expires_at' => now()->subHour(), + 'used_at' => now()->subDay(), + ]); + + $this->assertFalse($ticket->is_valid); + $this->assertFalse($ticket->is_expired); + $this->assertTrue($ticket->is_used); + } +}