feat(ticket): implement refund functionality and expose allow_refund flag
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
22
app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php
Normal file
22
app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php
Normal 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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user