diff --git a/app/Domains/Purchase/Models/PurchaseItem.php b/app/Domains/Purchase/Models/PurchaseItem.php index f15bf6b..ee4e9f5 100644 --- a/app/Domains/Purchase/Models/PurchaseItem.php +++ b/app/Domains/Purchase/Models/PurchaseItem.php @@ -27,6 +27,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; 'discount_total', 'tax_total', 'total', + 'refunded_amount', ])] class PurchaseItem extends Model { @@ -47,6 +48,7 @@ class PurchaseItem extends Model 'discount_total' => 'decimal:2', 'tax_total' => 'decimal:2', 'total' => 'decimal:2', + 'refunded_amount' => 'decimal:2', ]; } diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 1df25cc..895e4e1 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Models; use App\Domains\Auth\Models\User; use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\Variant; +use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Services\ResolvedTicketValidity; @@ -26,12 +27,15 @@ use Illuminate\Support\Collection; 'source_catalog_item_id', 'source_variant_id', 'used_at', + 'disabled_at', + 'cancelled_at', + 'refunded_at', 'scanner_user_id', 'user_id', ])] class Ticket extends Model { - use HasFactory; + use HasFactory, LogsValueChanges; private ?ResolvedTicketValidity $resolvedValidity = null; @@ -41,8 +45,22 @@ class Ticket extends Model public const STATUS_USED = 'used'; + public const STATUS_DISABLED = 'disabled'; + + public const STATUS_CANCELLED = 'cancelled'; + + public const STATUS_REFUNDED = 'refunded'; + public $timestamps = false; + /** @var list */ + protected array $loggedAttributes = [ + 'used_at', + 'disabled_at', + 'cancelled_at', + 'refunded_at', + ]; + protected $appends = [ 'name', 'description', @@ -59,11 +77,50 @@ class Ticket extends Model 'source_variant_id' => 'integer', 'source_purchase_item_id' => 'integer', 'used_at' => 'datetime', + 'disabled_at' => 'datetime', + 'cancelled_at' => 'datetime', + 'refunded_at' => 'datetime', 'scanner_user_id' => 'integer', 'user_id' => 'integer', ]; } + /** @return list */ + public static function statuses(): array + { + return array_keys(self::statusLabels()); + } + + /** @return array */ + public static function statusLabels(): array + { + return [ + self::STATUS_ACTIVE => 'Activo', + self::STATUS_USED => 'Usado', + self::STATUS_EXPIRED => 'Vencido', + self::STATUS_DISABLED => 'Inhabilitado', + self::STATUS_CANCELLED => 'Cancelado', + self::STATUS_REFUNDED => 'Reembolsado', + ]; + } + + /** @return list */ + public static function statusOptions(): array + { + return collect(self::statusLabels()) + ->map(fn (string $label, string $status): array => [ + 'value' => $status, + 'label' => $label, + ]) + ->values() + ->all(); + } + + public static function statusLabel(string $status): string + { + return self::statusLabels()[$status] ?? $status; + } + /** @return BelongsTo */ public function tenant(): BelongsTo { @@ -108,7 +165,7 @@ class Ticket extends Model public function isValid(): bool { - if ($this->used_at !== null) { + if ($this->hasTerminalStatus() || $this->used_at !== null) { return false; } @@ -122,7 +179,9 @@ class Ticket extends Model public function getIsExpiredAttribute(): bool { - return $this->used_at === null && $this->resolvedValidity()->isExpired(); + return ! $this->hasTerminalStatus() + && $this->used_at === null + && $this->resolvedValidity()->isExpired(); } public function getIsUsedAttribute(): bool @@ -132,6 +191,18 @@ class Ticket extends Model public function getStatusAttribute(): string { + if ($this->refunded_at !== null) { + return self::STATUS_REFUNDED; + } + + if ($this->cancelled_at !== null) { + return self::STATUS_CANCELLED; + } + + if ($this->disabled_at !== null) { + return self::STATUS_DISABLED; + } + if ($this->is_used) { return self::STATUS_USED; } @@ -143,6 +214,23 @@ class Ticket extends Model return self::STATUS_ACTIVE; } + public function getStatusLabelAttribute(): string + { + return self::statusLabel($this->status); + } + + protected function valueChangeTenantCode(): string + { + return $this->tenant_code; + } + + private function hasTerminalStatus(): bool + { + return $this->disabled_at !== null + || $this->cancelled_at !== null + || $this->refunded_at !== null; + } + public function getNameAttribute(): string { return app(TicketPresentationResolver::class)->name($this); diff --git a/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php b/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php new file mode 100644 index 0000000..5c087b0 --- /dev/null +++ b/database/migrations/2026_09_10_010000_add_state_timestamps_to_tickets_and_refunded_amount_to_purchase_items.php @@ -0,0 +1,36 @@ +dateTime('disabled_at')->nullable()->after('used_at'); + $table->dateTime('cancelled_at')->nullable()->after('disabled_at'); + $table->dateTime('refunded_at')->nullable()->after('cancelled_at'); + }); + + Schema::table('compra_items', function (Blueprint $table): void { + $table->decimal('refunded_amount', 10, 2)->default(0)->after('total'); + }); + } + + public function down(): void + { + Schema::table('compra_items', function (Blueprint $table): void { + $table->dropColumn('refunded_amount'); + }); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropColumn([ + 'disabled_at', + 'cancelled_at', + 'refunded_at', + ]); + }); + } +}; diff --git a/tests/Feature/Logging/LogsValueChangesTest.php b/tests/Feature/Logging/LogsValueChangesTest.php index bd4dc8c..157f7fb 100644 --- a/tests/Feature/Logging/LogsValueChangesTest.php +++ b/tests/Feature/Logging/LogsValueChangesTest.php @@ -6,6 +6,7 @@ use App\Domains\Logging\Enums\ValueChangeActorType; use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Ticket\Models\Ticket; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Schema\Blueprint; @@ -52,6 +53,16 @@ class LogsValueChangesTest extends TestCase $table->timestamps(); }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->uuid('ticket'); + $table->dateTime('used_at')->nullable(); + $table->dateTime('disabled_at')->nullable(); + $table->dateTime('cancelled_at')->nullable(); + $table->dateTime('refunded_at')->nullable(); + }); + $migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php'); $migration->up(); $tenantMigration = require database_path('migrations/2026_08_04_000000_add_tenant_code_to_value_changes_table.php'); @@ -144,6 +155,26 @@ class LogsValueChangesTest extends TestCase 'user_id' => null, ]); } + + public function test_ticket_logs_its_status_changes(): void + { + $ticket = Ticket::query()->create([ + 'tenant_code' => 'test', + 'ticket' => '794606d5-5f69-458d-9de7-03494757d626', + ]); + + $ticket->update(['disabled_at' => now()]); + + $this->assertDatabaseHas('value_changes', [ + 'tenant_code' => 'test', + 'trackable_type' => $ticket->getMorphClass(), + 'trackable_id' => $ticket->id, + 'attribute' => 'disabled_at', + 'old_value' => null, + 'actor_type' => ValueChangeActorType::System->value, + 'user_id' => null, + ]); + } } #[Fillable(['name', 'price', 'description'])] diff --git a/tests/Unit/Ticket/TicketTest.php b/tests/Unit/Ticket/TicketTest.php index 4f4c099..c4f24e1 100644 --- a/tests/Unit/Ticket/TicketTest.php +++ b/tests/Unit/Ticket/TicketTest.php @@ -31,6 +31,9 @@ class TicketTest extends TestCase 'source_catalog_item_id' => '20', 'source_variant_id' => '30', 'used_at' => null, + 'disabled_at' => '2026-09-10 10:00:00', + 'cancelled_at' => null, + 'refunded_at' => null, 'scanner_user_id' => '15', 'user_id' => '10', ]); @@ -40,6 +43,9 @@ class TicketTest extends TestCase $this->assertSame(20, $ticket->source_catalog_item_id); $this->assertSame(30, $ticket->source_variant_id); $this->assertNull($ticket->used_at); + $this->assertSame('2026-09-10 10:00:00', $ticket->disabled_at->format('Y-m-d H:i:s')); + $this->assertNull($ticket->cancelled_at); + $this->assertNull($ticket->refunded_at); $this->assertSame(15, $ticket->scanner_user_id); $this->assertSame(10, $ticket->user_id); $this->assertInstanceOf(Tenant::class, $ticket->tenant()->getRelated()); @@ -154,6 +160,48 @@ class TicketTest extends TestCase $this->assertSame(Ticket::STATUS_USED, $ticket->status); } + public function test_persisted_terminal_statuses_make_the_ticket_invalid(): void + { + foreach ([ + 'disabled_at' => Ticket::STATUS_DISABLED, + 'cancelled_at' => Ticket::STATUS_CANCELLED, + 'refunded_at' => Ticket::STATUS_REFUNDED, + ] as $timestamp => $status) { + $ticket = new Ticket([$timestamp => now()]); + + $this->assertSame($status, $ticket->status); + $this->assertFalse($ticket->is_valid); + $this->assertFalse($ticket->is_expired); + } + } + + public function test_it_exposes_every_supported_status(): void + { + $this->assertSame([ + Ticket::STATUS_ACTIVE, + Ticket::STATUS_USED, + Ticket::STATUS_EXPIRED, + Ticket::STATUS_DISABLED, + Ticket::STATUS_CANCELLED, + Ticket::STATUS_REFUNDED, + ], Ticket::statuses()); + + $this->assertSame('Inhabilitado', Ticket::statusLabel(Ticket::STATUS_DISABLED)); + $this->assertSame('Cancelado', Ticket::statusLabel(Ticket::STATUS_CANCELLED)); + $this->assertSame('Reembolsado', Ticket::statusLabel(Ticket::STATUS_REFUNDED)); + } + + public function test_refunded_has_priority_when_multiple_state_timestamps_exist(): void + { + $ticket = new Ticket([ + 'disabled_at' => now()->subHours(2), + 'cancelled_at' => now()->subHour(), + 'refunded_at' => now(), + ]); + + $this->assertSame(Ticket::STATUS_REFUNDED, $ticket->status); + } + public function test_all_validity_times_in_the_same_group_must_be_active(): void { Carbon::setTestNow('2026-08-20 13:00:00');