diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 6b63322..7e2c4b3 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -6,6 +6,7 @@ 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\AdminAppTicketRefundCalculationResource; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource; use App\Domains\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticket\Services\AdminAppTicketPdfService; @@ -39,6 +40,15 @@ class TicketController extends Controller return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket)); } + public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new AdminAppTicketRefundCalculationResource( + $this->ticketService->calculateRefund($tenant, $ticket) + ); + } + public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource { $tenant = $request->user()->tenant()->firstOrFail(); diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php new file mode 100644 index 0000000..40ebac4 --- /dev/null +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketRefundCalculationResource.php @@ -0,0 +1,26 @@ + $this->resource['total'], + 'partial' => $this->resource['partial'], + ]; + } +} diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 55d3b80..3717500 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -45,20 +45,29 @@ class AdminAppTicketService ->get(); $matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters); $tickets = $this->paginate($matchingTickets, $filters); - $scannedTickets = $matchingTickets->whereNotNull('used_at')->count(); + $scannedTickets = $matchingTickets + ->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_USED) + ->count(); + $activeTickets = $matchingTickets + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->count(); + $totalTickets = $activeTickets + $scannedTickets; } else { $tickets = (clone $query) ->with(self::RELATIONS) ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->paginateFromRequest() ->withQueryString(); - $scannedTickets = $countQuery->whereNotNull('used_at')->count(); + + $counts = $this->calculateTicketCounts($countQuery); + $scannedTickets = $counts['scanned']; + $totalTickets = $counts['total']; } return new AdminAppTicketResult( tickets: $tickets, scannedTickets: $scannedTickets, - totalTickets: $tickets->total(), + totalTickets: $totalTickets, ); } @@ -99,6 +108,57 @@ class AdminAppTicketService }); } + /** + * @return array{ + * total: string|null, + * partial: string|null, + * } + */ + public function calculateRefund(Tenant $tenant, int $ticketId): array + { + $ticket = Ticket::query() + ->where('tenant_code', $tenant->codigo) + ->findOrFail($ticketId); + + if (! $ticket->can_refund()) { + throw ValidationException::withMessages([ + 'refund' => 'El reembolso no está disponible para este ticket.', + ]); + } + + $purchaseItem = PurchaseItem::query() + ->find($ticket->source_purchase_item_id); + + if ($purchaseItem === null) { + throw ValidationException::withMessages([ + 'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.', + ]); + } + + $unitPrice = (float) $purchaseItem->precio_unitario; + $itemTotal = (float) $purchaseItem->total; + $itemRefundedAmount = (float) ($purchaseItem->refunded_amount ?? 0); + $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); + + $total = null; + if ($tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { + $total = number_format($unitPrice, 2, '.', ''); + } + + $partial = null; + if ($tenant->allow_partial_refund()) { + $partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial'); + if ($partialAmount <= $remainingItemAmount) { + $partial = number_format($partialAmount, 2, '.', ''); + } + } + + return [ + 'total' => $total, + 'partial' => $partial, + ]; + } + public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket { $this->ensureRefundIsAllowed($tenant, $refundType); @@ -398,6 +458,35 @@ class AdminAppTicketService $query->whereIn('tickets.id', $matchingIds); } + /** + * @param Builder $countQuery + * @return array{scanned: int, total: int} + */ + private function calculateTicketCounts(Builder $countQuery): array + { + $scannedTickets = (clone $countQuery) + ->whereNotNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->count(); + + $activeTickets = (clone $countQuery) + ->whereNull('used_at') + ->whereNull('disabled_at') + ->whereNull('cancelled_at') + ->whereNull('refunded_at') + ->with(TicketValidityResolver::RELATIONS) + ->get() + ->filter(fn (Ticket $ticket): bool => $ticket->is_active()) + ->count(); + + return [ + 'scanned' => $scannedTickets, + 'total' => $activeTickets + $scannedTickets, + ]; + } + private function normalizedCategory(string $category): string { return mb_strtolower(trim($category)); diff --git a/app/Domains/Ticket/routes/adminapp.php b/app/Domains/Ticket/routes/adminapp.php index d01e8d1..66abe50 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::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund']) + ->whereNumber('ticket') + ->middleware('tenant.menu:adminapp.tickets') + ->name('adminapp.tickets.calculate-refund'); Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund']) ->whereNumber('ticket') ->middleware('tenant.menu:adminapp.tickets') diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 284ea5f..f7b5ee4 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -235,6 +235,101 @@ class AdminAppTicketControllerTest extends TestCase ->assertJsonValidationErrors('refund_type'); } + public function test_it_calculates_total_and_partial_refund_for_a_ticket(): void + { + $tenant = $this->createTenant('ticket-calc-both'); + $tenant->update([ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 30.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + ->assertJsonPath('data.total', '100.00') + ->assertJsonPath('data.partial', '30.00'); + } + + public function test_it_calculates_only_total_when_partial_is_disabled(): void + { + $tenant = $this->createTenant('ticket-calc-total'); + $tenant->update([ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => false, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '150.00'); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + ->assertJsonPath('data.total', '150.00') + ->assertJsonPath('data.partial', null); + } + + public function test_it_returns_null_when_remaining_item_balance_is_insufficient(): void + { + $tenant = $this->createTenant('ticket-calc-insufficient'); + $tenant->update([ + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 50.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + // Simulate 70 already refunded out of 100 on the item (remaining is 30) + $purchaseItem->update(['refunded_amount' => '70.00']); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertOk() + // Total is 100, which exceeds remaining 30 -> total is null + ->assertJsonPath('data.total', null) + // Partial is 50, which exceeds remaining 30 -> partial is null + ->assertJsonPath('data.partial', null); + } + + public function test_it_fails_calculating_refund_if_ticket_is_not_active(): void + { + $tenant = $this->createTenant('ticket-calc-inactive'); + $tenant->update(['allow_ticket_total_refund' => true]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$ticket] = $this->createRefundableTicket($tenant, $admin, '100.00'); + + $ticket->update(['cancelled_at' => now()]); + + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticket->id}/refund") + ->assertUnprocessable() + ->assertJsonValidationErrors('refund'); + } + + public function test_it_cannot_calculate_refund_for_another_tenants_ticket(): void + { + $tenantA = $this->createTenant('ticket-calc-a'); + $tenantB = $this->createTenant('ticket-calc-b'); + $tenantA->update(['allow_ticket_total_refund' => true]); + $tenantB->update(['allow_ticket_total_refund' => true]); + + $adminA = $this->createAdminAppUser($tenantA); + $adminB = $this->createAdminAppUser($tenantB); + $this->grantTicketsMenu($tenantA); + + [$ticketB] = $this->createRefundableTicket($tenantB, $adminB, '100.00'); + + Sanctum::actingAs($adminA); + $this->getJson("/api/v1/adminapp/tenant/tickets/{$ticketB->id}/refund") + ->assertNotFound(); + } + public function test_it_searches_by_id_and_does_not_search_by_uuid(): void { $tenant = $this->createTenant('fiesta_futbol_infantil'); @@ -410,6 +505,9 @@ class AdminAppTicketControllerTest extends TestCase $this->createTicket($tenant, $admin)->update(['used_at' => now()]); $this->createTicket($tenant, $admin); + $this->createTicket($tenant, $admin)->update(['cancelled_at' => now()]); + $this->createTicket($tenant, $admin)->update(['refunded_at' => now()]); + $this->createTicket($tenant, $admin)->update(['disabled_at' => now()]); $this->createTicket($otherTenant, $otherUser)->update(['used_at' => now()]); $this->getJson('/api/v1/adminapp/tenant/tickets?q=does-not-match') @@ -420,6 +518,7 @@ class AdminAppTicketControllerTest extends TestCase $this->getJson('/api/v1/adminapp/tenant/tickets') ->assertOk() + ->assertJsonCount(5, 'data') ->assertJsonPath('scanned_tickets', 1) ->assertJsonPath('total_tickets', 2); }