feat(ticket): implement refund functionality and expose allow_refund flag

This commit is contained in:
2026-09-10 17:05:57 -03:00
parent 210c854fee
commit 5d00dc439e
8 changed files with 273 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Ticket\Controllers\AdminApp;
use App\Domains\Ticket\Requests\AdminAppTicketExportRequest;
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
use App\Domains\Ticket\Requests\AdminAppTicketRefundRequest;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource;
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
@@ -38,6 +39,15 @@ class TicketController extends Controller
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
}
public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource(
$this->ticketService->refund($tenant, $ticket, $request->validated('refund_type'))
);
}
public function downloadPdf(AdminAppTicketExportRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();

View File

@@ -135,6 +135,21 @@ class Ticket extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
public function allow_refund(): bool
{
return $this->tenant?->allow_refund() ?? false;
}
public function allowRefund(): bool
{
return $this->allow_refund();
}
public function getAllowRefundAttribute(): bool
{
return $this->allow_refund();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppTicketRefundRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string|object>> */
public function rules(): array
{
return [
'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])],
];
}
}

View File

@@ -19,6 +19,7 @@ class AdminAppTicketResource extends TicketResource
return [
...parent::toArray($request),
...$details,
'allow_refund' => $this->resource->allow_refund(),
'values' => $rowService->values($this->resource, $details),
];
}

View File

@@ -36,6 +36,7 @@ class AdminAppTicketRowService
'status' => $ticket->status,
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
'variant_properties' => $this->variantProperties($ticket),
'allow_refund' => $ticket->allow_refund(),
];
}

View File

@@ -9,12 +9,15 @@ use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AdminAppTicketService
{
private const RELATIONS = [
...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS,
'tenant',
'user',
'scannerUser',
'sourceCatalogItem.category',
@@ -87,6 +90,70 @@ class AdminAppTicketService
return $ticket->refresh()->load(self::RELATIONS);
}
public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket
{
$this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType): Ticket {
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->findOrFail($ticketId);
$purchaseItem = PurchaseItem::query()
->lockForUpdate()
->find($ticket->source_purchase_item_id);
if ($purchaseItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.',
]);
}
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType);
$refundedAmount = round((float) $purchaseItem->refunded_amount + $refundAmount, 2);
if ($refundedAmount > (float) $purchaseItem->total) {
throw ValidationException::withMessages([
'refund_type' => 'El importe reembolsado no puede superar el total del ítem de compra.',
]);
}
$ticket->markAsRefunded();
$ticket->save();
$purchaseItem->update([
'refunded_amount' => number_format($refundedAmount, 2, '.', ''),
]);
return $ticket->refresh()->load(self::RELATIONS);
});
}
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void
{
$isAllowed = match ($refundType) {
'partial' => $tenant->allow_partial_refund(),
'total' => (bool) $tenant->allow_ticket_total_refund,
};
if (! $isAllowed) {
throw ValidationException::withMessages([
'refund_type' => 'El tipo de reembolso solicitado no está habilitado para este tenant.',
]);
}
}
private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float
{
$ticketAmount = (float) $purchaseItem->precio_unitario;
return match ($refundType) {
'partial' => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2),
'total' => $ticketAmount,
};
}
/**
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
* @return Builder<Ticket>

View File

@@ -13,6 +13,10 @@ Route::prefix('v1/adminapp/tenant')
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.cancel');
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.refund');
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.pdf');

View File

@@ -73,6 +73,7 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('data.0.values.id', $ticket->id)
->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE)
->assertJsonPath('data.0.status_label', 'Activo')
->assertJsonPath('data.0.allow_refund', false)
->assertJsonMissingPath('data.0.values.ticket')
->assertJsonPath('data.0.values.date', '-')
->assertJsonPath('data.0.values.size', '-')
@@ -80,6 +81,45 @@ class AdminAppTicketControllerTest extends TestCase
->assertJsonPath('meta.total', 1);
}
public function test_it_exposes_allow_refund_flag_in_tickets_list(): void
{
$tenant = $this->createTenant('ticket-allow-refund');
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
$this->createTicket($tenant, $admin);
// Default: neither total nor partial refund allowed
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.allow_refund', false);
// Total refund allowed
$tenant->update(['allow_ticket_total_refund' => true]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.allow_refund', true);
// Partial refund allowed with percentage set
$tenant->update([
'allow_ticket_total_refund' => false,
'allow_ticket_partial_refund' => true,
'ticket_partial_refund_percentage' => 20.00,
]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.allow_refund', true);
// Partial refund enabled but percentage is 0
$tenant->update([
'ticket_partial_refund_percentage' => 0,
]);
$this->getJson('/api/v1/adminapp/tenant/tickets')
->assertOk()
->assertJsonPath('data.0.allow_refund', false);
}
public function test_it_cancels_a_ticket_from_the_authenticated_tenant(): void
{
$tenant = $this->createTenant('ticket-cancellation');
@@ -112,6 +152,82 @@ class AdminAppTicketControllerTest extends TestCase
$this->assertNull($ticket->fresh()->cancelled_at);
}
public function test_it_totally_refunds_a_ticket_when_the_tenant_allows_it(): void
{
$tenant = $this->createTenant('ticket-total-refund');
$tenant->update(['allow_ticket_total_refund' => true]);
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'total',
])
->assertOk()
->assertJsonPath('data.id', $ticket->id)
->assertJsonPath('data.status', Ticket::STATUS_REFUNDED)
->assertJsonPath('data.refunded_amount', '100.00');
$this->assertNotNull($ticket->fresh()->refunded_at);
$this->assertSame('100.00', $purchaseItem->fresh()->refunded_amount);
}
public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void
{
$tenant = $this->createTenant('ticket-partial-refund');
$tenant->update([
'allow_ticket_partial_refund' => true,
'ticket_partial_refund_percentage' => 25.50,
]);
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'partial',
])
->assertOk()
->assertJsonPath('data.status', Ticket::STATUS_REFUNDED)
->assertJsonPath('data.refunded_amount', '25.50');
$this->assertSame('25.50', $purchaseItem->fresh()->refunded_amount);
}
public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void
{
$tenant = $this->createTenant('ticket-refund-disabled');
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
[$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00');
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'total',
])
->assertUnprocessable()
->assertJsonValidationErrors('refund_type');
$this->assertNull($ticket->fresh()->refunded_at);
$this->assertSame('0.00', $purchaseItem->fresh()->refunded_amount);
}
public function test_it_validates_the_refund_type(): void
{
$tenant = $this->createTenant('ticket-refund-validation');
$admin = $this->createAdminAppUser($tenant);
$this->grantTicketsMenu($tenant);
Sanctum::actingAs($admin);
[$ticket] = $this->createRefundableTicket($tenant, $admin, '100.00');
$this->postJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund", [
'refund_type' => 'invalid',
])
->assertUnprocessable()
->assertJsonValidationErrors('refund_type');
}
public function test_it_searches_by_id_and_does_not_search_by_uuid(): void
{
$tenant = $this->createTenant('fiesta_futbol_infantil');
@@ -659,6 +775,43 @@ class AdminAppTicketControllerTest extends TestCase
]);
}
/** @return array{Ticket, PurchaseItem} */
private function createRefundableTicket(Tenant $tenant, User $admin, string $amount): array
{
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'ticket-reembolsable-'.Str::uuid(),
'nombre' => 'Ticket reembolsable',
'precio' => $amount,
]);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $admin->id,
'status' => Purchase::STATUS_PAID,
'nombre_apellido' => $admin->nombre_apellido,
'total' => $amount,
]);
$purchaseItem = PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'nombre' => 'Ticket reembolsable',
'descripcion' => '',
'slug' => 'ticket-reembolsable',
'item_nombre' => 'Ticket reembolsable',
'cantidad' => 1,
'precio_unitario' => $amount,
'total' => $amount,
]);
return [
$this->createTicket($tenant, $admin, [
'source_purchase_item_id' => $purchaseItem->id,
'source_catalog_item_id' => $catalogItem->id,
]),
$purchaseItem,
];
}
private function grantTicketsMenu(Tenant $tenant): void
{
$menu = Menu::query()->create([