feat(ticket): add refundable terminal states

This commit is contained in:
ncoronel 2026-09-10 16:10:08 -03:00
parent 1564985259
commit 18f739b712
5 changed files with 208 additions and 3 deletions

View File

@ -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',
];
}

View File

@ -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<string> */
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<string> */
public static function statuses(): array
{
return array_keys(self::statusLabels());
}
/** @return array<string, string> */
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<array{value: string, label: string}> */
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<Tenant, $this> */
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);

View File

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('tickets', function (Blueprint $table): void {
$table->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',
]);
});
}
};

View File

@ -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'])]

View File

@ -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');