diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 008faf5..821ccee 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -127,8 +127,7 @@ class TelepagosWebhookService DB::transaction(function () use ($compra, $paymentData) { TelepagosPayment::create($paymentData); - $this->checkoutService->confirmPurchase($compra); - $compra->markAsPaid(); + $this->checkoutService->confirmPaidPurchase($compra); }); Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}"); diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index 24d8712..aa0ce9f 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -18,12 +18,17 @@ class ReleaseCheckoutService public function cancel(Purchase $purchase): Purchase { - return $this->release($purchase, Purchase::STATUS_CANCELLED); + return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true); + } + + public function cancelWithoutRestoringCart(Purchase $purchase): Purchase + { + return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false); } public function expire(Purchase $purchase): Purchase { - return $this->release($purchase, Purchase::STATUS_EXPIRED); + return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: true); } public function expireOverdue(): int @@ -46,9 +51,9 @@ class ReleaseCheckoutService return $expiredCount; } - private function release(Purchase $purchase, string $targetStatus): Purchase + private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase { - return DB::transaction(function () use ($purchase, $targetStatus): Purchase { + return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase { $purchase = $this->lockPurchase($purchase); if ($purchase->status === Purchase::STATUS_PAID) { @@ -76,7 +81,7 @@ class ReleaseCheckoutService ->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE) ->lockForUpdate() ->get(); - $reservationReturnedToCart = $this->sourceCart->restore($purchase); + $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); foreach ($items as $item) { if (! $reservationReturnedToCart) { diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 1119191..b575acd 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -9,6 +9,7 @@ use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Tenant\Models\Tenant; +use Illuminate\Support\Facades\DB; /** * Stable checkout API used by controllers, commands and integrations. @@ -64,11 +65,26 @@ class CheckoutService $this->completer->confirm($purchase); } + public function confirmPaidPurchase(Purchase $purchase): Purchase + { + return DB::transaction(function () use ($purchase): Purchase { + $this->completer->confirm($purchase); + $purchase->markAsPaid(); + + return $purchase->refresh()->load(['items.imageAttachment']); + }); + } + public function cancelPurchase(Purchase $purchase): Purchase { return $this->releaser->cancel($purchase); } + public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase + { + return $this->releaser->cancelWithoutRestoringCart($purchase); + } + public function expirePurchase(Purchase $purchase): Purchase { return $this->releaser->expire($purchase); diff --git a/app/Domains/Sale/Controllers/AdminApp/SaleController.php b/app/Domains/Sale/Controllers/AdminApp/SaleController.php index 8091932..be6d562 100644 --- a/app/Domains/Sale/Controllers/AdminApp/SaleController.php +++ b/app/Domains/Sale/Controllers/AdminApp/SaleController.php @@ -38,6 +38,20 @@ class SaleController extends Controller return new SaleDetailResource($this->saleService->detail($tenant, $sale)); } + public function confirm(Request $request, int $sale): SaleResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new SaleResource($this->saleService->confirm($tenant, $sale)); + } + + public function cancel(Request $request, int $sale): SaleResource + { + $tenant = $request->user()->tenant()->firstOrFail(); + + return new SaleResource($this->saleService->cancel($tenant, $sale)); + } + public function modifications(Request $request): AnonymousResourceCollection { return SaleModificationResource::collection( diff --git a/app/Domains/Sale/Services/AdminAppSaleService.php b/app/Domains/Sale/Services/AdminAppSaleService.php index 6202ccc..9c8918e 100644 --- a/app/Domains/Sale/Services/AdminAppSaleService.php +++ b/app/Domains/Sale/Services/AdminAppSaleService.php @@ -4,6 +4,7 @@ namespace App\Domains\Sale\Services; use App\Domains\Logging\Models\ValueChange; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Tenant\Models\Tenant; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -11,6 +12,10 @@ use Illuminate\Support\Collection; class AdminAppSaleService { + public function __construct( + protected CheckoutService $checkoutService, + ) {} + public function confirmedSalesTotal(Tenant $tenant): string { $total = Purchase::query() @@ -47,6 +52,24 @@ class AdminAppSaleService ->findOrFail($saleId); } + public function confirm(Tenant $tenant, int $saleId): Purchase + { + $sale = $this->findForTenant($tenant, $saleId); + + return $this->saleForResponse( + $this->checkoutService->confirmPaidPurchase($sale) + ); + } + + public function cancel(Tenant $tenant, int $saleId): Purchase + { + $sale = $this->findForTenant($tenant, $saleId); + + return $this->saleForResponse( + $this->checkoutService->cancelPurchaseWithoutRestoringCart($sale) + ); + } + /** * @param array $filters * @return Collection @@ -125,4 +148,18 @@ class AdminAppSaleService ->orderByDesc('changed_at') ->orderByDesc('id'); } + + protected function findForTenant(Tenant $tenant, int $saleId): Purchase + { + return Purchase::query() + ->where('tenant_codigo', $tenant->codigo) + ->findOrFail($saleId); + } + + protected function saleForResponse(Purchase $sale): Purchase + { + return $sale->refresh() + ->loadSum('items as quantity', 'cantidad') + ->loadCount('tickets'); + } } diff --git a/app/Domains/Sale/routes/adminapp.php b/app/Domains/Sale/routes/adminapp.php index c5d4325..5136ecb 100644 --- a/app/Domains/Sale/routes/adminapp.php +++ b/app/Domains/Sale/routes/adminapp.php @@ -10,5 +10,7 @@ Route::prefix('v1/adminapp/tenant') Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); Route::get('sales/modifications', [SaleController::class, 'modifications']); Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']); + Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale'); + Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale'); Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale'); }); diff --git a/app/Domains/Tenant/Resources/TenantResource.php b/app/Domains/Tenant/Resources/TenantResource.php index c4251a9..e681a2d 100644 --- a/app/Domains/Tenant/Resources/TenantResource.php +++ b/app/Domains/Tenant/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Domains\Tenant\Models\Tenant; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; +use Illuminate\Support\Str; /** * @mixin Tenant @@ -159,6 +160,10 @@ class TenantResource extends JsonResource $formatted['submenues'] = $childrenByParent ->get($menu->code, collect()) + ->sortBy( + fn (Menu $submenu) => Str::lower(Str::ascii($submenu->label)), + SORT_NATURAL + ) ->map($formatMenu) ->values(); @@ -168,6 +173,10 @@ class TenantResource extends JsonResource return $menus ->filter(fn (Menu $menu) => $menu->parent_menu_code === null || ! $menuCodes->has($menu->parent_menu_code)) + ->sortBy( + fn (Menu $menu) => Str::lower(Str::ascii($menu->label)), + SORT_NATURAL + ) ->map($formatMenu) ->values(); } diff --git a/database/seeders/MenuSeeder.php b/database/seeders/MenuSeeder.php index 2605b1a..3c09308 100644 --- a/database/seeders/MenuSeeder.php +++ b/database/seeders/MenuSeeder.php @@ -252,6 +252,12 @@ class MenuSeeder extends Seeder 'adminapp.fiesta-futbol-infantil.merchandising', 'adminapp.fiesta-futbol-infantil.comida', ]; + $fiestaExcludedAdminMenuCodes = [ + 'adminapp.inicio', + 'adminapp.catalog', + 'adminapp.categories', + 'adminapp.combos', + ]; $frequentlyAskedQuestions = [ [ 'pregunta' => '¿Hay algún límite de compra?', @@ -311,6 +317,8 @@ class MenuSeeder extends Seeder if ($tenant->codigo !== 'fiesta_futbol_infantil') { $menuCodes = array_diff($menuCodes, $fiestaCategoryMenuCodes); + } else { + $menuCodes = array_diff($menuCodes, $fiestaExcludedAdminMenuCodes); } // Usar sync para asociar los menues al tenant diff --git a/tests/Feature/Sale/AdminAppSaleControllerTest.php b/tests/Feature/Sale/AdminAppSaleControllerTest.php index c0c2e40..98bf13b 100644 --- a/tests/Feature/Sale/AdminAppSaleControllerTest.php +++ b/tests/Feature/Sale/AdminAppSaleControllerTest.php @@ -4,12 +4,15 @@ namespace Tests\Feature\Sale; use App\Domains\Auth\Models\User; use App\Domains\Authorization\Enums\RoleCode; +use App\Domains\Cart\Models\Cart; +use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\WebsiteType; use Database\Seeders\AuthorizationSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Event; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -82,6 +85,100 @@ class AdminAppSaleControllerTest extends TestCase ->assertNotFound(); } + public function test_an_adminapp_user_can_confirm_a_pending_sale(): void + { + Event::fake([PurchasePaid::class]); + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_PAID); + + $this->assertSame(Purchase::STATUS_PAID, $purchase->fresh()->status); + $this->assertDatabaseHas('value_changes', [ + 'trackable_id' => $purchase->id, + 'attribute' => 'status', + 'old_value' => Purchase::STATUS_PENDING_PAYMENT, + 'new_value' => Purchase::STATUS_PAID, + 'user_id' => $admin->id, + ]); + Event::assertDispatchedTimes(PurchasePaid::class, 1); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_PAID); + Event::assertDispatchedTimes(PurchasePaid::class, 1); + } + + public function test_adminapp_cancellation_does_not_restore_the_source_cart(): void + { + $tenant = $this->createTenant('acme'); + $admin = $this->createAdminAppUser($tenant); + Sanctum::actingAs($admin); + + $cart = Cart::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'user_id' => $admin->id, + 'status' => 'converted', + ]); + $cart->delete(); + $purchase = Purchase::query()->create([ + 'cart_id' => $cart->id, + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); + + $this->assertSame(Purchase::STATUS_CANCELLED, $purchase->fresh()->status); + $this->assertSoftDeleted('carritos', ['id' => $cart->id]); + $this->assertSame('converted', Cart::withTrashed()->findOrFail($cart->id)->status); + } + + public function test_an_adminapp_user_cannot_change_a_sale_from_another_tenant(): void + { + $tenant = $this->createTenant('acme'); + $otherTenant = $this->createTenant('other'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + + $foreignPurchase = Purchase::query()->create([ + 'tenant_codigo' => $otherTenant->codigo, + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/confirm") + ->assertNotFound(); + $this->postJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}/cancel") + ->assertNotFound(); + } + + public function test_a_paid_sale_cannot_be_cancelled_from_adminapp(): void + { + $tenant = $this->createTenant('acme'); + Sanctum::actingAs($this->createAdminAppUser($tenant)); + $purchase = Purchase::query()->create([ + 'tenant_codigo' => $tenant->codigo, + 'status' => Purchase::STATUS_PAID, + 'total' => '10000.00', + ]); + + $this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel") + ->assertUnprocessable() + ->assertJsonValidationErrors('purchase'); + } + private function createTenant(string $code): Tenant { return Tenant::query()->create([ diff --git a/tests/Feature/Seeders/MenuSeederTest.php b/tests/Feature/Seeders/MenuSeederTest.php index ffbc56c..fcf11f0 100644 --- a/tests/Feature/Seeders/MenuSeederTest.php +++ b/tests/Feature/Seeders/MenuSeederTest.php @@ -25,6 +25,7 @@ class MenuSeederTest extends TestCase $this->seed(MenuSeeder::class); $expectedMenus = [ + 'adminapp.inicio' => ['Inicio', '/admin/inicio'], 'adminapp.catalog' => ['Catálogo', '/admin/catalog'], 'adminapp.categories' => ['Categorías', '/admin/categories'], 'adminapp.combos' => ['Combos', '/admin/combos'], @@ -81,6 +82,17 @@ class MenuSeederTest extends TestCase $this->assertTrue($fiestaTenant->menues()->where('menues.code', $code)->exists()); } + foreach ([ + 'adminapp.inicio', + 'adminapp.catalog', + 'adminapp.categories', + 'adminapp.combos', + ] as $code) { + $this->assertFalse( + $fiestaTenant->menues()->where('menues.code', $code)->exists() + ); + } + $this->assertFalse( Menu::query()->whereIn('code', [ 'admin.event', diff --git a/tests/Feature/Tenant/BootstrapTenantControllerTest.php b/tests/Feature/Tenant/BootstrapTenantControllerTest.php index 66dc046..c5e4162 100644 --- a/tests/Feature/Tenant/BootstrapTenantControllerTest.php +++ b/tests/Feature/Tenant/BootstrapTenantControllerTest.php @@ -248,10 +248,10 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonPath('data.menues.0.code', 'help') ->assertJsonPath('data.menues.0.label', 'Ayuda') ->assertJsonPath('data.menues.0.parent_menu_code', null) - ->assertJsonPath('data.menues.0.submenues.0.code', 'help.faq') - ->assertJsonPath('data.menues.0.submenues.0.label', 'Preguntas frecuentes') + ->assertJsonPath('data.menues.0.submenues.0.code', 'help.shipping') + ->assertJsonPath('data.menues.0.submenues.0.label', 'Envíos') ->assertJsonPath('data.menues.0.submenues.0.parent_menu_code', 'help') - ->assertJsonPath('data.menues.0.submenues.1.code', 'help.shipping'); + ->assertJsonPath('data.menues.0.submenues.1.code', 'help.faq'); $assertCleanMenu = function (array $menu) use (&$assertCleanMenu): void { $this->assertArrayNotHasKey('created_at', $menu); @@ -265,10 +265,10 @@ class BootstrapTenantControllerTest extends TestCase }; $menus = $response->json('data.menues'); - $this->assertEquals($staticContent, $menus[0]['submenues'][0]['static_content']); + $this->assertEquals($staticContent, $menus[0]['submenues'][1]['static_content']); $assertCleanMenu($menus[0]); $this->assertArrayNotHasKey('static_content', $menus[0]); - $this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][1]); + $this->assertArrayNotHasKey('static_content', $menus[0]['submenues'][0]); } public function test_it_returns_only_menus_assigned_to_the_user_role(): void @@ -303,6 +303,49 @@ class BootstrapTenantControllerTest extends TestCase ->assertJsonMissing(['code' => 'admin.catalog']); } + public function test_it_orders_parent_menus_and_their_submenus_by_label(): void + { + $tenant = $this->createTenant(); + $userRole = Role::query()->create([ + 'codigo' => RoleCode::User->value, + 'nombre' => 'Usuario', + ]); + + $zeta = Menu::query()->create([ + 'code' => 'zeta', + 'label' => 'Zeta', + 'route' => '/zeta', + ]); + $tree = Menu::query()->create([ + 'code' => 'tree', + 'label' => 'Árbol', + 'route' => '/arbol', + ]); + $fox = Menu::query()->create([ + 'code' => 'tree.fox', + 'label' => 'Zorro', + 'parent_menu_code' => $tree->code, + 'route' => '/arbol/zorro', + ]); + $eagle = Menu::query()->create([ + 'code' => 'tree.eagle', + 'label' => 'Águila', + 'parent_menu_code' => $tree->code, + 'route' => '/arbol/aguila', + ]); + + $menuCodes = [$zeta->code, $tree->code, $fox->code, $eagle->code]; + $tenant->menues()->sync($menuCodes); + $userRole->menus()->sync($menuCodes); + + $this->getJson('/api/tenants/bootstrap/acme.com') + ->assertOk() + ->assertJsonPath('data.menues.0.code', 'tree') + ->assertJsonPath('data.menues.1.code', 'zeta') + ->assertJsonPath('data.menues.0.submenues.0.code', 'tree.eagle') + ->assertJsonPath('data.menues.0.submenues.1.code', 'tree.fox'); + } + public function test_it_rejects_duplicate_domains_after_normalization_when_storing(): void { $base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';