feat(ticket): add refund calculation functionality and corresponding tests
This commit is contained in:
parent
6384c0046d
commit
4abb6c67fd
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue