From de259f4286e3d52e6797f41779d672b074cb49b1 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 16:20:35 -0300 Subject: [PATCH] feat(ticket): implement terminal status management and validation --- app/Domains/Ticket/Models/Ticket.php | 93 ++++++++++++++++++- .../Feature/Logging/LogsValueChangesTest.php | 14 +++ tests/Unit/Ticket/TicketTest.php | 31 +++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 895e4e1..899196f 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; +use Illuminate\Validation\ValidationException; #[Fillable([ 'tenant_code', @@ -121,6 +122,13 @@ class Ticket extends Model return self::statusLabels()[$status] ?? $status; } + protected static function booted(): void + { + static::saving(function (self $ticket): void { + $ticket->ensureTerminalStatusTransitionIsAllowed(); + }); + } + /** @return BelongsTo */ public function tenant(): BelongsTo { @@ -219,6 +227,21 @@ class Ticket extends Model return self::statusLabel($this->status); } + public function markAsDisabled(): void + { + $this->markAsTerminalStatus(self::STATUS_DISABLED); + } + + public function markAsCancelled(): void + { + $this->markAsTerminalStatus(self::STATUS_CANCELLED); + } + + public function markAsRefunded(): void + { + $this->markAsTerminalStatus(self::STATUS_REFUNDED); + } + protected function valueChangeTenantCode(): string { return $this->tenant_code; @@ -226,9 +249,73 @@ class Ticket extends Model private function hasTerminalStatus(): bool { - return $this->disabled_at !== null - || $this->cancelled_at !== null - || $this->refunded_at !== null; + return $this->terminalStatus() !== null; + } + + private function markAsTerminalStatus(string $status): void + { + $currentStatus = $this->terminalStatus(); + + if ($currentStatus === $status) { + return; + } + + if ($currentStatus !== null) { + $this->throwTerminalStatusTransitionException(); + } + + $this->ensureTerminalStatusTransitionIsAllowed($status); + + $this->{self::terminalStatusTimestampColumn($status)} = now(); + } + + private function ensureTerminalStatusTransitionIsAllowed(?string $targetStatus = null): void + { + $currentStatus = $this->terminalStatusFromAttributes($this->getRawOriginal()); + $nextStatus = $targetStatus ?? $this->terminalStatus(); + + if ($currentStatus === null || $nextStatus === null || $currentStatus === $nextStatus) { + return; + } + + $this->throwTerminalStatusTransitionException(); + } + + private function throwTerminalStatusTransitionException(): never + { + throw ValidationException::withMessages([ + 'status' => 'No se puede cambiar un ticket con estado terminal a otro estado terminal.', + ]); + } + + private function terminalStatus(): ?string + { + return $this->terminalStatusFromAttributes($this->getAttributes()); + } + + /** @param array $attributes */ + private function terminalStatusFromAttributes(array $attributes): ?string + { + foreach ([ + self::STATUS_REFUNDED, + self::STATUS_CANCELLED, + self::STATUS_DISABLED, + ] as $status) { + if (($attributes[self::terminalStatusTimestampColumn($status)] ?? null) !== null) { + return $status; + } + } + + return null; + } + + private static function terminalStatusTimestampColumn(string $status): string + { + return match ($status) { + self::STATUS_DISABLED => 'disabled_at', + self::STATUS_CANCELLED => 'cancelled_at', + self::STATUS_REFUNDED => 'refunded_at', + }; } public function getNameAttribute(): string diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php index 157f7fb..11d6d64 100644 --- a/tests/Feature/Logging/LogsValueChangesTest.php +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Schema; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class LogsValueChangesTest extends TestCase @@ -175,6 +176,19 @@ class LogsValueChangesTest extends TestCase 'user_id' => null, ]); } + + public function test_ticket_cannot_transition_between_terminal_statuses(): void + { + $ticket = Ticket::query()->create([ + 'tenant_code' => 'test', + 'ticket' => '794606d5-5f69-458d-9de7-03494757d626', + ]); + + $ticket->update(['disabled_at' => now()]); + + $this->expectException(ValidationException::class); + $ticket->update(['cancelled_at' => now()]); + } } #[Fillable(['name', 'price', 'description'])] diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index c4f24e1..a71541a 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -13,6 +13,7 @@ use App\Domains\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticket\Services\ResolvedValidityGroup; use App\Domains\Ticket\Services\TicketValidityResolver; use Illuminate\Support\Carbon; +use Illuminate\Validation\ValidationException; use Tests\TestCase; class TicketTest extends TestCase @@ -202,6 +203,36 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_REFUNDED, $ticket->status); } + public function test_it_marks_tickets_with_terminal_statuses(): void + { + Carbon::setTestNow('2026-09-10 12:00:00'); + + $disabled = new Ticket; + $disabled->markAsDisabled(); + + $cancelled = new Ticket; + $cancelled->markAsCancelled(); + + $refunded = new Ticket; + $refunded->markAsRefunded(); + + $this->assertSame(Ticket::STATUS_DISABLED, $disabled->status); + $this->assertSame('2026-09-10 12:00:00', $disabled->disabled_at->format('Y-m-d H:i:s')); + $this->assertSame(Ticket::STATUS_CANCELLED, $cancelled->status); + $this->assertSame('2026-09-10 12:00:00', $cancelled->cancelled_at->format('Y-m-d H:i:s')); + $this->assertSame(Ticket::STATUS_REFUNDED, $refunded->status); + $this->assertSame('2026-09-10 12:00:00', $refunded->refunded_at->format('Y-m-d H:i:s')); + } + + public function test_it_does_not_allow_a_transition_between_terminal_statuses(): void + { + $ticket = new Ticket; + $ticket->markAsDisabled(); + + $this->expectException(ValidationException::class); + $ticket->markAsRefunded(); + } + public function test_all_validity_times_in_the_same_group_must_be_active(): void { Carbon::setTestNow('2026-08-20 13:00:00');