61 Commits

Author SHA1 Message Date
b294e5c46e Merge branch 'fix/correcciones_secundarias' into dev 2026-08-05 10:28:59 -03:00
d02b0c5551 feat(auth): add reason field to reset password attempts and enhance email notifications for account lock scenarios 2026-08-05 10:27:29 -03:00
ee911171be fix(variant): simplify getName method and add test for variant without catalog attributes 2026-08-05 09:18:12 -03:00
e17d1fa15e feat: Add scanner user association to tickets; update Ticket model, resource, migration, and tests 2026-08-05 09:17:09 -03:00
2a15dc92be feat: Implement staff management features; add controller, service, resource, routes, and tests for staff and category assignments 2026-08-04 16:20:30 -03:00
3a039a6055 feat: Implement OnTicketFeaturedGroup management; add controller, service, resource, request, routes, and tests for featured groups 2026-08-04 13:41:11 -03:00
84e45bb964 feat: Introduce source type for featured groups; add category association and implement FeaturedGroupService for item sourcing 2026-08-04 13:17:00 -03:00
8aa3a26ee7 feat: Add event association to purchases; implement event relationship in models, update migration, and enhance tests 2026-08-04 11:45:03 -03:00
1de08c1ca4 feat: Implement sale management features including controllers, services, resources, and routes; add PDF generation for sales and modifications 2026-08-04 11:44:13 -03:00
3f9bcd84c4 feat: Add tenant code to value changes; implement PurchaseController and related services for tenant-specific purchase management 2026-08-04 10:27:33 -03:00
96f26e2e9d feat: Remove 'in_review' status from purchase workflow; implement logging for status changes and update related tests 2026-08-03 17:04:39 -03:00
c4b317d5fc feat: Implement value change logging functionality with models, traits, and migration; add tests for logging behavior and value change mapping 2026-08-03 15:43:50 -03:00
3b2977a330 feat: Enhance tenant website extras by adding detailed description HTML for hero and additional info configurations; update related tests for validation 2026-08-03 15:29:14 -03:00
3b36abd46d feat: Refactor event form functionality by removing social media data from AdminAppBootstrapResource and AdminAppBootstrapService; add EventFormController, EventFormResource, and EventFormService; implement routes and tests for event form 2026-08-03 14:02:08 -03:00
12697c2268 feat: Refactor bootstrap functionality by adding AdminApp and Tenant controllers, requests, resources, and services; remove deprecated event form components 2026-08-03 13:37:37 -03:00
f4638bc984 feat: Implement event management functionality with API endpoints and validation 2026-08-03 13:32:31 -03:00
c3713c62a6 feat: Add event management to tenant and catalog
- Introduced Event and EventDate models with relationships to Tenant and CatalogItem.
- Added active_event_id to Tenant model to track the currently active event.
- Updated TenantResource to include active event details in the response.
- Enhanced Ticket model to link to CatalogItem and Variant, allowing for event-specific ticketing.
- Implemented migrations to create events and link them to catalog items and variants.
- Updated seeders to populate events and their associated dates for the Fiesta Futbol Infantil tenant.
- Modified Ticket generation logic to respect event dates over standard ticket dates.
- Added tests for event and ticket functionalities, ensuring proper relationships and date handling.
2026-08-03 12:01:20 -03:00
4a51f3afde feat(website-extras): enhance toggle functionality and update related tests; add additional info configuration to tenant extras 2026-08-03 09:10:50 -03:00
f1df6a5918 feat(seeder): update MenuSeeder and tests to reflect new admin menu structure; change border color in WebsiteTypeSeeder 2026-08-03 09:07:49 -03:00
dbeaf19513 feat(auth): implement AdminAppMeController and AdminAppMeResource; add routes and tests for user context retrieval 2026-07-31 16:11:47 -03:00
dd76d5d951 feat(website-type): add footer logo support in WebsiteType model, controller, and resource; update seeder and tests 2026-07-31 14:16:58 -03:00
f603a82b5e feat(website-type): implement BootstrapWebsiteTypeController and related request/resource for domain-based bootstrapping 2026-07-31 13:39:31 -03:00
b478c23f3b feat(website-type): add new color fields and update related methods in WebsiteType model and service 2026-07-31 12:55:40 -03:00
6858b387c3 feat(website-type): add 'dominio' field to WebsiteType model and update related tests 2026-07-31 12:06:27 -03:00
893bea1492 feat(website-type): add login_header_footer_color field and update related tests 2026-07-31 12:06:13 -03:00
ec1d80dff2 feat(seeder): update image paths in ProductCatalogFromImagesSeeder and TenantSeeder, add new catalog images 2026-07-31 11:45:29 -03:00
3968a10f6a feat(website-type): add presentation fields and site logo relationship to WebsiteType model 2026-07-31 11:39:17 -03:00
cf2beb2ff5 feat(auth): implement AdminApp login functionality with controller, request, routes, and tests 2026-07-31 10:47:55 -03:00
484fa204b3 feat(tenant): add BootstrapTenantController and corresponding tests for tenant bootstrapping functionality 2026-07-31 10:36:46 -03:00
6dd057874a feat(website-extras): add showExtra method and resource for individual website extras, update routes and tests 2026-07-31 09:51:27 -03:00
40cbac17c6 feat(website-extras): implement toggle functionality for website extras and update related tests 2026-07-31 09:47:56 -03:00
45e56c43a6 feat(website-extras): add 'is_enabled' column to websites_extras table and update model and tests 2026-07-31 09:38:30 -03:00
cbb7b1c3a1 feat(database): remove 'database_rules' from WebsiteTypeExtra configurations and related tests 2026-07-31 09:28:12 -03:00
6b3a12b624 feat(website-extras): refactor WebsiteExtra management to use 'codigo' for identification, update routes, requests, and services 2026-07-31 09:27:35 -03:00
40a7dece11 feat(authorization): implement WebsiteExtra management with controller, request, resource, service, routes, middleware, and tests 2026-07-31 09:19:25 -03:00
5ec65ce3d3 fix(authorization): rename pivot table from 'menues_roles' to 'roles_menues' and update related references 2026-07-31 08:35:45 -03:00
b7e1332b8c feat(authorization): refactor role management to use RoleCode enum, update migrations, seeders, and tests 2026-07-31 08:32:00 -03:00
a087a511e0 feat(authorization): filter tenant menus by user roles in bootstrap process 2026-07-31 08:23:18 -03:00
a20b1713d3 feat(authorization): establish many-to-many relationship between roles and menus, add migration and seeder updates 2026-07-30 16:54:25 -03:00
d56d387933 feat(authorization): add role and tenant codes to users, implement validation logic, and create tests 2026-07-30 16:37:37 -03:00
cba32e90e7 feat(authorization): implement AuthorizationSeeder with roles and permissions, add tests 2026-07-30 16:37:37 -03:00
a674bcfabc feat(authorization): add roles and permissions models, migration, and tests 2026-07-30 16:37:20 -03:00
ace3f136e4 refactor(menu): add admin menu items and corresponding test cases 2026-07-30 16:36:43 -03:00
2cc72be850 refactor(website): add 'dates_text' field to event configuration and update related tests 2026-07-29 16:23:24 -03:00
8e3ee7eff6 refactor(tenant): introduce TenantInformationService for loading tenant data and resolving website extras 2026-07-29 15:48:01 -03:00
27d4f9fac6 feat(tenant): implement website extras functionality and validation in tenant creation 2026-07-29 15:16:33 -03:00
9e3163b74f refactor(website): update relationships and migration to use 'website_type_code' instead of 'website_type' 2026-07-29 15:03:31 -03:00
b9ad2e589f refactor(tenant): remove legacy presentation configuration and related carousel images 2026-07-29 14:54:57 -03:00
5416c0fd61 feat(seeder): add WebsiteTypeSeeder and update DatabaseSeeder to include it 2026-07-29 14:42:03 -03:00
04bf03fc2e feat(website): add website type and extras models with migration and tests 2026-07-29 14:09:47 -03:00
7c9ebc6d4d feat(auth): implement login security features including account locking and login attempt tracking 2026-07-29 10:47:26 -03:00
45553dc514 feat(purchase): add review submission for pending purchases and update related logic 2026-07-29 09:11:00 -03:00
1c870d9cfa feat(tickets): add source_purchase_id to tickets and update related logic 2026-07-28 16:10:37 -03:00
8ab94ec61f fix(validation): improve uniqueness message for attribute validation 2026-07-28 15:51:43 -03:00
4eacff86bc feat(purchase): update customer data for pending payment purchases 2026-07-28 15:06:00 -03:00
5c457db1c9 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-07-28 12:20:20 -03:00
8dc80411d7 feat(categories): add category retrieval and pagination for tenant-specific products 2026-07-28 12:20:17 -03:00
e16d91f3f9 feat(tenant): add categories relationship and structure to tenant resource and controller 2026-07-28 10:57:00 -03:00
89f6dab0c4 feat(seeders): associate categories with tenant codes in seeders and tests 2026-07-28 10:42:55 -03:00
ff9d7728e8 Implement localization for API responses and error messages
- Added localization support for API responses in English and Spanish.
- Introduced a middleware to set the API locale based on the Accept-Language header.
- Updated various exception messages and validation responses to use localized strings.
- Created new language files for English and Spanish translations.
- Refactored existing code to replace hardcoded messages with localized strings.
- Added tests to verify localization functionality and response correctness.
2026-07-28 10:19:39 -03:00
754aeebe3a Squashed commit of the following:
commit c993da3397b65488d919d1b929cbd4638754601b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:54:10 2026 -0300

    feat(tickets): update ticket metadata layout for improved readability and clarity

commit 140fa4676d84bed788894e1d7994b26dccac7a34
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:43:21 2026 -0300

    feat(tickets): enhance PDF ticket download with dynamic filename and improved styling

commit 8875c047b198776e61e8353c99cf4c7b00bc8fc9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:03:17 2026 -0300

    feat(tickets): add PDF download functionality for tickets

    - Implemented a new endpoint to download tickets as a PDF.
    - Created DownloadTicketsPdfRequest for validating ticket IDs.
    - Added TicketPdfService to handle PDF generation and QR code embedding.
    - Developed a Blade view for rendering ticket details in PDF format.
    - Updated API routes to include the new PDF download route.
    - Added tests to ensure authenticated users can download their tickets and that users cannot download tickets belonging to others.
    - Updated composer.json and composer.lock to include necessary dependencies for PDF generation and QR code creation.
2026-07-28 09:54:49 -03:00
270 changed files with 11778 additions and 1005 deletions

View File

@@ -10,8 +10,8 @@ PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
@@ -25,6 +25,11 @@ GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
BCRYPT_ROUNDS=12
AUTH_MAX_LOGIN_ATTEMPTS=5
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
LOG_CHANNEL=stack
LOG_STACK=single

View File

@@ -34,7 +34,7 @@
"response": []
},
{
"name": "Create Tenant (JSON)",
"name": "Create Tenant OnTicket (JSON)",
"request": {
"method": "POST",
"header": [
@@ -51,7 +51,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme\",\n \"dominio\": \"acme.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
"raw": "{\n \"codigo\": \"festival-demo\",\n \"nombre\": \"Festival Demo\",\n \"dominio\": \"festival-demo.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"success_color\": \"#00aa55\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"website_type_code\": \"onticket\",\n \"extras\": {\n \"heroConfig\": {\n \"title_html\": \"<h1>Festival Demo</h1>\",\n \"description_html\": \"<p>Una experiencia inolvidable</p>\",\n \"button_text\": \"Comprar entradas\",\n \"button_href\": \"/tickets\",\n \"background_image_id\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n },\n \"eventConfig\": {\n \"title\": \"Festival Demo\",\n \"location\": \"Buenos Aires\",\n \"dates_text\": \"10 y 11 de octubre de 2026\",\n \"dates\": [\n \"2026-10-10\",\n \"2026-10-11\"\n ]\n }\n }\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants",
@@ -67,7 +67,7 @@
"response": []
},
{
"name": "Create Tenant (Form Data)",
"name": "Create Tenant ShopIt (Form Data)",
"request": {
"method": "POST",
"header": [
@@ -110,6 +110,11 @@
"value": "#ff0000",
"type": "text"
},
{
"key": "success_color",
"value": "#00aa55",
"type": "text"
},
{
"key": "header_bg_color",
"value": "#333333",
@@ -129,6 +134,32 @@
"key": "footer_logo",
"type": "file",
"src": []
},
{
"key": "search_product_layout",
"value": "column_with_image",
"type": "text"
},
{
"key": "search_group_layout",
"value": "paginated",
"type": "text"
},
{
"key": "search_items_per_page",
"value": "12",
"type": "text"
},
{
"key": "website_type_code",
"value": "shopit",
"type": "text"
},
{
"key": "extras[carousel][]",
"type": "file",
"src": [],
"description": "Se puede repetir esta key hasta 10 veces. También acepta una imagen base64 o el UUID de un attachment."
}
]
},

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\AdminAppLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class AdminAppLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(AdminAppLoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticateAdminApp(
$credentials['email'],
$credentials['password'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'adminapp-token',
['adminapp'],
now()->addMinutes($expirationMinutes),
)->plainTextToken;
return response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\AdminAppMeResource;
use App\Domains\Auth\Services\AdminAppContextService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AdminAppMeController extends Controller
{
public function __construct(
private readonly AdminAppContextService $adminAppContextService,
) {}
public function __invoke(Request $request): AdminAppMeResource
{
/** @var User $user */
$user = $request->user();
return AdminAppMeResource::make(
$this->adminAppContextService->load($user)
);
}
}

View File

@@ -24,7 +24,8 @@ class CreateResetPasswordAttemptController extends Controller
);
return response()->json([
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}

View File

@@ -35,7 +35,8 @@ class GoogleTokenExchangeController extends Controller
);
$response = response()->json([
'message' => 'Sesion iniciada correctamente.',
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $authentication['token'],
'token_type' => 'Bearer',
'user' => UserResource::make($authentication['user']),

View File

@@ -2,35 +2,31 @@
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Requests\LoginUserRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
public function __construct(
private readonly GuestCartMergeService $guestCartMergeService,
private readonly PasswordLoginService $passwordLoginService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(LoginUserRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = User::query()->where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email o Contraseña incorrectos.',
]);
}
$user = $this->passwordLoginService->authenticate(
$credentials['email'],
$credentials['password'],
$credentials['tenant_codigo'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
@@ -50,7 +46,8 @@ class LoginController extends Controller
);
$response = response()->json([
'message' => 'Sesion iniciada correctamente.',
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),

View File

@@ -23,7 +23,8 @@ class LogoutController extends Controller
}
return response()->json([
'message' => 'Sesion cerrada correctamente.',
'code' => 'auth.logout_success',
'message' => __('api.auth.logout_success'),
]);
}
}

View File

@@ -12,15 +12,17 @@ class RegisterController extends Controller
{
public function __construct(
protected RegisterUserService $registerUserService,
) {
}
) {}
public function __invoke(RegisterUserRequest $request): JsonResponse
{
$user = $this->registerUserService->register($request->validated());
return UserResource::make($user)
->additional(['message' => 'Usuario registrado correctamente.'])
->additional([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->response()
->setStatusCode(201);
}

View File

@@ -28,12 +28,13 @@ class ResetPasswordController extends Controller
$data['password'],
)) {
throw ValidationException::withMessages([
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'codigo' => __('api.auth.password_reset_invalid'),
]);
}
return response()->json([
'message' => 'Contraseña modificada correctamente.',
'code' => 'auth.password_updated',
'message' => __('api.auth.password_updated'),
'status' => ResetPasswordAttempt::STATUS_USED,
]);
}

View File

@@ -27,12 +27,13 @@ class ValidateResetPasswordAttemptController extends Controller
$data['codigo'],
)) {
throw ValidationException::withMessages([
'codigo' => 'El código ingresado es inválido.',
'codigo' => __('api.auth.reset_code_invalid'),
]);
}
return response()->json([
'message' => 'Código validado correctamente.',
'code' => 'auth.reset_code_valid',
'message' => __('api.auth.reset_code_valid'),
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Auth\Exceptions;
use Carbon\CarbonImmutable;
use RuntimeException;
class AccountLockedException extends RuntimeException
{
public function __construct(
public readonly CarbonImmutable $lockedUntil,
) {
parent::__construct('The account is temporarily locked.');
}
public function retryAfterSeconds(): int
{
return max(1, (int) now()->diffInSeconds($this->lockedUntil, false));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'email_fingerprint',
'tenant_codigo',
'outcome',
'ip_address',
'user_agent',
])]
class LoginAttempt extends Model
{
public const OUTCOME_SUCCESS = 'success';
public const OUTCOME_INVALID_CREDENTIALS = 'invalid_credentials';
public const OUTCOME_ACCOUNT_LOCKED = 'account_locked';
public const UPDATED_AT = null;
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function casts(): array
{
return [
'user_id' => 'integer',
'created_at' => 'datetime',
];
}
}

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'status'])]
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{

View File

@@ -2,22 +2,32 @@
namespace App\Domains\Auth\Models;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected $attributes = [
'rol_codigo' => RoleCode::User->value,
];
protected static function newFactory(): UserFactory
{
return UserFactory::new();
@@ -29,6 +39,39 @@ class User extends Authenticatable
return $this->hasMany(ResetPasswordAttempt::class);
}
/** @return HasMany<LoginAttempt, $this> */
public function loginAttempts(): HasMany
{
return $this->hasMany(LoginAttempt::class);
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return BelongsToMany<Category, $this> */
public function scanCategories(): BelongsToMany
{
return $this->belongsToMany(
Category::class,
'category_scanners',
'user_id',
'categoria_id',
)->withTimestamps();
}
/**
* @return array<string, string>
*/
@@ -37,6 +80,9 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'failed_login_attempts' => 'integer',
'last_failed_login_at' => 'datetime',
'locked_until' => 'datetime',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class AdminAppLoginRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest
{
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Resources\TenantResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
*/
class AdminAppMeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant),
];
}
}

View File

@@ -2,11 +2,12 @@
namespace App\Domains\Auth\Resources;
use App\Domains\Auth\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Auth\Models\User
* @mixin User
*/
class UserResource extends JsonResource
{
@@ -21,6 +22,8 @@ class UserResource extends JsonResource
'email' => $this->email,
'dni' => $this->dni,
'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo,
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class AdminAppContextService
{
public function load(User $user): User
{
$tenant = $user->tenant()
->with([
'menues' => fn ($query) => $query
->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::AdminApp->value)
),
])
->firstOrFail();
$user->setRelation('tenant', $tenant);
return $user;
}
}

View File

@@ -25,7 +25,7 @@ class GoogleAuthService
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([
'tenant' => 'El tenant o la URL de retorno no son validos.',
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]);
}
@@ -50,13 +50,13 @@ class GoogleAuthService
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
abort(400, __('api.auth.request_expired'));
}
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
abort(400, 'La URL de retorno no es valida.');
abort(400, __('api.auth.invalid_return_url'));
}
/** @var SocialiteUser $googleUser */
@@ -88,13 +88,13 @@ class GoogleAuthService
if (! $authentication) {
throw ValidationException::withMessages([
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
'oauth_code' => __('api.auth.oauth_code_expired'),
]);
}
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
throw ValidationException::withMessages([
'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.',
'tenant_codigo' => __('api.auth.tenant_mismatch'),
]);
}
@@ -112,7 +112,7 @@ class GoogleAuthService
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages([
'google' => 'Google no devolvio una identidad valida.',
'google' => __('api.auth.google_email_required'),
]);
}
@@ -120,7 +120,7 @@ class GoogleAuthService
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([
'google' => 'La cuenta de Google debe tener el email verificado.',
'google' => __('api.auth.google_email_unverified'),
]);
}

View File

@@ -0,0 +1,242 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PasswordLoginService
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticate(
string $email,
string $password,
string $tenantCode,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
$tenantCode,
$ipAddress,
$userAgent,
);
}
/**
* Authenticate a tenant-bound AdminApp user without requiring the caller
* to know their tenant code beforehand.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateAdminApp(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
RoleCode::AdminApp,
true,
);
}
private function authenticateUser(
string $email,
string $password,
?string $tenantCode,
?string $ipAddress,
?string $userAgent,
RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false,
): User {
$normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now();
/** @var array{outcome: string, user: User|null, locked_until: CarbonImmutable|null} $result */
$result = DB::transaction(function () use (
$normalizedEmail,
$password,
$tenantCode,
$ipAddress,
$userAgent,
$now,
$requiredRole,
$requiresTenant,
): array {
$user = User::query()
->where('email', $normalizedEmail)
->where('rol_codigo', $requiredRole->value)
->when(
$requiresTenant,
fn ($query) => $query->whereNotNull('tenant_codigo'),
)
->lockForUpdate()
->first();
$attemptTenantCode = $tenantCode ?? $user?->tenant_codigo;
if ($user?->locked_until?->isFuture()) {
$this->recordAttempt(
$user,
$normalizedEmail,
$attemptTenantCode,
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
$ipAddress,
$userAgent,
);
return [
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
'user' => $user,
'locked_until' => CarbonImmutable::instance($user->locked_until),
];
}
if ($user !== null && $user->locked_until !== null) {
$user->forceFill([
'failed_login_attempts' => 0,
'last_failed_login_at' => null,
'locked_until' => null,
])->save();
}
if ($user === null || ! Hash::check($password, $user->password)) {
if ($user !== null) {
$this->registerFailure($user, $now, $tenantCode);
}
$outcome = $user?->locked_until?->isFuture()
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
$this->recordAttempt(
$user,
$normalizedEmail,
$attemptTenantCode,
$outcome,
$ipAddress,
$userAgent,
);
return [
'outcome' => $outcome,
'user' => $user,
'locked_until' => $user?->locked_until === null
? null
: CarbonImmutable::instance($user->locked_until),
];
}
$user->forceFill([
'failed_login_attempts' => 0,
'last_failed_login_at' => null,
'locked_until' => null,
])->save();
$this->recordAttempt(
$user,
$normalizedEmail,
$attemptTenantCode,
LoginAttempt::OUTCOME_SUCCESS,
$ipAddress,
$userAgent,
);
return [
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
'user' => $user,
'locked_until' => null,
];
});
if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) {
throw new AccountLockedException($result['locked_until']);
}
if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) {
throw ValidationException::withMessages([
'email' => __('api.auth.invalid_credentials'),
]);
}
return $result['user'];
}
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
{
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
$withinAttemptWindow = $user->last_failed_login_at !== null
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
$attempts = $withinAttemptWindow
? $user->failed_login_attempts + 1
: 1;
$previousAttempts = $user->failed_login_attempts;
$user->forceFill([
'failed_login_attempts' => $attempts,
'last_failed_login_at' => $now,
'locked_until' => $attempts >= $maxAttempts
? $now->addMinutes($lockMinutes)
: null,
])->save();
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try {
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
} catch (\Throwable $e) {
Log::error('Failed to trigger reset password on account lock', [
'user_id' => $user->id,
'exception' => $e
]);
}
}
}
private function recordAttempt(
?User $user,
string $normalizedEmail,
?string $tenantCode,
string $outcome,
?string $ipAddress,
?string $userAgent,
): void {
LoginAttempt::query()->create([
'user_id' => $user?->getKey(),
'email_fingerprint' => hash_hmac(
'sha256',
$normalizedEmail,
(string) config('app.key'),
),
'tenant_codigo' => $tenantCode,
'outcome' => $outcome,
'ip_address' => $ipAddress,
'user_agent' => $userAgent === null
? null
: mb_substr($userAgent, 0, 1024),
]);
}
}

View File

@@ -11,12 +11,12 @@ use Throwable;
class ResetPasswordAttemptService
{
public function createForEmail(string $email, string $tenantCode): void
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
{
$emailFingerprint = $this->emailFingerprint($email);
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query()
->where('email', $email)
->lockForUpdate()
@@ -39,6 +39,7 @@ class ResetPasswordAttemptService
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
@@ -138,6 +139,9 @@ class ResetPasswordAttemptService
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
$user->locked_until = null;
$user->save();
$user->tokens()->delete();

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Auth\Controllers\AdminAppLoginController;
use App\Domains\Auth\Controllers\AdminAppMeController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp')->group(function (): void {
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class);
});

View File

@@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class);
Route::post('/login', LoginController::class)->middleware('throttle:login');
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
->middleware('throttle:5,1');
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
@@ -23,3 +23,5 @@ Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Authorization\Enums;
enum RoleCode: string
{
case Admin = 'admin';
case AdminApp = 'adminapp';
case Scanner = 'scanner';
case User = 'user';
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Authorization\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
protected $table = 'permisos';
protected $fillable = [
'codigo',
'nombre',
'descripcion',
];
/**
* @return BelongsToMany<Role, $this>
*/
public function roles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'roles_permisos',
'codigo_permiso',
'rol_codigo',
'codigo',
'codigo'
)->withTimestamps();
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Authorization\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\MenuRole;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Role extends Model
{
protected $table = 'roles';
protected $fillable = [
'codigo',
'nombre',
'descripcion',
];
/**
* @return BelongsToMany<Permission, $this>
*/
public function permissions(): BelongsToMany
{
return $this->belongsToMany(
Permission::class,
'roles_permisos',
'rol_codigo',
'codigo_permiso',
'codigo',
'codigo'
)->withTimestamps();
}
/**
* @return HasMany<User, $this>
*/
public function users(): HasMany
{
return $this->hasMany(User::class, 'rol_codigo', 'codigo');
}
/**
* @return BelongsToMany<Menu, $this>
*/
public function menus(): BelongsToMany
{
return $this->belongsToMany(
Menu::class,
'roles_menues',
'rol_codigo',
'menu_codigo',
'codigo',
'code'
)->using(MenuRole::class);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Authorization\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RolePermission extends Model
{
protected $table = 'roles_permisos';
protected $fillable = [
'codigo_permiso',
'rol_codigo',
];
/**
* @return BelongsTo<Permission, $this>
*/
public function permission(): BelongsTo
{
return $this->belongsTo(Permission::class, 'codigo_permiso', 'codigo');
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\AdminAppBootstrapRequest;
use App\Domains\Bootstrap\Resources\AdminAppBootstrapResource;
use App\Domains\Bootstrap\Services\AdminAppBootstrapService;
use App\Http\Controllers\Controller;
class AdminAppBootstrapController extends Controller
{
public function __construct(protected AdminAppBootstrapService $bootstrapService) {}
public function __invoke(AdminAppBootstrapRequest $request): AdminAppBootstrapResource
{
return AdminAppBootstrapResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Bootstrap\Controllers;
use App\Domains\Bootstrap\Requests\TenantBootstrapRequest;
use App\Domains\Bootstrap\Services\TenantBootstrapService;
use App\Domains\Tenant\Resources\TenantResource;
use App\Http\Controllers\Controller;
class TenantBootstrapController extends Controller
{
public function __construct(protected TenantBootstrapService $bootstrapService) {}
public function __invoke(TenantBootstrapRequest $request): TenantResource
{
return TenantResource::make(
$this->bootstrapService->get((string) $request->validated('dominio'))
);
}
}

View File

@@ -0,0 +1,5 @@
<?php
namespace App\Domains\Bootstrap\Requests;
class AdminAppBootstrapRequest extends TenantBootstrapRequest {}

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Tenant\Requests;
namespace App\Domains\Bootstrap\Requests;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
class BootstrapTenantRequest extends FormRequest
class TenantBootstrapRequest extends FormRequest
{
protected bool $hasInvalidDomain = false;
@@ -23,14 +23,10 @@ class BootstrapTenantRequest extends FormRequest
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
&& $normalizedDomain === null;
$this->merge([
'dominio' => $normalizedDomain,
]);
$this->merge(['dominio' => $normalizedDomain]);
}
/**
* @return array<string, mixed>
*/
/** @return array<string, mixed> */
public function rules(): array
{
return [

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Bootstrap\Resources;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin array{website_type: WebsiteType} */
class AdminAppBootstrapResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var WebsiteType $websiteType */
$websiteType = $this->resource['website_type'];
return [
'website_type_code' => $websiteType->codigo,
'primary_color' => $websiteType->primary_color,
'secondary_color' => $websiteType->secondary_color,
'danger_color' => $websiteType->danger_color,
'success_color' => $websiteType->success_color,
'warning_color' => $websiteType->warning_color,
'body_color' => $websiteType->body_color,
'darker_body_color' => $websiteType->darker_body_color,
'surface_color' => $websiteType->surface_color,
'background_color' => $websiteType->background_color,
'border_color' => $websiteType->border_color,
'login_header_footer_color' => $websiteType->login_header_footer_color,
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Tenant\Models\WebsiteType;
class AdminAppBootstrapService
{
/** @return array{website_type: WebsiteType} */
public function get(string $domain): array
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail(),
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Bootstrap\Services;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Services\TenantInformationService;
class TenantBootstrapService
{
public function __construct(protected TenantInformationService $tenantInformationService) {}
public function get(string $domain): Tenant
{
return $this->tenantInformationService->load(
Tenant::query()->where('dominio', $domain)->firstOrFail(),
[
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)
),
'categories' => fn ($query) => $query->orderBy('nombre'),
]
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
use App\Domains\Bootstrap\Controllers\AdminAppBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get(
'v1/adminapp/bootstrap/{dominio}',
AdminAppBootstrapController::class
);

View File

@@ -0,0 +1,9 @@
<?php
use App\Domains\Bootstrap\Controllers\TenantBootstrapController;
use Illuminate\Support\Facades\Route;
Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
->where('dominio', '.*');
require __DIR__.'/adminapp.php';

View File

@@ -36,7 +36,10 @@ class CartController extends Controller
);
$response = CartResource::make($result['cart'])
->additional(['message' => 'Producto agregado al carrito.'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response();
if ($result['guest_token'] !== null) {
@@ -58,13 +61,19 @@ class CartController extends Controller
$cartItem->getKey(),
(int) $request->validated('cantidad'),
)
)->additional(['message' => 'Cantidad de producto actualizada.']);
)->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional(['message' => 'Producto eliminado del carrito.']);
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
}
}

View File

@@ -79,7 +79,7 @@ class Cart extends Model
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
@@ -91,7 +91,7 @@ class Cart extends Model
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
@@ -123,7 +123,7 @@ class Cart extends Model
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
@@ -146,7 +146,7 @@ class Cart extends Model
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]);
}
@@ -209,13 +209,13 @@ class Cart extends Model
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => 'Un bundle no admite una variante.',
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => 'El bundle no tiene componentes.',
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
@@ -225,7 +225,7 @@ class Cart extends Model
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => 'Debe seleccionar una variante para este ítem.',
'variant_id' => __('api.cart.variant_required'),
]);
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Catalog\Controllers\AdminApp;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class OnTicketFeaturedGroupController extends Controller
{
public function __construct(
private readonly OnTicketFeaturedGroupService $featuredGroupService,
) {}
public function index(Request $request): AnonymousResourceCollection
{
return OnTicketFeaturedGroupResource::collection(
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
);
}
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
{
$featuredGroup = $this->featuredGroupService->create(
$this->onTicketTenant($request),
$request->validated(),
);
return OnTicketFeaturedGroupResource::make($featuredGroup)
->response()
->setStatusCode(201);
}
public function update(
UpsertOnTicketFeaturedGroupRequest $request,
FeaturedGroup $featuredGroup,
): OnTicketFeaturedGroupResource {
$tenant = $this->onTicketTenant($request);
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
return OnTicketFeaturedGroupResource::make(
$this->featuredGroupService->update(
$tenant,
$featuredGroup,
$request->validated(),
)
);
}
private function onTicketTenant(Request $request): Tenant
{
$tenant = $request->user()->tenant()->firstOrFail();
abort_unless($tenant->website_type_code === 'onticket', 404);
return $tenant;
}
}

View File

@@ -2,32 +2,28 @@
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Catalog\Services\FeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class CatalogController extends Controller
{
private const ITEMS_PER_PAGE = 12;
public function index(Tenant $tenant): JsonResponse
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
{
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
@@ -37,7 +33,7 @@ class CatalogController extends Controller
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$this->featuredItemsResponse($featuredGroup, 1),
$featuredGroupService->itemsResponse($featuredGroup, 1),
))->resolve()
));
}
@@ -57,16 +53,43 @@ class CatalogController extends Controller
);
}
public function category(
CategoryPageRequest $request,
Tenant $tenant,
Category $category,
CatalogService $catalogService,
): AnonymousResourceCollection {
abort_unless($category->tenant_code === $tenant->codigo, 404);
return CatalogSearchItemResource::collection(
$catalogService->categoryItems(
$tenant,
$category,
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
)
)->additional([
'category' => [
'id' => $category->id,
'nombre' => $category->nombre,
'categoria_id' => $category->categoria_id,
],
'layout' => $tenant->search_product_layout->value,
'group_layout' => $tenant->search_group_layout->value,
]);
}
public function featuredGroupItems(
FeaturedGroupPageRequest $request,
Tenant $tenant,
FeaturedGroup $featuredGroup,
FeaturedGroupService $featuredGroupService,
): JsonResponse {
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
$page = (int) $request->validated('page', 1);
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page));
}
public function show(
@@ -101,58 +124,4 @@ class CatalogController extends Controller
->response()
->setStatusCode(201);
}
/** @return array<array-key, mixed> */
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$featuredItems = $this->featuredItemsQuery($featuredGroup)->get();
$featuredItems->each(
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
);
return CatalogFeaturedItemResource::collection($featuredItems)->resolve();
}
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
$paginator->getCollection()->each(
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
);
return CatalogFeaturedItemResource::collection($paginator)
->response()
->getData(true);
}
private function paginateFeaturedItems(
FeaturedGroup $featuredGroup,
int $page,
): LengthAwarePaginator {
$paginator = $this->featuredItemsQuery($featuredGroup)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,
);
return $paginator->withPath(route('catalog.featured-groups.items.index', [
'tenant' => $featuredGroup->tenant_code,
'featuredGroup' => $featuredGroup->id,
]));
}
/** @return HasMany<FeaturedItem, FeaturedGroup> */
private function featuredItemsQuery(FeaturedGroup $featuredGroup): HasMany
{
return $featuredGroup->featuredItems()->with([
'catalogItem.inventory',
'catalogItem.attachments',
'catalogItem.variants.inventory',
'catalogItem.variants.attachments',
'catalogItem.variants.definitions.itemAttribute.attribute',
'catalogItem.bundleComponents.catalogItem',
'catalogItem.bundleComponents.variant.catalogItem',
]);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Catalog\Enums;
enum EventProductType: string
{
case Entry = 'entrada';
case Product = 'producto';
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Catalog\Enums;
enum FeaturedGroupSource: string
{
case Manual = 'manual';
case Category = 'category';
case All = 'all';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -4,9 +4,12 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -17,6 +20,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'event_id',
'event_product_type',
'category_id',
'brand_id',
'inventory_id',
@@ -50,6 +55,8 @@ class CatalogItem extends Model
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'event_id' => 'integer',
'event_product_type' => EventProductType::class,
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
@@ -65,6 +72,12 @@ class CatalogItem extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
@@ -101,6 +114,12 @@ class CatalogItem extends Model
return $this->hasMany(Variant::class);
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{

View File

@@ -2,11 +2,13 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
@@ -64,4 +66,15 @@ class Category extends Model
{
return $this->hasMany(CatalogItem::class);
}
/** @return BelongsToMany<User, $this> */
public function scanners(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'category_scanners',
'categoria_id',
'user_id',
)->withTimestamps();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'source_type',
'category_id',
'product_layout',
'group_layout',
'group_name',
@@ -26,9 +29,15 @@ class FeaturedGroup extends Model
protected $table = 'featured_groups';
protected $attributes = [
'source_type' => FeaturedGroupSource::Manual->value,
];
protected function casts(): array
{
return [
'source_type' => FeaturedGroupSource::class,
'category_id' => 'integer',
'product_layout' => ProductLayout::class,
'group_layout' => GroupLayout::class,
'group_order' => 'integer',
@@ -41,6 +50,12 @@ class FeaturedGroup extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -13,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'event_date_id',
'inventory_id',
'minimum_use_date',
'maximum_use_date',
@@ -29,6 +32,7 @@ class Variant extends Model
{
return [
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
@@ -41,6 +45,18 @@ class Variant extends Model
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<EventDate, $this> */
public function eventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class);
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_variant_id');
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
@@ -79,31 +95,20 @@ class Variant extends Model
public function getName(): string
{
$name = $this->catalogItem->nombre;
$this->loadMissing('definitions.itemAttribute.attribute');
$definitions = $this->definitions
->map(function (VariantDefinition $definition): ?string {
$attributeName = $definition->itemAttribute?->attribute?->nombre;
return $attributeName
? "{$attributeName}: {$definition->value}"
: $definition->value;
})
->filter()
->implode(', ');
return $definitions === '' ? $name : "{$name} ({$definitions})";
return $this->catalogItem->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date
return $this->eventDate?->startsAt()
?? $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date
return $this->eventDate?->endsAt()
?? $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Catalog\Requests\AdminApp;
use Illuminate\Foundation\Http\FormRequest;
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'category_name' => ['required', 'string', 'max:255'],
'is_featured' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CategoryPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
@@ -25,6 +26,20 @@ class StoreCatalogItemRequest extends FormRequest
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'event_id' => [
'sometimes',
'nullable',
'required_with:event_product_type',
Rule::exists('events', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'event_product_type' => [
'sometimes',
'nullable',
'required_with:event_id',
Rule::enum(EventProductType::class),
],
'category_id' => [
'sometimes',
'nullable',
@@ -73,6 +88,14 @@ class StoreCatalogItemRequest extends FormRequest
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('event_id', $this->input('event_id'))
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Catalog\Resources\AdminApp;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
class OnTicketFeaturedGroupResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'category_id' => $this->category_id,
'category_name' => $this->category->nombre,
'group_name' => $this->group_name,
'is_featured' => $this->product_layout === ProductLayout::Row,
'type' => $this->source_type->value,
'product_layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,
'order' => $this->group_order,
];
}
}

View File

@@ -5,20 +5,22 @@ namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedItem */
/** @mixin CatalogItem */
class CatalogFeaturedItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$catalogItem = $this->catalogItem;
$catalogItem = $this->resource;
/** @var FeaturedGroup $featuredGroup */
$featuredGroup = $catalogItem->getRelation('featuredGroup');
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
return $this->columnWithImageData($catalogItem);
}
@@ -32,6 +34,8 @@ class CatalogFeaturedItemResource extends JsonResource
'variants' => $catalogItem->variants
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),

View File

@@ -22,6 +22,8 @@ class CatalogItemDetailResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -110,12 +112,16 @@ class CatalogItemDetailResource extends JsonResource
{
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $this->variantStock($variant),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [

View File

@@ -15,6 +15,8 @@ class CatalogItemResource extends JsonResource
return [
'id' => $this->id,
'type' => $this->type->value,
'event_id' => $this->event_id,
'event_product_type' => $this->event_product_type?->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
@@ -32,12 +34,16 @@ class CatalogItemResource extends JsonResource
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'real_stock' => $variant->inventory?->real_stock,
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
'effective_minimum_use_date' => $variant->eventDate?->startsAt()
?? $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
'effective_maximum_use_date' => $variant->eventDate?->endsAt()
?? $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [

View File

@@ -30,6 +30,8 @@ class CatalogSearchItemResource extends JsonResource
'variants' => $this->variants
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),

View File

@@ -5,11 +5,15 @@ namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\EventProductType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -38,14 +42,19 @@ class CatalogService
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasVariants = $attributeCodes !== [];
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
fn (array $variant): bool => ! empty($variant['event_date_id'])
);
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
$this->validateEventData($data);
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
} else {
if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([
'components' => ['Un item standard no puede tener componentes.'],
'components' => [__('api.catalog.standard_with_components')],
]);
}
@@ -120,9 +129,11 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
@@ -137,10 +148,12 @@ class CatalogService
'inventory',
'category',
'brand',
'event',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem.inventory',
@@ -194,6 +207,7 @@ class CatalogService
'inventory',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
@@ -210,6 +224,30 @@ class CatalogService
]));
}
/** @return LengthAwarePaginator<CatalogItem> */
public function categoryItems(
Tenant $tenant,
Category $category,
int $perPage,
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->with([
'attachments',
'inventory',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
}
public function delete(CatalogItem $catalogItem): void
{
DB::transaction(function () use ($catalogItem): void {
@@ -265,7 +303,7 @@ class CatalogService
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"components.{$index}" => ['El componente esta duplicado.'],
"components.{$index}" => [__('api.catalog.duplicate_component')],
]);
}
$seen[$key] = true;
@@ -278,7 +316,7 @@ class CatalogService
if ($componentItem === null) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El item no pertenece al tenant del bundle.',
__('api.catalog.component_wrong_tenant'),
],
]);
}
@@ -286,7 +324,7 @@ class CatalogService
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
'El componente debe ser un item standard distinto del bundle.',
__('api.catalog.invalid_component'),
],
]);
}
@@ -295,7 +333,7 @@ class CatalogService
if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'Debe seleccionar una variante para este componente.',
__('api.catalog.component_variant_required'),
],
]);
}
@@ -303,7 +341,7 @@ class CatalogService
if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'El componente con inventario directo no admite una variante.',
__('api.catalog.component_variant_forbidden'),
],
]);
}
@@ -311,7 +349,7 @@ class CatalogService
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
'La variante no pertenece al componente indicado.',
__('api.catalog.component_variant_invalid'),
],
]);
}
@@ -333,7 +371,7 @@ class CatalogService
{
if ($components === []) {
throw ValidationException::withMessages([
'components' => ['Un bundle debe tener al menos un componente.'],
'components' => [__('api.catalog.bundle_component_required')],
]);
}
@@ -348,7 +386,7 @@ class CatalogService
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
$field => ["{$field} no se admite para un bundle."],
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
]);
}
}
@@ -376,7 +414,7 @@ class CatalogService
if ($attachment === null) {
throw ValidationException::withMessages([
$validationKey => ['El attachment indicado no existe.'],
$validationKey => [__('api.catalog.attachment_not_found')],
]);
}
@@ -408,7 +446,7 @@ class CatalogService
if ($attribute === null) {
throw ValidationException::withMessages([
'attribute_codes' => [
"El atributo {$attributeCode} no existe para el tenant del ítem.",
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
],
]);
}
@@ -442,14 +480,34 @@ class CatalogService
) {
throw ValidationException::withMessages([
"variants.{$index}.inventory" => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
__('api.catalog.managed_inventory_fields'),
],
]);
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$eventDateId = $data['event_date_id'] ?? null;
if (
$eventDateId !== null
&& (
$catalogItem->event_id === null
|| ! EventDate::query()
->whereKey($eventDateId)
->where('event_id', $catalogItem->event_id)
->exists()
)
) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_id" => [
'The event date must belong to the catalog item event.',
],
]);
}
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'event_date_id' => $eventDateId,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
]);
@@ -463,7 +521,7 @@ class CatalogService
if ($itemAttribute === null) {
throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [
'El atributo no pertenece al ítem de catálogo.',
__('api.catalog.attribute_not_on_item'),
],
]);
}
@@ -477,6 +535,38 @@ class CatalogService
return $variant;
}
/** @param array<string, mixed> $data */
private function validateEventData(array $data): void
{
$eventId = $data['event_id'] ?? null;
$eventProductType = $data['event_product_type'] ?? null;
if (($eventId === null) !== ($eventProductType === null)) {
throw ValidationException::withMessages([
'event_id' => ['Event and event product type must be provided together.'],
]);
}
if ($eventId === null) {
return;
}
if (! Event::query()
->whereKey($eventId)
->where('tenant_code', $data['tenant_code'] ?? null)
->exists()) {
throw ValidationException::withMessages([
'event_id' => ['The event must belong to the catalog item tenant.'],
]);
}
if (! in_array($eventProductType, EventProductType::values(), true)) {
throw ValidationException::withMessages([
'event_product_type' => ['The event product type is invalid.'],
]);
}
}
private function validateVariantUseDates(Variant $variant, int $index): void
{
$minimumUseDate = $variant->getMinimumUseDate();
@@ -489,7 +579,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [
'La fecha maxima efectiva debe ser posterior o igual a la fecha minima efectiva.',
__('api.catalog.invalid_effective_date_range'),
],
]);
}
@@ -508,7 +598,7 @@ class CatalogService
if ($hasVariants && $variants === []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem con attribute_codes debe tener variantes.',
__('api.catalog.variants_required'),
],
]);
}
@@ -516,7 +606,7 @@ class CatalogService
if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([
'variants' => [
'Un ítem sin attribute_codes no puede tener variantes.',
__('api.catalog.variants_forbidden'),
],
]);
}
@@ -524,7 +614,7 @@ class CatalogService
if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([
'real_stock' => [
'Un ítem con variantes no puede tener inventario directo.',
__('api.catalog.direct_inventory_forbidden'),
],
]);
}
@@ -536,7 +626,7 @@ class CatalogService
) {
throw ValidationException::withMessages([
'inventory' => [
'inventory_id, reserved_stock y sold_units son administrados internamente.',
__('api.catalog.managed_inventory_fields'),
],
]);
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
class FeaturedGroupService
{
private const ITEMS_PER_PAGE = 12;
/** @return array<array-key, mixed> */
public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$items = $this->itemsQuery($featuredGroup)->get();
$this->attachGroup($items, $featuredGroup);
return CatalogFeaturedItemResource::collection($items)->resolve();
}
$paginator = $this->paginateItems($featuredGroup, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup);
return CatalogFeaturedItemResource::collection($paginator)
->response()
->getData(true);
}
/** @return Builder<CatalogItem> */
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->with([
'inventory',
'attachments',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
return match ($featuredGroup->source_type) {
FeaturedGroupSource::Manual => $query
->select('catalog_items.*')
->join('featured_items', 'featured_items.catalog_item_id', '=', 'catalog_items.id')
->where('featured_items.featured_group_id', $featuredGroup->id)
->orderBy('featured_items.order')
->orderBy('featured_items.id'),
FeaturedGroupSource::Category => $query
->where('catalog_items.category_id', $featuredGroup->category_id)
->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
};
}
private function paginateItems(
FeaturedGroup $featuredGroup,
int $page,
): LengthAwarePaginator {
$paginator = $this->itemsQuery($featuredGroup)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,
);
return $paginator->withPath(route('catalog.featured-groups.items.index', [
'tenant' => $featuredGroup->tenant_code,
'featuredGroup' => $featuredGroup->id,
]));
}
private function attachGroup(iterable $items, FeaturedGroup $featuredGroup): void
{
foreach ($items as $item) {
$item->setRelation('featuredGroup', $featuredGroup);
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class OnTicketFeaturedGroupService
{
/** @return Collection<int, FeaturedGroup> */
public function forTenant(Tenant $tenant): Collection
{
return FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->where('source_type', FeaturedGroupSource::Category)
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
->with('category')
->orderBy('group_order')
->orderBy('id')
->get();
}
/** @param array{category_name: string, is_featured: bool} $data */
public function create(Tenant $tenant, array $data): FeaturedGroup
{
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
$category = $tenant->categories()->create([
'nombre' => $data['category_name'],
]);
$featuredGroup = FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'source_type' => FeaturedGroupSource::Category,
'category_id' => $category->id,
'product_layout' => $this->productLayout($data['is_featured']),
'group_layout' => GroupLayout::Paginated,
'group_name' => $data['category_name'],
'group_order' => $this->nextOrder($tenant),
]);
return $featuredGroup->setRelation('category', $category);
});
}
/** @param array{category_name: string, is_featured: bool} $data */
public function update(
Tenant $tenant,
FeaturedGroup $featuredGroup,
array $data,
): FeaturedGroup {
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
$featuredGroup = FeaturedGroup::query()
->whereKey($featuredGroup->getKey())
->where('tenant_code', $tenant->codigo)
->where('source_type', FeaturedGroupSource::Category)
->lockForUpdate()
->firstOrFail();
$category = Category::query()
->whereKey($featuredGroup->category_id)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->firstOrFail();
$category->update(['nombre' => $data['category_name']]);
$featuredGroup->update([
'group_name' => $data['category_name'],
'product_layout' => $this->productLayout($data['is_featured']),
'group_layout' => GroupLayout::Paginated,
]);
return $featuredGroup->setRelation('category', $category);
});
}
private function productLayout(bool $isFeatured): ProductLayout
{
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
}
private function nextOrder(Tenant $tenant): int
{
$maximumOrder = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->max('group_order');
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
}
}

View File

@@ -0,0 +1,15 @@
<?php
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
->name('adminapp.featured-groups.index');
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
->name('adminapp.featured-groups.store');
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
->name('adminapp.featured-groups.update');
});

View File

@@ -9,6 +9,10 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
->name('catalog.featured-groups.items.index');
Route::get('catalog-items', [CatalogController::class, 'search'])
->name('catalog-items.index');
Route::get('categories/{category}', [CatalogController::class, 'category'])
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Requests\UpdateEventRequest;
use App\Domains\Event\Resources\EventResource;
use App\Domains\Event\Services\EventService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EventController extends Controller
{
public function __construct(protected EventService $eventService) {}
public function show(Request $request): EventResource
{
return EventResource::make(
$this->eventService->activeForTenant($request->user()->tenant()->firstOrFail())
);
}
public function update(UpdateEventRequest $request): EventResource
{
return EventResource::make(
$this->eventService->updateActiveForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated()
)
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'name',
'address',
])]
class Event extends Model
{
use HasFactory;
public $timestamps = false;
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return HasMany<EventDate, $this> */
public function dates(): HasMany
{
return $this->hasMany(EventDate::class)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon;
#[Fillable([
'event_id',
'date',
'time_start',
'time_end',
])]
class EventDate extends Model
{
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'event_id' => 'integer',
'date' => 'date:Y-m-d',
];
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
public function startsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
}
public function endsAt(): CarbonInterface
{
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateEventRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'],
'dates' => ['required', 'array', 'min:1'],
'dates.*' => ['required', 'array:date,start_time,end_time'],
'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'],
'dates.*.start_time' => ['required', 'date_format:H:i'],
'dates.*.end_time' => ['required', 'date_format:H:i'],
'social_media' => ['sometimes', 'array'],
'social_media.*' => ['required', 'array:code,url,orden'],
'social_media.*.code' => [
'required',
'string',
'distinct',
Rule::exists('social_media', 'code'),
],
'social_media.*.url' => ['required', 'url', 'max:2048'],
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
'contact' => ['sometimes', 'array:whatsapp_url,instagram_url,facebook_url'],
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
];
}
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
$input = $this->all();
if (! array_key_exists('social_media', $input) && ! array_key_exists('contact', $input)) {
$validator->errors()->add(
'social_media',
'The social media field is required.'
);
}
},
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Event */
class EventResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$socialMedia = $this->tenant->socialMedia->keyBy('code');
return [
'id' => $this->id,
'title' => $this->name,
'location' => $this->address,
'dates' => $this->dates->map(fn ($eventDate): array => [
'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'start_time' => substr($eventDate->time_start, 0, 5),
'end_time' => substr($eventDate->time_end, 0, 5),
])->values(),
'social_media' => $this->tenant->socialMedia->map(fn ($item): array => [
'code' => $item->code,
'url' => $item->pivot->url,
'orden' => $item->pivot->orden,
])->values(),
'contact' => [
'whatsapp_url' => $socialMedia->get('whatsapp')?->pivot->url,
'instagram_url' => $socialMedia->get('instagram')?->pivot->url,
'facebook_url' => $socialMedia->get('facebook')?->pivot->url,
],
];
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\Event;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class EventService
{
private const CONTACT_CODES = [
'whatsapp_url' => 'whatsapp',
'instagram_url' => 'instagram',
'facebook_url' => 'facebook',
];
public function activeForTenant(Tenant $tenant): Event
{
return $tenant->events()
->whereKey($tenant->active_event_id)
->with(['dates', 'tenant.socialMedia'])
->firstOrFail();
}
/** @param array<string, mixed> $data */
public function updateActiveForTenant(Tenant $tenant, array $data): Event
{
return DB::transaction(function () use ($tenant, $data): Event {
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$event = $tenant->active_event_id === null
? $tenant->events()->create([
'name' => $data['title'],
'address' => $data['location'],
])
: $tenant->events()
->whereKey($tenant->active_event_id)
->lockForUpdate()
->firstOrFail();
$event->update([
'name' => $data['title'],
'address' => $data['location'],
]);
if ($tenant->active_event_id === null) {
$tenant->update(['active_event_id' => $event->id]);
}
$this->syncDates($event, $data['dates']);
if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']);
} else {
$this->syncLegacyContact($tenant, $data['contact']);
}
return $event->load(['dates', 'tenant.socialMedia']);
});
}
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
private function syncDates(Event $event, array $dates): void
{
$existingDates = $event->dates()->get()->values();
foreach (array_values($dates) as $index => $date) {
$attributes = [
'date' => $date['date'],
'time_start' => $date['start_time'],
'time_end' => $date['end_time'],
];
$existingDate = $existingDates->get($index);
if ($existingDate) {
$existingDate->update($attributes);
} else {
$event->dates()->create($attributes);
}
}
$existingDates->slice(count($dates))->each->delete();
$event->unsetRelation('dates');
}
/** @param array<string, string|null> $contact */
private function syncLegacyContact(Tenant $tenant, array $contact): void
{
foreach (self::CONTACT_CODES as $field => $code) {
$url = $contact[$field] ?? null;
if ($url === null || $url === '') {
$tenant->socialMedia()->detach($code);
continue;
}
$tenant->socialMedia()->syncWithoutDetaching([
$code => ['url' => $url],
]);
}
$tenant->unsetRelation('socialMedia');
}
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
{
$associations = [];
foreach (array_values($socialMedia) as $index => $item) {
$associations[$item['code']] = [
'url' => $item['url'],
'orden' => $item['orden'] ?? $index,
];
}
$tenant->socialMedia()->sync($associations);
$tenant->unsetRelation('socialMedia');
}
}

View File

@@ -0,0 +1,11 @@
<?php
use App\Domains\Event\Controllers\AdminApp\EventController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\EventFormResource;
use App\Domains\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class EventFormController extends Controller
{
public function __construct(protected EventFormService $eventFormService) {}
public function __invoke(Request $request): EventFormResource
{
return EventFormResource::make(
$this->eventFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\SaleFormResource;
use App\Domains\Forms\Services\SaleFormService;
use App\Http\Controllers\Controller;
class SaleFormController extends Controller
{
public function __construct(protected SaleFormService $saleFormService) {}
public function __invoke(): SaleFormResource
{
return SaleFormResource::make($this->saleFormService->get());
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\StaffFormResource;
use App\Domains\Forms\Services\StaffFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class StaffFormController extends Controller
{
public function __construct(protected StaffFormService $staffFormService) {}
public function __invoke(Request $request): StaffFormResource
{
return StaffFormResource::make(
$this->staffFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'social_media' => SocialMediaOptionResource::collection(
$this->resource['social_media']
),
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SaleFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Resources;
use App\Domains\Tenant\Models\SocialMedia;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin SocialMedia */
class SocialMediaOptionResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->code,
'name' => $this->name,
'icon' => $this->icon,
'url' => $this->url,
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StaffFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'categories' => $this->resource['categories']->map(fn ($category) => [
'id' => $category->id,
'nombre' => $category->nombre,
])->values(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\SocialMedia;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
class EventFormService
{
/** @return array{social_media: Collection<int, SocialMedia>} */
public function get(Tenant $tenant): array
{
$urls = $tenant->socialMedia()
->pluck('tenant_social_media.url', 'social_media.code');
return [
'social_media' => SocialMedia::query()
->orderBy('id')
->get()
->each(fn (SocialMedia $item) => $item->setAttribute(
'url',
$urls->get($item->code)
)),
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Purchase\Models\Purchase;
class SaleFormService
{
/** @return array{statuses: list<array{code: string, name: string}>} */
public function get(): array
{
$names = [
Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
];
return [
'statuses' => array_map(
fn (string $status): array => [
'code' => $status,
'name' => $names[$status],
],
Purchase::statuses(),
),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class StaffFormService
{
/** @return array{categories: Collection<int, Category>} */
public function get(Tenant $tenant): array
{
return [
'categories' => Category::query()
->whereNull('categoria_id')
->where(function (Builder $query) use ($tenant): void {
$query->where('tenant_code', $tenant->codigo)
->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo));
})
->orderBy('nombre')
->get(),
];
}
}

View File

@@ -0,0 +1,14 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', EventFormController::class);
Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TelepagosWebhookController extends Controller
{
/**
* Handle the incoming Telepagos webhook.
*
* @param TelepagosWebhookRequest $request
* @param string $tenantCodigo
* @param TelepagosWebhookService $service
* @return JsonResponse
*/
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
{
try {
$cashinId = $request->validated('id');
$service->handleWebhook($tenantCodigo, $cashinId);
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
}
}
}

View File

@@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller
public function show(string $tenantCode, string $integrationCode)
{
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (!$integration) {
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
try {
$this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
);
return response()->json([
'message' => 'integration configured correctly',
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $e) {
return response()->json([
'message' => 'Error validando la configuración: ' . $e->getMessage()
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
], 400);
}
}

View File

@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
if (!$this->integrationModel) {
if (! $this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => 'Integration not found.'
'integration_code' => __('api.integration.not_configured'),
]);
}
}
@@ -34,7 +34,7 @@ class StoreTenantIntegrationRequest extends FormRequest
// Dynamic validation rules based on the integration data schema
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.' . $field] = $rule;
$rules['integration_data.'.$field] = $rule;
}
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TelepagosWebhookService
@@ -19,16 +20,13 @@ class TelepagosWebhookService
/**
* Handle the Telepagos webhook notification.
*
* @param string $tenantCodigo
* @param string $cashinId
* @return void
* @throws Exception
*/
public function handleWebhook(string $tenantCodigo, string $cashinId): void
{
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
$telepagosService = new TelepagosIntegrationService();
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo);
try {
@@ -48,14 +46,18 @@ class TelepagosWebhookService
if (! $cuit) {
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
return;
}
$dni = substr($cuit, 2, -1);
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
->where('transfer_payer_dni', $dni)
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->where('transfer_payer_dni', $dni)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
])
->where('payment_method', 'transfer')
->where('total', $amount)
->latest()
@@ -63,11 +65,13 @@ class TelepagosWebhookService
if (! $compra) {
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
return;
}
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
if (! $qrOrderId) {
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
return;
}
@@ -75,6 +79,7 @@ class TelepagosWebhookService
if (! $telepagosQr) {
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
return;
}
@@ -82,11 +87,15 @@ class TelepagosWebhookService
if (! $compra) {
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
return;
}
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
return;
}
@@ -94,10 +103,12 @@ class TelepagosWebhookService
if ($amount !== $totalAmount) {
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
return;
}
} else {
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
return;
}
@@ -114,7 +125,7 @@ class TelepagosWebhookService
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
];
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPurchase($compra);
$compra->markAsPaid();
@@ -122,7 +133,7 @@ class TelepagosWebhookService
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
} catch (Exception $e) {
Log::error("Telepagos webhook error: " . $e->getMessage());
Log::error('Telepagos webhook error: '.$e->getMessage());
throw $e;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Logging\Enums;
enum ValueChangeActorType: string
{
case User = 'user';
case System = 'system';
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Logging\Models\Concerns;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Facades\Auth;
use LogicException;
trait LogsValueChanges
{
abstract protected function valueChangeTenantCode(): string;
public static function bootLogsValueChanges(): void
{
static::updated(function (Model $model): void {
$changedAttributes = array_values(array_intersect(
$model->getLoggedAttributes(),
array_keys($model->getChanges()),
));
if ($changedAttributes === []) {
return;
}
$userId = Auth::id();
$actorType = $userId === null
? ValueChangeActorType::System
: ValueChangeActorType::User;
foreach ($changedAttributes as $attribute) {
$model->valueChanges()->create([
'tenant_code' => $model->valueChangeTenantCode(),
'attribute' => $attribute,
'old_value' => $model->getRawOriginal($attribute),
'new_value' => $model->getAttributes()[$attribute] ?? null,
'changed_at' => now(),
'actor_type' => $actorType,
'user_id' => $userId,
]);
}
});
}
/** @return array<int, string> */
public function getLoggedAttributes(): array
{
if (! property_exists($this, 'loggedAttributes')) {
throw new LogicException(sprintf(
'The [%s] model must define a $loggedAttributes property.',
static::class,
));
}
return array_values(array_unique($this->loggedAttributes));
}
/** @return MorphMany<ValueChange, $this> */
public function valueChanges(): MorphMany
{
return $this->morphMany(ValueChange::class, 'trackable');
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Logging\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'tenant_code',
'trackable_type',
'trackable_id',
'attribute',
'old_value',
'new_value',
'changed_at',
'actor_type',
'user_id',
])]
class ValueChange extends Model
{
public $timestamps = false;
/** @return MorphTo<Model, $this> */
public function trackable(): MorphTo
{
return $this->morphTo();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
protected function casts(): array
{
return [
'trackable_id' => 'integer',
'changed_at' => 'datetime',
'actor_type' => ValueChangeActorType::class,
'user_id' => 'integer',
];
}
}

View File

@@ -24,7 +24,8 @@ class MailTestService
);
return [
'message' => 'Correo de prueba enviado correctamente.',
'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient,
'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(),

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Menu\Models;
use App\Domains\Authorization\Models\Role;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -48,7 +49,7 @@ class Menu extends Model
&& empty($menu->static_content_schema)
) {
throw ValidationException::withMessages([
'static_content_schema' => 'El schema es obligatorio para los menús estáticos.',
'static_content_schema' => __('api.menu.schema_required'),
]);
}
});
@@ -78,4 +79,16 @@ class Menu extends Model
->withPivot('static_content')
->withTimestamps();
}
public function roles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'roles_menues',
'menu_codigo',
'rol_codigo',
'code',
'codigo'
)->using(MenuRole::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Menu\Models;
use App\Domains\Authorization\Models\Role;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;
class MenuRole extends Pivot
{
protected $table = 'roles_menues';
public $incrementing = true;
public $timestamps = false;
protected $fillable = [
'rol_codigo',
'menu_codigo',
];
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
}
public function menu(): BelongsTo
{
return $this->belongsTo(Menu::class, 'menu_codigo', 'code');
}
}

View File

@@ -23,7 +23,7 @@ class StoreTenantMenuRequest extends FormRequest
if (! $this->menuModel) {
throw ValidationException::withMessages([
'menu_code' => 'El menú indicado no existe.',
'menu_code' => __('api.menu.not_found'),
]);
}
}

View File

@@ -15,6 +15,7 @@ use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -55,7 +56,7 @@ class PurchaseController extends Controller
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing('items');
$compra->loadMissing('items')->loadCount('tickets');
$compra->items->load('imageAttachment');
return PurchaseResource::make($compra);
@@ -124,18 +125,32 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
}
$updated = Purchase::query()
->whereKey($compra->getKey())
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->where(function ($query): void {
$query->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->update($purchaseUpdate);
$updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->whereKey($compra->getKey())
->lockForUpdate()
->first();
if (
$purchase === null
|| ! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
return false;
}
$purchase->update($purchaseUpdate);
return true;
});
if ($updated === 0) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer available for payment.',
'purchase' => __('api.purchase.not_available_for_payment'),
]);
}
@@ -159,8 +174,14 @@ class PurchaseController extends Controller
],
]);
} catch (\Exception $e) {
Log::error('Unable to retrieve TelePagos account information.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([
'message' => 'Error getting account info: '.$e->getMessage(),
'code' => 'integration.account_info_failed',
'message' => __('api.integration.account_info_failed'),
], 500);
}
}
@@ -182,8 +203,14 @@ class PurchaseController extends Controller
'qr_code' => $qrResponse['qr_code'] ?? '',
]);
} catch (\Exception $e) {
Log::error('Unable to generate TelePagos QR code.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([
'message' => 'Error generating QR: '.$e->getMessage(),
'code' => 'integration.qr_generation_failed',
'message' => __('api.integration.qr_generation_failed'),
], 500);
}
@@ -197,7 +224,8 @@ class PurchaseController extends Controller
}
return response()->json([
'message' => 'Invalid payment method.',
'code' => 'integration.invalid_payment_method',
'message' => __('api.integration.invalid_payment_method'),
], 400);
}
@@ -210,6 +238,19 @@ class PurchaseController extends Controller
);
}
public function submitForReview(
Request $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->submitForReview($compra),
);
}
public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
@@ -222,7 +263,7 @@ class PurchaseController extends Controller
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
throw new NotFoundHttpException('Purchase not found for tenant.');
throw new NotFoundHttpException(__('api.errors.not_found'));
}
return $purchase;

View File

@@ -4,8 +4,11 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Event\Models\Event;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -17,6 +20,7 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'tenant_codigo',
'event_id',
'user_id',
'status',
'payment_method',
@@ -30,7 +34,7 @@ use Illuminate\Support\Facades\DB;
])]
class Purchase extends Model
{
use HasFactory;
use HasFactory, LogsValueChanges;
public const STATUS_CREATED = 'created';
@@ -44,12 +48,31 @@ class Purchase extends Model
public const STATUS_EXPIRED = 'expired';
/** @return list<string> */
public static function statuses(): array
{
return [
self::STATUS_CREATED,
self::STATUS_PENDING_PAYMENT,
self::STATUS_PAID,
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
];
}
protected $table = 'compras';
/** @var array<int, string> */
protected array $loggedAttributes = [
'status',
];
protected function casts(): array
{
return [
'cart_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -64,6 +87,12 @@ class Purchase extends Model
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/**
* @return BelongsTo<User, $this>
*/
@@ -88,6 +117,14 @@ class Purchase extends Model
return $this->hasMany(PurchaseItem::class, 'compra_id');
}
/**
* @return HasMany<Ticket, $this>
*/
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_purchase_id');
}
/**
* @return HasOne<TelepagosQr, $this>
*/
@@ -122,6 +159,11 @@ class Purchase extends Model
return (float) $this->items()->sum('total');
}
protected function valueChangeTenantCode(): string
{
return $this->tenant_codigo;
}
public function markAsPendingPayment(): void
{
$this->update([

View File

@@ -20,6 +20,9 @@ class PurchaseResource extends JsonResource
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$subtotal = $items->isNotEmpty()
? $items->reduce(
@@ -39,6 +42,7 @@ class PurchaseResource extends JsonResource
'id' => $this->id,
'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo,
'event_id' => $this->event_id,
'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status,
@@ -51,6 +55,8 @@ class PurchaseResource extends JsonResource
'email' => $this->email,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items' => PurchaseItemResource::collection($items),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),
];

View File

@@ -26,6 +26,11 @@ class CheckoutService
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
/** @var Tenant $tenant */
$tenant = Tenant::query()
->lockForUpdate()
->findOrFail($tenant->getKey());
$directItem = $purchaseData['direct_item'] ?? null;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
@@ -41,7 +46,7 @@ class CheckoutService
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => 'A cart or direct item is required.',
'cart_id' => __('api.purchase.source_required'),
]);
}
@@ -64,7 +69,7 @@ class CheckoutService
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => 'The purchase payment method must be selected before finalizing.',
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
@@ -86,6 +91,37 @@ class CheckoutService
});
}
public function submitForReview(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()
->lockForUpdate()
->findOrFail($purchase->getKey());
if (in_array($purchase->status, [
Purchase::STATUS_PAID,
], true)) {
return $this->loadPurchase($purchase);
}
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
}
$purchase->update([
'expires_at' => null,
]);
return $this->loadPurchase($purchase);
});
}
/**
* @param array<string, string> $customerData
*/
@@ -98,11 +134,14 @@ class CheckoutService
->findOrFail($purchase->getKey());
if (
$purchase->status !== Purchase::STATUS_CREATED
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.',
'purchase' => __('api.purchase.not_editable'),
]);
}
@@ -128,7 +167,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.',
'purchase' => __('api.purchase.not_editable'),
]);
}
@@ -144,7 +183,7 @@ class CheckoutService
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => 'The purchase item is no longer editable.',
'item' => __('api.purchase.item_not_editable'),
]);
}
@@ -162,7 +201,7 @@ class CheckoutService
}
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'quantity' => 'No hay suficiente stock disponible.',
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
@@ -197,7 +236,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.',
'purchase' => __('api.purchase.not_editable'),
]);
}
@@ -233,7 +272,7 @@ class CheckoutService
Purchase::STATUS_EXPIRED,
], true)) {
throw ValidationException::withMessages([
'purchase' => 'A cancelled, rejected or expired purchase cannot be confirmed.',
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
@@ -249,7 +288,7 @@ class CheckoutService
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => 'The purchase has an inconsistent stock reservation.',
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
@@ -306,7 +345,7 @@ class CheckoutService
}
throw ValidationException::withMessages([
'purchase' => 'A paid purchase cannot be cancelled.',
'purchase' => __('api.purchase.paid_cannot_cancel'),
]);
}
@@ -339,7 +378,7 @@ class CheckoutService
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'items' => 'The purchase has an inconsistent stock reservation.',
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
@@ -375,7 +414,7 @@ class CheckoutService
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'direct_item.cantidad' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.",
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]);
}
@@ -383,7 +422,7 @@ class CheckoutService
$this->catalogInventoryService->reserve($selection, $quantity);
} catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([
'direct_item.cantidad' => 'No hay suficiente stock disponible.',
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]);
}
@@ -416,7 +455,7 @@ class CheckoutService
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart does not contain items.',
'cart_id' => __('api.purchase.empty_cart'),
]);
}
@@ -581,6 +620,7 @@ class CheckoutService
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'event_id' => $tenant->active_event_id,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
@@ -596,13 +636,13 @@ class CheckoutService
foreach ($cartItems as $item) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items could not be loaded.',
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items do not belong to the tenant.',
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
@@ -621,7 +661,7 @@ class CheckoutService
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => 'The selected cart is no longer active.',
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
@@ -682,13 +722,13 @@ class CheckoutService
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => 'Un bundle no admite una variante.',
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'direct_item.catalog_item_id' => 'El bundle no tiene componentes.',
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
@@ -698,7 +738,7 @@ class CheckoutService
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'direct_item.variant_id' => 'Debe seleccionar una variante para este item.',
'direct_item.variant_id' => __('api.cart.variant_required'),
]);
}

View File

@@ -12,5 +12,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
});

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated())
)->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
]);
}
public function modifications(Request $request): AnonymousResourceCollection
{
return SaleModificationResource::collection(
$this->saleService->modifications(
$request->user()->tenant()->firstOrFail()
)
);
}
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
);
}
public function downloadModificationsPdf(Request $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppSaleIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValueChange */
class SaleModificationResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var Purchase|null $sale */
$sale = $this->whenLoaded('trackable');
$user = $this->whenLoaded('user');
return [
'id' => $this->id,
'sale_id' => $this->trackable_id,
'attribute' => $this->attribute,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value,
'sale' => $sale instanceof Purchase ? [
'id' => $sale->id,
'customer_name' => $sale->nombre_apellido,
'status' => $sale->status,
] : null,
'modified_by' => $user ? [
'id' => $user->id,
'name' => $user->nombre_apellido,
'email' => $user->email,
] : null,
];
}
}

Some files were not shown because too many files have changed in this diff Show More