Compare commits
13 Commits
5107d858cc
...
homo
| Author | SHA1 | Date | |
|---|---|---|---|
| d02b0c5551 | |||
| ee911171be | |||
| 7c9ebc6d4d | |||
| 45553dc514 | |||
| 1c870d9cfa | |||
| 8ab94ec61f | |||
| 4eacff86bc | |||
| 5c457db1c9 | |||
| 8dc80411d7 | |||
| e16d91f3f9 | |||
| 89f6dab0c4 | |||
| ff9d7728e8 | |||
| 754aeebe3a |
@@ -10,8 +10,8 @@ PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||
FRONTEND_URLS=http://localhost:4200
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_LOCALE=es
|
||||
APP_FALLBACK_LOCALE=es
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
@@ -25,6 +25,11 @@ GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
||||
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||
AUTH_LOGIN_LOCK_MINUTES=15
|
||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
|
||||
@@ -24,7 +24,8 @@ class CreateResetPasswordAttemptController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
|
||||
'code' => 'auth.password_reset_requested',
|
||||
'message' => __('api.auth.password_reset_requested'),
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
], 202);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ class GoogleTokenExchangeController extends Controller
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $authentication['token'],
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($authentication['user']),
|
||||
|
||||
@@ -2,35 +2,31 @@
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\PasswordLoginService;
|
||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GuestCartMergeService $guestCartMergeService,
|
||||
private readonly PasswordLoginService $passwordLoginService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()->where('email', $credentials['email'])->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'Email o Contraseña incorrectos.',
|
||||
]);
|
||||
}
|
||||
$user = $this->passwordLoginService->authenticate(
|
||||
$credentials['email'],
|
||||
$credentials['password'],
|
||||
$credentials['tenant_codigo'],
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
);
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
@@ -50,7 +46,8 @@ class LoginController extends Controller
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($user),
|
||||
|
||||
@@ -23,7 +23,8 @@ class LogoutController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion cerrada correctamente.',
|
||||
'code' => 'auth.logout_success',
|
||||
'message' => __('api.auth.logout_success'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,17 @@ class RegisterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RegisterUserService $registerUserService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function __invoke(RegisterUserRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->registerUserService->register($request->validated());
|
||||
|
||||
return UserResource::make($user)
|
||||
->additional(['message' => 'Usuario registrado correctamente.'])
|
||||
->additional([
|
||||
'code' => 'auth.register_success',
|
||||
'message' => __('api.auth.register_success'),
|
||||
])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ class ResetPasswordController extends Controller
|
||||
$data['password'],
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||
'codigo' => __('api.auth.password_reset_invalid'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Contraseña modificada correctamente.',
|
||||
'code' => 'auth.password_updated',
|
||||
'message' => __('api.auth.password_updated'),
|
||||
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,13 @@ class ValidateResetPasswordAttemptController extends Controller
|
||||
$data['codigo'],
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
'codigo' => 'El código ingresado es inválido.',
|
||||
'codigo' => __('api.auth.reset_code_invalid'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Código validado correctamente.',
|
||||
'code' => 'auth.reset_code_valid',
|
||||
'message' => __('api.auth.reset_code_valid'),
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
}
|
||||
|
||||
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Exceptions;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
class AccountLockedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly CarbonImmutable $lockedUntil,
|
||||
) {
|
||||
parent::__construct('The account is temporarily locked.');
|
||||
}
|
||||
|
||||
public function retryAfterSeconds(): int
|
||||
{
|
||||
return max(1, (int) now()->diffInSeconds($this->lockedUntil, false));
|
||||
}
|
||||
}
|
||||
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'user_id',
|
||||
'email_fingerprint',
|
||||
'tenant_codigo',
|
||||
'outcome',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
])]
|
||||
class LoginAttempt extends Model
|
||||
{
|
||||
public const OUTCOME_SUCCESS = 'success';
|
||||
|
||||
public const OUTCOME_INVALID_CREDENTIALS = 'invalid_credentials';
|
||||
|
||||
public const OUTCOME_ACCOUNT_LOCKED = 'account_locked';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'codigo', 'status'])]
|
||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||
#[Hidden(['codigo'])]
|
||||
class ResetPasswordAttempt extends Model
|
||||
{
|
||||
|
||||
@@ -29,6 +29,12 @@ class User extends Authenticatable
|
||||
return $this->hasMany(ResetPasswordAttempt::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<LoginAttempt, $this> */
|
||||
public function loginAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(LoginAttempt::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -37,6 +43,9 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'failed_login_attempts' => 'integer',
|
||||
'last_failed_login_at' => 'datetime',
|
||||
'locked_until' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LoginUserRequest extends FormRequest
|
||||
{
|
||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
|
||||
if (is_string($email)) {
|
||||
$this->merge([
|
||||
'email' => Str::lower(trim($email)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -25,7 +25,7 @@ class GoogleAuthService
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant' => 'El tenant o la URL de retorno no son validos.',
|
||||
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ class GoogleAuthService
|
||||
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
|
||||
|
||||
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
|
||||
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
|
||||
abort(400, __('api.auth.request_expired'));
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
|
||||
abort(400, 'La URL de retorno no es valida.');
|
||||
abort(400, __('api.auth.invalid_return_url'));
|
||||
}
|
||||
|
||||
/** @var SocialiteUser $googleUser */
|
||||
@@ -88,13 +88,13 @@ class GoogleAuthService
|
||||
|
||||
if (! $authentication) {
|
||||
throw ValidationException::withMessages([
|
||||
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
|
||||
'oauth_code' => __('api.auth.oauth_code_expired'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.',
|
||||
'tenant_codigo' => __('api.auth.tenant_mismatch'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ class GoogleAuthService
|
||||
|
||||
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'Google no devolvio una identidad valida.',
|
||||
'google' => __('api.auth.google_email_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ class GoogleAuthService
|
||||
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
||||
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'La cuenta de Google debe tener el email verificado.',
|
||||
'google' => __('api.auth.google_email_unverified'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
191
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
191
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?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 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 {
|
||||
$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,
|
||||
): array {
|
||||
$user = User::query()
|
||||
->where('email', $normalizedEmail)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($user?->locked_until?->isFuture()) {
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
'user' => $user,
|
||||
'locked_until' => CarbonImmutable::instance($user->locked_until),
|
||||
];
|
||||
}
|
||||
|
||||
if ($user !== null && $user->locked_until !== null) {
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 0,
|
||||
'last_failed_login_at' => null,
|
||||
'locked_until' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||
if ($user !== null) {
|
||||
$this->registerFailure($user, $now, $tenantCode);
|
||||
}
|
||||
|
||||
$outcome = $user?->locked_until?->isFuture()
|
||||
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
|
||||
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
$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,
|
||||
$tenantCode,
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@ use Throwable;
|
||||
|
||||
class ResetPasswordAttemptService
|
||||
{
|
||||
public function createForEmail(string $email, string $tenantCode): void
|
||||
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||
{
|
||||
$emailFingerprint = $this->emailFingerprint($email);
|
||||
|
||||
try {
|
||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
|
||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->lockForUpdate()
|
||||
@@ -39,6 +39,7 @@ class ResetPasswordAttemptService
|
||||
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => $this->generateCode(),
|
||||
'reason' => $reason,
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
@@ -138,6 +139,9 @@ class ResetPasswordAttemptService
|
||||
}
|
||||
|
||||
$user->password = $password;
|
||||
$user->failed_login_attempts = 0;
|
||||
$user->last_failed_login_at = null;
|
||||
$user->locked_until = null;
|
||||
$user->save();
|
||||
$user->tokens()->delete();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||
|
||||
@@ -36,7 +36,10 @@ class CartController extends Controller
|
||||
);
|
||||
|
||||
$response = CartResource::make($result['cart'])
|
||||
->additional(['message' => 'Producto agregado al carrito.'])
|
||||
->additional([
|
||||
'code' => 'cart.item_added',
|
||||
'message' => __('api.cart.item_added'),
|
||||
])
|
||||
->response();
|
||||
|
||||
if ($result['guest_token'] !== null) {
|
||||
@@ -58,13 +61,19 @@ class CartController extends Controller
|
||||
$cartItem->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
||||
)->additional([
|
||||
'code' => 'cart.quantity_updated',
|
||||
'message' => __('api.cart.quantity_updated'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||
)->additional([
|
||||
'code' => 'cart.item_removed',
|
||||
'message' => __('api.cart.item_removed'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ class Cart extends Model
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class Cart extends Model
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
|
||||
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ class Cart extends Model
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class Cart extends Model
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -209,13 +209,13 @@ class Cart extends Model
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Un bundle no admite una variante.',
|
||||
'variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
'catalog_item_id' => __('api.cart.empty_bundle'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ class Cart extends Model
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
||||
'variant_id' => __('api.cart.variant_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\FeaturedItem;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||
@@ -57,6 +59,32 @@ class CatalogController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function category(
|
||||
CategoryPageRequest $request,
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
CatalogService $catalogService,
|
||||
): AnonymousResourceCollection {
|
||||
abort_unless($category->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
return CatalogSearchItemResource::collection(
|
||||
$catalogService->categoryItems(
|
||||
$tenant,
|
||||
$category,
|
||||
$tenant->search_items_per_page,
|
||||
(int) $request->validated('page', 1),
|
||||
)
|
||||
)->additional([
|
||||
'category' => [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'categoria_id' => $category->categoria_id,
|
||||
],
|
||||
'layout' => $tenant->search_product_layout->value,
|
||||
'group_layout' => $tenant->search_group_layout->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function featuredGroupItems(
|
||||
FeaturedGroupPageRequest $request,
|
||||
Tenant $tenant,
|
||||
|
||||
@@ -79,20 +79,7 @@ class Variant extends Model
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
$name = $this->catalogItem->nombre;
|
||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
||||
$definitions = $this->definitions
|
||||
->map(function (VariantDefinition $definition): ?string {
|
||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
||||
|
||||
return $attributeName
|
||||
? "{$attributeName}: {$definition->value}"
|
||||
: $definition->value;
|
||||
})
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
||||
return $this->catalogItem->nombre;
|
||||
}
|
||||
|
||||
public function getMinimumUseDate(): ?CarbonInterface
|
||||
|
||||
21
app/Domains/Catalog/Requests/CategoryPageRequest.php
Normal file
21
app/Domains/Catalog/Requests/CategoryPageRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CategoryPageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
@@ -45,7 +46,7 @@ class CatalogService
|
||||
} else {
|
||||
if (array_key_exists('components', $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un item standard no puede tener componentes.'],
|
||||
'components' => [__('api.catalog.standard_with_components')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -210,6 +211,29 @@ class CatalogService
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return LengthAwarePaginator<CatalogItem> */
|
||||
public function categoryItems(
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->orderBy('nombre')
|
||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||
}
|
||||
|
||||
public function delete(CatalogItem $catalogItem): void
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
@@ -265,7 +289,7 @@ class CatalogService
|
||||
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}" => ['El componente esta duplicado.'],
|
||||
"components.{$index}" => [__('api.catalog.duplicate_component')],
|
||||
]);
|
||||
}
|
||||
$seen[$key] = true;
|
||||
@@ -278,7 +302,7 @@ class CatalogService
|
||||
if ($componentItem === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El item no pertenece al tenant del bundle.',
|
||||
__('api.catalog.component_wrong_tenant'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -286,7 +310,7 @@ class CatalogService
|
||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El componente debe ser un item standard distinto del bundle.',
|
||||
__('api.catalog.invalid_component'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -295,7 +319,7 @@ class CatalogService
|
||||
if ($hasVariants && $variantId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'Debe seleccionar una variante para este componente.',
|
||||
__('api.catalog.component_variant_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -303,7 +327,7 @@ class CatalogService
|
||||
if (! $hasVariants && $variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'El componente con inventario directo no admite una variante.',
|
||||
__('api.catalog.component_variant_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -311,7 +335,7 @@ class CatalogService
|
||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'La variante no pertenece al componente indicado.',
|
||||
__('api.catalog.component_variant_invalid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -333,7 +357,7 @@ class CatalogService
|
||||
{
|
||||
if ($components === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un bundle debe tener al menos un componente.'],
|
||||
'components' => [__('api.catalog.bundle_component_required')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -348,7 +372,7 @@ class CatalogService
|
||||
] as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => ["{$field} no se admite para un bundle."],
|
||||
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -376,7 +400,7 @@ class CatalogService
|
||||
|
||||
if ($attachment === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => ['El attachment indicado no existe.'],
|
||||
$validationKey => [__('api.catalog.attachment_not_found')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -408,7 +432,7 @@ class CatalogService
|
||||
if ($attribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attribute_codes' => [
|
||||
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
||||
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -442,7 +466,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.inventory" => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -463,7 +487,7 @@ class CatalogService
|
||||
if ($itemAttribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.values.{$attributeCode}" => [
|
||||
'El atributo no pertenece al ítem de catálogo.',
|
||||
__('api.catalog.attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -489,7 +513,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.maximum_use_date" => [
|
||||
'La fecha maxima efectiva debe ser posterior o igual a la fecha minima efectiva.',
|
||||
__('api.catalog.invalid_effective_date_range'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -508,7 +532,7 @@ class CatalogService
|
||||
if ($hasVariants && $variants === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem con attribute_codes debe tener variantes.',
|
||||
__('api.catalog.variants_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -516,7 +540,7 @@ class CatalogService
|
||||
if (! $hasVariants && $variants !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem sin attribute_codes no puede tener variantes.',
|
||||
__('api.catalog.variants_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -524,7 +548,7 @@ class CatalogService
|
||||
if ($hasVariants && $hasDirectStock) {
|
||||
throw ValidationException::withMessages([
|
||||
'real_stock' => [
|
||||
'Un ítem con variantes no puede tener inventario directo.',
|
||||
__('api.catalog.direct_inventory_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -536,7 +560,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'inventory' => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
->name('catalog.featured-groups.items.index');
|
||||
Route::get('catalog-items', [CatalogController::class, 'search'])
|
||||
->name('catalog-items.index');
|
||||
Route::get('categories/{category}', [CatalogController::class, 'category'])
|
||||
->name('categories.show');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*
|
||||
* @param TelepagosWebhookRequest $request
|
||||
* @param string $tenantCodigo
|
||||
* @param TelepagosWebhookService $service
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
|
||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'code' => 'integration.webhook_failed',
|
||||
'message' => __('api.integration.webhook_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller
|
||||
public function show(string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||
|
||||
if (!$integration) {
|
||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
||||
|
||||
if (! $integration) {
|
||||
return response()->json([
|
||||
'code' => 'integration.not_configured',
|
||||
'message' => __('api.integration.not_configured'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json($integration);
|
||||
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
|
||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||
|
||||
|
||||
try {
|
||||
$this->tenantIntegrationService->updateOrCreateIntegration(
|
||||
$tenantCode,
|
||||
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'integration configured correctly',
|
||||
'code' => 'integration.configured',
|
||||
'message' => __('api.integration.configured'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||
'code' => 'integration.validation_failed',
|
||||
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
$integrationCode = $this->route('integration_code');
|
||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||
|
||||
if (!$this->integrationModel) {
|
||||
if (! $this->integrationModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'integration_code' => 'Integration not found.'
|
||||
'integration_code' => __('api.integration.not_configured'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
// Dynamic validation rules based on the integration data schema
|
||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
||||
$rules['integration_data.' . $field] = $rule;
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
@@ -19,16 +20,13 @@ class TelepagosWebhookService
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @param string $tenantCodigo
|
||||
* @param string $cashinId
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService();
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
@@ -48,26 +46,36 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $cuit) {
|
||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +83,7 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,11 +91,16 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
], true)) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,10 +108,12 @@ class TelepagosWebhookService
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +130,7 @@ class TelepagosWebhookService
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||
DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPurchase($compra);
|
||||
$compra->markAsPaid();
|
||||
@@ -122,7 +138,7 @@ class TelepagosWebhookService
|
||||
|
||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||
} catch (Exception $e) {
|
||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||
Log::error('Telepagos webhook error: '.$e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ class MailTestService
|
||||
);
|
||||
|
||||
return [
|
||||
'message' => 'Correo de prueba enviado correctamente.',
|
||||
'code' => 'mail.test_sent',
|
||||
'message' => __('api.mail.test_sent'),
|
||||
'recipient' => $recipient,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'mailer' => $mailService->mailerName(),
|
||||
|
||||
@@ -48,7 +48,7 @@ class Menu extends Model
|
||||
&& empty($menu->static_content_schema)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'static_content_schema' => 'El schema es obligatorio para los menús estáticos.',
|
||||
'static_content_schema' => __('api.menu.schema_required'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ class StoreTenantMenuRequest extends FormRequest
|
||||
|
||||
if (! $this->menuModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'menu_code' => 'El menú indicado no existe.',
|
||||
'menu_code' => __('api.menu.not_found'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class PurchaseController extends Controller
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
$compra->loadMissing('items');
|
||||
$compra->loadMissing('items')->loadCount('tickets');
|
||||
$compra->items->load('imageAttachment');
|
||||
|
||||
return PurchaseResource::make($compra);
|
||||
@@ -135,7 +135,7 @@ class PurchaseController extends Controller
|
||||
|
||||
if ($updated === 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer available for payment.',
|
||||
'purchase' => __('api.purchase.not_available_for_payment'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -159,8 +159,14 @@ class PurchaseController extends Controller
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Unable to retrieve TelePagos account information.', [
|
||||
'purchase_id' => $compra->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Error getting account info: '.$e->getMessage(),
|
||||
'code' => 'integration.account_info_failed',
|
||||
'message' => __('api.integration.account_info_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -182,8 +188,14 @@ class PurchaseController extends Controller
|
||||
'qr_code' => $qrResponse['qr_code'] ?? '',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Unable to generate TelePagos QR code.', [
|
||||
'purchase_id' => $compra->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Error generating QR: '.$e->getMessage(),
|
||||
'code' => 'integration.qr_generation_failed',
|
||||
'message' => __('api.integration.qr_generation_failed'),
|
||||
], 500);
|
||||
}
|
||||
|
||||
@@ -197,7 +209,8 @@ class PurchaseController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invalid payment method.',
|
||||
'code' => 'integration.invalid_payment_method',
|
||||
'message' => __('api.integration.invalid_payment_method'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
@@ -210,6 +223,19 @@ class PurchaseController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function submitForReview(
|
||||
Request $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
CheckoutService $checkoutService,
|
||||
): PurchaseResource {
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$checkoutService->submitForReview($compra),
|
||||
);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
@@ -222,7 +248,7 @@ class PurchaseController extends Controller
|
||||
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
||||
{
|
||||
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
||||
throw new NotFoundHttpException('Purchase not found for tenant.');
|
||||
throw new NotFoundHttpException(__('api.errors.not_found'));
|
||||
}
|
||||
|
||||
return $purchase;
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -36,6 +37,8 @@ class Purchase extends Model
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
|
||||
public const STATUS_IN_REVIEW = 'in_review';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
@@ -88,6 +91,14 @@ class Purchase extends Model
|
||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Ticket, $this>
|
||||
*/
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<TelepagosQr, $this>
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,9 @@ class PurchaseResource extends JsonResource
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||
? (int) $this->resource->getAttribute('tickets_count')
|
||||
: null;
|
||||
|
||||
$subtotal = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
@@ -51,6 +54,8 @@ class PurchaseResource extends JsonResource
|
||||
'email' => $this->email,
|
||||
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
|
||||
@@ -41,7 +41,7 @@ class CheckoutService
|
||||
|
||||
if ($cartId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'A cart or direct item is required.',
|
||||
'cart_id' => __('api.purchase.source_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -64,12 +64,13 @@ class CheckoutService
|
||||
|
||||
if ($purchase->payment_method === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'payment_method' => 'The purchase payment method must be selected before finalizing.',
|
||||
'payment_method' => __('api.purchase.payment_method_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_PAID,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
@@ -86,6 +87,39 @@ class CheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
public function submitForReview(Purchase $purchase): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($purchase): Purchase {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_PAID,
|
||||
], true)) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $customerData
|
||||
*/
|
||||
@@ -98,11 +132,14 @@ class CheckoutService
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_CREATED
|
||||
! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -128,7 +165,7 @@ class CheckoutService
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -144,7 +181,7 @@ class CheckoutService
|
||||
|
||||
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => 'The purchase item is no longer editable.',
|
||||
'item' => __('api.purchase.item_not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -162,7 +199,7 @@ class CheckoutService
|
||||
}
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => 'No hay suficiente stock disponible.',
|
||||
'quantity' => __('api.purchase.insufficient_stock'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -197,7 +234,7 @@ class CheckoutService
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -233,7 +270,7 @@ class CheckoutService
|
||||
Purchase::STATUS_EXPIRED,
|
||||
], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'A cancelled, rejected or expired purchase cannot be confirmed.',
|
||||
'purchase' => __('api.purchase.cannot_confirm'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -249,7 +286,7 @@ class CheckoutService
|
||||
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'The purchase has an inconsistent stock reservation.',
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -306,7 +343,7 @@ class CheckoutService
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'A paid purchase cannot be cancelled.',
|
||||
'purchase' => __('api.purchase.paid_cannot_cancel'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -339,7 +376,7 @@ class CheckoutService
|
||||
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => 'The purchase has an inconsistent stock reservation.',
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -375,7 +412,7 @@ class CheckoutService
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.cantidad' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.",
|
||||
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -383,7 +420,7 @@ class CheckoutService
|
||||
$this->catalogInventoryService->reserve($selection, $quantity);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.cantidad' => 'No hay suficiente stock disponible.',
|
||||
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -416,7 +453,7 @@ class CheckoutService
|
||||
|
||||
if ($cartItems->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart does not contain items.',
|
||||
'cart_id' => __('api.purchase.empty_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -596,13 +633,13 @@ class CheckoutService
|
||||
foreach ($cartItems as $item) {
|
||||
if ($item->selectedItem() === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'One or more catalog items could not be loaded.',
|
||||
'cart_id' => __('api.purchase.catalog_item_missing'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'One or more catalog items do not belong to the tenant.',
|
||||
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -621,7 +658,7 @@ class CheckoutService
|
||||
|
||||
if ($cart->status !== 'active') {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart is no longer active.',
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -682,13 +719,13 @@ class CheckoutService
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.variant_id' => 'Un bundle no admite una variante.',
|
||||
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -698,7 +735,7 @@ class CheckoutService
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.variant_id' => 'Debe seleccionar una variante para este item.',
|
||||
'direct_item.variant_id' => __('api.cart.variant_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,5 +12,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,14 @@ class BootstrapTenantController extends Controller
|
||||
|
||||
return TenantResource::make(
|
||||
Tenant::query()
|
||||
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues', 'socialMedia'])
|
||||
->with([
|
||||
'headerLogo',
|
||||
'footerLogo',
|
||||
'mainCarouselImages',
|
||||
'menues',
|
||||
'socialMedia',
|
||||
'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||
])
|
||||
->where('dominio', $dominio)
|
||||
->firstOrFail()
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
@@ -109,6 +110,14 @@ class Tenant extends Model
|
||||
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SocialMedia, $this>
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Tenant\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -64,9 +65,43 @@ class TenantResource extends JsonResource
|
||||
'menues',
|
||||
fn () => $this->menuTree($this->menues)
|
||||
),
|
||||
'categories' => $this->whenLoaded(
|
||||
'categories',
|
||||
fn () => $this->categoryTree($this->categories)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Category> $categories
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function categoryTree(Collection $categories): Collection
|
||||
{
|
||||
$categoryIds = $categories->pluck('id')->flip();
|
||||
$childrenByParent = $categories
|
||||
->filter(fn (Category $category) => $category->categoria_id !== null
|
||||
&& $categoryIds->has($category->categoria_id))
|
||||
->groupBy('categoria_id');
|
||||
|
||||
$formatCategory = function (Category $category) use (&$formatCategory, $childrenByParent): array {
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'subcategories' => $childrenByParent
|
||||
->get($category->id, collect())
|
||||
->map($formatCategory)
|
||||
->values(),
|
||||
];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->filter(fn (Category $category) => $category->categoria_id === null
|
||||
|| ! $categoryIds->has($category->categoria_id))
|
||||
->map($formatCategory)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Menu> $menus
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
|
||||
@@ -225,7 +225,7 @@ class TenantService
|
||||
if ($attachment === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"main_carousel_images.{$order}" => [
|
||||
'La imagen indicada no existe o no es un attachment de tipo imagen.',
|
||||
__('api.tenant.invalid_carousel_image'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
namespace App\Domains\Ticket\Controllers;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
|
||||
use App\Domains\Ticket\Resources\TicketResource;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TicketPdfService $ticketPdfService) {}
|
||||
|
||||
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$tickets = Ticket::query()
|
||||
@@ -21,4 +27,21 @@ class TicketController extends Controller
|
||||
|
||||
return TicketResource::collection($tickets)->response();
|
||||
}
|
||||
|
||||
public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response
|
||||
{
|
||||
$ticketIds = $request->validated('ticket_ids');
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('user_id', $request->user()->getKey())
|
||||
->whereIn('id', $ticketIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
if ($tickets->count() !== count($ticketIds)) {
|
||||
throw new TicketNotAvailableException(__('api.ticket.not_available'));
|
||||
}
|
||||
|
||||
return $this->ticketPdfService->download($tenant, $tickets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,22 +11,22 @@ class TicketGenerationException extends RuntimeException
|
||||
{
|
||||
public static function invalidQuantity(): self
|
||||
{
|
||||
return new self('La cantidad de tickets a generar debe ser mayor a cero.');
|
||||
return new self(__('api.ticket.positive_quantity'));
|
||||
}
|
||||
|
||||
public static function emptyBundle(CatalogItem $bundle): self
|
||||
{
|
||||
return new self("El bundle {$bundle->id} no tiene componentes.");
|
||||
return new self(__('api.ticket.empty_bundle', ['bundle' => $bundle->id]));
|
||||
}
|
||||
|
||||
public static function ticketsDisabled(CatalogItem $catalogItem): self
|
||||
{
|
||||
return new self("El producto {$catalogItem->id} no tiene tickets habilitados.");
|
||||
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
|
||||
}
|
||||
|
||||
public static function maximumUseDateReached(CatalogItem $catalogItem): self
|
||||
{
|
||||
return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso.");
|
||||
return new self(__('api.ticket.expired', ['product' => $catalogItem->id]));
|
||||
}
|
||||
|
||||
public static function variantNotFound(
|
||||
@@ -34,17 +34,20 @@ class TicketGenerationException extends RuntimeException
|
||||
int $variantId,
|
||||
): self {
|
||||
return new self(
|
||||
"La variante {$variantId} no pertenece al producto {$catalogItem->id}.",
|
||||
__('api.ticket.invalid_variant', [
|
||||
'variant' => $variantId,
|
||||
'product' => $catalogItem->id,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function purchaseWithoutUser(Purchase $purchase): self
|
||||
{
|
||||
return new self("La compra {$purchase->id} no tiene un usuario asociado.");
|
||||
return new self(__('api.ticket.purchase_without_user', ['purchase' => $purchase->id]));
|
||||
}
|
||||
|
||||
public static function catalogItemNotFound(PurchaseItem $purchaseItem): self
|
||||
{
|
||||
return new self("No se encontró el producto de la línea de compra {$purchaseItem->id}.");
|
||||
return new self(__('api.ticket.product_not_found', ['purchase_item' => $purchaseItem->id]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TicketNotAvailableException extends NotFoundHttpException {}
|
||||
@@ -45,6 +45,7 @@ class GenerateTicketsForPaidPurchase
|
||||
$user,
|
||||
$purchaseItem->cantidad,
|
||||
$purchaseItem->source_variant_id,
|
||||
$purchase->getKey(),
|
||||
);
|
||||
|
||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Ticket\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'ticket',
|
||||
'name',
|
||||
'description',
|
||||
'source_purchase_id',
|
||||
'source_catalog_item_id',
|
||||
'source_variant_id',
|
||||
'starts_at',
|
||||
@@ -38,6 +40,7 @@ class Ticket extends Model
|
||||
return [
|
||||
'source_catalog_item_id' => 'integer',
|
||||
'source_variant_id' => 'integer',
|
||||
'source_purchase_id' => 'integer',
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
@@ -57,6 +60,12 @@ class Ticket extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function sourcePurchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
22
app/Domains/Ticket/Requests/DownloadTicketsPdfRequest.php
Normal file
22
app/Domains/Ticket/Requests/DownloadTicketsPdfRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DownloadTicketsPdfRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ticket_ids' => ['required', 'array', 'min:1', 'max:25'],
|
||||
'ticket_ids.*' => ['required', 'integer', 'distinct'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -21,12 +21,13 @@ class TicketGeneratorService
|
||||
User $user,
|
||||
int $quantity = 1,
|
||||
?int $sourceVariantId = null,
|
||||
?int $sourcePurchaseId = null,
|
||||
): Collection {
|
||||
if ($quantity < 1) {
|
||||
throw TicketGenerationException::invalidQuantity();
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId): Collection {
|
||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection {
|
||||
$targets = $this->resolveTargets(
|
||||
$catalogItem,
|
||||
$quantity,
|
||||
@@ -36,6 +37,7 @@ class TicketGeneratorService
|
||||
return $targets->map(function (array $target) use (
|
||||
$catalogItem,
|
||||
$sourceVariantId,
|
||||
$sourcePurchaseId,
|
||||
$user,
|
||||
): Ticket {
|
||||
$item = $target['catalog_item'];
|
||||
@@ -46,6 +48,7 @@ class TicketGeneratorService
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'name' => $item->nombre,
|
||||
'description' => (string) ($item->descripcion ?? ''),
|
||||
'source_purchase_id' => $sourcePurchaseId,
|
||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||
'source_variant_id' => $sourceVariantId,
|
||||
'starts_at' => $selectedItem->getMinimumUseDate(),
|
||||
|
||||
88
app/Domains/Ticket/Services/TicketPdfService.php
Normal file
88
app/Domains/Ticket/Services/TicketPdfService.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Services;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
|
||||
class TicketPdfService
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, Ticket> $tickets
|
||||
*/
|
||||
public function download(Tenant $tenant, Collection $tickets): Response
|
||||
{
|
||||
$tenant->loadMissing('headerLogo');
|
||||
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
||||
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
||||
|
||||
$pdf = Pdf::loadView('pdf.tickets', [
|
||||
'tenant' => $tenant,
|
||||
'tickets' => $tickets,
|
||||
'logoDataUri' => $this->logoDataUri($tenant),
|
||||
'primaryColor' => $primaryColor,
|
||||
'headerBackgroundColor' => $headerBackgroundColor,
|
||||
'headerTextColor' => $this->contrastingTextColor($headerBackgroundColor),
|
||||
'qrCodes' => $tickets->mapWithKeys(
|
||||
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
||||
),
|
||||
])->setPaper('a4');
|
||||
|
||||
$ticketIds = $tickets->pluck('id')->implode('_');
|
||||
|
||||
return $pdf->download("tickets_{$ticketIds}.pdf");
|
||||
}
|
||||
|
||||
private function qrCodeDataUri(string $value): string
|
||||
{
|
||||
$qrCode = new QrCode(
|
||||
data: $value,
|
||||
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
|
||||
size: 700,
|
||||
margin: 10,
|
||||
);
|
||||
|
||||
return (new PngWriter)->write($qrCode)->getDataUri();
|
||||
}
|
||||
|
||||
private function logoDataUri(Tenant $tenant): ?string
|
||||
{
|
||||
$logo = $tenant->headerLogo;
|
||||
if ($logo === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = Storage::disk('s3')->get($logo->path);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'data:'.($logo->mime_type ?: 'image/png').';base64,'.base64_encode($contents);
|
||||
}
|
||||
|
||||
private function color(?string $color, string $fallback): string
|
||||
{
|
||||
return is_string($color) && preg_match('/^#[0-9A-Fa-f]{6}$/', $color)
|
||||
? $color
|
||||
: $fallback;
|
||||
}
|
||||
|
||||
private function contrastingTextColor(string $backgroundColor): string
|
||||
{
|
||||
$red = hexdec(substr($backgroundColor, 1, 2));
|
||||
$green = hexdec(substr($backgroundColor, 3, 2));
|
||||
$blue = hexdec(substr($backgroundColor, 5, 2));
|
||||
$luminance = ($red * 299 + $green * 587 + $blue * 114) / 1000;
|
||||
|
||||
return $luminance > 160 ? '#17211b' : '#ffffff';
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ Route::prefix('tenants/{tenant:codigo}')
|
||||
->middleware('auth:sanctum')
|
||||
->group(function (): void {
|
||||
Route::get('tickets', [TicketController::class, 'index']);
|
||||
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
|
||||
});
|
||||
|
||||
33
app/Http/Middleware/SetApiLocale.php
Normal file
33
app/Http/Middleware/SetApiLocale.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetApiLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->is('api/*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$supportedLocales = config('app.supported_locales', ['es', 'en']);
|
||||
$acceptLanguage = $request->header('Accept-Language');
|
||||
$locale = is_string($acceptLanguage) && $acceptLanguage !== ''
|
||||
? $request->getPreferredLanguage($supportedLocales)
|
||||
: null;
|
||||
$locale ??= config('app.locale', 'es');
|
||||
|
||||
App::setLocale($locale);
|
||||
|
||||
$response = $next($request);
|
||||
$response->headers->set('Content-Language', $locale);
|
||||
$response->setVary('Accept-Language', false);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,11 @@ use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -36,6 +39,25 @@ class AppServiceProvider extends ServiceProvider
|
||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request): array {
|
||||
$normalizedEmail = mb_strtolower(trim((string) $request->input('email')));
|
||||
$emailFingerprint = hash_hmac(
|
||||
'sha256',
|
||||
$normalizedEmail,
|
||||
(string) config('app.key'),
|
||||
);
|
||||
$ipAddress = $request->ip() ?? 'unknown';
|
||||
|
||||
return [
|
||||
Limit::perMinute(
|
||||
max(1, (int) config('login-security.rate_limit_per_minute'))
|
||||
)->by("login:identity:{$emailFingerprint}:{$ipAddress}"),
|
||||
Limit::perMinute(
|
||||
max(1, (int) config('login-security.ip_rate_limit_per_minute'))
|
||||
)->by("login:ip:{$ipAddress}"),
|
||||
];
|
||||
});
|
||||
|
||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||
/** @var Builder $this */
|
||||
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Http\Middleware\SetApiLocale;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -16,9 +24,86 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->encryptCookies(except: [
|
||||
'guest_token',
|
||||
]);
|
||||
$middleware->prepend(SetApiLocale::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
$exceptions->render(function (AuthenticationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.unauthenticated',
|
||||
'message' => __('api.auth.unauthenticated'),
|
||||
], 401);
|
||||
});
|
||||
$exceptions->render(function (AccountLockedException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$retryAfter = $exception->retryAfterSeconds();
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.account_locked',
|
||||
'message' => __('api.auth.account_locked'),
|
||||
'retry_after' => $retryAfter,
|
||||
'locked_until' => $exception->lockedUntil->toIso8601String(),
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
]);
|
||||
});
|
||||
$exceptions->render(function (AuthorizationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.forbidden',
|
||||
'message' => __('api.errors.forbidden'),
|
||||
], 403);
|
||||
});
|
||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => __('api.errors.not_found'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (TicketNotAvailableException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'ticket.not_available',
|
||||
'message' => __('api.ticket.not_available'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (NotFoundHttpException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => __('api.errors.not_found'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (MethodNotAllowedHttpException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.method_not_allowed',
|
||||
'message' => __('api.errors.method_not_allowed'),
|
||||
], 405, $exception->getHeaders());
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"endroid/qr-code": "^6.1",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/socialite": "^5.29",
|
||||
|
||||
701
composer.lock
generated
701
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "930f49a939d8b56a337d2fca9c48ad86",
|
||||
"content-hash": "aa7e98d017dd610946c62579b20c501f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -157,6 +157,138 @@
|
||||
},
|
||||
"time": "2026-07-23T18:06:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "bacon/bacon-qr-code",
|
||||
"version": "v3.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dasprid/enum": "^1.0.3",
|
||||
"ext-iconv": "*",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phly/keep-a-changelog": "^2.12",
|
||||
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
|
||||
"spatie/phpunit-snapshot-assertions": "^5.1.5",
|
||||
"spatie/pixelmatch-php": "^1.2.0",
|
||||
"squizlabs/php_codesniffer": "^3.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-imagick": "to generate QR code images"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"BaconQrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||
"support": {
|
||||
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
|
||||
},
|
||||
"time": "2026-04-05T21:06:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "barryvdh/laravel-dompdf",
|
||||
"version": "v3.1.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/barryvdh/laravel-dompdf.git",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/dompdf": "^3.0",
|
||||
"illuminate/support": "^9|^10|^11|^12|^13.0",
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.7|^3.0",
|
||||
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
|
||||
"phpro/grumphp": "^2.5",
|
||||
"squizlabs/php_codesniffer": "^3.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"aliases": {
|
||||
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
|
||||
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
|
||||
},
|
||||
"providers": [
|
||||
"Barryvdh\\DomPDF\\ServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "3.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Barryvdh\\DomPDF\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Barry vd. Heuvel",
|
||||
"email": "barryvdh@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "A DOMPDF Wrapper for Laravel",
|
||||
"keywords": [
|
||||
"dompdf",
|
||||
"laravel",
|
||||
"pdf"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
|
||||
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://fruitcake.nl",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/barryvdh",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-21T08:51:10+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "0.18.0",
|
||||
@@ -285,6 +417,56 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/DASPRiD/Enum.git",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=7.1 <9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"DASPRiD\\Enum\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-2-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Ben Scholzen 'DASPRiD'",
|
||||
"email": "mail@dasprids.de",
|
||||
"homepage": "https://dasprids.de/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "PHP 7.1 enum implementation",
|
||||
"keywords": [
|
||||
"enum",
|
||||
"map"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||
},
|
||||
"time": "2025-09-16T12:23:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
@@ -527,6 +709,161 @@
|
||||
],
|
||||
"time": "2024-02-05T11:56:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/dompdf",
|
||||
"version": "v3.1.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/dompdf.git",
|
||||
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dompdf/php-font-lib": "^1.0.0",
|
||||
"dompdf/php-svg-lib": "^1.0.0",
|
||||
"ext-dom": "*",
|
||||
"ext-mbstring": "*",
|
||||
"masterminds/html5": "^2.0",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"ext-json": "*",
|
||||
"ext-zip": "*",
|
||||
"mockery/mockery": "^1.3",
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Needed to process images",
|
||||
"ext-gmagick": "Improves image processing performance",
|
||||
"ext-imagick": "Improves image processing performance",
|
||||
"ext-zlib": "Needed for pdf stream compression"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dompdf\\": "src/"
|
||||
},
|
||||
"classmap": [
|
||||
"lib/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The Dompdf Community",
|
||||
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
|
||||
"homepage": "https://github.com/dompdf/dompdf",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/dompdf/issues",
|
||||
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
|
||||
},
|
||||
"time": "2026-07-20T12:29:38+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-font-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-font-lib.git",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"FontLib\\": "src/FontLib"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The FontLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse, export and make subsets of different types of font files.",
|
||||
"homepage": "https://github.com/dompdf/php-font-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-font-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-20T14:10:26+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dompdf/php-svg-lib",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dompdf/php-svg-lib.git",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabberworm/php-css-parser": "^8.4 || ^9.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Svg\\": "src/Svg"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-3.0-or-later"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "The SvgLib Community",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
|
||||
}
|
||||
],
|
||||
"description": "A library to read, parse and export to PDF SVG files.",
|
||||
"homepage": "https://github.com/dompdf/php-svg-lib",
|
||||
"support": {
|
||||
"issues": "https://github.com/dompdf/php-svg-lib/issues",
|
||||
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
|
||||
},
|
||||
"time": "2026-01-02T16:01:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dragonmantank/cron-expression",
|
||||
"version": "v3.6.0",
|
||||
@@ -658,6 +995,78 @@
|
||||
],
|
||||
"time": "2025-03-06T22:45:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "endroid/qr-code",
|
||||
"version": "6.1.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/endroid/qr-code.git",
|
||||
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
|
||||
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"bacon/bacon-qr-code": "^3.0",
|
||||
"php": "^8.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"endroid/quality": "dev-main",
|
||||
"ext-gd": "*",
|
||||
"khanamiryan/qrcode-detector-decoder": "^2.0.3",
|
||||
"setasign/fpdf": "^1.8.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "Enables you to write PNG images",
|
||||
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
|
||||
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
|
||||
"setasign/fpdf": "Enables you to use the PDF writer"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "6.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Endroid\\QrCode\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeroen van den Enden",
|
||||
"email": "info@endroid.nl"
|
||||
}
|
||||
],
|
||||
"description": "Endroid QR Code",
|
||||
"homepage": "https://github.com/endroid/qr-code",
|
||||
"keywords": [
|
||||
"code",
|
||||
"endroid",
|
||||
"php",
|
||||
"qr",
|
||||
"qrcode"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/endroid/qr-code/issues",
|
||||
"source": "https://github.com/endroid/qr-code/tree/6.1.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/endroid",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-05T07:01:58+00:00"
|
||||
},
|
||||
{
|
||||
"name": "firebase/php-jwt",
|
||||
"version": "v7.1.0",
|
||||
@@ -2515,6 +2924,73 @@
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Masterminds/html5-php.git",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"php": ">=5.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.7-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Masterminds\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matt Butcher",
|
||||
"email": "technosophos@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Matt Farina",
|
||||
"email": "matt@mattfarina.com"
|
||||
},
|
||||
{
|
||||
"name": "Asmir Mustafic",
|
||||
"email": "goetas@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "An HTML5 parser and serializer.",
|
||||
"homepage": "http://masterminds.github.io/html5-php",
|
||||
"keywords": [
|
||||
"HTML5",
|
||||
"dom",
|
||||
"html",
|
||||
"parser",
|
||||
"querypath",
|
||||
"serializer",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/Masterminds/html5-php/issues",
|
||||
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
|
||||
},
|
||||
"time": "2026-06-23T18:43:15+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.10.0",
|
||||
@@ -4084,6 +4560,86 @@
|
||||
},
|
||||
"time": "2026-06-18T03:57:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabberworm/php-css-parser",
|
||||
"version": "v9.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-iconv": "*",
|
||||
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
|
||||
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "1.4.0",
|
||||
"phpstan/extension-installer": "1.4.3",
|
||||
"phpstan/phpstan": "1.12.33 || 2.2.2",
|
||||
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
|
||||
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
|
||||
"phpunit/phpunit": "8.5.52",
|
||||
"rawr/phpunit-data-provider": "3.3.1",
|
||||
"rector/rector": "1.2.10 || 2.4.6",
|
||||
"rector/type-perfect": "1.0.0 || 2.1.3",
|
||||
"squizlabs/php_codesniffer": "4.0.1",
|
||||
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-mbstring": "for parsing UTF-8 CSS"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "9.5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"src/Rule/Rule.php",
|
||||
"src/RuleSet/RuleContainer.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabberworm\\CSS\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Raphael Schweikert"
|
||||
},
|
||||
{
|
||||
"name": "Oliver Klee",
|
||||
"email": "github@oliverklee.de"
|
||||
},
|
||||
{
|
||||
"name": "Jake Hotson",
|
||||
"email": "jake.github@qzdesign.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "Parser for CSS Files written in PHP",
|
||||
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
|
||||
"keywords": [
|
||||
"css",
|
||||
"parser",
|
||||
"stylesheet"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
|
||||
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
|
||||
},
|
||||
"time": "2026-06-18T15:10:53+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/clock",
|
||||
"version": "v8.1.0",
|
||||
@@ -6634,6 +7190,149 @@
|
||||
],
|
||||
"time": "2026-06-09T10:54:51+00:00"
|
||||
},
|
||||
{
|
||||
"name": "thecodingmachine/safe",
|
||||
"version": "v3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thecodingmachine/safe.git",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"php-parallel-lint/php-parallel-lint": "^1.4",
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpunit/phpunit": "^10",
|
||||
"squizlabs/php_codesniffer": "^3.2"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/special_cases.php",
|
||||
"generated/apache.php",
|
||||
"generated/apcu.php",
|
||||
"generated/array.php",
|
||||
"generated/bzip2.php",
|
||||
"generated/calendar.php",
|
||||
"generated/classobj.php",
|
||||
"generated/com.php",
|
||||
"generated/cubrid.php",
|
||||
"generated/curl.php",
|
||||
"generated/datetime.php",
|
||||
"generated/dir.php",
|
||||
"generated/eio.php",
|
||||
"generated/errorfunc.php",
|
||||
"generated/exec.php",
|
||||
"generated/fileinfo.php",
|
||||
"generated/filesystem.php",
|
||||
"generated/filter.php",
|
||||
"generated/fpm.php",
|
||||
"generated/ftp.php",
|
||||
"generated/funchand.php",
|
||||
"generated/gettext.php",
|
||||
"generated/gmp.php",
|
||||
"generated/gnupg.php",
|
||||
"generated/hash.php",
|
||||
"generated/ibase.php",
|
||||
"generated/ibmDb2.php",
|
||||
"generated/iconv.php",
|
||||
"generated/image.php",
|
||||
"generated/imap.php",
|
||||
"generated/info.php",
|
||||
"generated/inotify.php",
|
||||
"generated/json.php",
|
||||
"generated/ldap.php",
|
||||
"generated/libxml.php",
|
||||
"generated/lzf.php",
|
||||
"generated/mailparse.php",
|
||||
"generated/mbstring.php",
|
||||
"generated/misc.php",
|
||||
"generated/mysql.php",
|
||||
"generated/mysqli.php",
|
||||
"generated/network.php",
|
||||
"generated/oci8.php",
|
||||
"generated/opcache.php",
|
||||
"generated/openssl.php",
|
||||
"generated/outcontrol.php",
|
||||
"generated/pcntl.php",
|
||||
"generated/pcre.php",
|
||||
"generated/pgsql.php",
|
||||
"generated/posix.php",
|
||||
"generated/ps.php",
|
||||
"generated/pspell.php",
|
||||
"generated/readline.php",
|
||||
"generated/rnp.php",
|
||||
"generated/rpminfo.php",
|
||||
"generated/rrd.php",
|
||||
"generated/sem.php",
|
||||
"generated/session.php",
|
||||
"generated/shmop.php",
|
||||
"generated/sockets.php",
|
||||
"generated/sodium.php",
|
||||
"generated/solr.php",
|
||||
"generated/spl.php",
|
||||
"generated/sqlsrv.php",
|
||||
"generated/ssdeep.php",
|
||||
"generated/ssh2.php",
|
||||
"generated/stream.php",
|
||||
"generated/strings.php",
|
||||
"generated/swoole.php",
|
||||
"generated/uodbc.php",
|
||||
"generated/uopz.php",
|
||||
"generated/url.php",
|
||||
"generated/var.php",
|
||||
"generated/xdiff.php",
|
||||
"generated/xml.php",
|
||||
"generated/xmlrpc.php",
|
||||
"generated/yaml.php",
|
||||
"generated/yaz.php",
|
||||
"generated/zip.php",
|
||||
"generated/zlib.php"
|
||||
],
|
||||
"classmap": [
|
||||
"lib/DateTime.php",
|
||||
"lib/DateTimeImmutable.php",
|
||||
"lib/Exceptions/",
|
||||
"generated/Exceptions/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
|
||||
"support": {
|
||||
"issues": "https://github.com/thecodingmachine/safe/issues",
|
||||
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/OskarStark",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/shish",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/silasjoisten",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/staabm",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-02-04T18:08:13+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.4.0",
|
||||
|
||||
@@ -78,9 +78,11 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
'locale' => env('APP_LOCALE', 'es'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'),
|
||||
|
||||
'supported_locales' => ['es', 'en'],
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
|
||||
9
config/login-security.php
Normal file
9
config/login-security.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'max_attempts' => (int) env('AUTH_MAX_LOGIN_ATTEMPTS', 3),
|
||||
'attempt_window_minutes' => (int) env('AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES', 30),
|
||||
'lock_minutes' => (int) env('AUTH_LOGIN_LOCK_MINUTES', 15),
|
||||
'rate_limit_per_minute' => (int) env('AUTH_LOGIN_RATE_LIMIT_PER_MINUTE', 10),
|
||||
'ip_rate_limit_per_minute' => (int) env('AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE', 30),
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->foreignId('source_purchase_id')
|
||||
->nullable()
|
||||
->after('description')
|
||||
->constrained('compras')
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('source_purchase_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->unsignedSmallInteger('failed_login_attempts')->default(0);
|
||||
$table->timestamp('last_failed_login_at')->nullable();
|
||||
$table->timestamp('locked_until')->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropIndex(['locked_until']);
|
||||
$table->dropColumn([
|
||||
'failed_login_attempts',
|
||||
'last_failed_login_at',
|
||||
'locked_until',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('login_attempts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->constrained()
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
$table->string('email_fingerprint', 64);
|
||||
$table->string('tenant_codigo')->nullable();
|
||||
$table->string('outcome', 32);
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('user_agent', 1024)->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['email_fingerprint', 'created_at']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['ip_address', 'created_at']);
|
||||
$table->index(['outcome', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('login_attempts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->string('reason')->default('manual')->after('codigo');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->dropColumn('reason');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
@@ -12,6 +13,12 @@ class CategorySeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tenant = Tenant::query()->where('codigo', 'sonder')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categories = [
|
||||
'Remeras',
|
||||
'Pantalones',
|
||||
@@ -23,8 +30,21 @@ class CategorySeeder extends Seeder
|
||||
foreach ($categories as $nombre) {
|
||||
Category::firstOrCreate([
|
||||
'nombre' => $nombre,
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
|
||||
$indumentaria = Category::updateOrCreate(
|
||||
[
|
||||
'nombre' => 'Indumentaria',
|
||||
'tenant_code' => $tenant->codigo,
|
||||
],
|
||||
['categoria_id' => null],
|
||||
);
|
||||
|
||||
Category::query()
|
||||
->where('nombre', 'Remeras')
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->update(['categoria_id' => $indumentaria->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,15 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
|
||||
$ticketCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Entradas',
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
$foodCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Gastronomía',
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
$parkingCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Estacionamiento',
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
|
||||
@@ -69,8 +73,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $ticketCategory->id],
|
||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id],
|
||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id],
|
||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id],
|
||||
];
|
||||
|
||||
$createdItems = [];
|
||||
|
||||
@@ -236,11 +236,11 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
|
||||
$category = Category::query()
|
||||
->where('nombre', $metadata['category_name'])
|
||||
->whereNull('tenant_code')
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->first();
|
||||
|
||||
if (! $category instanceof Category) {
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found for tenant '{$tenant->codigo}'.");
|
||||
}
|
||||
|
||||
$attributeCodes = Attribute::query()
|
||||
|
||||
106
lang/en/api.php
Normal file
106
lang/en/api.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'auth' => [
|
||||
'unauthenticated' => 'Unauthenticated.',
|
||||
'invalid_credentials' => 'Email or password is incorrect.',
|
||||
'account_locked' => 'The account is temporarily locked. Please try again later.',
|
||||
'login_success' => 'Signed in successfully.',
|
||||
'logout_success' => 'Signed out successfully.',
|
||||
'register_success' => 'User registered successfully.',
|
||||
'password_reset_requested' => 'If the email is registered, you will receive a code to reset your password.',
|
||||
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
||||
'password_updated' => 'Password updated successfully.',
|
||||
'reset_code_invalid' => 'The code you entered is invalid.',
|
||||
'reset_code_valid' => 'Code validated successfully.',
|
||||
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
||||
'request_expired' => 'The authentication request expired. Please try again.',
|
||||
'invalid_return_url' => 'The return URL is invalid.',
|
||||
'oauth_code_expired' => 'The authentication code expired or has already been used.',
|
||||
'tenant_mismatch' => 'The tenant does not match the authentication request.',
|
||||
'google_email_required' => 'Google did not return an email for the account.',
|
||||
'google_email_unverified' => 'The Google account must have a verified email.',
|
||||
],
|
||||
'cart' => [
|
||||
'item_added' => 'Product added to cart.',
|
||||
'quantity_updated' => 'Product quantity updated.',
|
||||
'item_removed' => 'Product removed from cart.',
|
||||
'positive_quantity' => 'The quantity must be greater than zero.',
|
||||
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
|
||||
'max_quantity' => 'You can add a maximum of :max.',
|
||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||
'empty_bundle' => 'The bundle has no components.',
|
||||
'variant_required' => 'You must select a variant for this item.',
|
||||
],
|
||||
'purchase' => [
|
||||
'source_required' => 'A cart or direct item is required.',
|
||||
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
|
||||
'not_editable' => 'The purchase is no longer editable.',
|
||||
'item_not_editable' => 'The purchase item is no longer editable.',
|
||||
'insufficient_stock' => 'There is not enough stock available.',
|
||||
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
|
||||
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
|
||||
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
|
||||
'direct_item_max_stock' => 'There is not enough stock. Maximum available: :max.',
|
||||
'empty_cart' => 'The selected cart does not contain items.',
|
||||
'catalog_item_missing' => 'One or more catalog items could not be loaded.',
|
||||
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
|
||||
'inactive_cart' => 'The selected cart is no longer active.',
|
||||
'not_available_for_payment' => 'The purchase is no longer available for payment.',
|
||||
'not_available_for_review' => 'The purchase is no longer available for review.',
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'One or more tickets are not available.',
|
||||
'positive_quantity' => 'The number of tickets to generate must be greater than zero.',
|
||||
'empty_bundle' => 'Bundle :bundle has no components.',
|
||||
'disabled' => 'Product :product does not have tickets enabled.',
|
||||
'expired' => 'Product :product has reached its maximum usage date.',
|
||||
'invalid_variant' => 'Variant :variant does not belong to product :product.',
|
||||
'purchase_without_user' => 'Purchase :purchase does not have an associated user.',
|
||||
'product_not_found' => 'The product for purchase item :purchase_item was not found.',
|
||||
],
|
||||
'integration' => [
|
||||
'not_configured' => 'The integration is not configured for this tenant.',
|
||||
'configured' => 'Integration configured successfully.',
|
||||
'validation_failed' => 'Configuration validation failed: :error',
|
||||
'invalid_payment_method' => 'Invalid payment method.',
|
||||
'account_info_failed' => 'Could not retrieve account information.',
|
||||
'qr_generation_failed' => 'Could not generate the QR code.',
|
||||
'webhook_failed' => 'The webhook could not be processed.',
|
||||
],
|
||||
'mail' => [
|
||||
'test_sent' => 'Test email sent successfully.',
|
||||
],
|
||||
'catalog' => [
|
||||
'standard_with_components' => 'A standard item cannot have components.',
|
||||
'duplicate_component' => 'The component is duplicated.',
|
||||
'component_wrong_tenant' => 'The item does not belong to the bundle tenant.',
|
||||
'invalid_component' => 'The component must be a standard item other than the bundle.',
|
||||
'component_variant_required' => 'You must select a variant for this component.',
|
||||
'component_variant_forbidden' => 'A component with direct inventory cannot have a variant.',
|
||||
'component_variant_invalid' => 'The variant does not belong to the selected component.',
|
||||
'bundle_component_required' => 'A bundle must have at least one component.',
|
||||
'bundle_field_forbidden' => ':field is not allowed for a bundle.',
|
||||
'attachment_not_found' => 'The specified attachment does not exist.',
|
||||
'attribute_not_found' => 'Attribute :attribute does not exist for the item tenant.',
|
||||
'managed_inventory_fields' => 'inventory_id, reserved_stock, and sold_units are managed internally.',
|
||||
'attribute_not_on_item' => 'The attribute does not belong to the catalog item.',
|
||||
'invalid_effective_date_range' => 'The effective maximum date must be after or equal to the effective minimum date.',
|
||||
'variants_required' => 'An item with attribute_codes must have variants.',
|
||||
'variants_forbidden' => 'An item without attribute_codes cannot have variants.',
|
||||
'direct_inventory_forbidden' => 'An item with variants cannot have direct inventory.',
|
||||
],
|
||||
'menu' => [
|
||||
'schema_required' => 'The schema is required for static menus.',
|
||||
'not_found' => 'The specified menu does not exist.',
|
||||
],
|
||||
'tenant' => [
|
||||
'invalid_carousel_image' => 'The specified image does not exist or is not an image attachment.',
|
||||
],
|
||||
'errors' => [
|
||||
'forbidden' => 'You do not have permission to perform this action.',
|
||||
'not_found' => 'The requested resource was not found.',
|
||||
'method_not_allowed' => 'The HTTP method is not allowed for this route.',
|
||||
'request_failed' => 'The request could not be processed.',
|
||||
],
|
||||
];
|
||||
57
lang/en/validation.php
Normal file
57
lang/en/validation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
|
||||
'array' => 'The :attribute must be an array.',
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'confirmed' => 'The :attribute confirmation does not match.',
|
||||
'date' => 'The :attribute must be a valid date.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'email' => 'The :attribute must be a valid email address.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'file' => 'The :attribute must be a file.',
|
||||
'integer' => 'The :attribute must be an integer.',
|
||||
'max' => [
|
||||
'array' => 'The :attribute must not have more than :max items.',
|
||||
'file' => 'The :attribute must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute must not be greater than :max.',
|
||||
'string' => 'The :attribute must not be greater than :max characters.',
|
||||
],
|
||||
'min' => [
|
||||
'array' => 'The :attribute must have at least :min items.',
|
||||
'file' => 'The :attribute must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute must be at least :min.',
|
||||
'string' => 'The :attribute must be at least :min characters.',
|
||||
],
|
||||
'numeric' => 'The :attribute must be a number.',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute must contain at least one letter.',
|
||||
'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute must contain at least one number.',
|
||||
'symbols' => 'The :attribute must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different one.',
|
||||
],
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'regex' => 'The :attribute format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'string' => 'The :attribute must be a string.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'url' => 'The :attribute must be a valid URL.',
|
||||
'uuid' => 'The :attribute must be a valid UUID.',
|
||||
'attributes' => [
|
||||
'cantidad' => 'quantity',
|
||||
'catalog_item_id' => 'product',
|
||||
'codigo' => 'code',
|
||||
'dni' => 'ID number',
|
||||
'email' => 'email',
|
||||
'nombre_apellido' => 'full name',
|
||||
'oauth_code' => 'authentication code',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password confirmation',
|
||||
'telefono' => 'phone number',
|
||||
'tenant_codigo' => 'tenant',
|
||||
'variant_id' => 'variant',
|
||||
],
|
||||
];
|
||||
106
lang/es/api.php
Normal file
106
lang/es/api.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'auth' => [
|
||||
'unauthenticated' => 'No autenticado.',
|
||||
'invalid_credentials' => 'Email o contraseña incorrectos.',
|
||||
'account_locked' => 'La cuenta está bloqueada temporalmente. Intenta nuevamente más tarde.',
|
||||
'login_success' => 'Sesión iniciada correctamente.',
|
||||
'logout_success' => 'Sesión cerrada correctamente.',
|
||||
'register_success' => 'Usuario registrado correctamente.',
|
||||
'password_reset_requested' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
|
||||
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||
'password_updated' => 'Contraseña modificada correctamente.',
|
||||
'reset_code_invalid' => 'El código ingresado es inválido.',
|
||||
'reset_code_valid' => 'Código validado correctamente.',
|
||||
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
||||
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
||||
'invalid_return_url' => 'La URL de retorno no es válida.',
|
||||
'oauth_code_expired' => 'El código de autenticación expiró o ya fue utilizado.',
|
||||
'tenant_mismatch' => 'El tenant no coincide con la solicitud de autenticación.',
|
||||
'google_email_required' => 'Google no devolvió un email para la cuenta.',
|
||||
'google_email_unverified' => 'La cuenta de Google debe tener el email verificado.',
|
||||
],
|
||||
'cart' => [
|
||||
'item_added' => 'Producto agregado al carrito.',
|
||||
'quantity_updated' => 'Cantidad de producto actualizada.',
|
||||
'item_removed' => 'Producto eliminado del carrito.',
|
||||
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
|
||||
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
|
||||
'max_quantity' => 'El máximo que se puede agregar es :max.',
|
||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
||||
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
||||
],
|
||||
'purchase' => [
|
||||
'source_required' => 'Se requiere un carrito o un producto directo.',
|
||||
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
|
||||
'not_editable' => 'La compra ya no se puede modificar.',
|
||||
'item_not_editable' => 'El producto de la compra ya no se puede modificar.',
|
||||
'insufficient_stock' => 'No hay suficiente stock disponible.',
|
||||
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
|
||||
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
|
||||
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
|
||||
'direct_item_max_stock' => 'Stock insuficiente. Máximo disponible: :max.',
|
||||
'empty_cart' => 'El carrito seleccionado no contiene productos.',
|
||||
'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.',
|
||||
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
|
||||
'inactive_cart' => 'El carrito seleccionado ya no está activo.',
|
||||
'not_available_for_payment' => 'La compra ya no está disponible para el pago.',
|
||||
'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.",
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'Uno o más tickets no están disponibles.',
|
||||
'positive_quantity' => 'La cantidad de tickets a generar debe ser mayor a cero.',
|
||||
'empty_bundle' => 'El bundle :bundle no tiene componentes.',
|
||||
'disabled' => 'El producto :product no tiene tickets habilitados.',
|
||||
'expired' => 'El producto :product alcanzó su fecha máxima de uso.',
|
||||
'invalid_variant' => 'La variante :variant no pertenece al producto :product.',
|
||||
'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.',
|
||||
'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.',
|
||||
],
|
||||
'integration' => [
|
||||
'not_configured' => 'La integración no está configurada para este tenant.',
|
||||
'configured' => 'Integración configurada correctamente.',
|
||||
'validation_failed' => 'Error validando la configuración: :error',
|
||||
'invalid_payment_method' => 'Método de pago inválido.',
|
||||
'account_info_failed' => 'No se pudo obtener la información de la cuenta.',
|
||||
'qr_generation_failed' => 'No se pudo generar el código QR.',
|
||||
'webhook_failed' => 'No se pudo procesar el webhook.',
|
||||
],
|
||||
'mail' => [
|
||||
'test_sent' => 'Correo de prueba enviado correctamente.',
|
||||
],
|
||||
'catalog' => [
|
||||
'standard_with_components' => 'Un ítem standard no puede tener componentes.',
|
||||
'duplicate_component' => 'El componente está duplicado.',
|
||||
'component_wrong_tenant' => 'El ítem no pertenece al tenant del bundle.',
|
||||
'invalid_component' => 'El componente debe ser un ítem standard distinto del bundle.',
|
||||
'component_variant_required' => 'Debe seleccionar una variante para este componente.',
|
||||
'component_variant_forbidden' => 'El componente con inventario directo no admite una variante.',
|
||||
'component_variant_invalid' => 'La variante no pertenece al componente indicado.',
|
||||
'bundle_component_required' => 'Un bundle debe tener al menos un componente.',
|
||||
'bundle_field_forbidden' => ':field no se admite para un bundle.',
|
||||
'attachment_not_found' => 'El attachment indicado no existe.',
|
||||
'attribute_not_found' => 'El atributo :attribute no existe para el tenant del ítem.',
|
||||
'managed_inventory_fields' => 'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
'attribute_not_on_item' => 'El atributo no pertenece al ítem de catálogo.',
|
||||
'invalid_effective_date_range' => 'La fecha máxima efectiva debe ser posterior o igual a la fecha mínima efectiva.',
|
||||
'variants_required' => 'Un ítem con attribute_codes debe tener variantes.',
|
||||
'variants_forbidden' => 'Un ítem sin attribute_codes no puede tener variantes.',
|
||||
'direct_inventory_forbidden' => 'Un ítem con variantes no puede tener inventario directo.',
|
||||
],
|
||||
'menu' => [
|
||||
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
|
||||
'not_found' => 'El menú indicado no existe.',
|
||||
],
|
||||
'tenant' => [
|
||||
'invalid_carousel_image' => 'La imagen indicada no existe o no es un attachment de tipo imagen.',
|
||||
],
|
||||
'errors' => [
|
||||
'forbidden' => 'No tienes permiso para realizar esta acción.',
|
||||
'not_found' => 'El recurso solicitado no fue encontrado.',
|
||||
'method_not_allowed' => 'El método HTTP no está permitido para esta ruta.',
|
||||
'request_failed' => 'No se pudo procesar la solicitud.',
|
||||
],
|
||||
];
|
||||
57
lang/es/validation.php
Normal file
57
lang/es/validation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'after_or_equal' => ':Attribute debe ser una fecha posterior o igual a :date.',
|
||||
'array' => ':Attribute debe ser un arreglo.',
|
||||
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
|
||||
'confirmed' => 'La confirmación de :attribute no coincide.',
|
||||
'date' => ':Attribute no es una fecha válida.',
|
||||
'distinct' => 'El campo :attribute tiene un valor duplicado.',
|
||||
'email' => ':Attribute debe ser una dirección de email válida.',
|
||||
'exists' => 'El valor seleccionado para :attribute no es válido.',
|
||||
'file' => ':Attribute debe ser un archivo.',
|
||||
'integer' => ':Attribute debe ser un número entero.',
|
||||
'max' => [
|
||||
'array' => ':Attribute no debe tener más de :max elementos.',
|
||||
'file' => ':Attribute no debe superar los :max kilobytes.',
|
||||
'numeric' => ':Attribute no debe ser mayor que :max.',
|
||||
'string' => ':Attribute no debe tener más de :max caracteres.',
|
||||
],
|
||||
'min' => [
|
||||
'array' => ':Attribute debe tener al menos :min elementos.',
|
||||
'file' => ':Attribute debe pesar al menos :min kilobytes.',
|
||||
'numeric' => ':Attribute debe ser al menos :min.',
|
||||
'string' => ':Attribute debe tener al menos :min caracteres.',
|
||||
],
|
||||
'numeric' => ':Attribute debe ser un número.',
|
||||
'password' => [
|
||||
'letters' => ':Attribute debe contener al menos una letra.',
|
||||
'mixed' => ':Attribute debe contener al menos una letra mayúscula y una minúscula.',
|
||||
'numbers' => ':Attribute debe contener al menos un número.',
|
||||
'symbols' => ':Attribute debe contener al menos un símbolo.',
|
||||
'uncompromised' => ':Attribute aparece en una filtración de datos. Elige una contraseña diferente.',
|
||||
],
|
||||
'prohibited' => 'El campo :attribute está prohibido.',
|
||||
'regex' => 'El formato de :attribute no es válido.',
|
||||
'required' => 'El campo :attribute es obligatorio.',
|
||||
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
|
||||
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
|
||||
'string' => ':Attribute debe ser texto.',
|
||||
'unique' => 'El :attribute ya está en uso.',
|
||||
'url' => ':Attribute debe ser una URL válida.',
|
||||
'uuid' => ':Attribute debe ser un UUID válido.',
|
||||
'attributes' => [
|
||||
'cantidad' => 'cantidad',
|
||||
'catalog_item_id' => 'producto',
|
||||
'codigo' => 'código',
|
||||
'dni' => 'DNI',
|
||||
'email' => 'email',
|
||||
'nombre_apellido' => 'nombre y apellido',
|
||||
'oauth_code' => 'código de autenticación',
|
||||
'password' => 'contraseña',
|
||||
'password_confirmation' => 'confirmación de contraseña',
|
||||
'telefono' => 'teléfono',
|
||||
'tenant_codigo' => 'tenant',
|
||||
'variant_id' => 'variante',
|
||||
],
|
||||
];
|
||||
@@ -2,10 +2,16 @@
|
||||
Recuperá tu contraseña
|
||||
</h1>
|
||||
|
||||
@if($attempt->reason === 'account_locked')
|
||||
<p>
|
||||
Hola {{ $attempt->user->nombre_apellido }}, registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, hemos bloqueado el acceso temporalmente. Puedes utilizar este código para cambiar tu contraseña y desbloquearla inmediatamente.
|
||||
</p>
|
||||
@else
|
||||
<p>
|
||||
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
||||
la contraseña de tu cuenta.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||
|
||||
@@ -15,6 +21,21 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email);
|
||||
@endphp
|
||||
|
||||
<div style="text-align: center; margin-bottom: 28px;">
|
||||
<a href="{{ $recoveryUrl }}"
|
||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||
Ingresar código ahora
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px;">
|
||||
@if($attempt->reason === 'account_locked')
|
||||
Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.
|
||||
@else
|
||||
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
||||
@endif
|
||||
</p>
|
||||
|
||||
213
resources/views/pdf/tickets.blade.php
Normal file
213
resources/views/pdf/tickets.blade.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||
<style>
|
||||
@page { margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background: #f4f7f5;
|
||||
color: #17211b;
|
||||
font-family: DejaVu Sans, sans-serif;
|
||||
font-size: 11px;
|
||||
margin: 0;
|
||||
}
|
||||
.ticket {
|
||||
background: #f4f7f5;
|
||||
min-height: 1120px;
|
||||
padding: 0 54px;
|
||||
position: relative;
|
||||
}
|
||||
.ticket + .ticket { page-break-before: always; }
|
||||
.accent { background: {{ $primaryColor }}; height: 8px; margin: 0 -54px; }
|
||||
.header {
|
||||
background: {{ $headerBackgroundColor }};
|
||||
color: {{ $headerTextColor }};
|
||||
height: 132px;
|
||||
margin: 0 -54px;
|
||||
padding: 32px 54px 0;
|
||||
}
|
||||
.header-table, .meta-table, .footer-table { border-collapse: collapse; width: 100%; }
|
||||
.brand-cell { vertical-align: middle; }
|
||||
.type-cell { text-align: right; vertical-align: middle; width: 190px; }
|
||||
.logo {
|
||||
height: 64px;
|
||||
max-width: 180px;
|
||||
object-fit: contain;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tenant-name {
|
||||
display: inline-block;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
max-width: 340px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ticket-type {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
color: {{ $primaryColor }};
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1.25px;
|
||||
padding: 8px 13px;
|
||||
}
|
||||
.content { padding-top: 36px; text-align: center; }
|
||||
.eyebrow {
|
||||
color: {{ $primaryColor }};
|
||||
font-size: 9px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1.4px;
|
||||
margin: 0 0 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ticket-name {
|
||||
font-size: 27px;
|
||||
font-weight: bold;
|
||||
line-height: 1.2;
|
||||
margin: 0 auto 23px;
|
||||
max-width: 570px;
|
||||
}
|
||||
.meta-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 10px;
|
||||
margin: 0 auto 24px;
|
||||
padding: 15px 18px;
|
||||
text-align: left;
|
||||
width: 500px;
|
||||
}
|
||||
.meta-cell {
|
||||
border-right: 1px solid #e5ebe7;
|
||||
padding: 0 14px;
|
||||
vertical-align: top;
|
||||
width: 33.33%;
|
||||
}
|
||||
.meta-cell:first-child { padding-left: 0; }
|
||||
.meta-cell:last-child { border-right: 0; padding-right: 0; }
|
||||
.meta-label {
|
||||
color: #758078;
|
||||
font-size: 8px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.9px;
|
||||
margin-bottom: 5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.meta-value { color: #26322a; font-size: 12px; font-weight: bold; }
|
||||
.qr-card {
|
||||
background: #fff;
|
||||
border: 1px solid #dfe7e2;
|
||||
border-radius: 16px;
|
||||
display: inline-block;
|
||||
padding: 18px 22px 17px;
|
||||
}
|
||||
.qr-frame {
|
||||
border: 3px solid {{ $primaryColor }};
|
||||
border-radius: 14px;
|
||||
padding: 11px;
|
||||
}
|
||||
.qr { display: block; height: 276px; width: 276px; }
|
||||
.scan-label {
|
||||
color: #26322a;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin: 14px 0 3px;
|
||||
}
|
||||
.scan-help { color: #77827b; font-size: 9px; margin: 0; }
|
||||
.notice {
|
||||
background: #eaf1ed;
|
||||
border-left: 4px solid {{ $primaryColor }};
|
||||
border-radius: 6px;
|
||||
color: #445049;
|
||||
line-height: 1.45;
|
||||
margin: 22px auto 0;
|
||||
padding: 11px 14px;
|
||||
text-align: left;
|
||||
width: 500px;
|
||||
}
|
||||
.notice strong { color: #26322a; }
|
||||
.footer {
|
||||
border-top: 1px solid #dfe7e2;
|
||||
bottom: 32px;
|
||||
color: #7b857e;
|
||||
font-size: 8px;
|
||||
left: 54px;
|
||||
padding-top: 11px;
|
||||
position: absolute;
|
||||
right: 54px;
|
||||
}
|
||||
.footer-right { text-align: right; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@foreach ($tickets as $ticket)
|
||||
<section class="ticket">
|
||||
<div class="accent"></div>
|
||||
|
||||
<header class="header">
|
||||
<table class="header-table" role="presentation">
|
||||
<tr>
|
||||
<td class="brand-cell">
|
||||
@if ($logoDataUri)
|
||||
<img class="logo" src="{{ $logoDataUri }}" alt="Logo de {{ $tenant->nombre }}">
|
||||
@else
|
||||
<span class="tenant-name">{{ $tenant->nombre }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="type-cell">
|
||||
<span class="ticket-type">TICKET DIGITAL</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<p class="eyebrow">Tu acceso</p>
|
||||
<h1 class="ticket-name">{{ $ticket->name }}</h1>
|
||||
|
||||
<div class="meta-card">
|
||||
<table class="meta-table" role="presentation">
|
||||
<tr>
|
||||
<td class="meta-cell">
|
||||
<div class="meta-label">Número de ticket</div>
|
||||
<div class="meta-value">#{{ $ticket->id }}</div>
|
||||
</td>
|
||||
<td class="meta-cell">
|
||||
<div class="meta-label">Desde</div>
|
||||
<div class="meta-value">{{ $ticket->starts_at?->format('d/m/Y') ?? 'Sin fecha inicial' }}</div>
|
||||
</td>
|
||||
<td class="meta-cell">
|
||||
<div class="meta-label">Hasta</div>
|
||||
<div class="meta-value">{{ $ticket->expires_at?->format('d/m/Y') ?? 'Sin vencimiento' }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="qr-card">
|
||||
<div class="qr-frame">
|
||||
<img class="qr" src="{{ $qrCodes[$ticket->id] }}" alt="Código QR del ticket {{ $ticket->id }}">
|
||||
</div>
|
||||
<p class="scan-label">Escaneá este código al ingresar</p>
|
||||
<p class="scan-help">Aumentá el brillo de la pantalla para una lectura más rápida.</p>
|
||||
</div>
|
||||
|
||||
<div class="notice">
|
||||
<strong>Importante:</strong> este ticket es personal. No compartas el código QR con otras personas.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<table class="footer-table" role="presentation">
|
||||
<tr>
|
||||
<td>{{ $tenant->nombre }}</td>
|
||||
<td class="footer-right">Conservá este ticket hasta finalizar tu ingreso.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</footer>
|
||||
</section>
|
||||
@endforeach
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
@@ -35,7 +36,9 @@ class LoginControllerTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Sesion iniciada correctamente.')
|
||||
->assertHeader('Content-Language', 'es')
|
||||
->assertJsonPath('code', 'auth.login_success')
|
||||
->assertJsonPath('message', 'Sesión iniciada correctamente.')
|
||||
->assertJsonPath('token_type', 'Bearer')
|
||||
->assertJsonPath('user.id', $user->id)
|
||||
->assertJsonPath('user.nombre_apellido', 'Grace Hopper')
|
||||
@@ -85,7 +88,8 @@ class LoginControllerTest extends TestCase
|
||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||
->postJson('/api/logout')
|
||||
->assertOk()
|
||||
->assertJsonPath('message', 'Sesion cerrada correctamente.');
|
||||
->assertJsonPath('code', 'auth.logout_success')
|
||||
->assertJsonPath('message', 'Sesión cerrada correctamente.');
|
||||
|
||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||
}
|
||||
@@ -99,11 +103,190 @@ class LoginControllerTest extends TestCase
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
$this->withHeader('User-Agent', 'Shopit login test')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
|
||||
$user = User::query()->where('email', 'grace@example.com')->sole();
|
||||
|
||||
$this->assertSame(1, $user->failed_login_attempts);
|
||||
$this->assertNotNull($user->last_failed_login_at);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Shopit login test',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_locks_an_account_after_the_maximum_failed_attempts(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 3,
|
||||
'login-security.lock_minutes' => 15,
|
||||
'login-security.rate_limit_per_minute' => 100,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
$this->travelTo(now()->startOfSecond());
|
||||
|
||||
$tenant = $this->createTenant('locked');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'locked@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$payload = [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
];
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
}
|
||||
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertJsonPath('code', 'auth.account_locked');
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(3, $user->failed_login_attempts);
|
||||
$this->assertTrue($user->locked_until->equalTo(now()->addMinutes(15)));
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
...$payload,
|
||||
'password' => 'secret123',
|
||||
])
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After', '900')
|
||||
->assertJsonPath('code', 'auth.account_locked')
|
||||
->assertJsonPath('retry_after', 900);
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 4);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_successful_login_resets_failures_and_is_audited(): void
|
||||
{
|
||||
$tenant = $this->createTenant('successful');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'successful@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 2,
|
||||
'last_failed_login_at' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_an_expired_lock_allows_login_again(): void
|
||||
{
|
||||
$tenant = $this->createTenant('expired-lock');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'expired@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now()->subMinutes(20),
|
||||
'locked_until' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_failures_outside_the_attempt_window_start_a_new_count(): void
|
||||
{
|
||||
config(['login-security.attempt_window_minutes' => 30]);
|
||||
|
||||
$tenant = $this->createTenant('attempt-window');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'window@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 4,
|
||||
'last_failed_login_at' => now()->subMinutes(31),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$this->assertSame(1, $user->refresh()->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_unknown_emails_are_audited_without_storing_the_email(): void
|
||||
{
|
||||
$tenant = $this->createTenant('unknown');
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'missing@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$attempt = LoginAttempt::query()->sole();
|
||||
$this->assertNull($attempt->user_id);
|
||||
$this->assertSame(LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $attempt->outcome);
|
||||
$this->assertSame(64, strlen($attempt->email_fingerprint));
|
||||
$this->assertStringNotContainsString('missing@example.com', $attempt->email_fingerprint);
|
||||
}
|
||||
|
||||
public function test_login_is_rate_limited_by_email_and_ip(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 100,
|
||||
'login-security.rate_limit_per_minute' => 2,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
|
||||
$tenant = $this->createTenant('rate-limit');
|
||||
$payload = [
|
||||
'email' => 'rate-limited@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
];
|
||||
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After');
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 2);
|
||||
}
|
||||
|
||||
public function test_it_validates_required_login_fields(): void
|
||||
@@ -118,6 +301,34 @@ class LoginControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_localizes_success_and_validation_responses_from_accept_language(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
User::query()->create([
|
||||
'nombre_apellido' => 'Grace Hopper',
|
||||
'email' => 'grace@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])
|
||||
->assertOk()
|
||||
->assertHeader('Content-Language', 'en')
|
||||
->assertJsonPath('code', 'auth.login_success')
|
||||
->assertJsonPath('message', 'Signed in successfully.');
|
||||
|
||||
$this->withHeader('Accept-Language', 'en')
|
||||
->postJson('/api/login', [])
|
||||
->assertUnprocessable()
|
||||
->assertHeader('Content-Language', 'en')
|
||||
->assertJsonPath('errors.email.0', 'The email field is required.')
|
||||
->assertJsonPath('errors.password.0', 'The password field is required.');
|
||||
}
|
||||
|
||||
public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
|
||||
@@ -18,6 +18,11 @@ class ResetPasswordControllerTest extends TestCase
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'OldSecret!123',
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now(),
|
||||
'locked_until' => now()->addMinutes(15),
|
||||
])->save();
|
||||
$user->createToken('existing-session');
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
@@ -38,6 +43,9 @@ class ResetPasswordControllerTest extends TestCase
|
||||
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
||||
|
||||
123
tests/Feature/Catalog/CategoryDetailTest.php
Normal file
123
tests/Feature/Catalog/CategoryDetailTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Catalog;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CategoryDetailTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_category_products_paginated_with_the_tenant_search_layout(): void
|
||||
{
|
||||
$tenant = $this->createTenant('category-detail');
|
||||
$tenant->update([
|
||||
'search_product_layout' => ProductLayout::Row->value,
|
||||
'search_group_layout' => GroupLayout::SimpleVertical->value,
|
||||
'search_items_per_page' => 2,
|
||||
]);
|
||||
$category = $this->createCategory($tenant, 'Remeras');
|
||||
$otherCategory = $this->createCategory($tenant, 'Pantalones');
|
||||
|
||||
$this->createCatalogItem($tenant, $category, 'Remera C');
|
||||
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
|
||||
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
|
||||
$this->createCatalogItem($tenant, $otherCategory, 'Pantalón');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('category.id', $category->id)
|
||||
->assertJsonPath('category.nombre', 'Remeras')
|
||||
->assertJsonPath('category.categoria_id', null)
|
||||
->assertJsonPath('layout', ProductLayout::Row->value)
|
||||
->assertJsonPath('group_layout', GroupLayout::SimpleVertical->value)
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonPath('meta.per_page', 2)
|
||||
->assertJsonPath('meta.total', 3)
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $firstItem->id)
|
||||
->assertJsonPath('data.1.id', $secondItem->id)
|
||||
->assertJsonMissing(['nombre' => 'Pantalón']);
|
||||
}
|
||||
|
||||
public function test_it_returns_an_empty_paginated_response_for_a_category_without_products(): void
|
||||
{
|
||||
$tenant = $this->createTenant('empty-category');
|
||||
$category = $this->createCategory($tenant, 'Sin productos');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('category.id', $category->id)
|
||||
->assertJsonPath('meta.total', 0)
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonCount(0, 'data');
|
||||
}
|
||||
|
||||
public function test_it_rejects_categories_from_another_tenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('category-owner');
|
||||
$otherTenant = $this->createTenant('category-foreign');
|
||||
$foreignCategory = $this->createCategory($otherTenant, 'Ajena');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$foreignCategory->id}")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_validates_the_page(): void
|
||||
{
|
||||
$tenant = $this->createTenant('category-page');
|
||||
$category = $this->createCategory($tenant, 'Remeras');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}?page=0")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('page');
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.local",
|
||||
'primary_color' => '#000000',
|
||||
'secondary_color' => '#000000',
|
||||
'danger_color' => '#000000',
|
||||
'success_color' => '#000000',
|
||||
'header_bg_color' => '#000000',
|
||||
'footer_bg_color' => '#000000',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCategory(Tenant $tenant, string $name): Category
|
||||
{
|
||||
return Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => $name,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCatalogItem(
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
string $name,
|
||||
): CatalogItem {
|
||||
$inventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
|
||||
return CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'category_id' => $category->id,
|
||||
'inventory_id' => $inventory->id,
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'nombre' => $name,
|
||||
'descripcion' => "{$name} description",
|
||||
'precio' => 100,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class TelepagosWebhookTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
||||
public function test_transfer_webhook_matches_purchase_in_review_by_dni_and_total_amount(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
@@ -122,6 +122,8 @@ class TelepagosWebhookTest extends TestCase
|
||||
'12345678'
|
||||
);
|
||||
|
||||
$matchingPurchase->update(['status' => Purchase::STATUS_IN_REVIEW]);
|
||||
|
||||
$newerPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
$newerUser->id,
|
||||
|
||||
@@ -31,7 +31,7 @@ class TenantIntegrationControllerTest extends TestCase
|
||||
Mockery::on(fn (Integration $argument) => $argument->is($integration)),
|
||||
['api_key' => 'secret']
|
||||
)
|
||||
->andReturn(new TenantIntegration());
|
||||
->andReturn(new TenantIntegration);
|
||||
});
|
||||
|
||||
$this->postJson('/api/test-tenant/integrations/test_integration', [
|
||||
@@ -40,7 +40,8 @@ class TenantIntegrationControllerTest extends TestCase
|
||||
],
|
||||
])->assertOk()
|
||||
->assertExactJson([
|
||||
'message' => 'integration configured correctly',
|
||||
'code' => 'integration.configured',
|
||||
'message' => 'Integración configurada correctamente.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
40
tests/Feature/Localization/ApiLocalizationTest.php
Normal file
40
tests/Feature/Localization/ApiLocalizationTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Localization;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class ApiLocalizationTest extends TestCase
|
||||
{
|
||||
public function test_it_negotiates_spanish_from_accept_language(): void
|
||||
{
|
||||
$this->withHeader('Accept-Language', 'es-AR,es;q=0.9')
|
||||
->postJson('/api/login')
|
||||
->assertUnprocessable()
|
||||
->assertHeader('Content-Language', 'es')
|
||||
->assertJsonPath('errors.email.0', 'El campo email es obligatorio.')
|
||||
->assertJsonPath('errors.password.0', 'El campo contraseña es obligatorio.');
|
||||
}
|
||||
|
||||
public function test_it_negotiates_english_from_accept_language(): void
|
||||
{
|
||||
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
|
||||
->postJson('/api/login')
|
||||
->assertUnprocessable()
|
||||
->assertHeader('Content-Language', 'en')
|
||||
->assertJsonPath('errors.email.0', 'The email field is required.')
|
||||
->assertJsonPath('errors.password.0', 'The password field is required.');
|
||||
}
|
||||
|
||||
public function test_it_localizes_framework_error_responses(): void
|
||||
{
|
||||
$this->withHeader('Accept-Language', 'en')
|
||||
->getJson('/api/route-that-does-not-exist')
|
||||
->assertNotFound()
|
||||
->assertHeader('Content-Language', 'en')
|
||||
->assertJson([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => 'The requested resource was not found.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -304,6 +304,32 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||
->assertJsonPath('data.dni', '987654321');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'dni' => '987654321',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
@@ -448,6 +474,62 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_submits_a_pending_purchase_for_review_idempotently(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$purchase->update([
|
||||
'payment_method' => 'transfer',
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
|
||||
$url = "/api/tenants/sonder/compras/{$purchase->id}/review";
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson($url)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW)
|
||||
->assertJsonPath('data.expires_at', null);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson($url)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||
}
|
||||
|
||||
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/review")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['purchase']);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_CREATED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_expires_an_abandoned_purchase_and_restores_its_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
@@ -155,6 +156,24 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
);
|
||||
$this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
|
||||
$this->assertSame(10, Inventory::query()->count());
|
||||
$this->assertSame(
|
||||
['Entradas', 'Estacionamiento', 'Gastronomía'],
|
||||
Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orderBy('nombre')
|
||||
->pluck('nombre')
|
||||
->all(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'Estacionamiento',
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'estacionamiento-auto')
|
||||
->with('category')
|
||||
->sole()
|
||||
->category
|
||||
->nombre,
|
||||
);
|
||||
|
||||
$featuredGroups = FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Database\Seeders\AttributeSeeder;
|
||||
@@ -91,5 +92,19 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
||||
->pluck('catalog_item_id')
|
||||
->diff($catalogItemIds),
|
||||
);
|
||||
$this->assertSame(
|
||||
['Accesorios', 'Buzos', 'Pantalones', 'Remeras', 'Zapatillas'],
|
||||
Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orderBy('nombre')
|
||||
->pluck('nombre')
|
||||
->all(),
|
||||
);
|
||||
$this->assertFalse(
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('category', fn ($query) => $query->where('tenant_code', '!=', $tenant->codigo))
|
||||
->exists(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -95,6 +96,45 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.dominio', 'acme.com');
|
||||
}
|
||||
|
||||
public function test_it_returns_only_the_tenant_categories_in_the_bootstrap(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$otherTenant = $this->createTenant([
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'globex.com',
|
||||
]);
|
||||
|
||||
$parent = Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => 'Remeras',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'categoria_id' => $parent->id,
|
||||
'nombre' => 'Manga corta',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'tenant_code' => $otherTenant->codigo,
|
||||
'nombre' => 'Otra categoría',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'tenant_code' => null,
|
||||
'nombre' => 'Global',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/tenants/bootstrap/acme.com')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data.categories')
|
||||
->assertJsonPath('data.categories.0.nombre', 'Remeras')
|
||||
->assertJsonCount(1, 'data.categories.0.subcategories')
|
||||
->assertJsonPath('data.categories.0.subcategories.0.nombre', 'Manga corta')
|
||||
->assertJsonPath('data.categories.0.subcategories.0.subcategories', [])
|
||||
->assertJsonMissingPath('data.categories.0.categoria_id')
|
||||
->assertJsonMissing(['nombre' => 'Otra categoría'])
|
||||
->assertJsonMissing(['nombre' => 'Global']);
|
||||
}
|
||||
|
||||
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
||||
{
|
||||
$response = $this->getJson('/api/tenants/bootstrap/missing.example');
|
||||
|
||||
@@ -61,6 +61,43 @@ class TicketControllerTest extends TestCase
|
||||
->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_an_authenticated_user_can_download_a_pdf_for_their_tickets(): void
|
||||
{
|
||||
$tenant = $this->createTenant('current');
|
||||
$user = User::factory()->create();
|
||||
$firstTicket = $this->createTicket($tenant, $user, 'First ticket');
|
||||
$secondTicket = $this->createTicket($tenant, $user, 'Second ticket');
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->post("/api/tenants/{$tenant->codigo}/tickets/pdf", [
|
||||
'ticket_ids' => [$firstTicket->id, $secondTicket->id],
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertHeader('content-type', 'application/pdf')
|
||||
->assertHeader(
|
||||
'content-disposition',
|
||||
"attachment; filename=tickets_{$secondTicket->id}_{$firstTicket->id}.pdf"
|
||||
);
|
||||
$this->assertStringStartsWith('%PDF', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_a_user_cannot_download_someone_elses_ticket(): void
|
||||
{
|
||||
$tenant = $this->createTenant('current');
|
||||
$user = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$ownTicket = $this->createTicket($tenant, $user, 'Own ticket');
|
||||
$otherTicket = $this->createTicket($tenant, $otherUser, 'Other ticket');
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/{$tenant->codigo}/tickets/pdf", [
|
||||
'ticket_ids' => [$ownTicket->id, $otherTicket->id],
|
||||
])
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
private function createTicket(Tenant $tenant, User $user, string $name): Ticket
|
||||
{
|
||||
return Ticket::query()->create([
|
||||
|
||||
@@ -193,6 +193,12 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
||||
|
||||
$this->actingAs($this->user, 'sanctum')
|
||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.tickets_count', 2)
|
||||
->assertJsonPath('data.has_generated_tickets', true);
|
||||
|
||||
$purchase->markAsPaid();
|
||||
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
@@ -209,6 +215,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$purchase->markAsPaid();
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'source_purchase_id' => $purchase->id,
|
||||
'source_catalog_item_id' => $item->id,
|
||||
'source_variant_id' => $variant->id,
|
||||
]);
|
||||
@@ -226,6 +233,12 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
Event::assertNotDispatched(TicketsAvailable::class);
|
||||
|
||||
$this->actingAs($this->user, 'sanctum')
|
||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.tickets_count', 0)
|
||||
->assertJsonPath('data.has_generated_tickets', false);
|
||||
}
|
||||
|
||||
public function test_paid_status_is_confirmed_when_ticket_maximum_use_date_was_reached(): void
|
||||
|
||||
@@ -155,6 +155,28 @@ class CatalogModelsTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_event_date_identifies_a_variant_without_catalog_attributes(): void
|
||||
{
|
||||
$item = new CatalogItem;
|
||||
$item->nombre = 'Entrada General';
|
||||
|
||||
$eventDate = new EventDate;
|
||||
$eventDate->date = '2026-10-09';
|
||||
$eventDate->time_start = '09:00:00';
|
||||
$eventDate->time_end = '18:00:00';
|
||||
|
||||
$variant = new Variant;
|
||||
$variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00');
|
||||
$variant->maximum_use_date = Carbon::parse('2026-10-09 20:00:00');
|
||||
$variant->setRelation('catalogItem', $item);
|
||||
$variant->setRelation('eventDate', $eventDate);
|
||||
$variant->setRelation('definitions', new EloquentCollection);
|
||||
|
||||
$this->assertSame('Entrada General', $variant->getName());
|
||||
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
|
||||
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
|
||||
{
|
||||
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);
|
||||
|
||||
Reference in New Issue
Block a user