feat(ticket): add refundable terminal states
This commit is contained in:
parent
1564985259
commit
18f739b712
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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'])]
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue