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\AdminAppTicketExportRequest;
|
||||||
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
|
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\AdminAppTicketCollection;
|
||||||
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource;
|
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource;
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
|
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
|
||||||
@@ -38,6 +39,15 @@ class TicketController extends Controller
|
|||||||
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
|
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
|
public function downloadPdf(AdminAppTicketExportRequest $request): Response
|
||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|||||||
@@ -135,6 +135,21 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
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> */
|
/** @return BelongsTo<User, $this> */
|
||||||
public function user(): BelongsTo
|
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 [
|
return [
|
||||||
...parent::toArray($request),
|
...parent::toArray($request),
|
||||||
...$details,
|
...$details,
|
||||||
|
'allow_refund' => $this->resource->allow_refund(),
|
||||||
'values' => $rowService->values($this->resource, $details),
|
'values' => $rowService->values($this->resource, $details),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class AdminAppTicketRowService
|
|||||||
'status' => $ticket->status,
|
'status' => $ticket->status,
|
||||||
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
|
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
|
||||||
'variant_properties' => $this->variantProperties($ticket),
|
'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\Database\Eloquent\Builder;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AdminAppTicketService
|
class AdminAppTicketService
|
||||||
{
|
{
|
||||||
private const RELATIONS = [
|
private const RELATIONS = [
|
||||||
...TicketValidityResolver::RELATIONS,
|
...TicketValidityResolver::RELATIONS,
|
||||||
...TicketPresentationResolver::RELATIONS,
|
...TicketPresentationResolver::RELATIONS,
|
||||||
|
'tenant',
|
||||||
'user',
|
'user',
|
||||||
'scannerUser',
|
'scannerUser',
|
||||||
'sourceCatalogItem.category',
|
'sourceCatalogItem.category',
|
||||||
@@ -87,6 +90,70 @@ class AdminAppTicketService
|
|||||||
return $ticket->refresh()->load(self::RELATIONS);
|
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
|
* @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>
|
* @return Builder<Ticket>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.cancel');
|
->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'])
|
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.pdf');
|
->name('adminapp.tickets.pdf');
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.0.values.id', $ticket->id)
|
->assertJsonPath('data.0.values.id', $ticket->id)
|
||||||
->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE)
|
->assertJsonPath('data.0.values.status', Ticket::STATUS_ACTIVE)
|
||||||
->assertJsonPath('data.0.status_label', 'Activo')
|
->assertJsonPath('data.0.status_label', 'Activo')
|
||||||
|
->assertJsonPath('data.0.allow_refund', false)
|
||||||
->assertJsonMissingPath('data.0.values.ticket')
|
->assertJsonMissingPath('data.0.values.ticket')
|
||||||
->assertJsonPath('data.0.values.date', '-')
|
->assertJsonPath('data.0.values.date', '-')
|
||||||
->assertJsonPath('data.0.values.size', '-')
|
->assertJsonPath('data.0.values.size', '-')
|
||||||
@@ -80,6 +81,45 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
->assertJsonPath('meta.total', 1);
|
->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
|
public function test_it_cancels_a_ticket_from_the_authenticated_tenant(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('ticket-cancellation');
|
$tenant = $this->createTenant('ticket-cancellation');
|
||||||
@@ -112,6 +152,82 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
$this->assertNull($ticket->fresh()->cancelled_at);
|
$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
|
public function test_it_searches_by_id_and_does_not_search_by_uuid(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
$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
|
private function grantTicketsMenu(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
|
|||||||
Reference in New Issue
Block a user