Compare commits

..

4 Commits

558 changed files with 6434 additions and 35830 deletions

View File

@@ -3,15 +3,11 @@ APP_ENV=production
APP_KEY= APP_KEY=
APP_DEBUG=false APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
FRONTEND_URL=http://localhost:4200
INTEGRATION_SECRET=
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30 APP_LOCALE=en
PURCHASE_QR_EXPIRATION_MINUTES=15 APP_FALLBACK_LOCALE=en
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
@@ -19,38 +15,25 @@ APP_MAINTENANCE_DRIVER=file
# PHP_CLI_SERVER_WORKERS=4 # PHP_CLI_SERVER_WORKERS=4
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
BCRYPT_ROUNDS=12 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_CHANNEL=stack
LOG_STACK=single LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=sqlite
DB_HOST=127.0.0.1 # DB_HOST=127.0.0.1
DB_PORT=3306 # DB_PORT=3306
DB_DATABASE=laravel # DB_DATABASE=laravel
DB_USERNAME=root # DB_USERNAME=root
DB_PASSWORD= # DB_PASSWORD=
INTEGRATION_SECRET=change-me
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
SANCTUM_EXPIRATION=720
SESSION_SECURE_COOKIE=false
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
@@ -66,7 +49,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=log
MAIL_SCHEME=null MAIL_SCHEME=null
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
MAIL_PORT=2525 MAIL_PORT=2525
@@ -75,11 +58,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
AWS_ENDPOINT= AWS_ENDPOINT=
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=
AWS_HTTP_VERIFY=true AWS_HTTP_VERIFY=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

File diff suppressed because it is too large Load Diff

View File

@@ -66,42 +66,6 @@ class AttachmentService
$attachment->delete(); $attachment->delete();
} }
public function copy(Attachment $source, string $path): Attachment
{
$normalizedPath = $this->normalizeDirectory($path);
if ($normalizedPath === '') {
throw new AttachmentStorageException('The attachment path cannot be empty.');
}
$key = (string) Str::uuid();
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
if (! $copied) {
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
}
try {
/** @var Attachment $attachment */
$attachment = Attachment::query()->create([
'key' => $key,
'path' => $storedPath,
'filename' => $source->filename,
'type' => $source->type,
'mime_type' => $source->mime_type,
'extension' => $source->extension,
'size' => $source->size,
]);
return $attachment;
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
}
protected function normalizeDirectory(string $path): string protected function normalizeDirectory(string $path): string
{ {
return trim($path, '/'); return trim($path, '/');

View File

@@ -1,29 +0,0 @@
# Dominio Attachable
## Propósito
Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única.
## Componentes principales
- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso.
- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos.
- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos.
- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento.
## Flujo principal
1. El consumidor entrega un `UploadedFile` o una cadena Base64 y un directorio.
2. El servicio valida el contenido, detecta MIME/extensión y genera una clave UUID.
3. El archivo se guarda en el disco `s3`.
4. Se crea el registro `Attachment`; ante error se elimina el objeto que había sido subido.
## API y dependencias
No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog` y `Tenant`. Depende de Laravel Storage, Symfony Mime y del modelo `Attachment`.
## Consideraciones
- El directorio no puede quedar vacío después de normalizarlo.
- La eliminación se considera fallida si S3 no confirma el borrado.
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.

View File

@@ -1,42 +0,0 @@
<?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

@@ -1,26 +0,0 @@
<?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

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\CreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(CreateResetPasswordAttemptRequest $request): JsonResponse
{
$data = $request->validated();
$this->resetPasswordAttemptService->createForEmail(
$data['email'],
$data['tenant_codigo'],
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Services\GoogleAuthService;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class GoogleAuthController extends Controller
{
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
public function redirect(Request $request): RedirectResponse
{
return $this->googleAuthService->redirect($request);
}
public function callback(Request $request): RedirectResponse
{
return $this->googleAuthService->callback($request);
}
}

View File

@@ -1,51 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\GoogleAuthService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
class GoogleTokenExchangeController extends Controller
{
public function __construct(
private readonly GoogleAuthService $googleAuthService,
private readonly GuestCartMergeService $guestCartMergeService,
) {}
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
{
$authentication = $this->googleAuthService->exchange(
$request->validated('oauth_code'),
$request->validated('tenant_codigo'),
);
$guestTokenCookie = $request->cookie('guest_token');
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
? $guestTokenCookie
: null;
$this->guestCartMergeService->merge(
$authentication['tenant_codigo'],
$authentication['user'],
$guestToken,
);
$response = response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $authentication['token'],
'token_type' => 'Bearer',
'user' => UserResource::make($authentication['user']),
]);
if ($guestToken !== null) {
$response->withCookie(Cookie::forget('guest_token'));
}
return $response;
}
}

View File

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

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ResetPasswordRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ResetPasswordController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(ResetPasswordRequest $request): JsonResponse
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->resetPassword(
$data['email'],
$data['codigo'],
$data['password'],
)) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.password_reset_invalid'),
]);
}
return response()->json([
'code' => 'auth.password_updated',
'message' => __('api.auth.password_updated'),
'status' => ResetPasswordAttempt::STATUS_USED,
]);
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\ScannerLoginRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ScannerLoginController extends Controller
{
public function __construct(
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(ScannerLoginRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticateScanner(
$credentials['email'],
$credentials['password'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'scanner-token',
['scanner'],
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

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

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ValidateResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(ValidateResetPasswordAttemptRequest $request): JsonResponse
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode(
$data['email'],
$data['codigo'],
)) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'),
]);
}
return response()->json([
'code' => 'auth.reset_code_valid',
'message' => __('api.auth.reset_code_valid'),
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
}
}

View File

@@ -1,20 +0,0 @@
<?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

@@ -1,40 +0,0 @@
<?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

@@ -1,36 +0,0 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated';
public const STATUS_USED = 'used';
public const STATUS_EXPIRED = 'expired';
public $timestamps = false;
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -2,86 +2,26 @@
namespace App\Domains\Auth\Models; 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 Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; 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\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] #[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable;
protected $attributes = [
'rol_codigo' => RoleCode::User->value,
];
protected static function newFactory(): UserFactory protected static function newFactory(): UserFactory
{ {
return UserFactory::new(); return UserFactory::new();
} }
/** @return HasMany<ResetPasswordAttempt, $this> */
public function resetPasswordAttempts(): HasMany
{
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');
}
public function hasPermission(string $permissionCode): bool
{
return $this->role()
->whereHas(
'permissions',
fn ($query) => $query->where('permisos.codigo', $permissionCode)
)
->exists();
}
/**
* @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> * @return array<string, string>
*/ */
@@ -90,9 +30,6 @@ class User extends Authenticatable
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'failed_login_attempts' => 'integer',
'last_failed_login_at' => 'datetime',
'locked_until' => 'datetime',
]; ];
} }
} }

View File

@@ -1,36 +0,0 @@
<?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

@@ -1,37 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class CreateResetPasswordAttemptRequest 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 [
'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')],
'email' => ['required', 'string', 'email', 'max:255'],
];
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GoogleTokenExchangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'oauth_code' => ['required', 'uuid'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Auth\Requests; namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest class LoginUserRequest extends FormRequest
{ {
@@ -12,17 +11,6 @@ class LoginUserRequest extends FormRequest
return true; 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> * @return array<string, mixed>
*/ */
@@ -31,7 +19,6 @@ class LoginUserRequest extends FormRequest
return [ return [
'email' => ['required', 'string', 'email', 'max:255'], 'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'], 'password' => ['required', 'string'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
]; ];
} }
} }

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class RegisterUserRequest extends FormRequest class RegisterUserRequest extends FormRequest
{ {
@@ -19,10 +18,9 @@ class RegisterUserRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'nombre_apellido' => ['required', 'string', 'max:255'], 'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()], 'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'], 'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'], 'telefono' => ['nullable', 'string', 'max:255'],
]; ];

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
class ResetPasswordRequest 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'],
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
'password' => [
'required',
'string',
'confirmed',
Password::min(8)->mixedCase()->symbols(),
],
];
}
}

View File

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
class ScannerLoginRequest extends AdminAppLoginRequest {}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class ValidateResetPasswordAttemptRequest 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'],
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
];
}
}

View File

@@ -1,25 +0,0 @@
<?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

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

View File

@@ -1,26 +0,0 @@
<?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

@@ -1,173 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
class GoogleAuthService
{
public function redirect(Request $request): RedirectResponse
{
$tenantCode = $request->string('tenant')->toString();
$returnUrl = $request->string('return_url')->toString();
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]);
}
$state = (string) Str::uuid();
Cache::put("google-oauth-context:{$state}", [
'tenant_codigo' => $tenant->codigo,
'return_url' => rtrim($returnUrl, '/'),
], now()->addMinutes(10));
return Socialite::driver('google')
->scopes(['openid', 'profile', 'email'])
->stateless()
->with(['state' => $state])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
$state = $request->string('state')->toString();
/** @var array{tenant_codigo?: string, return_url?: string}|null $context */
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
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, __('api.auth.invalid_return_url'));
}
/** @var SocialiteUser $googleUser */
$googleUser = Socialite::driver('google')->stateless()->user();
$user = $this->resolveUser($googleUser, $tenant);
$token = $user->createToken(
'google-oauth',
['*'],
now()->addMinutes((int) config('sanctum.expiration')),
)->plainTextToken;
$exchangeCode = (string) Str::uuid();
Cache::put("google-oauth-exchange:{$exchangeCode}", [
'user_id' => $user->id,
'token' => $token,
'tenant_codigo' => $tenant->codigo,
], now()->addMinutes(5));
return redirect()->to($context['return_url'].'/login?'.http_build_query([
'oauth_code' => $exchangeCode,
]));
}
/** @return array{user: User, token: string, tenant_codigo: string} */
public function exchange(string $exchangeCode, string $tenantCodigo): array
{
/** @var array{user_id: int, token: string, tenant_codigo?: string}|null $authentication */
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
if (! $authentication) {
throw ValidationException::withMessages([
'oauth_code' => __('api.auth.oauth_code_expired'),
]);
}
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
throw ValidationException::withMessages([
'tenant_codigo' => __('api.auth.tenant_mismatch'),
]);
}
return [
'user' => User::query()->findOrFail($authentication['user_id']),
'token' => $authentication['token'],
'tenant_codigo' => $tenantCodigo,
];
}
private function resolveUser(SocialiteUser $googleUser, Tenant $tenant): User
{
$googleId = $googleUser->getId();
$email = $googleUser->getEmail();
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages([
'google' => __('api.auth.google_email_required'),
]);
}
$rawUser = $googleUser instanceof \Laravel\Socialite\Two\User ? $googleUser->getRaw() : [];
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([
'google' => __('api.auth.google_email_unverified'),
]);
}
$user = User::query()->where('google_id', $googleId)->first();
if ($user) {
return $user;
}
$user = User::query()->where('email', $email)->first();
if ($user) {
$user->forceFill(['google_id' => $googleId])->save();
return $user;
}
$name = $googleUser->getName();
$user = User::query()->create([
'nombre_apellido' => is_string($name) && $name !== '' ? $name : $email,
'email' => $email,
'email_verified_at' => now(),
'google_id' => $googleId,
'password' => Str::password(64),
]);
UserRegistered::dispatch($user, $tenant->codigo);
return $user;
}
private function isTenantReturnUrl(string $returnUrl, Tenant $tenant): bool
{
$parts = parse_url($returnUrl);
if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|| ($parts['path'] ?? '') !== '') {
return false;
}
$scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
return false;
}
return $scheme === 'https' || ($scheme === 'http' && in_array($host, ['localhost', '127.0.0.1'], true));
}
}

View File

@@ -1,279 +0,0 @@
<?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\PermissionCode;
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,
);
}
/**
* Authenticate a tenant-bound user authorized to scan tickets.
*
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticateScanner(
string $email,
string $password,
?string $ipAddress,
?string $userAgent,
): User {
return $this->authenticateUser(
$email,
$password,
null,
$ipAddress,
$userAgent,
null,
true,
PermissionCode::ScanTickets->value,
);
}
private function authenticateUser(
string $email,
string $password,
?string $tenantCode,
?string $ipAddress,
?string $userAgent,
?RoleCode $requiredRole = RoleCode::User,
bool $requiresTenant = false,
?string $requiredPermission = null,
): 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,
$requiredPermission,
): array {
$user = User::query()
->where('email', $normalizedEmail)
->when(
$requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
)
->when(
$requiredPermission !== null,
fn ($query) => $query->whereHas(
'role.permissions',
fn ($query) => $query->where('permisos.codigo', $requiredPermission)
),
)
->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 && $attemptTenantCode !== null) {
$this->registerFailure($user, $now, $attemptTenantCode);
}
$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

@@ -3,27 +3,20 @@
namespace App\Domains\Auth\Services; namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\UserRegistered;
class RegisterUserService class RegisterUserService
{ {
/** /**
* @param array{tenant_codigo?: string|null, nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data * @param array{nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
*/ */
public function register(array $data): User public function register(array $data): User
{ {
$user = User::query()->create([ return User::query()->create([
'nombre_apellido' => $data['nombre_apellido'], 'nombre_apellido' => $data['nombre_apellido'],
'email' => $data['email'], 'email' => $data['email'],
'password' => $data['password'], 'password' => $data['password'],
'dni' => $data['dni'] ?? null, 'dni' => $data['dni'] ?? null,
'telefono' => $data['telefono'] ?? null, 'telefono' => $data['telefono'] ?? null,
]); ]);
if (! empty($data['tenant_codigo'])) {
UserRegistered::dispatch($user, $data['tenant_codigo']);
}
return $user;
} }
} }

View File

@@ -1,181 +0,0 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\PasswordResetRequested;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class ResetPasswordAttemptService
{
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
{
$emailFingerprint = $this->emailFingerprint($email);
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
if ($user === null) {
Log::warning('Password reset attempt was not created because the user was not found.', [
'email_fingerprint' => $emailFingerprint,
]);
return null;
}
$user->resetPasswordAttempts()
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
return $attempt->getKey();
});
} catch (Throwable $exception) {
Log::error('Failed to create password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
if ($attemptId !== null) {
try {
PasswordResetRequested::dispatch($attemptId, $tenantCode);
} catch (Throwable $exception) {
Log::error('Failed to dispatch password reset email.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
}
public function validateCode(string $email, string $code): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
$attempt = $user?->resetPasswordAttempts()
->where('codigo', $code)
->where('status', ResetPasswordAttempt::STATUS_PENDING)
->latest('id')
->lockForUpdate()
->first();
if ($attempt === null) {
Log::warning('Password reset code validation failed: no matching pending attempt.', [
'email_fingerprint' => $emailFingerprint,
]);
return false;
}
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
public function resetPassword(string $email, string $code, string $password): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
$attempt = $user?->resetPasswordAttempts()
->where('codigo', $code)
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
->latest('id')
->lockForUpdate()
->first();
if ($user === null || $attempt === null) {
Log::warning('Password reset failed: no matching validated attempt.', [
'email_fingerprint' => $emailFingerprint,
]);
return false;
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
$user->locked_until = null;
$user->save();
$user->tokens()->delete();
$user->resetPasswordAttempts()
->whereKeyNot($attempt->getKey())
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_USED,
]);
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to reset user password.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
private function generateCode(): string
{
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
}
private function emailFingerprint(string $email): string
{
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
}
}

View File

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

View File

@@ -1,36 +0,0 @@
# Dominio Auth
## Propósito
Gestiona identidad y acceso de usuarios de la tienda y del panel administrativo: registro, inicio y cierre de sesión, perfil, autenticación con Google y recuperación de contraseña.
## Modelo y servicios
- `User`: usuario autenticable, asociado a tenant, rol, intentos de acceso y categorías habilitadas para escaneo.
- `LoginAttempt` y `ResetPasswordAttempt`: trazabilidad de accesos y recuperación de contraseña.
- `PasswordLoginService`: autentica tienda y AdminApp, incluyendo bloqueo por intentos.
- `RegisterUserService` y `ProfileService`: alta y edición del usuario.
- `ResetPasswordAttemptService`: crea, valida y consume códigos de recuperación.
- `GoogleAuthService`: redirección, callback e intercambio de código para Google OAuth.
- `AdminAppContextService`: carga el contexto requerido por un usuario administrativo.
## Endpoints públicos
- `POST /register`, `POST /login` y `POST /logout`.
- `GET /me` y `PUT /me`, protegidos por `auth:sanctum`.
- Creación, validación y aplicación de intentos de recuperación bajo `/password`.
- `POST /auth/google/exchange` para canjear el código de autenticación.
- `POST /v1/adminapp/login` y consulta del usuario administrativo dentro del grupo autenticado de AdminApp.
## Validación y respuestas
Los `FormRequest` validan cada operación. `UserResource` y `AdminAppMeResource` definen las representaciones de salida. Los endpoints sensibles aplican `auth:sanctum` y límites de frecuencia.
## Dependencias y eventos
Se relaciona con `Tenant` y `Authorization`; el registro y la recuperación disparan flujos atendidos por `Notification`. El carrito invitado puede integrarse al usuario autenticado mediante el dominio `Cart`.
## Consideraciones
- La resolución del tenant forma parte de la autenticación y no debe omitirse.
- Los cambios en reglas de login deben conservar los límites de intentos y el manejo de `AccountLockedException`.

View File

@@ -1,11 +0,0 @@
<?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

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

View File

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

View File

@@ -1,8 +0,0 @@
<?php
namespace App\Domains\Authorization\Enums;
enum PermissionCode: string
{
case ScanTickets = 'tickets.escanear';
}

View File

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

View File

@@ -1,32 +0,0 @@
<?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

@@ -1,59 +0,0 @@
<?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

@@ -1,32 +0,0 @@
<?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

@@ -1,26 +0,0 @@
# Dominio Authorization
## Propósito
Define el esquema de roles y permisos usado para autorizar funcionalidades de la aplicación.
## Componentes principales
- `Enums/RoleCode.php`: códigos de roles conocidos por el sistema.
- `Models/Role.php`: rol con relaciones hacia permisos, usuarios y menús.
- `Models/Permission.php`: permiso asignable a uno o más roles.
- `Models/RolePermission.php`: entidad de asociación entre rol y permiso.
## API
No expone controladores ni rutas propias. Su información se consume desde autenticación, menús, políticas y middleware de autorización.
## Relaciones relevantes
- `Role` tiene muchos usuarios del dominio `Auth`.
- Roles y permisos mantienen una relación muchos-a-muchos.
- Los roles determinan los menús disponibles mediante el dominio `Menu`.
## Consideraciones
Los códigos definidos en `RoleCode` funcionan como contrato entre datos persistidos y lógica de aplicación. Al agregar un rol o permiso se deben revisar seeds, asociaciones y consumidores.

View File

@@ -1,20 +0,0 @@
<?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

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

View File

@@ -1,20 +0,0 @@
<?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

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

View File

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

View File

@@ -1,35 +0,0 @@
<?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

@@ -1,5 +0,0 @@
<?php
namespace App\Domains\Bootstrap\Resources;
class ScannerBootstrapResource extends AdminAppBootstrapResource {}

View File

@@ -1,19 +0,0 @@
<?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

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

View File

@@ -1,26 +0,0 @@
<?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

@@ -1,28 +0,0 @@
# Dominio Bootstrap
## Propósito
Entrega la configuración inicial que necesitan la tienda y el panel administrativo antes de renderizar su interfaz.
## Flujos
- `TenantBootstrapService` resuelve un tenant desde el dominio solicitado y carga su información pública.
- `AdminAppBootstrapService` prepara el contexto inicial del panel administrativo para el tenant autenticado.
- Los controladores invocables transforman el resultado mediante `TenantResource` o `AdminAppBootstrapResource`.
## Endpoints
- `GET /tenants/bootstrap/{dominio}`: bootstrap público de la tienda.
- Endpoint de bootstrap bajo `/v1/adminapp`, protegido por `auth:sanctum` y `adminapp.tenant`.
## Validación
`TenantBootstrapRequest` valida el dominio recibido. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel.
## Dependencias
Depende principalmente de `Tenant` para resolver y cargar la tienda, y de los dominios que aportan datos al contexto administrativo.
## Consideraciones
Este dominio es un agregador de lectura. Debe mantenerse liviano y delegar la obtención de cada dato al dominio propietario.

View File

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

View File

@@ -1,10 +0,0 @@
<?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';
require __DIR__.'/scanner.php';

View File

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

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Domains\Bundle\Models;
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;
use Illuminate\Database\Eloquent\Casts\Attribute;
use App\Domains\Shared\Contracts\Buyable;
#[Fillable([
'tenant_codigo',
'nombre',
'descripcion',
'precio',
])]
class Bundle extends Model implements Buyable
{
use HasFactory;
protected $table = 'bundles';
protected $appends = ['stock_tecnico'];
protected function casts(): array
{
return [
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<BundleItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(BundleItem::class, 'bundle_id');
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre ?? 'Bundle';
}
public function incrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->incrementRealStock($amount * $item->cantidad);
}
}
public function decrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementRealStock($amount * $item->cantidad);
}
}
public function incrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->incrementReservedStock($amount * $item->cantidad);
}
}
public function decrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementReservedStock($amount * $item->cantidad);
}
}
public function confirmReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->confirmReservedStock($amount * $item->cantidad);
}
}
public function validateStock(): void
{
if ($this->stock_tecnico <= 0) {
throw new \InvalidArgumentException('El bundle no tiene stock técnico disponible.');
}
}
protected function stockTecnico(): Attribute
{
return Attribute::get(function () {
if ($this->items->isEmpty()) {
return 0;
}
$minStock = PHP_INT_MAX;
foreach ($this->items as $item) {
// Ensure variant is loaded
if ($item->variant) {
$itemStock = floor($item->variant->stock_tecnico / $item->cantidad);
if ($itemStock < $minStock) {
$minStock = $itemStock;
}
}
}
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_id',
'producto_variante_id',
'cantidad',
])]
class BundleItem extends Model
{
use HasFactory;
protected $table = 'bundle_items';
protected function casts(): array
{
return [
'bundle_id' => 'integer',
'producto_variante_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Bundle, $this>
*/
public function bundle(): BelongsTo
{
return $this->belongsTo(Bundle::class, 'bundle_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
}

View File

@@ -2,11 +2,11 @@
namespace App\Domains\Cart\Controllers; namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest; use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest; use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource; use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService; use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -16,7 +16,8 @@ class CartController extends Controller
{ {
public function __construct( public function __construct(
protected CartService $cartService, protected CartService $cartService,
) {} ) {
}
public function show(Request $request, Tenant $tenant): CartResource public function show(Request $request, Tenant $tenant): CartResource
{ {
@@ -28,18 +29,13 @@ class CartController extends Controller
$result = $this->cartService->addItem( $result = $this->cartService->addItem(
$tenant, $tenant,
$request, $request,
(int) $request->validated('catalog_item_id'), $request->mappedBuyableType(),
$request->validated('variant_id') !== null (int) $request->validated('buyable_id'),
? (int) $request->validated('variant_id')
: null,
(int) $request->validated('cantidad'), (int) $request->validated('cantidad'),
); );
$response = CartResource::make($result['cart']) $response = CartResource::make($result['cart'])
->additional([ ->additional(['message' => 'Producto agregado al carrito.'])
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response(); ->response();
if ($result['guest_token'] !== null) { if ($result['guest_token'] !== null) {
@@ -52,38 +48,22 @@ class CartController extends Controller
public function updateItemQuantity( public function updateItemQuantity(
UpdateCartItemQuantityRequest $request, UpdateCartItemQuantityRequest $request,
Tenant $tenant, Tenant $tenant,
CartItem $cartItem, \App\Domains\Cart\Models\CartItem $cartItem,
): CartResource { ): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make( return CartResource::make(
$this->cartService->updateItem( $this->cartService->updateItemQuantity(
$tenant, $tenant,
$request, $request,
$cartItem->getKey(), $cartItem->getKey(),
(int) $request->validated('cantidad'), (int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
) )
)->additional([ )->additional(['message' => 'Cantidad de producto actualizada.']);
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
} }
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource public function removeItem(Request $request, Tenant $tenant, \App\Domains\Cart\Models\CartItem $cartItem): CartResource
{ {
return CartResource::make( return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey()) $this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional([ )->additional(['message' => 'Producto eliminado del carrito.']);
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
} }
} }

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domains\Cart\Middleware;
use App\Domains\Cart\Models\Cart;
use App\Domains\Tenant\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Symfony\Component\HttpFoundation\Response;
class MergeGuestCartMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$guestToken = $request->cookie('guest_token');
// Resolve authenticated user optionally (from default guard or sanctum guard)
$user = $request->user() ?? Auth::guard('sanctum')->user();
$userId = $user?->getKey();
\Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [
'user_id' => $userId,
'guest_token' => $guestToken,
]);
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
$tenantParam = $request->route('tenant');
$tenantCodigo = null;
if ($tenantParam instanceof Tenant) {
$tenantCodigo = $tenantParam->codigo;
} elseif (is_string($tenantParam)) {
$tenantCodigo = $tenantParam;
}
if ($tenantCodigo !== null) {
$guestCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('guest_token', $guestToken)
->first();
if ($guestCart !== null && $guestCart->items()->exists()) {
$userCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('user_id', $userId)
->first();
if ($userCart !== null) {
$userCart->delete();
}
$guestCart->update([
'user_id' => $userId,
'guest_token' => null,
]);
}
}
}
$response = $next($request);
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
// Remove the cookie from the response since the user is authenticated.
if (method_exists($response, 'withCookie')) {
$response->withCookie(Cookie::forget('guest_token'));
}
}
return $response;
}
}

View File

@@ -3,11 +3,7 @@
namespace App\Domains\Cart\Models; namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -67,50 +63,42 @@ class Cart extends Model
{ {
$items = $this->relationLoaded('items') $items = $this->relationLoaded('items')
? $this->getRelation('items') ? $this->getRelation('items')
: $this->items()->with(['catalogItem', 'variant'])->get(); : $this->items()->with('buyable')->get();
return (float) $items->reduce( return (float) $items->reduce(
fn (float $carry, CartItem $item): float => $carry fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad),
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0, 0.0,
); );
} }
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
{ {
if ($quantity <= 0) { if ($quantity <= 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'), 'cantidad' => 'La cantidad debe ser mayor a cero.',
]); ]);
} }
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem { return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail(); $buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
if ($availableQuantity !== null && $availableQuantity < $quantity) { if ($buyable->stock_tecnico < $quantity) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]), 'cantidad' => "Stock insuficiente para la variante/bundle solicitado. Maximo disponible: {$buyable->stock_tecnico}.",
]); ]);
} }
/** @var CartItem|null $item */ /** @var CartItem|null $item */
$item = $this->items() $item = $this->items()
->where('catalog_item_id', $catalogItemId) ->where('buyable_type', $buyableType)
->where('variant_id', $variantId) ->where('buyable_id', $buyable->getKey())
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($item === null) { if ($item === null) {
$item = $this->items()->create([ $item = $this->items()->create([
'catalog_item_id' => $catalogItemId, 'buyable_type' => $buyableType,
'variant_id' => $variantId, 'buyable_id' => $buyable->getKey(),
'cantidad' => $quantity, 'cantidad' => $quantity,
]); ]);
} else { } else {
@@ -118,110 +106,34 @@ class Cart extends Model
$item->save(); $item->save();
} }
$inventoryService->reserve($selectedItem, $quantity); $buyable->incrementReservedStock($quantity);
return $item->fresh(); return $item->fresh();
}); });
} }
public function updateItem( public function updateItem(int $cartItemId, int $quantity): CartItem
int $cartItemId, {
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
): CartItem {
if ($quantity <= 0) { if ($quantity <= 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'), 'cantidad' => 'La cantidad debe ser mayor a cero.',
]); ]);
} }
return DB::transaction(function () use ( return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
$cartItemId,
$quantity,
$variantId,
$updateVariant,
): CartItem {
/** @var CartItem $item */ /** @var CartItem $item */
$item = $this->items() $item = $this->items()
->where('id', $cartItemId) ->where('id', $cartItemId)
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$currentSelection = $this->resolveScopedItem( $buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$item->catalog_item_id,
$item->variant_id,
true,
);
$inventoryService = app(CatalogInventoryService::class);
if ($updateVariant && $variantId !== $item->variant_id) {
$nextSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$variantId,
true,
);
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
);
$inventoryService->release($currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
$targetItem = $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $variantId)
->whereKeyNot($item->getKey())
->lockForUpdate()
->first();
$inventoryService->reserve($nextSelection, $quantity);
if ($targetItem !== null) {
$targetItem->cantidad += $quantity;
$targetItem->save();
$item->delete();
return $targetItem->fresh();
}
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
return $item->fresh();
}
$delta = $quantity - $item->cantidad; $delta = $quantity - $item->cantidad;
if ($delta > 0) { if ($delta > 0 && $buyable->stock_tecnico < $delta) {
$otherVariantsQuantity = (int) $this->items() $maxAvailable = $buyable->stock_tecnico + $item->cantidad;
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$currentSelection,
$otherVariantsQuantity + $quantity,
);
}
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]), 'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
]); ]);
} }
@@ -229,11 +141,11 @@ class Cart extends Model
$item->save(); $item->save();
if ($delta > 0) { if ($delta > 0) {
$inventoryService->reserve($currentSelection, $delta); $buyable->incrementReservedStock($delta);
} }
if ($delta < 0) { if ($delta < 0) {
$inventoryService->release($currentSelection, abs($delta)); $buyable->decrementReservedStock(abs($delta));
} }
return $item->fresh(); return $item->fresh();
@@ -249,116 +161,36 @@ class Cart extends Model
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$selectedItem = $this->resolveScopedItem( $buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$item->catalog_item_id, $buyable->decrementReservedStock($item->cantidad);
$item->variant_id,
true,
);
app(CatalogInventoryService::class)->release(
$selectedItem,
$item->cantidad,
);
$item->delete(); $item->delete();
}); });
} }
protected function resolveScopedItem( protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false)
int $catalogItemId,
?int $variantId,
bool $lockForUpdate = false,
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
}
$catalogItem = $catalogItemQuery->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_required'),
]);
}
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
$catalogItem->setRelation('inventory', $inventory);
return $catalogItem;
}
$variantQuery = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id);
if ($lockForUpdate) {
$variantQuery->lockForUpdate();
}
$variant = $variantQuery->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);
return $variant;
}
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
): void {
if ($this->user_id === null) {
return;
}
$catalogItem = $selectedItem instanceof Variant
? $selectedItem->catalogItem
: $selectedItem;
app(UserPurchaseLimitService::class)->assertCanPurchase(
$catalogItem,
$this->user_id,
$cartQuantity,
field: 'cantidad',
);
}
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
{ {
$query = Inventory::query()->whereKey($inventoryId); if ($buyableType === \App\Domains\Catalog\Models\ProductVariant::class) {
$query = \App\Domains\Catalog\Models\ProductVariant::query()
->whereKey($buyableId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
} elseif ($buyableType === \App\Domains\Bundle\Models\Bundle::class) {
$query = \App\Domains\Bundle\Models\Bundle::query()
->whereKey($buyableId)
->where('tenant_codigo', $this->tenant_codigo);
} else {
throw new \InvalidArgumentException('Invalid buyable type');
}
if ($lockForUpdate) { if ($lockForUpdate) {
$query->lockForUpdate(); $query->lockForUpdate();
} }
return $query->firstOrFail(); $buyable = $query->first();
if ($buyable === null) {
throw new NotFoundHttpException('Buyable not found for tenant.');
}
return $buyable;
} }
} }

View File

@@ -2,8 +2,7 @@
namespace App\Domains\Cart\Models; namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -11,8 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'cart_id', 'cart_id',
'catalog_item_id', 'buyable_id',
'variant_id', 'buyable_type',
'cantidad', 'cantidad',
])] ])]
class CartItem extends Model class CartItem extends Model
@@ -25,8 +24,8 @@ class CartItem extends Model
{ {
return [ return [
'cart_id' => 'integer', 'cart_id' => 'integer',
'catalog_item_id' => 'integer', 'buyable_id' => 'integer',
'variant_id' => 'integer', 'buyable_type' => 'string',
'cantidad' => 'integer', 'cantidad' => 'integer',
]; ];
} }
@@ -39,20 +38,11 @@ class CartItem extends Model
return $this->belongsTo(Cart::class, 'cart_id'); return $this->belongsTo(Cart::class, 'cart_id');
} }
/** @return BelongsTo<CatalogItem, $this> */ /**
public function catalogItem(): BelongsTo * @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function buyable()
{ {
return $this->belongsTo(CatalogItem::class); return $this->morphTo();
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class);
}
public function selectedItem(): CatalogItem|Variant|null
{
return $this->variant ?? $this->catalogItem;
} }
} }

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Cart\Requests; namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddCartItemRequest extends FormRequest class AddCartItemRequest extends FormRequest
{ {
@@ -17,25 +16,19 @@ class AddCartItemRequest extends FormRequest
*/ */
public function rules(): array public function rules(): array
{ {
$tenantCode = $this->route('tenant')?->codigo;
return [ return [
'catalog_item_id' => [ 'buyable_type' => ['required', 'string', \Illuminate\Validation\Rule::in(['variant', 'bundle'])],
'required', 'buyable_id' => ['required', 'integer'],
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id')->where(
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
),
],
'cantidad' => ['required', 'integer', 'min:1'], 'cantidad' => ['required', 'integer', 'min:1'],
]; ];
} }
public function mappedBuyableType(): string
{
return match ($this->input('buyable_type')) {
'variant' => \App\Domains\Catalog\Models\ProductVariant::class,
'bundle' => \App\Domains\Bundle\Models\Bundle::class,
default => throw new \InvalidArgumentException('Invalid buyable type'),
};
}
} }

View File

@@ -18,8 +18,6 @@ class UpdateCartItemQuantityRequest extends FormRequest
{ {
return [ return [
'cantidad' => ['required', 'integer', 'min:1'], 'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['sometimes', 'nullable', 'integer'],
]; ];
} }
} }

View File

@@ -2,14 +2,11 @@
namespace App\Domains\Cart\Resources; namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @mixin CartItem * @mixin \App\Domains\Cart\Models\CartItem
*/ */
class CartItemResource extends JsonResource class CartItemResource extends JsonResource
{ {
@@ -18,40 +15,42 @@ class CartItemResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$selectedItem = $this->selectedItem(); /** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
$buyable = $this->buyable;
$productName = $buyable?->getName();
$precio = $buyable?->getPrice();
$imageUrl = null; $imageUrl = null;
if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
if ($selectedItem?->relationLoaded('attachments')) { $firstAttachment = $buyable->attachments->first();
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440); if ($firstAttachment) {
} $imageUrl = $firstAttachment->getTemporaryUrl(1440);
}
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
} }
return [ return [
'id' => $this->id, 'id' => $this->id,
'cantidad' => $this->cantidad, 'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()), 'precio_unitario' => $this->formatMoney($precio),
'catalog_item_id' => $this->catalog_item_id, 'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'variant_id' => $this->variant_id, 'buyable_id' => $this->buyable_id,
'product' => $selectedItem === null ? null : [ 'product' => $buyable === null ? null : [
'nombre' => $selectedItem->getName(), 'nombre' => $productName,
'imagen' => $imageUrl, 'imagen' => $imageUrl,
'variants' => $this->catalogItem->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->getPrice()),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectionOptions($this->catalogItem->itemAttributes),
])
->values(),
], ],
]; ];
} }
protected function mapBuyableTypeToAlias(?string $type): string
{
return match ($type) {
\App\Domains\Catalog\Models\ProductVariant::class => 'variant',
\App\Domains\Bundle\Models\Bundle::class => 'bundle',
default => 'unknown',
};
}
protected function formatMoney(float|int|string|null $amount): string protected function formatMoney(float|int|string|null $amount): string
{ {
return number_format((float) ($amount ?? 0), 2, '.', ''); return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@@ -2,12 +2,11 @@
namespace App\Domains\Cart\Resources; namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @mixin Cart * @mixin \App\Domains\Cart\Models\Cart
*/ */
class CartResource extends JsonResource class CartResource extends JsonResource
{ {
@@ -21,8 +20,7 @@ class CartResource extends JsonResource
: collect(); : collect();
$subtotal = $items->reduce( $subtotal = $items->reduce(
fn (float $carry, $item): float => $carry fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * $item->cantidad),
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0, 0.0,
); );

View File

@@ -2,11 +2,10 @@
namespace App\Domains\Cart\Services; namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie; use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -33,17 +32,12 @@ class CartService
/** /**
* @return array{cart: Cart, guest_token: ?string} * @return array{cart: Cart, guest_token: ?string}
*/ */
public function addItem( public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array
Tenant $tenant, {
Request $request,
int $catalogItemId,
?int $variantId,
int $quantity,
): array {
$resolvedIdentity = $this->resolveIdentity($request, true); $resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity']; $identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity); $cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($catalogItemId, $variantId, $quantity); $cart->addItem($buyableType, $buyableId, $quantity);
return [ return [
'cart' => $this->loadCart($cart), 'cart' => $this->loadCart($cart),
@@ -51,17 +45,11 @@ class CartService
]; ];
} }
public function updateItem( public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
Tenant $tenant, {
Request $request,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
$identity = $this->requireIdentity($request); $identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity); $cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant); $cart->updateItem($cartItemId, $quantity);
return $this->loadCart($cart); return $this->loadCart($cart);
} }
@@ -105,18 +93,9 @@ class CartService
protected function loadCart(Cart $cart): Cart protected function loadCart(Cart $cart): Cart
{ {
return $cart->fresh()->load([ return $cart->fresh()->load([
'items.catalogItem.attachments', 'items.variant.product',
'items.catalogItem.inventory', 'items.variant.definitions.productAttribute.attribute',
'items.catalogItem.itemAttributes.attribute',
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
'items.variant.attachments', 'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
]); ]);
} }
@@ -125,7 +104,7 @@ class CartService
*/ */
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{ {
$user = $request->user() ?? Auth::guard('sanctum')->user(); $user = $request->user() ?? \Illuminate\Support\Facades\Auth::guard('sanctum')->user();
if ($user instanceof User) { if ($user instanceof User) {
return [ return [
@@ -185,7 +164,6 @@ class CartService
{ {
return Cart::query() return Cart::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('status', 'active')
->when( ->when(
$identity['user_id'] !== null, $identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']), fn ($query) => $query->where('user_id', $identity['user_id']),
@@ -213,10 +191,7 @@ class CartService
*/ */
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{ {
$attributes = [ $attributes = ['tenant_codigo' => $tenant->codigo];
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
];
if ($identity['user_id'] !== null) { if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id']; $attributes['user_id'] = $identity['user_id'];
@@ -224,6 +199,7 @@ class CartService
$attributes['guest_token'] = $identity['guest_token']; $attributes['guest_token'] = $identity['guest_token'];
} }
return Cart::query()->firstOrCreate($attributes); return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
} }
} }

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use Illuminate\Support\Facades\DB;
class GuestCartMergeService
{
public function merge(string $tenantCodigo, User $user, ?string $guestToken): void
{
if ($guestToken === null || $guestToken === '') {
return;
}
DB::transaction(function () use ($tenantCodigo, $user, $guestToken): void {
$guestCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('guest_token', $guestToken)
->where('status', 'active')
->lockForUpdate()
->first();
if ($guestCart === null || ! $guestCart->items()->exists()) {
return;
}
$userCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('user_id', $user->getKey())
->where('status', 'active')
->lockForUpdate()
->first();
if ($userCart !== null) {
$userCart->update([
'status' => 'converted',
]);
$userCart->delete();
}
$guestCart->update([
'user_id' => $user->getKey(),
'guest_token' => null,
]);
});
}
}

View File

@@ -1,32 +0,0 @@
# Dominio Cart
## Propósito
Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios autenticados.
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
Bajo `/tenants/{tenant:codigo}`:
- `GET /cart`.
- `POST /cart/items`.
- `PATCH /cart/items/{cartItem}`.
- `DELETE /cart/items/{cartItem}`.
## Contratos
`AddCartItemRequest` y `UpdateCartItemQuantityRequest` validan selección y cantidad. `CartResource` y `CartItemResource` estabilizan la respuesta pública.
## Dependencias y reglas
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.

View File

@@ -4,6 +4,7 @@ use App\Domains\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}') Route::prefix('tenants/{tenant:codigo}')
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
->group(function (): void { ->group(function (): void {
Route::get('cart', [CartController::class, 'show']); Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']); Route::post('cart/items', [CartController::class, 'addItem']);

View File

@@ -1,65 +0,0 @@
<?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

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Requests\StoreAttributeRequest;
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
use App\Domains\Catalog\Resources\AttributeResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return AttributeResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = ProductService::createAttribute($tenant, $request->validated());
return AttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
return AttributeResource::make($attribute->load('options'));
}
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
$attribute = ProductService::updateAttribute($attribute, $request->validated());
return AttributeResource::make($attribute);
}
public function destroy(Tenant $tenant, Attribute $attribute): Response
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
ProductService::deleteAttribute($attribute);
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Requests\StoreBrandRequest;
use App\Domains\Catalog\Requests\UpdateBrandRequest;
use App\Domains\Catalog\Resources\BrandResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BrandController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return BrandResource::collection(
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
)->response();
}
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
{
$brand = Brand::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return BrandResource::make($brand)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
return BrandResource::make($marca);
}
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->update($request->validated());
return BrandResource::make($marca);
}
public function destroy(Tenant $tenant, Brand $marca): Response
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->delete();
return response()->noContent();
}
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
{
if ($brand->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Brand not found for tenant.');
}
return $brand;
}
}

View File

@@ -2,126 +2,22 @@
namespace App\Domains\Catalog\Controllers; namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
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\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\CatalogItemDetailResource; use App\Domains\Catalog\Models\FeaturedGroup;
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\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Routing\Controller;
class CatalogController extends Controller class CatalogController extends Controller
{ {
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse public function index(string $tenant): JsonResponse
{ {
$featuredGroups = FeaturedGroup::query() $featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
->where('tenant_code', $tenant->codigo) ->with(['featuredVariants' => function ($query) {
$query->orderBy('order');
}, 'featuredVariants.variant.product', 'featuredVariants.variant.attachments', 'featuredVariants.variant.product.attachments'])
->orderBy('group_order') ->orderBy('group_order')
->get(); ->get();
return response()->json($featuredGroups->map( return response()->json(CatalogFeaturedGroupResource::collection($featuredGroups)->resolve());
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1),
))->resolve()
));
}
public function search(
SearchCatalogItemsRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): AnonymousResourceCollection {
return CatalogSearchItemResource::collection(
$catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
)
);
}
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($featuredGroupService->itemsResponse($featuredGroup, $page));
}
public function show(
CatalogItemDetailRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
CatalogService $catalogService,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$variantId = $request->validated('variant_id');
return CatalogItemDetailResource::make(
$catalogService->getDetail(
$catalogItem,
$variantId === null ? null : (int) $variantId,
)
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
} }
} }

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Requests\StoreCategoryRequest;
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
use App\Domains\Catalog\Resources\CategoryResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CategoryController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return CategoryResource::collection(
Category::query()
->where(function ($query) use ($tenant): void {
$query
->where('tenant_code', $tenant->codigo)
->orWhereNull('tenant_code');
})
->with(['parent', 'subCategories', 'tenant'])
->orderByDesc('id')
->get()
)->response();
}
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
{
$category = Category::query()->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
->response()
->setStatusCode(201);
}
public function show(Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$this->ensureCategoryIsMutable($categoria);
$categoria->update($request->validated());
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function destroy(Tenant $tenant, Category $categoria): Response
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$this->ensureCategoryIsMutable($categoria);
$categoria->delete();
return response()->noContent();
}
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
{
if ($category->tenant_code !== null && $category->tenant_code !== $tenant->codigo) {
throw new NotFoundHttpException('Category not found for tenant.');
}
return $category;
}
protected function ensureCategoryIsMutable(Category $category): void
{
if ($category->isGlobal()) {
throw new AccessDeniedHttpException('Global categories are read-only.');
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\StoreFeaturedGroupRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedGroupRequest;
use App\Domains\Catalog\Resources\FeaturedGroupResource;
use App\Domains\Catalog\Services\FeaturedGroupService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedGroupController extends Controller
{
public function __construct(
private readonly FeaturedGroupService $featuredGroupService
) {}
public function index(string $tenant): AnonymousResourceCollection
{
$groups = FeaturedGroup::where('tenant_codigo', $tenant)->get();
return FeaturedGroupResource::collection($groups);
}
public function store(StoreFeaturedGroupRequest $request, string $tenant): FeaturedGroupResource
{
$group = $this->featuredGroupService->createGroup($tenant, $request->validated());
return new FeaturedGroupResource($group);
}
public function show(string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return new FeaturedGroupResource($featuredGroup->load('featuredVariants'));
}
public function update(UpdateFeaturedGroupRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$group = $this->featuredGroupService->updateGroup($featuredGroup, $request->validated());
return new FeaturedGroupResource($group);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$this->featuredGroupService->deleteGroup($featuredGroup);
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedVariant;
use App\Domains\Catalog\Requests\StoreFeaturedVariantRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedVariantRequest;
use App\Domains\Catalog\Resources\FeaturedVariantResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedVariantController extends Controller
{
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return FeaturedVariantResource::collection($featuredGroup->featuredVariants);
}
public function store(StoreFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedVariantResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$variant = $featuredGroup->featuredVariants()->create($request->validated());
return new FeaturedVariantResource($variant);
}
public function update(UpdateFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup, FeaturedVariant $featuredVariant): FeaturedVariantResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->update($request->validated());
return new FeaturedVariantResource($featuredVariant);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup, FeaturedVariant $featuredVariant): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Requests\ProductDetailRequest;
use App\Domains\Catalog\Requests\StoreProductRequest;
use App\Domains\Catalog\Requests\UpdateProductRequest;
use App\Domains\Catalog\Resources\ProductResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductController extends Controller
{
public function index(Tenant $tenant, ProductService $productService): JsonResponse
{
return ProductResource::collection(
$productService->getProductos($tenant)
)->response();
}
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
{
$product = $productService->create($tenant, $request->validated());
return ProductResource::make($product)->response()->setStatusCode(201);
}
public function show(ProductDetailRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$variantId = $request->query('variant_id');
$variantId = $variantId !== null ? (int) $variantId : null;
$producto = $productService->getProductDetail($tenant, $producto, $variantId);
return ProductResource::make($producto);
}
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto = $productService->update($producto, $request->validated());
return ProductResource::make($producto);
}
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productService->delete($producto);
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found.');
}
return $product;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
use App\Domains\Catalog\Resources\ProductVariantResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductVariantController extends Controller
{
public function index(Tenant $tenant, Product $producto): JsonResponse
{
$query = ProductVariant::query()
->where('producto_id', $producto->id)
->with(['product', 'definitions.productAttribute.attribute.options', 'attachments'])
->latest();
return ProductVariantResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreProductVariantRequest $request, Tenant $tenant, Product $producto, ProductService $productService): JsonResponse
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$variant = $productService->createVariant($producto, $request->validated());
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Product $producto, ProductVariant $productVariant): ProductVariantResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
return ProductVariantResource::make($productVariant->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'definitions.productAttribute.attribute.options',
'product.attachments' => fn ($query) => $query->orderBy('attachments.id'),
]));
}
public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
$productVariant = $productService->updateVariant($productVariant, $request->validated());
return ProductVariantResource::make($productVariant);
}
public function destroy(Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
$productService->deleteVariant($productVariant);
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found for tenant.');
}
return $product;
}
protected function resolveScopedVariant(Product $product, ProductVariant $variant): ProductVariant
{
if ($variant->producto_id !== $product->id) {
throw new NotFoundHttpException('Product variant not found for product.');
}
return $variant;
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogItemType: string
{
case Standard = 'standard';
case Bundle = 'bundle';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,18 +0,0 @@
<?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

@@ -1,19 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum GroupLayout: string
{
case Paginated = 'paginated';
case Simple = 'simple';
case SimpleVertical = 'simple_vertical';
case Carousel = 'carousel';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Domains\Catalog\Enums;
enum ProductLayout: string
{
case Row = 'row';
case ColumnWithImage = 'column_with_image';
case ColumnWithCart = 'column_with_cart';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Models; namespace App\Domains\Catalog\Models;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -10,6 +9,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable([ #[Fillable([
'tenant_codigo', 'tenant_codigo',
@@ -54,12 +54,25 @@ class Attribute extends Model
} }
/** /**
* @return HasMany<EventDate, $this> * @return HasMany<ProductAttribute, $this>
*/ */
public function eventDates(): HasMany public function productAttributes(): HasMany
{ {
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo') return $this->hasMany(ProductAttribute::class, 'attribute_id');
->orderBy('date') }
->orderBy('time_start');
/**
* @return HasManyThrough<ProductVariantDefinition, ProductAttribute, $this>
*/
public function variantDefinitions(): HasManyThrough
{
return $this->hasManyThrough(
ProductVariantDefinition::class,
ProductAttribute::class,
'attribute_id',
'products_attribute_id',
'id',
'id'
);
} }
} }

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Catalog\Models; namespace App\Domains\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -10,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'attribute_id', 'attribute_id',
'validity_time_id',
'value', 'value',
'label', 'label',
'sort_order', 'sort_order',
@@ -28,7 +26,6 @@ class AttributeOption extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'validity_time_id' => 'integer',
'sort_order' => 'integer', 'sort_order' => 'integer',
'metadata' => 'array', 'metadata' => 'array',
]; ];
@@ -41,10 +38,4 @@ class AttributeOption extends Model
{ {
return $this->belongsTo(Attribute::class, 'attribute_id'); return $this->belongsTo(Attribute::class, 'attribute_id');
} }
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
} }

View File

@@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
'tenant_codigo', 'tenant_codigo',
@@ -27,10 +26,4 @@ class Brand extends Model
{ {
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
} }
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
} }

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_catalog_item_id',
'component_catalog_item_id',
'component_variant_id',
'quantity',
])]
class BundleComponent extends Model
{
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'bundle_catalog_item_id' => 'integer',
'component_catalog_item_id' => 'integer',
'component_variant_id' => 'integer',
'quantity' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function bundle(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id');
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'component_variant_id');
}
}

View File

@@ -1,220 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
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;
use Illuminate\Support\Collection;
#[Fillable([
'tenant_code',
'category_id',
'brand_id',
'inventory_id',
'type',
'slug',
'nombre',
'descripcion',
'precio',
'inventory_policy',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
'validity_time_id',
])]
class CatalogItem extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'catalog_items';
protected $attributes = [
'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
];
protected function casts(): array
{
return [
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'max_units_per_user' => 'integer',
'has_tickets' => 'boolean',
'ticket_generation_policy' => TicketGenerationPolicy::class,
'validity_time_id' => 'integer',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/** @return BelongsTo<Brand, $this> */
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return HasMany<BundleComponent, $this> */
public function bundleComponents(): HasMany
{
return $this->hasMany(BundleComponent::class, 'bundle_catalog_item_id');
}
/** @return HasMany<BundleComponent, $this> */
public function bundleComponentUsages(): HasMany
{
return $this->hasMany(BundleComponent::class, 'component_catalog_item_id');
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{
return $this->belongsToMany(Attribute::class, 'item_attributes')
->withTimestamps();
}
/** @return HasMany<ItemAttribute, $this> */
public function itemAttributes(): HasMany
{
return $this->hasMany(ItemAttribute::class);
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{
return $this->hasMany(FeaturedItem::class);
}
/** @return BelongsToMany<Attachment, $this> */
public function attachments(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'catalog_items_attachments',
'catalog_item_id',
'attachment_id'
)
->withPivot('orden')
->wherePivotNull('variant_id')
->orderByPivot('orden');
}
public function availableStock(): ?int
{
return app(CatalogInventoryService::class)->availableQuantity($this);
}
public function isAvailable(): bool
{
if ($this->type === CatalogItemType::Bundle) {
$availableStock = $this->availableStock();
return $availableStock === null || $availableStock > 0;
}
if ($this->inventory_policy === InventoryPolicy::Unlimited) {
return true;
}
return ($this->availableStock() ?? 0) > 0;
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereDoesntHave('variants')
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
}
/** @return Collection<int, Variant> */
public function visibleVariants(?int $includedVariantId = null): Collection
{
return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
->values();
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre;
}
public function getDescription(): ?string
{
return $this->descripcion;
}
public function isBundle(): bool
{
return $this->type === CatalogItemType::Bundle;
}
}

View File

@@ -2,13 +2,11 @@
namespace App\Domains\Catalog\Models; namespace App\Domains\Catalog\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
@@ -22,9 +20,6 @@ class Category extends Model
protected $table = 'categorias'; protected $table = 'categorias';
/**
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -60,21 +55,4 @@ class Category extends Model
{ {
return $this->hasMany(self::class, 'categoria_id'); return $this->hasMany(self::class, 'categoria_id');
} }
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
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,63 +2,20 @@
namespace App\Domains\Catalog\Models; 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;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'source_type',
'category_id',
'product_layout',
'group_layout',
'group_name',
'group_order',
])]
class FeaturedGroup extends Model class FeaturedGroup extends Model
{ {
use HasFactory; protected $fillable = [
'tenant_codigo',
public $timestamps = false; 'group_name',
'product_layout',
protected $table = 'featured_groups'; 'group_order',
protected $attributes = [
'source_type' => FeaturedGroupSource::Manual->value,
]; ];
protected function casts(): array public function featuredVariants(): HasMany
{ {
return [ return $this->hasMany(FeaturedVariant::class);
'source_type' => FeaturedGroupSource::class,
'category_id' => 'integer',
'product_layout' => ProductLayout::class,
'group_layout' => GroupLayout::class,
'group_order' => 'integer',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
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
{
return $this->hasMany(FeaturedItem::class)->orderBy('order');
} }
} }

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'featured_group_id',
'catalog_item_id',
'order',
])]
class FeaturedItem extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'featured_items';
protected function casts(): array
{
return [
'featured_group_id' => 'integer',
'catalog_item_id' => 'integer',
'order' => 'integer',
];
}
/** @return BelongsTo<FeaturedGroup, $this> */
public function featuredGroup(): BelongsTo
{
return $this->belongsTo(FeaturedGroup::class);
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeaturedVariant extends Model
{
protected $table = 'featured_variants';
protected $fillable = [
'featured_group_id',
'product_variant_id',
'order',
];
public function featuredGroup(): BelongsTo
{
return $this->belongsTo(FeaturedGroup::class);
}
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'product_variant_id');
}
}

View File

@@ -1,97 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'sold_units',
'reserved_stock',
'real_stock',
])]
class Inventory extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'inventories';
protected $attributes = [
'sold_units' => 0,
'reserved_stock' => 0,
'real_stock' => 0,
];
protected function casts(): array
{
return [
'sold_units' => 'integer',
'reserved_stock' => 'integer',
'real_stock' => 'integer',
];
}
/** @return HasOne<CatalogItem, $this> */
public function catalogItem(): HasOne
{
return $this->hasOne(CatalogItem::class);
}
/** @return HasOne<Variant, $this> */
public function variant(): HasOne
{
return $this->hasOne(Variant::class);
}
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);
}
public function reserve(int $amount, bool $tracksInventory): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
}
if ($tracksInventory && $this->availableStock() < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
}
$this->reserved_stock += $amount;
$this->save();
}
public function release(int $amount): void
{
if ($amount < 0 || $this->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
}
$this->reserved_stock -= $amount;
$this->save();
}
public function buy(int $amount, bool $tracksInventory): void
{
if ($amount < 0 || $this->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
}
if ($tracksInventory && $this->real_stock < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
if ($tracksInventory) {
$this->real_stock -= $amount;
}
$this->reserved_stock -= $amount;
$this->sold_units += $amount;
$this->save();
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
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([
'catalog_item_id',
'attribute_id',
'allow_multi_select',
'sort_order',
])]
class ItemAttribute extends Model
{
use HasFactory;
protected $table = 'item_attributes';
protected function casts(): array
{
return [
'catalog_item_id' => 'integer',
'attribute_id' => 'integer',
'allow_multi_select' => 'boolean',
'sort_order' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Attribute, $this> */
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class);
}
/** @return HasMany<VariantDefinition, $this> */
public function variantDefinitions(): HasMany
{
return $this->hasMany(VariantDefinition::class);
}
}

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