feat(ticket): add refund calculation functionality and corresponding tests

This commit is contained in:
ncoronel 2026-09-11 10:43:03 -03:00
parent 6384c0046d
commit 4abb6c67fd
5 changed files with 231 additions and 3 deletions

View File

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

View File

@ -0,0 +1,26 @@
<?php
namespace App\Domains\Ticket\Resources\AdminApp;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property-read array{
* total: string|null,
* partial: string|null,
* } $resource
*/
class AdminAppTicketRefundCalculationResource extends JsonResource
{
/**
* @return array{total: string|null, partial: string|null}
*/
public function toArray(Request $request): array
{
return [
'total' => $this->resource['total'],
'partial' => $this->resource['partial'],
];
}
}

View File

@ -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<Ticket> $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));

View File

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

View File

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