From 5d00dc439e143e48e96dd0a1b4080e08121f6801 Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 10 Sep 2026 17:05:57 -0300 Subject: [PATCH] feat(ticket): implement refund functionality and expose allow_refund flag --- .../Controllers/AdminApp/TicketController.php | 10 ++ app/Domains/Ticket/Models/Ticket.php | 15 ++ .../Requests/AdminAppTicketRefundRequest.php | 22 +++ .../AdminApp/AdminAppTicketResource.php | 1 + .../Services/AdminAppTicketRowService.php | 1 + .../Ticket/Services/AdminAppTicketService.php | 67 ++++++++ app/Domains/Ticket/routes/adminapp.php | 4 + .../Ticket/AdminAppTicketControllerTest.php | 153 ++++++++++++++++++ 8 files changed, 273 insertions(+) create mode 100644 app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 35e174e..6b63322 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -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(); diff --git a/app/Domains/Ticket/Models/Ticket.php b/app/Domains/Ticket/Models/Ticket.php index 899196f..f93fac1 100644 --- a/app/Domains/Ticket/Models/Ticket.php +++ b/app/Domains/Ticket/Models/Ticket.php @@ -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 */ public function user(): BelongsTo { diff --git a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php new file mode 100644 index 0000000..c542516 --- /dev/null +++ b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])], + ]; + } +} diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index feda27c..bef2479 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -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), ]; } diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index 31b5a7c..457acfc 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -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(), ]; } diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index ebf8f9f..4f54759 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -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 diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index 0393ee4..d01e8d1 100644 --- a/app/Domains/Ticket/routes/adminapp.php +++ b/app/Domains/Ticket/routes/adminapp.php @@ -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'); diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index b8bc401..a959bba 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -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([