diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index d329a96..4003634 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -67,7 +67,7 @@ class AdminAppSaleService { return $this->findForTenant($tenant, $saleId) ->tickets() - ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS]) + ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->orderBy('id') ->get(); } diff --git a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php index 7e2c4b3..d041fe6 100644 --- a/app/Domains/Ticket/Controllers/AdminApp/TicketController.php +++ b/app/Domains/Ticket/Controllers/AdminApp/TicketController.php @@ -54,7 +54,12 @@ class TicketController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return new AdminAppTicketResource( - $this->ticketService->refund($tenant, $ticket, $request->validated('refund_type')) + $this->ticketService->refund( + $tenant, + $ticket, + $request->validated('refund_type'), + $request->user(), + ) ); } diff --git a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php index c542516..4de9323 100644 --- a/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php +++ b/app/Domains/Ticket/Requests/AdminAppTicketRefundRequest.php @@ -2,6 +2,7 @@ namespace App\Domains\Ticket\Requests; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -16,7 +17,7 @@ class AdminAppTicketRefundRequest extends FormRequest public function rules(): array { return [ - 'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])], + 'refund_type' => ['required', 'string', Rule::in(TicketRefund::types())], ]; } } diff --git a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php index cae2bd1..4205f4e 100644 --- a/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php +++ b/app/Domains/Ticket/Resources/AdminApp/AdminAppTicketResource.php @@ -23,6 +23,13 @@ class AdminAppTicketResource extends TicketResource 'is_active' => $this->resource->is_active(), 'can_cancel' => $this->resource->can_cancel(), 'can_refund' => $this->resource->can_refund(), + 'refund' => $this->resource->refund === null ? null : [ + 'type' => $this->resource->refund->type, + 'type_label' => $this->resource->refund->typeLabel(), + 'amount' => $this->resource->refund->amount, + 'created_at' => $this->resource->refund->created_at, + 'created_by' => $this->resource->refund->createdBy?->nombre_apellido, + ], 'values' => $rowService->values($this->resource, $details), ]; } diff --git a/app/Domains/Ticket/Resources/TicketResource.php b/app/Domains/Ticket/Resources/TicketResource.php index d45a3eb..16f7531 100644 --- a/app/Domains/Ticket/Resources/TicketResource.php +++ b/app/Domains/Ticket/Resources/TicketResource.php @@ -18,6 +18,12 @@ class TicketResource extends JsonResource 'ticket' => $this->ticket, 'status' => $this->status, 'status_label' => $this->status_label, + 'refund' => $this->whenLoaded('refund', fn (): ?array => $this->refund === null ? null : [ + 'type' => $this->refund->type, + 'type_label' => $this->refund->typeLabel(), + 'amount' => $this->refund->amount, + 'created_at' => $this->refund->created_at, + ]), 'name' => $this->name, 'description' => $this->description, 'client' => $this->user?->nombre_apellido, diff --git a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php index c34496e..d89024f 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketExcelService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketExcelService.php @@ -41,7 +41,9 @@ class AdminAppTicketExcelService $row = $index + 2; foreach ($columns as $columnIndex => $column) { $coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row; - $value = $ticket[$column['key']] ?? null; + $value = $column['type'] === 'status' + ? ($ticket['status_label'] ?? $ticket[$column['key']] ?? null) + : ($ticket[$column['key']] ?? null); if ($column['type'] === 'currency' && $value !== null) { $sheet->setCellValue($coordinate, (float) $value); diff --git a/app/Domains/Ticket/Services/AdminAppTicketRowService.php b/app/Domains/Ticket/Services/AdminAppTicketRowService.php index 457acfc..7b8b8a6 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketRowService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketRowService.php @@ -23,6 +23,7 @@ class AdminAppTicketRowService public function details(Ticket $ticket): array { $purchaseItem = $ticket->sourcePurchaseItem; + $refund = $ticket->refund; return [ 'source_purchase_item_id' => $ticket->source_purchase_item_id, @@ -31,7 +32,9 @@ class AdminAppTicketRowService ?? $ticket->sourceCatalogItem?->nombre ?? $ticket->name, 'amount' => $purchaseItem?->precio_unitario, - 'refunded_amount' => $purchaseItem?->refunded_amount, + 'refunded_amount' => $refund?->amount ?? $purchaseItem?->refunded_amount, + 'refund_type' => $refund?->type, + 'refund_type_label' => $refund?->typeLabel(), 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, 'status' => $ticket->status, 'scanned_by' => $ticket->scannerUser?->nombre_apellido, @@ -57,6 +60,7 @@ class AdminAppTicketRowService 'client' => $details['client'] ?? 'Sin nombre', 'id' => $ticket->id, 'status' => $details['status'], + 'status_label' => $ticket->status_label, 'scanned_by' => $details['scanned_by'] ?? '-', ]; } @@ -80,7 +84,9 @@ class AdminAppTicketRowService return $rows->map(fn (array $row): array => collect($columns) ->mapWithKeys(fn (array $column): array => [ $column['key'] => $this->displayValue( - $row[$column['key']] ?? null, + $column['type'] === 'status' + ? ($row['status_label'] ?? $row[$column['key']] ?? null) + : ($row[$column['key']] ?? null), $column['type'], $timeZone, ), diff --git a/app/Domains/Ticket/Services/AdminAppTicketService.php b/app/Domains/Ticket/Services/AdminAppTicketService.php index 64eb532..19cac45 100644 --- a/app/Domains/Ticket/Services/AdminAppTicketService.php +++ b/app/Domains/Ticket/Services/AdminAppTicketService.php @@ -7,6 +7,7 @@ use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Tenant\Models\Tenant; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -23,6 +24,7 @@ class AdminAppTicketService 'scannerUser', 'sourceCatalogItem.category', 'sourcePurchaseItem.purchase', + 'refund.createdBy', ]; public function __construct( @@ -162,11 +164,15 @@ class AdminAppTicketService ]; } - public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket - { + public function refund( + Tenant $tenant, + int $ticketId, + string $refundType, + ?User $createdBy = null, + ): Ticket { $this->ensureRefundIsAllowed($tenant, $refundType); - return DB::transaction(function () use ($tenant, $ticketId, $refundType): Ticket { + return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket { $ticket = Ticket::query() ->where('tenant_code', $tenant->codigo) ->lockForUpdate() @@ -206,6 +212,14 @@ class AdminAppTicketService $ticket->markAsRefunded(); $ticket->save(); + TicketRefund::query()->create([ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $createdBy?->id, + 'type' => $refundType, + 'amount' => number_format($refundAmount, 2, '.', ''), + ]); + $purchaseItem->update([ 'refunded_amount' => number_format($refundedAmount, 2, '.', ''), ]); @@ -217,8 +231,8 @@ class AdminAppTicketService private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void { $isAllowed = match ($refundType) { - 'partial' => $tenant->allow_refund() && $tenant->allow_partial_refund(), - 'total' => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, + TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(), + TicketRefund::TYPE_TOTAL => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund, }; if (! $isAllowed) { @@ -233,8 +247,8 @@ class AdminAppTicketService $ticketAmount = (float) $purchaseItem->precio_unitario; return match ($refundType) { - 'partial' => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), - 'total' => $ticketAmount, + TicketRefund::TYPE_PARTIAL => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2), + TicketRefund::TYPE_TOTAL => $ticketAmount, }; } diff --git a/tests/Feature/Ticket/AdminAppTicketControllerTest.php b/tests/Feature/Ticket/AdminAppTicketControllerTest.php index 1887e4e..89adb78 100644 --- a/tests/Feature/Ticket/AdminAppTicketControllerTest.php +++ b/tests/Feature/Ticket/AdminAppTicketControllerTest.php @@ -17,6 +17,7 @@ use App\Domains\Shared\Enums\FieldType; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use App\Domains\Ticket\Models\Ticket; +use App\Domains\Ticket\Models\TicketRefund; use Barryvdh\DomPDF\ServiceProvider as DomPdfServiceProvider; use Database\Seeders\AttributeSeeder; use Database\Seeders\AuthorizationSeeder; @@ -192,10 +193,22 @@ class AdminAppTicketControllerTest extends TestCase ->assertOk() ->assertJsonPath('data.id', $ticket->id) ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) - ->assertJsonPath('data.refunded_amount', '100.00'); + ->assertJsonPath('data.status_label', 'Reembolso total') + ->assertJsonPath('data.refunded_amount', '100.00') + ->assertJsonPath('data.refund.type', TicketRefund::TYPE_TOTAL) + ->assertJsonPath('data.refund.type_label', 'Reembolso total') + ->assertJsonPath('data.refund.amount', '100.00') + ->assertJsonPath('data.refund.created_by', $admin->nombre_apellido); $this->assertNotNull($ticket->fresh()->refunded_at); $this->assertSame('100.00', $purchaseItem->fresh()->refunded_amount); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $ticket->id, + 'purchase_item_id' => $purchaseItem->id, + 'created_by_user_id' => $admin->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); } public function test_it_partially_refunds_a_ticket_using_the_tenant_percentage(): void @@ -216,9 +229,58 @@ class AdminAppTicketControllerTest extends TestCase ]) ->assertOk() ->assertJsonPath('data.status', Ticket::STATUS_REFUNDED) - ->assertJsonPath('data.refunded_amount', '25.50'); + ->assertJsonPath('data.status_label', 'Reembolso parcial') + ->assertJsonPath('data.refunded_amount', '25.50') + ->assertJsonPath('data.refund.type', TicketRefund::TYPE_PARTIAL) + ->assertJsonPath('data.refund.amount', '25.50'); $this->assertSame('25.50', $purchaseItem->fresh()->refunded_amount); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $ticket->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '25.50', + ]); + } + + public function test_it_records_different_refund_types_for_tickets_from_the_same_purchase_item(): void + { + $tenant = $this->createTenant('ticket-mixed-refunds'); + $tenant->update([ + 'allow_ticket_refund' => true, + 'allow_ticket_total_refund' => true, + 'allow_ticket_partial_refund' => true, + 'ticket_partial_refund_percentage' => 25.00, + ]); + $admin = $this->createAdminAppUser($tenant); + $this->grantTicketsMenu($tenant); + Sanctum::actingAs($admin); + [$partialTicket, $purchaseItem] = $this->createRefundableTicket($tenant, $admin, '100.00'); + $purchaseItem->update(['cantidad' => 2, 'total' => '200.00']); + $purchaseItem->purchase->update(['total' => '200.00']); + $totalTicket = $this->createTicket($tenant, $admin, [ + 'source_purchase_item_id' => $purchaseItem->id, + 'source_catalog_item_id' => $partialTicket->source_catalog_item_id, + ]); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$partialTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_PARTIAL, + ])->assertOk()->assertJsonPath('data.status_label', 'Reembolso parcial'); + + $this->postJson("/api/v1/adminapp/tenant/tickets/{$totalTicket->id}/refund", [ + 'refund_type' => TicketRefund::TYPE_TOTAL, + ])->assertOk()->assertJsonPath('data.status_label', 'Reembolso total'); + + $this->assertSame('125.00', $purchaseItem->fresh()->refunded_amount); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $partialTicket->id, + 'type' => TicketRefund::TYPE_PARTIAL, + 'amount' => '25.00', + ]); + $this->assertDatabaseHas('ticket_refunds', [ + 'ticket_id' => $totalTicket->id, + 'type' => TicketRefund::TYPE_TOTAL, + 'amount' => '100.00', + ]); } public function test_it_does_not_refund_a_ticket_when_the_requested_refund_type_is_disabled(): void diff --git a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php index 0bf122e..fd352b5 100644 --- a/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php +++ b/tests/Unit/Ticket/AdminAppTicketExportServiceTest.php @@ -98,6 +98,21 @@ class AdminAppTicketExportServiceTest extends TestCase $this->assertStringContainsString('Vianda', $html); } + public function test_it_uses_the_refund_type_label_when_displaying_a_refunded_status(): void + { + $row = $this->row(); + $row['status'] = 'refunded'; + $row['status_label'] = 'Reembolso parcial'; + + $displayRow = (new AdminAppTicketRowService)->displayRows( + collect([$row]), + $this->columnService()->columns($this->tenant()), + 'America/La_Paz', + )->first(); + + $this->assertSame('Reembolso parcial', $displayRow['status']); + } + private function reportService(): AdminAppTicketReportService { $rowService = Mockery::mock(AdminAppTicketRowService::class)->makePartial();