From 31955f862d645d7d62d7b0f168f7d3d4ddbcebac Mon Sep 17 00:00:00 2001 From: ncoronel Date: Thu, 1 Oct 2026 09:20:43 -0300 Subject: [PATCH] feat(sale): implement event scope for sales, totals, and modifications; add tests for event-based access --- .../Services/PurchaseRefundSummaryService.php | 6 +- .../Controllers/AdminApp/SaleController.php | 23 +-- .../Sale/Services/AdminAppSaleService.php | 69 ++++--- .../Commerce/Sale/documentacion/README.md | 4 + .../Sale/AdminAppSaleEventScopeTest.php | 183 ++++++++++++++++++ 5 files changed, 243 insertions(+), 42 deletions(-) create mode 100644 tests/Feature/Sale/AdminAppSaleEventScopeTest.php diff --git a/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php b/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php index 9c113035..dffbb7aa 100644 --- a/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php +++ b/app/Domains/Commerce/Purchase/Services/PurchaseRefundSummaryService.php @@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Builder; class PurchaseRefundSummaryService { - public function totalForTenant(Tenant $tenant): string + public function totalForTenant(Tenant $tenant, ?int $eventId = null): string { $total = TicketRefund::query() ->whereHas( 'purchaseItem.purchase', - fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) + fn (Builder $query): Builder => $query + ->where('tenant_codigo', $tenant->codigo) + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)) ) ->sum('amount'); diff --git a/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php index bb94f0dc..e9cdc514 100644 --- a/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Commerce/Sale/Controllers/AdminApp/SaleController.php @@ -32,10 +32,10 @@ class SaleController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return SaleResource::collection( - $this->saleService->sales($tenant, $request->validated()) + $this->saleService->sales($tenant, $request->validated(), $request->user()->event_id) )->additional([ - 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), - 'refunded_total' => $this->saleService->refundedTotal($tenant), + 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $request->user()->event_id), + 'refunded_total' => $this->saleService->refundedTotal($tenant, $request->user()->event_id), ]); } @@ -43,7 +43,7 @@ class SaleController extends Controller { $tenant = $request->user()->tenant()->firstOrFail(); - return new SaleDetailResource($this->saleService->detail($tenant, $sale)); + return new SaleDetailResource($this->saleService->detail($tenant, $sale, $request->user()->event_id)); } public function tickets(Request $request, int $sale): AnonymousResourceCollection @@ -51,7 +51,7 @@ class SaleController extends Controller $tenant = $request->user()->tenant()->firstOrFail(); return SaleTicketResource::collection( - $this->saleService->tickets($tenant, $sale) + $this->saleService->tickets($tenant, $sale, $request->user()->event_id) ); } @@ -59,14 +59,14 @@ class SaleController extends Controller { $tenant = $request->user()->tenant()->firstOrFail(); - return new SaleResource($this->saleService->confirm($tenant, $sale)); + return new SaleResource($this->saleService->confirm($tenant, $sale, $request->user()->event_id)); } public function cancel(Request $request, int $sale): SaleResource { $tenant = $request->user()->tenant()->firstOrFail(); - return new SaleResource($this->saleService->cancel($tenant, $sale)); + return new SaleResource($this->saleService->cancel($tenant, $sale, $request->user()->event_id)); } public function modifications( @@ -76,6 +76,7 @@ class SaleController extends Controller $this->saleService->modifications( $request->user()->tenant()->firstOrFail(), $request->validated(), + $request->user()->event_id, ) ); } @@ -86,7 +87,7 @@ class SaleController extends Controller return $this->salePdfService->downloadSales( $tenant, - $this->saleService->salesForExport($tenant, $request->validated()), + $this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -97,7 +98,7 @@ class SaleController extends Controller return $this->salePdfService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant, $request->validated()), + $this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -108,7 +109,7 @@ class SaleController extends Controller return $this->saleExcelService->downloadSales( $tenant, - $this->saleService->salesForExport($tenant, $request->validated()), + $this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } @@ -120,7 +121,7 @@ class SaleController extends Controller return $this->saleExcelService->downloadModifications( $tenant, - $this->saleService->modificationsForExport($tenant, $request->validated()), + $this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id), $request->validated('timezone'), ); } diff --git a/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php b/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php index 79a663e5..8265e7c5 100644 --- a/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Commerce/Sale/Services/AdminAppSaleService.php @@ -2,7 +2,6 @@ namespace App\Domains\Commerce\Sale\Services; -use App\Shared\Logging\Models\ValueChange; use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; @@ -10,6 +9,7 @@ use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; +use App\Shared\Logging\Models\ValueChange; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -21,19 +21,18 @@ class AdminAppSaleService protected PurchaseRefundSummaryService $refundSummaryService, ) {} - public function confirmedSalesTotal(Tenant $tenant): string + public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string { - $total = Purchase::query() - ->where('tenant_codigo', $tenant->codigo) + $total = $this->purchasesQuery($tenant, $eventId) ->where('status', Purchase::STATUS_PAID) ->sum('total'); return number_format((float) $total, 2, '.', ''); } - public function refundedTotal(Tenant $tenant): string + public function refundedTotal(Tenant $tenant, ?int $eventId = null): string { - return $this->refundSummaryService->totalForTenant($tenant); + return $this->refundSummaryService->totalForTenant($tenant, $eventId); } /** @@ -47,43 +46,42 @@ class AdminAppSaleService * } $filters * @return LengthAwarePaginator */ - public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator + public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator { - return $this->salesQuery($tenant, $filters) + return $this->salesQuery($tenant, $filters, $eventId) ->paginateFromRequest() ->withQueryString(); } - public function detail(Tenant $tenant, int $saleId): Purchase + public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase { - return Purchase::query() - ->where('tenant_codigo', $tenant->codigo) + return $this->purchasesQuery($tenant, $eventId) ->with('items') ->findOrFail($saleId); } /** @return Collection */ - public function tickets(Tenant $tenant, int $saleId): Collection + public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection { - return $this->findForTenant($tenant, $saleId) + return $this->findForTenant($tenant, $saleId, $eventId) ->tickets() ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->orderBy('id') ->get(); } - public function confirm(Tenant $tenant, int $saleId): Purchase + public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase { - $sale = $this->findForTenant($tenant, $saleId); + $sale = $this->findForTenant($tenant, $saleId, $eventId); return $this->saleForResponse( $this->checkoutService->confirmPaidPurchase($sale) ); } - public function cancel(Tenant $tenant, int $saleId): Purchase + public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase { - $sale = $this->findForTenant($tenant, $saleId); + $sale = $this->findForTenant($tenant, $saleId, $eventId); return $this->saleForResponse( $this->checkoutService->cancelPurchaseFromAdmin($sale) @@ -94,18 +92,18 @@ class AdminAppSaleService * @param array $filters * @return Collection */ - public function salesForExport(Tenant $tenant, array $filters = []): Collection + public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection { - return $this->salesQuery($tenant, $filters)->get(); + return $this->salesQuery($tenant, $filters, $eventId)->get(); } /** * @param array $filters * @return LengthAwarePaginator */ - public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator + public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator { - return $this->modificationsQuery($tenant, $filters) + return $this->modificationsQuery($tenant, $filters, $eventId) ->paginateFromRequest() ->withQueryString(); } @@ -114,13 +112,13 @@ class AdminAppSaleService * @param array $filters * @return Collection */ - public function modificationsForExport(Tenant $tenant, array $filters = []): Collection + public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection { - return $this->modificationsQuery($tenant, $filters)->get(); + return $this->modificationsQuery($tenant, $filters, $eventId)->get(); } /** @param array $filters */ - protected function salesQuery(Tenant $tenant, array $filters): Builder + protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder { $sortColumns = [ 'id' => 'id', @@ -137,8 +135,7 @@ class AdminAppSaleService ? $requestedDirection : 'desc'; - return Purchase::query() - ->where('tenant_codigo', $tenant->codigo) + return $this->purchasesQuery($tenant, $eventId) ->when($filters['q'] ?? null, function (Builder $query, string $search): void { $term = trim($search); @@ -176,11 +173,18 @@ class AdminAppSaleService * @param array $filters * @return Builder */ - protected function modificationsQuery(Tenant $tenant, array $filters): Builder + protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder { return ValueChange::query() ->where('tenant_code', $tenant->codigo) ->where('trackable_type', (new Purchase)->getMorphClass()) + ->when($eventId !== null, fn (Builder $query): Builder => $query->whereHasMorph( + 'trackable', + [Purchase::class], + fn (Builder $sales): Builder => $sales + ->where('tenant_codigo', $tenant->codigo) + ->where('event_id', $eventId), + )) ->when($filters['q'] ?? null, function (Builder $query, string $search): void { $term = trim($search); @@ -221,11 +225,18 @@ class AdminAppSaleService ->orderByDesc('id'); } - protected function findForTenant(Tenant $tenant, int $saleId): Purchase + protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase + { + return $this->purchasesQuery($tenant, $eventId) + ->findOrFail($saleId); + } + + /** @return Builder */ + protected function purchasesQuery(Tenant $tenant, ?int $eventId): Builder { return Purchase::query() ->where('tenant_codigo', $tenant->codigo) - ->findOrFail($saleId); + ->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId)); } protected function saleForResponse(Purchase $sale): Purchase diff --git a/app/Domains/Commerce/Sale/documentacion/README.md b/app/Domains/Commerce/Sale/documentacion/README.md index 2c1b5fe8..9c41bff3 100644 --- a/app/Domains/Commerce/Sale/documentacion/README.md +++ b/app/Domains/Commerce/Sale/documentacion/README.md @@ -29,4 +29,8 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel. +Cuando el usuario autenticado tiene `event_id`, el controlador lo pasa al servicio como alcance obligatorio para ventas, totales, historial y exportaciones. El alcance se combina con el tenant y no se obtiene de los filtros enviados por el cliente. Los administradores sin `event_id` conservan el alcance del tenant. + +El detalle, los tickets de una venta, la confirmación y la cancelación buscan la compra dentro del mismo alcance. Una venta de otro evento o sin evento devuelve 404 para un administrador con `event_id`, antes de ejecutar cualquier acción en `CheckoutService`. + El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. diff --git a/tests/Feature/Sale/AdminAppSaleEventScopeTest.php b/tests/Feature/Sale/AdminAppSaleEventScopeTest.php new file mode 100644 index 00000000..2dfe7f9b --- /dev/null +++ b/tests/Feature/Sale/AdminAppSaleEventScopeTest.php @@ -0,0 +1,183 @@ +id(); + $table->string('codigo'); + }); + Schema::create('users', function (Blueprint $table): void { + $table->id(); + $table->softDeletes(); + }); + Schema::create('compras', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_codigo'); + $table->unsignedBigInteger('event_id')->nullable(); + $table->string('nombre_apellido'); + $table->string('status'); + $table->decimal('total', 12, 2); + $table->timestamps(); + }); + Schema::create('compra_items', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('compra_id'); + $table->integer('cantidad'); + }); + Schema::create('tickets', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('source_purchase_item_id'); + }); + Schema::create('ticket_refunds', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('purchase_item_id'); + $table->decimal('amount', 12, 2); + }); + Schema::create('value_changes', function (Blueprint $table): void { + $table->id(); + $table->string('tenant_code'); + $table->string('trackable_type'); + $table->unsignedBigInteger('trackable_id'); + $table->string('attribute'); + $table->string('old_value'); + $table->string('new_value'); + $table->timestamp('changed_at'); + $table->string('actor_type'); + $table->unsignedBigInteger('user_id')->nullable(); + }); + + DB::table('tenants')->insert(['codigo' => 'onticket']); + foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) { + DB::table('compras')->insert([ + 'id' => $id, + 'tenant_codigo' => $tenant, + 'event_id' => $event, + 'nombre_apellido' => 'Cliente', + 'status' => Purchase::STATUS_PAID, + 'total' => $id * 100, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'cantidad' => 1]); + DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]); + DB::table('value_changes')->insert([ + 'id' => $id, + 'tenant_code' => $tenant, + 'trackable_type' => (new Purchase)->getMorphClass(), + 'trackable_id' => $id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'changed_at' => now(), + 'actor_type' => 'system', + ]); + } + $this->actingAsAdministrator(10); + } + + public function test_list_totals_and_filters_cannot_escape_the_authenticated_event(): void + { + $this->getJson('/api/v1/adminapp/tenant/sales?event_id=20&q=Cliente') + ->assertOk() + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0.id', 1) + ->assertJsonPath('confirmed_sales_total', '100.00') + ->assertJsonPath('refunded_total', '10.00'); + $this->getJson('/api/v1/adminapp/tenant/sales?id=2')->assertOk()->assertJsonCount(0, 'data'); + $this->getJson('/api/v1/adminapp/tenant/sales/modifications?q=Cliente') + ->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.sale_id', 1); + } + + public function test_unscoped_administrators_keep_access_to_all_sales_in_their_tenant(): void + { + $this->actingAsAdministrator(null); + $this->getJson('/api/v1/adminapp/tenant/sales')->assertOk()->assertJsonCount(3, 'data') + ->assertJsonPath('confirmed_sales_total', '600.00')->assertJsonPath('refunded_total', '60.00'); + $this->getJson('/api/v1/adminapp/tenant/sales/modifications')->assertOk()->assertJsonCount(3, 'data'); + $this->getJson('/api/v1/adminapp/tenant/sales/2')->assertOk(); + $this->getJson('/api/v1/adminapp/tenant/sales/3')->assertOk(); + } + + public function test_foreign_and_unassigned_sales_are_inaccessible_before_any_checkout_action(): void + { + $this->mock(CheckoutService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('confirmPaidPurchase'); + $mock->shouldNotReceive('cancelPurchaseFromAdmin'); + }); + foreach ([2, 3, 4] as $id) { + $this->getJson("/api/v1/adminapp/tenant/sales/{$id}")->assertNotFound(); + $this->getJson("/api/v1/adminapp/tenant/sales/{$id}/tickets")->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/sales/{$id}/confirm", ['event_id' => 20])->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/sales/{$id}/cancel", ['event_id' => 20])->assertNotFound(); + } + $this->assertSame(Purchase::STATUS_PAID, DB::table('compras')->where('id', 2)->value('status')); + } + + public function test_own_event_allows_detail_tickets_and_checkout_actions(): void + { + // Empty snapshots keep this fixture focused on authorization. + DB::table('compra_items')->where('compra_id', 1)->delete(); + $this->mock(CheckoutService::class, function (MockInterface $mock): void { + foreach (['confirmPaidPurchase', 'cancelPurchaseFromAdmin'] as $method) { + $mock->shouldReceive($method)->once()->withArgs(fn (Purchase $sale): bool => $sale->id === 1) + ->andReturnUsing(fn (Purchase $sale): Purchase => $sale); + } + }); + $this->getJson('/api/v1/adminapp/tenant/sales/1')->assertOk()->assertJsonPath('data.id', 1); + $this->getJson('/api/v1/adminapp/tenant/sales/1/tickets')->assertOk(); + $this->postJson('/api/v1/adminapp/tenant/sales/1/confirm')->assertOk()->assertJsonPath('data.id', 1); + $this->postJson('/api/v1/adminapp/tenant/sales/1/cancel')->assertOk()->assertJsonPath('data.id', 1); + } + + public function test_pdf_and_excel_exports_only_receive_sales_and_history_for_the_own_event(): void + { + foreach ([AdminAppSalePdfService::class, AdminAppSaleExcelService::class] as $class) { + $this->mock($class, function (MockInterface $mock) use ($class): void { + foreach (['downloadSales', 'downloadModifications'] as $method) { + $mock->shouldReceive($method)->once()->withArgs( + fn ($tenant, Collection $rows, $timezone): bool => $tenant->codigo === 'onticket' + && $rows->pluck('id')->all() === [1] && $timezone === 'UTC' + )->andReturn($class === AdminAppSalePdfService::class + ? response('pdf') : new StreamedResponse(fn () => print ('excel'))); + } + }); + } + foreach (['pdf', 'excel', 'modifications/pdf', 'modifications/excel'] as $path) { + $this->getJson("/api/v1/adminapp/tenant/sales/{$path}?timezone=UTC&event_id=20")->assertOk(); + } + } + + private function actingAsAdministrator(?int $eventId): void + { + $user = new User; + $user->setRawAttributes([ + 'id' => 1, + 'rol_codigo' => RoleCode::AdminApp->value, + 'tenant_codigo' => 'onticket', + 'event_id' => $eventId, + ]); + Sanctum::actingAs($user); + } +}