Compare commits
41 Commits
auth/googl
...
homo
| Author | SHA1 | Date | |
|---|---|---|---|
| d02b0c5551 | |||
| ee911171be | |||
| 7c9ebc6d4d | |||
| 45553dc514 | |||
| 1c870d9cfa | |||
| 8ab94ec61f | |||
| 4eacff86bc | |||
| 5c457db1c9 | |||
| 8dc80411d7 | |||
| e16d91f3f9 | |||
| 89f6dab0c4 | |||
| ff9d7728e8 | |||
| 754aeebe3a | |||
| 5107d858cc | |||
| aa4f3303fc | |||
| 069867522a | |||
| 8a19b0136d | |||
| 8b26a2b63e | |||
| c37b8894e4 | |||
| 7b565e7fa7 | |||
| cb76d1c559 | |||
| 135a3bea3e | |||
| 9e6923e732 | |||
| 18c23a112a | |||
| c1be9ecf7f | |||
| 979ed428a2 | |||
| 7889d747d9 | |||
| f6ce85cbde | |||
| 460eec3c78 | |||
| 2b1ef5f8a9 | |||
| 5ada399adf | |||
| aed818da6e | |||
| 018f3f6c13 | |||
| 217f86f624 | |||
| 25891cefbc | |||
| c66d2019d6 | |||
| 12d496544d | |||
| c31be9c927 | |||
| 67ede1a2b4 | |||
| e778d862ba | |||
| 1438ff66c7 |
58
.env.example
58
.env.example
@@ -3,11 +3,15 @@ APP_ENV=production
|
|||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=false
|
APP_DEBUG=false
|
||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
FRONTEND_URL=http://localhost:4200
|
|
||||||
INTEGRATION_SECRET=
|
|
||||||
|
|
||||||
APP_LOCALE=en
|
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||||
APP_FALLBACK_LOCALE=en
|
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||||
|
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||||
|
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||||
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
|
APP_LOCALE=es
|
||||||
|
APP_FALLBACK_LOCALE=es
|
||||||
APP_FAKER_LOCALE=en_US
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
APP_MAINTENANCE_DRIVER=file
|
APP_MAINTENANCE_DRIVER=file
|
||||||
@@ -15,27 +19,38 @@ APP_MAINTENANCE_DRIVER=file
|
|||||||
|
|
||||||
# PHP_CLI_SERVER_WORKERS=4
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
||||||
|
|
||||||
|
|
||||||
BCRYPT_ROUNDS=12
|
BCRYPT_ROUNDS=12
|
||||||
|
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||||
|
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||||
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=stack
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=laravel
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
INTEGRATION_SECRET=change-me
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
|
||||||
SANCTUM_EXPIRATION=720
|
SANCTUM_EXPIRATION=720
|
||||||
|
SESSION_SECURE_COOKIE=false
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -51,27 +66,20 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
|
||||||
GOOGLE_CLIENT_ID=...
|
|
||||||
GOOGLE_CLIENT_SECRET=...
|
|
||||||
GOOGLE_REDIRECT_URI=https://grub-renewed-nicely.ngrok-free.app/auth/google/callback
|
|
||||||
|
|
||||||
MAIL_MAILER=smtp
|
MAIL_MAILER=smtp
|
||||||
MAIL_SCHEME=smtp
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=smtp.gmail.com
|
MAIL_HOST=127.0.0.1
|
||||||
MAIL_PORT=587
|
MAIL_PORT=2525
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
|
||||||
AWS_ENDPOINT=
|
AWS_ENDPOINT=
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=garage
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
AWS_HTTP_VERIFY=
|
AWS_HTTP_VERIFY=true
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\CreateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(CreateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$this->resetPasswordAttemptService->createForEmail(
|
||||||
|
$data['email'],
|
||||||
|
$data['tenant_codigo'],
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.password_reset_requested',
|
||||||
|
'message' => __('api.auth.password_reset_requested'),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
], 202);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,22 +5,47 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
|
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
|
||||||
use App\Domains\Auth\Resources\UserResource;
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
use App\Domains\Auth\Services\GoogleAuthService;
|
use App\Domains\Auth\Services\GoogleAuthService;
|
||||||
|
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
class GoogleTokenExchangeController extends Controller
|
class GoogleTokenExchangeController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
public function __construct(
|
||||||
|
private readonly GoogleAuthService $googleAuthService,
|
||||||
|
private readonly GuestCartMergeService $guestCartMergeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$authentication = $this->googleAuthService->exchange($request->validated('oauth_code'));
|
$authentication = $this->googleAuthService->exchange(
|
||||||
|
$request->validated('oauth_code'),
|
||||||
|
$request->validated('tenant_codigo'),
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
$guestTokenCookie = $request->cookie('guest_token');
|
||||||
'message' => 'Sesion iniciada correctamente.',
|
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||||
|
? $guestTokenCookie
|
||||||
|
: null;
|
||||||
|
$this->guestCartMergeService->merge(
|
||||||
|
$authentication['tenant_codigo'],
|
||||||
|
$authentication['user'],
|
||||||
|
$guestToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
'token' => $authentication['token'],
|
'token' => $authentication['token'],
|
||||||
'token_type' => 'Bearer',
|
'token_type' => 'Bearer',
|
||||||
'user' => UserResource::make($authentication['user']),
|
'user' => UserResource::make($authentication['user']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($guestToken !== null) {
|
||||||
|
$response->withCookie(Cookie::forget('guest_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,28 +3,30 @@
|
|||||||
namespace App\Domains\Auth\Controllers;
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||||
use App\Domains\Auth\Models\User;
|
|
||||||
use App\Domains\Auth\Resources\UserResource;
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\PasswordLoginService;
|
||||||
|
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Cookie;
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class LoginController extends Controller
|
class LoginController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
public function __construct(
|
||||||
* @throws ValidationException
|
private readonly GuestCartMergeService $guestCartMergeService,
|
||||||
*/
|
private readonly PasswordLoginService $passwordLoginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$credentials = $request->validated();
|
$credentials = $request->validated();
|
||||||
$user = User::query()->where('email', $credentials['email'])->first();
|
$user = $this->passwordLoginService->authenticate(
|
||||||
|
$credentials['email'],
|
||||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
$credentials['password'],
|
||||||
throw ValidationException::withMessages([
|
$credentials['tenant_codigo'],
|
||||||
'email' => 'Email o Contraseña incorrectos.',
|
$request->ip(),
|
||||||
]);
|
$request->userAgent(),
|
||||||
}
|
);
|
||||||
|
|
||||||
$expirationMinutes = (int) config('sanctum.expiration');
|
$expirationMinutes = (int) config('sanctum.expiration');
|
||||||
$token = $user->createToken(
|
$token = $user->createToken(
|
||||||
@@ -33,11 +35,28 @@ class LoginController extends Controller
|
|||||||
now()->addMinutes($expirationMinutes),
|
now()->addMinutes($expirationMinutes),
|
||||||
)->plainTextToken;
|
)->plainTextToken;
|
||||||
|
|
||||||
return response()->json([
|
$guestTokenCookie = $request->cookie('guest_token');
|
||||||
'message' => 'Sesion iniciada correctamente.',
|
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||||
|
? $guestTokenCookie
|
||||||
|
: null;
|
||||||
|
$this->guestCartMergeService->merge(
|
||||||
|
$credentials['tenant_codigo'],
|
||||||
|
$user,
|
||||||
|
$guestToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'token_type' => 'Bearer',
|
'token_type' => 'Bearer',
|
||||||
'user' => UserResource::make($user),
|
'user' => UserResource::make($user),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($guestToken !== null) {
|
||||||
|
$response->withCookie(Cookie::forget('guest_token'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ class LogoutController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
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(
|
public function __construct(
|
||||||
protected RegisterUserService $registerUserService,
|
protected RegisterUserService $registerUserService,
|
||||||
) {
|
) {}
|
||||||
}
|
|
||||||
|
|
||||||
public function __invoke(RegisterUserRequest $request): JsonResponse
|
public function __invoke(RegisterUserRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$user = $this->registerUserService->register($request->validated());
|
$user = $this->registerUserService->register($request->validated());
|
||||||
|
|
||||||
return UserResource::make($user)
|
return UserResource::make($user)
|
||||||
->additional(['message' => 'Usuario registrado correctamente.'])
|
->additional([
|
||||||
|
'code' => 'auth.register_success',
|
||||||
|
'message' => __('api.auth.register_success'),
|
||||||
|
])
|
||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
41
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ResetPasswordController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function __invoke(ResetPasswordRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (! $this->resetPasswordAttemptService->resetPassword(
|
||||||
|
$data['email'],
|
||||||
|
$data['codigo'],
|
||||||
|
$data['password'],
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.password_reset_invalid'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.password_updated',
|
||||||
|
'message' => __('api.auth.password_updated'),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function __invoke(ValidateResetPasswordAttemptRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validated();
|
||||||
|
|
||||||
|
if (! $this->resetPasswordAttemptService->validateCode(
|
||||||
|
$data['email'],
|
||||||
|
$data['codigo'],
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_invalid'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.reset_code_valid',
|
||||||
|
'message' => __('api.auth.reset_code_valid'),
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Domains/Auth/Models/ResetPasswordAttempt.php
Normal file
36
app/Domains/Auth/Models/ResetPasswordAttempt.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||||
|
#[Hidden(['codigo'])]
|
||||||
|
class ResetPasswordAttempt extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_VALIDATED = 'validated';
|
||||||
|
|
||||||
|
public const STATUS_USED = 'used';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<User, $this> */
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use Database\Factories\UserFactory;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
@@ -22,6 +23,18 @@ class User extends Authenticatable
|
|||||||
return UserFactory::new();
|
return UserFactory::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ResetPasswordAttempt, $this> */
|
||||||
|
public function resetPasswordAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ResetPasswordAttempt::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<LoginAttempt, $this> */
|
||||||
|
public function loginAttempts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(LoginAttempt::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -30,6 +43,9 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'failed_login_attempts' => 'integer',
|
||||||
|
'last_failed_login_at' => 'datetime',
|
||||||
|
'locked_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class CreateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ class GoogleTokenExchangeRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'oauth_code' => ['required', 'uuid'],
|
'oauth_code' => ['required', 'uuid'],
|
||||||
|
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class LoginUserRequest extends FormRequest
|
class LoginUserRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
@@ -19,6 +31,7 @@ class LoginUserRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'email' => ['required', 'string', 'email', 'max:255'],
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
|
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
43
app/Domains/Auth/Requests/ResetPasswordRequest.php
Normal file
43
app/Domains/Auth/Requests/ResetPasswordRequest.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
class ResetPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
|
||||||
|
'password' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'confirmed',
|
||||||
|
Password::min(8)->mixedCase()->symbols(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class ValidateResetPasswordAttemptRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ class GoogleAuthService
|
|||||||
|
|
||||||
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
||||||
throw ValidationException::withMessages([
|
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;
|
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
|
||||||
|
|
||||||
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
|
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();
|
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
|
||||||
|
|
||||||
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
|
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 */
|
/** @var SocialiteUser $googleUser */
|
||||||
@@ -72,6 +72,7 @@ class GoogleAuthService
|
|||||||
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
], now()->addMinutes(5));
|
], now()->addMinutes(5));
|
||||||
|
|
||||||
return redirect()->to($context['return_url'].'/login?'.http_build_query([
|
return redirect()->to($context['return_url'].'/login?'.http_build_query([
|
||||||
@@ -79,21 +80,28 @@ class GoogleAuthService
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{user: User, token: string} */
|
/** @return array{user: User, token: string, tenant_codigo: string} */
|
||||||
public function exchange(string $exchangeCode): array
|
public function exchange(string $exchangeCode, string $tenantCodigo): array
|
||||||
{
|
{
|
||||||
/** @var array{user_id: int, token: string}|null $authentication */
|
/** @var array{user_id: int, token: string, tenant_codigo?: string}|null $authentication */
|
||||||
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
|
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
|
||||||
|
|
||||||
if (! $authentication) {
|
if (! $authentication) {
|
||||||
throw ValidationException::withMessages([
|
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' => __('api.auth.tenant_mismatch'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'user' => User::query()->findOrFail($authentication['user_id']),
|
'user' => User::query()->findOrFail($authentication['user_id']),
|
||||||
'token' => $authentication['token'],
|
'token' => $authentication['token'],
|
||||||
|
'tenant_codigo' => $tenantCodigo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +112,7 @@ class GoogleAuthService
|
|||||||
|
|
||||||
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'google' => 'Google no devolvio una identidad valida.',
|
'google' => __('api.auth.google_email_required'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +120,7 @@ class GoogleAuthService
|
|||||||
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
|
||||||
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
||||||
throw ValidationException::withMessages([
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
181
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ResetPasswordAttemptService
|
||||||
|
{
|
||||||
|
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
Log::warning('Password reset attempt was not created because the user was not found.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->resetPasswordAttempts()
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => $this->generateCode(),
|
||||||
|
'reason' => $reason,
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $attempt->getKey();
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to create password reset attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attemptId !== null) {
|
||||||
|
try {
|
||||||
|
PasswordResetRequested::dispatch($attemptId, $tenantCode);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to dispatch password reset email.', [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validateCode(string $email, string $code): bool
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attempt = $user?->resetPasswordAttempts()
|
||||||
|
->where('codigo', $code)
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_PENDING)
|
||||||
|
->latest('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($attempt === null) {
|
||||||
|
Log::warning('Password reset code validation failed: no matching pending attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to validate password reset code.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(string $email, string $code, string $password): bool
|
||||||
|
{
|
||||||
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $email)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$attempt = $user?->resetPasswordAttempts()
|
||||||
|
->where('codigo', $code)
|
||||||
|
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
|
||||||
|
->latest('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user === null || $attempt === null) {
|
||||||
|
Log::warning('Password reset failed: no matching validated attempt.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->password = $password;
|
||||||
|
$user->failed_login_attempts = 0;
|
||||||
|
$user->last_failed_login_at = null;
|
||||||
|
$user->locked_until = null;
|
||||||
|
$user->save();
|
||||||
|
$user->tokens()->delete();
|
||||||
|
|
||||||
|
$user->resetPasswordAttempts()
|
||||||
|
->whereKeyNot($attempt->getKey())
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to reset user password.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCode(): string
|
||||||
|
{
|
||||||
|
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function emailFingerprint(string $email): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
|
||||||
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
||||||
use App\Domains\Auth\Controllers\LoginController;
|
use App\Domains\Auth\Controllers\LoginController;
|
||||||
use App\Domains\Auth\Controllers\LogoutController;
|
use App\Domains\Auth\Controllers\LogoutController;
|
||||||
use App\Domains\Auth\Controllers\MeController;
|
use App\Domains\Auth\Controllers\MeController;
|
||||||
use App\Domains\Auth\Controllers\RegisterController;
|
use App\Domains\Auth\Controllers\RegisterController;
|
||||||
|
use App\Domains\Auth\Controllers\ResetPasswordController;
|
||||||
use App\Domains\Auth\Controllers\UpdateProfileController;
|
use App\Domains\Auth\Controllers\UpdateProfileController;
|
||||||
|
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::post('/register', RegisterController::class);
|
Route::post('/register', RegisterController::class);
|
||||||
Route::post('/login', LoginController::class);
|
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)
|
||||||
|
->middleware('throttle:10,1');
|
||||||
|
Route::post('/password/reset', ResetPasswordController::class)
|
||||||
|
->middleware('throttle:5,1');
|
||||||
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
||||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ class CartController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
$response = CartResource::make($result['cart'])
|
$response = CartResource::make($result['cart'])
|
||||||
->additional(['message' => 'Producto agregado al carrito.'])
|
->additional([
|
||||||
|
'code' => 'cart.item_added',
|
||||||
|
'message' => __('api.cart.item_added'),
|
||||||
|
])
|
||||||
->response();
|
->response();
|
||||||
|
|
||||||
if ($result['guest_token'] !== null) {
|
if ($result['guest_token'] !== null) {
|
||||||
@@ -58,13 +61,19 @@ class CartController extends Controller
|
|||||||
$cartItem->getKey(),
|
$cartItem->getKey(),
|
||||||
(int) $request->validated('cantidad'),
|
(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
|
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||||
{
|
{
|
||||||
return CartResource::make(
|
return CartResource::make(
|
||||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
)->additional([
|
||||||
|
'code' => 'cart.item_removed',
|
||||||
|
'message' => __('api.cart.item_removed'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Cart\Middleware;
|
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Cookie;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class MergeGuestCartMiddleware
|
|
||||||
{
|
|
||||||
public function handle(Request $request, Closure $next): Response
|
|
||||||
{
|
|
||||||
$guestToken = $request->cookie('guest_token');
|
|
||||||
|
|
||||||
// Resolve authenticated user optionally (from default guard or sanctum guard)
|
|
||||||
$user = $request->user() ?? Auth::guard('sanctum')->user();
|
|
||||||
$userId = $user?->getKey();
|
|
||||||
|
|
||||||
\Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [
|
|
||||||
'user_id' => $userId,
|
|
||||||
'guest_token' => $guestToken,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
|
||||||
$tenantParam = $request->route('tenant');
|
|
||||||
$tenantCodigo = null;
|
|
||||||
|
|
||||||
if ($tenantParam instanceof Tenant) {
|
|
||||||
$tenantCodigo = $tenantParam->codigo;
|
|
||||||
} elseif (is_string($tenantParam)) {
|
|
||||||
$tenantCodigo = $tenantParam;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenantCodigo !== null) {
|
|
||||||
$guestCart = Cart::query()
|
|
||||||
->where('tenant_codigo', $tenantCodigo)
|
|
||||||
->where('guest_token', $guestToken)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($guestCart !== null && $guestCart->items()->exists()) {
|
|
||||||
$userCart = Cart::query()
|
|
||||||
->where('tenant_codigo', $tenantCodigo)
|
|
||||||
->where('user_id', $userId)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($userCart !== null) {
|
|
||||||
$userCart->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
$guestCart->update([
|
|
||||||
'user_id' => $userId,
|
|
||||||
'guest_token' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $next($request);
|
|
||||||
|
|
||||||
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
|
|
||||||
// Remove the cookie from the response since the user is authenticated.
|
|
||||||
if (method_exists($response, 'withCookie')) {
|
|
||||||
$response->withCookie(Cookie::forget('guest_token'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -79,7 +79,7 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
throw ValidationException::withMessages([
|
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 ($catalogItem->isBundle()) {
|
||||||
if ($variantId !== null) {
|
if ($variantId !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variant_id' => 'Un bundle no admite una variante.',
|
'variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $catalogItem->bundleComponents()->exists()) {
|
if (! $catalogItem->bundleComponents()->exists()) {
|
||||||
throw ValidationException::withMessages([
|
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 ($variantId === null) {
|
||||||
if ($catalogItem->inventory_id === null) {
|
if ($catalogItem->inventory_id === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
'variant_id' => __('api.cart.variant_required'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class CartService
|
|||||||
{
|
{
|
||||||
return Cart::query()
|
return Cart::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('status', 'active')
|
||||||
->when(
|
->when(
|
||||||
$identity['user_id'] !== null,
|
$identity['user_id'] !== null,
|
||||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
@@ -199,7 +200,10 @@ class CartService
|
|||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$attributes = ['tenant_codigo' => $tenant->codigo];
|
$attributes = [
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'status' => 'active',
|
||||||
|
];
|
||||||
|
|
||||||
if ($identity['user_id'] !== null) {
|
if ($identity['user_id'] !== null) {
|
||||||
$attributes['user_id'] = $identity['user_id'];
|
$attributes['user_id'] = $identity['user_id'];
|
||||||
@@ -207,6 +211,6 @@ class CartService
|
|||||||
$attributes['guest_token'] = $identity['guest_token'];
|
$attributes['guest_token'] = $identity['guest_token'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
|
return Cart::query()->firstOrCreate($attributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
app/Domains/Cart/Services/GuestCartMergeService.php
Normal file
49
app/Domains/Cart/Services/GuestCartMergeService.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Cart\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class GuestCartMergeService
|
||||||
|
{
|
||||||
|
public function merge(string $tenantCodigo, User $user, ?string $guestToken): void
|
||||||
|
{
|
||||||
|
if ($guestToken === null || $guestToken === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($tenantCodigo, $user, $guestToken): void {
|
||||||
|
$guestCart = Cart::query()
|
||||||
|
->where('tenant_codigo', $tenantCodigo)
|
||||||
|
->where('guest_token', $guestToken)
|
||||||
|
->where('status', 'active')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($guestCart === null || ! $guestCart->items()->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userCart = Cart::query()
|
||||||
|
->where('tenant_codigo', $tenantCodigo)
|
||||||
|
->where('user_id', $user->getKey())
|
||||||
|
->where('status', 'active')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($userCart !== null) {
|
||||||
|
$userCart->update([
|
||||||
|
'status' => 'converted',
|
||||||
|
]);
|
||||||
|
$userCart->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$guestCart->update([
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'guest_token' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ use App\Domains\Cart\Controllers\CartController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('tenants/{tenant:codigo}')
|
Route::prefix('tenants/{tenant:codigo}')
|
||||||
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
|
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('cart', [CartController::class, 'show']);
|
Route::get('cart', [CartController::class, 'show']);
|
||||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||||
|
|||||||
@@ -2,19 +2,27 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
namespace App\Domains\Catalog\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedItem;
|
||||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||||
|
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||||
|
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
class CatalogController extends Controller
|
class CatalogController extends Controller
|
||||||
@@ -36,6 +44,47 @@ class CatalogController extends Controller
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function search(
|
||||||
|
SearchCatalogItemsRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
CatalogService $catalogService,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
|
return CatalogSearchItemResource::collection(
|
||||||
|
$catalogService->search(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('q'),
|
||||||
|
$tenant->search_items_per_page,
|
||||||
|
(int) $request->validated('page', 1),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function category(
|
||||||
|
CategoryPageRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
Category $category,
|
||||||
|
CatalogService $catalogService,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
|
abort_unless($category->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
return CatalogSearchItemResource::collection(
|
||||||
|
$catalogService->categoryItems(
|
||||||
|
$tenant,
|
||||||
|
$category,
|
||||||
|
$tenant->search_items_per_page,
|
||||||
|
(int) $request->validated('page', 1),
|
||||||
|
)
|
||||||
|
)->additional([
|
||||||
|
'category' => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
'categoria_id' => $category->categoria_id,
|
||||||
|
],
|
||||||
|
'layout' => $tenant->search_product_layout->value,
|
||||||
|
'group_layout' => $tenant->search_group_layout->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function featuredGroupItems(
|
public function featuredGroupItems(
|
||||||
FeaturedGroupPageRequest $request,
|
FeaturedGroupPageRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
@@ -81,9 +130,19 @@ class CatalogController extends Controller
|
|||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<array-key, mixed> */
|
||||||
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
||||||
{
|
{
|
||||||
|
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
||||||
|
$featuredItems = $this->featuredItemsQuery($featuredGroup)->get();
|
||||||
|
|
||||||
|
$featuredItems->each(
|
||||||
|
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
||||||
|
);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($featuredItems)->resolve();
|
||||||
|
}
|
||||||
|
|
||||||
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
||||||
|
|
||||||
$paginator->getCollection()->each(
|
$paginator->getCollection()->each(
|
||||||
@@ -99,17 +158,7 @@ class CatalogController extends Controller
|
|||||||
FeaturedGroup $featuredGroup,
|
FeaturedGroup $featuredGroup,
|
||||||
int $page,
|
int $page,
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
$paginator = $featuredGroup->featuredItems()
|
$paginator = $this->featuredItemsQuery($featuredGroup)->paginate(
|
||||||
->with([
|
|
||||||
'catalogItem.inventory',
|
|
||||||
'catalogItem.attachments',
|
|
||||||
'catalogItem.variants.inventory',
|
|
||||||
'catalogItem.variants.attachments',
|
|
||||||
'catalogItem.variants.definitions.itemAttribute.attribute',
|
|
||||||
'catalogItem.bundleComponents.catalogItem',
|
|
||||||
'catalogItem.bundleComponents.variant.catalogItem',
|
|
||||||
])
|
|
||||||
->paginate(
|
|
||||||
perPage: self::ITEMS_PER_PAGE,
|
perPage: self::ITEMS_PER_PAGE,
|
||||||
pageName: 'page',
|
pageName: 'page',
|
||||||
page: $page,
|
page: $page,
|
||||||
@@ -120,4 +169,18 @@ class CatalogController extends Controller
|
|||||||
'featuredGroup' => $featuredGroup->id,
|
'featuredGroup' => $featuredGroup->id,
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<FeaturedItem, FeaturedGroup> */
|
||||||
|
private function featuredItemsQuery(FeaturedGroup $featuredGroup): HasMany
|
||||||
|
{
|
||||||
|
return $featuredGroup->featuredItems()->with([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.attachments',
|
||||||
|
'catalogItem.variants.inventory',
|
||||||
|
'catalogItem.variants.attachments',
|
||||||
|
'catalogItem.variants.definitions.itemAttribute.attribute',
|
||||||
|
'catalogItem.bundleComponents.catalogItem',
|
||||||
|
'catalogItem.bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
app/Domains/Catalog/Enums/GroupLayout.php
Normal file
19
app/Domains/Catalog/Enums/GroupLayout.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum GroupLayout: string
|
||||||
|
{
|
||||||
|
case Paginated = 'paginated';
|
||||||
|
case Simple = 'simple';
|
||||||
|
case SimpleVertical = 'simple_vertical';
|
||||||
|
case Carousel = 'carousel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -163,6 +164,16 @@ class CatalogItem extends Model
|
|||||||
return $this->nombre;
|
return $this->nombre;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getMinimumUseDate(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return $this->minimum_use_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMaximumUseDate(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return $this->maximum_use_date;
|
||||||
|
}
|
||||||
|
|
||||||
public function isBundle(): bool
|
public function isBundle(): bool
|
||||||
{
|
{
|
||||||
return $this->type === CatalogItemType::Bundle;
|
return $this->type === CatalogItemType::Bundle;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
|
'group_layout',
|
||||||
'group_name',
|
'group_name',
|
||||||
'group_order',
|
'group_order',
|
||||||
])]
|
])]
|
||||||
@@ -28,6 +30,7 @@ class FeaturedGroup extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'product_layout' => ProductLayout::class,
|
'product_layout' => ProductLayout::class,
|
||||||
|
'group_layout' => GroupLayout::class,
|
||||||
'group_order' => 'integer',
|
'group_order' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'catalog_item_id',
|
'catalog_item_id',
|
||||||
'inventory_id',
|
'inventory_id',
|
||||||
|
'minimum_use_date',
|
||||||
|
'maximum_use_date',
|
||||||
])]
|
])]
|
||||||
class Variant extends Model
|
class Variant extends Model
|
||||||
{
|
{
|
||||||
@@ -27,6 +30,8 @@ class Variant extends Model
|
|||||||
return [
|
return [
|
||||||
'catalog_item_id' => 'integer',
|
'catalog_item_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
|
'minimum_use_date' => 'datetime',
|
||||||
|
'maximum_use_date' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,19 +79,18 @@ class Variant extends Model
|
|||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
$name = $this->catalogItem->nombre;
|
return $this->catalogItem->nombre;
|
||||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
}
|
||||||
$definitions = $this->definitions
|
|
||||||
->map(function (VariantDefinition $definition): ?string {
|
|
||||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
|
||||||
|
|
||||||
return $attributeName
|
public function getMinimumUseDate(): ?CarbonInterface
|
||||||
? "{$attributeName}: {$definition->value}"
|
{
|
||||||
: $definition->value;
|
return $this->minimum_use_date
|
||||||
})
|
?? $this->catalogItem->getMinimumUseDate();
|
||||||
->filter()
|
}
|
||||||
->implode(', ');
|
|
||||||
|
|
||||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
public function getMaximumUseDate(): ?CarbonInterface
|
||||||
|
{
|
||||||
|
return $this->maximum_use_date
|
||||||
|
?? $this->catalogItem->getMaximumUseDate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php
Normal file
29
app/Domains/Catalog/Requests/SearchCatalogItemsRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SearchCatalogItemsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'q' => ['required', 'string', 'min:2', 'max:100'],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if ($this->has('q') && is_string($this->input('q'))) {
|
||||||
|
$this->merge(['q' => trim($this->string('q')->toString())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,13 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
'variants.*.inventory_id' => ['prohibited'],
|
'variants.*.inventory_id' => ['prohibited'],
|
||||||
'variants.*.reserved_stock' => ['prohibited'],
|
'variants.*.reserved_stock' => ['prohibited'],
|
||||||
'variants.*.sold_units' => ['prohibited'],
|
'variants.*.sold_units' => ['prohibited'],
|
||||||
|
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'variants.*.maximum_use_date' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'date',
|
||||||
|
'after_or_equal:variants.*.minimum_use_date',
|
||||||
|
],
|
||||||
'variants.*.values' => ['sometimes', 'array'],
|
'variants.*.values' => ['sometimes', 'array'],
|
||||||
'variants.*.values.*' => ['nullable', 'string'],
|
'variants.*.values.*' => ['nullable', 'string'],
|
||||||
'variants.*.images' => ['sometimes', 'array'],
|
'variants.*.images' => ['sometimes', 'array'],
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
/** @mixin FeaturedGroup */
|
/** @mixin FeaturedGroup */
|
||||||
class CatalogFeaturedGroupResource extends JsonResource
|
class CatalogFeaturedGroupResource extends JsonResource
|
||||||
{
|
{
|
||||||
/** @param array<string, mixed> $itemsPage */
|
/** @param array<array-key, mixed> $items */
|
||||||
public function __construct($resource, private readonly array $itemsPage)
|
public function __construct($resource, private readonly array $items)
|
||||||
{
|
{
|
||||||
parent::__construct($resource);
|
parent::__construct($resource);
|
||||||
}
|
}
|
||||||
@@ -22,8 +22,9 @@ class CatalogFeaturedGroupResource extends JsonResource
|
|||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'title' => $this->group_name,
|
'title' => $this->group_name,
|
||||||
'layout' => $this->product_layout->value,
|
'layout' => $this->product_layout->value,
|
||||||
|
'group_layout' => $this->group_layout->value,
|
||||||
'group_order' => $this->group_order,
|
'group_order' => $this->group_order,
|
||||||
'items' => $this->itemsPage,
|
'items' => $this->items,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'stock_tecnico' => $this->variantStock($variant),
|
'stock_tecnico' => $this->variantStock($variant),
|
||||||
|
'minimum_use_date' => $variant->minimum_use_date,
|
||||||
|
'maximum_use_date' => $variant->maximum_use_date,
|
||||||
|
'effective_minimum_use_date' => $variant->minimum_use_date
|
||||||
|
?? $this->minimum_use_date,
|
||||||
|
'effective_maximum_use_date' => $variant->maximum_use_date
|
||||||
|
?? $this->maximum_use_date,
|
||||||
'values' => $variant->definitions
|
'values' => $variant->definitions
|
||||||
->mapWithKeys(fn ($definition) => [
|
->mapWithKeys(fn ($definition) => [
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ class CatalogItemResource extends JsonResource
|
|||||||
->map(fn ($variant) => [
|
->map(fn ($variant) => [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'real_stock' => $variant->inventory?->real_stock,
|
'real_stock' => $variant->inventory?->real_stock,
|
||||||
|
'minimum_use_date' => $variant->minimum_use_date,
|
||||||
|
'maximum_use_date' => $variant->maximum_use_date,
|
||||||
|
'effective_minimum_use_date' => $variant->minimum_use_date
|
||||||
|
?? $this->minimum_use_date,
|
||||||
|
'effective_maximum_use_date' => $variant->maximum_use_date
|
||||||
|
?? $this->maximum_use_date,
|
||||||
'values' => $variant->definitions
|
'values' => $variant->definitions
|
||||||
->mapWithKeys(fn ($definition) => [
|
->mapWithKeys(fn ($definition) => [
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
|||||||
45
app/Domains/Catalog/Resources/CatalogSearchItemResource.php
Normal file
45
app/Domains/Catalog/Resources/CatalogSearchItemResource.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class CatalogSearchItemResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$attachment = $this->attachments->first()
|
||||||
|
?? $this->variants
|
||||||
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'type' => $this->type->value,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'descripcion' => $this->descripcion,
|
||||||
|
'precio' => $this->precio,
|
||||||
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
|
'stock_tecnico' => $this->availableStock(),
|
||||||
|
'variants' => $this->variants
|
||||||
|
->map(fn (Variant $variant): array => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
? null
|
||||||
|
: $variant->inventory?->availableStock(),
|
||||||
|
'values' => $variant->definitions
|
||||||
|
->mapWithKeys(fn ($definition) => [
|
||||||
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
])
|
||||||
|
->filter(fn ($value, $key): bool => $key !== null),
|
||||||
|
])
|
||||||
|
->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,13 @@ use App\Domains\Attachable\Services\AttachmentService;
|
|||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Models\Attribute;
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -42,7 +46,7 @@ class CatalogService
|
|||||||
} else {
|
} else {
|
||||||
if (array_key_exists('components', $data)) {
|
if (array_key_exists('components', $data)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'components' => ['Un item standard no puede tener componentes.'],
|
'components' => [__('api.catalog.standard_with_components')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +162,78 @@ class CatalogService
|
|||||||
return $catalogItem;
|
return $catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return LengthAwarePaginator<CatalogItem> */
|
||||||
|
public function search(
|
||||||
|
Tenant $tenant,
|
||||||
|
string $term,
|
||||||
|
int $perPage,
|
||||||
|
int $page,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$normalizedTerm = mb_strtolower($term);
|
||||||
|
$containsPattern = "%{$normalizedTerm}%";
|
||||||
|
$startsWithPattern = "{$normalizedTerm}%";
|
||||||
|
|
||||||
|
$paginator = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where(function (Builder $query) use ($containsPattern): void {
|
||||||
|
$query
|
||||||
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
|
->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern])
|
||||||
|
->orWhereHas(
|
||||||
|
'brand',
|
||||||
|
fn (Builder $brandQuery) => $brandQuery
|
||||||
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
|
)
|
||||||
|
->orWhereHas(
|
||||||
|
'category',
|
||||||
|
fn (Builder $categoryQuery) => $categoryQuery
|
||||||
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'attachments',
|
||||||
|
'inventory',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
])
|
||||||
|
->orderByRaw(
|
||||||
|
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
||||||
|
[$normalizedTerm, $startsWithPattern],
|
||||||
|
)
|
||||||
|
->orderBy('nombre')
|
||||||
|
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||||
|
|
||||||
|
return $paginator->withPath(route('catalog-items.index', [
|
||||||
|
'tenant' => $tenant->codigo,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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
|
public function delete(CatalogItem $catalogItem): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($catalogItem): void {
|
DB::transaction(function () use ($catalogItem): void {
|
||||||
@@ -213,7 +289,7 @@ class CatalogService
|
|||||||
|
|
||||||
if (isset($seen[$key])) {
|
if (isset($seen[$key])) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}" => ['El componente esta duplicado.'],
|
"components.{$index}" => [__('api.catalog.duplicate_component')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$seen[$key] = true;
|
$seen[$key] = true;
|
||||||
@@ -226,7 +302,7 @@ class CatalogService
|
|||||||
if ($componentItem === null) {
|
if ($componentItem === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.catalog_item_id" => [
|
"components.{$index}.catalog_item_id" => [
|
||||||
'El item no pertenece al tenant del bundle.',
|
__('api.catalog.component_wrong_tenant'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -234,7 +310,7 @@ class CatalogService
|
|||||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.catalog_item_id" => [
|
"components.{$index}.catalog_item_id" => [
|
||||||
'El componente debe ser un item standard distinto del bundle.',
|
__('api.catalog.invalid_component'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -243,7 +319,7 @@ class CatalogService
|
|||||||
if ($hasVariants && $variantId === null) {
|
if ($hasVariants && $variantId === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
'Debe seleccionar una variante para este componente.',
|
__('api.catalog.component_variant_required'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -251,7 +327,7 @@ class CatalogService
|
|||||||
if (! $hasVariants && $variantId !== null) {
|
if (! $hasVariants && $variantId !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
'El componente con inventario directo no admite una variante.',
|
__('api.catalog.component_variant_forbidden'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -259,7 +335,7 @@ class CatalogService
|
|||||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"components.{$index}.variant_id" => [
|
"components.{$index}.variant_id" => [
|
||||||
'La variante no pertenece al componente indicado.',
|
__('api.catalog.component_variant_invalid'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -281,7 +357,7 @@ class CatalogService
|
|||||||
{
|
{
|
||||||
if ($components === []) {
|
if ($components === []) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'components' => ['Un bundle debe tener al menos un componente.'],
|
'components' => [__('api.catalog.bundle_component_required')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,7 +372,7 @@ class CatalogService
|
|||||||
] as $field) {
|
] as $field) {
|
||||||
if (array_key_exists($field, $data)) {
|
if (array_key_exists($field, $data)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
$field => ["{$field} no se admite para un bundle."],
|
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,7 +400,7 @@ class CatalogService
|
|||||||
|
|
||||||
if ($attachment === null) {
|
if ($attachment === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
$validationKey => ['El attachment indicado no existe.'],
|
$validationKey => [__('api.catalog.attachment_not_found')],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +432,7 @@ class CatalogService
|
|||||||
if ($attribute === null) {
|
if ($attribute === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'attribute_codes' => [
|
'attribute_codes' => [
|
||||||
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -390,7 +466,7 @@ class CatalogService
|
|||||||
) {
|
) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"variants.{$index}.inventory" => [
|
"variants.{$index}.inventory" => [
|
||||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
__('api.catalog.managed_inventory_fields'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -398,7 +474,12 @@ class CatalogService
|
|||||||
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||||
$variant = $catalogItem->variants()->create([
|
$variant = $catalogItem->variants()->create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
|
'minimum_use_date' => $data['minimum_use_date'] ?? null,
|
||||||
|
'maximum_use_date' => $data['maximum_use_date'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
|
|
||||||
|
$this->validateVariantUseDates($variant, $index);
|
||||||
|
|
||||||
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
||||||
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||||
@@ -406,7 +487,7 @@ class CatalogService
|
|||||||
if ($itemAttribute === null) {
|
if ($itemAttribute === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"variants.{$index}.values.{$attributeCode}" => [
|
"variants.{$index}.values.{$attributeCode}" => [
|
||||||
'El atributo no pertenece al ítem de catálogo.',
|
__('api.catalog.attribute_not_on_item'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -420,6 +501,24 @@ class CatalogService
|
|||||||
return $variant;
|
return $variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function validateVariantUseDates(Variant $variant, int $index): void
|
||||||
|
{
|
||||||
|
$minimumUseDate = $variant->getMinimumUseDate();
|
||||||
|
$maximumUseDate = $variant->getMaximumUseDate();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$minimumUseDate !== null
|
||||||
|
&& $maximumUseDate !== null
|
||||||
|
&& $maximumUseDate->lessThan($minimumUseDate)
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.maximum_use_date" => [
|
||||||
|
__('api.catalog.invalid_effective_date_range'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @param array<int, array<string, mixed>> $variants
|
* @param array<int, array<string, mixed>> $variants
|
||||||
@@ -433,7 +532,7 @@ class CatalogService
|
|||||||
if ($hasVariants && $variants === []) {
|
if ($hasVariants && $variants === []) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variants' => [
|
'variants' => [
|
||||||
'Un ítem con attribute_codes debe tener variantes.',
|
__('api.catalog.variants_required'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -441,7 +540,7 @@ class CatalogService
|
|||||||
if (! $hasVariants && $variants !== []) {
|
if (! $hasVariants && $variants !== []) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'variants' => [
|
'variants' => [
|
||||||
'Un ítem sin attribute_codes no puede tener variantes.',
|
__('api.catalog.variants_forbidden'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -449,7 +548,7 @@ class CatalogService
|
|||||||
if ($hasVariants && $hasDirectStock) {
|
if ($hasVariants && $hasDirectStock) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'real_stock' => [
|
'real_stock' => [
|
||||||
'Un ítem con variantes no puede tener inventario directo.',
|
__('api.catalog.direct_inventory_forbidden'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -461,7 +560,7 @@ class CatalogService
|
|||||||
) {
|
) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'inventory' => [
|
'inventory' => [
|
||||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
__('api.catalog.managed_inventory_fields'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
|||||||
Route::get('catalog', [CatalogController::class, 'index']);
|
Route::get('catalog', [CatalogController::class, 'index']);
|
||||||
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
||||||
->name('catalog.featured-groups.items.index');
|
->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::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,17 +6,11 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
|||||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TelepagosWebhookController extends Controller
|
class TelepagosWebhookController extends Controller
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Handle the incoming Telepagos webhook.
|
* 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
|
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -27,7 +21,11 @@ class TelepagosWebhookController extends Controller
|
|||||||
|
|
||||||
return response()->json(['status' => 'success']);
|
return response()->json(['status' => 'success']);
|
||||||
} catch (\Exception $e) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ class TenantIntegrationController extends Controller
|
|||||||
{
|
{
|
||||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||||
|
|
||||||
if (!$integration) {
|
if (! $integration) {
|
||||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
return response()->json([
|
||||||
|
'code' => 'integration.not_configured',
|
||||||
|
'message' => __('api.integration.not_configured'),
|
||||||
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($integration);
|
return response()->json($integration);
|
||||||
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
|
|||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'integration configured correctly',
|
'code' => 'integration.configured',
|
||||||
|
'message' => __('api.integration.configured'),
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
'code' => 'integration.validation_failed',
|
||||||
|
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
|
|||||||
$integrationCode = $this->route('integration_code');
|
$integrationCode = $this->route('integration_code');
|
||||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||||
|
|
||||||
if (!$this->integrationModel) {
|
if (! $this->integrationModel) {
|
||||||
throw ValidationException::withMessages([
|
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
|
// Dynamic validation rules based on the integration data schema
|
||||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
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\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TelepagosWebhookService
|
class TelepagosWebhookService
|
||||||
@@ -19,16 +20,13 @@ class TelepagosWebhookService
|
|||||||
/**
|
/**
|
||||||
* Handle the Telepagos webhook notification.
|
* Handle the Telepagos webhook notification.
|
||||||
*
|
*
|
||||||
* @param string $tenantCodigo
|
|
||||||
* @param string $cashinId
|
|
||||||
* @return void
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||||
|
|
||||||
$telepagosService = new TelepagosIntegrationService();
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
$telepagosService->forTenant($tenant->codigo);
|
$telepagosService->forTenant($tenant->codigo);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -48,6 +46,7 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $cuit) {
|
if (! $cuit) {
|
||||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,19 +54,28 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||||
->where('transfer_payer_dni', $dni)
|
->where('transfer_payer_dni', $dni)
|
||||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
->whereIn('status', [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
])
|
||||||
->where('payment_method', 'transfer')
|
->where('payment_method', 'transfer')
|
||||||
->where('total', $amount)
|
->where('total', $amount)
|
||||||
|
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
])
|
||||||
->latest()
|
->latest()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $compra) {
|
if (! $compra) {
|
||||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||||
if (! $qrOrderId) {
|
if (! $qrOrderId) {
|
||||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,6 +83,7 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $telepagosQr) {
|
if (! $telepagosQr) {
|
||||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +91,16 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if (! $compra) {
|
if (! $compra) {
|
||||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||||
|
|
||||||
return;
|
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");
|
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +108,12 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
if ($amount !== $totalAmount) {
|
if ($amount !== $totalAmount) {
|
||||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +130,7 @@ class TelepagosWebhookService
|
|||||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
'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);
|
TelepagosPayment::create($paymentData);
|
||||||
$this->checkoutService->confirmPurchase($compra);
|
$this->checkoutService->confirmPurchase($compra);
|
||||||
$compra->markAsPaid();
|
$compra->markAsPaid();
|
||||||
@@ -122,7 +138,7 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
Log::error('Telepagos webhook error: '.$e->getMessage());
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class MailTestService
|
|||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'message' => 'Correo de prueba enviado correctamente.',
|
'code' => 'mail.test_sent',
|
||||||
|
'message' => __('api.mail.test_sent'),
|
||||||
'recipient' => $recipient,
|
'recipient' => $recipient,
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
'mailer' => $mailService->mailerName(),
|
'mailer' => $mailService->mailerName(),
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ namespace App\Domains\Menu\Controllers;
|
|||||||
|
|
||||||
use App\Domains\Menu\Models\Menu;
|
use App\Domains\Menu\Models\Menu;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class MenuController extends Controller
|
class MenuController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$menues = Menu::all();
|
$menues = Menu::all();
|
||||||
|
|
||||||
return response()->json($menues);
|
return response()->json($menues);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,10 +21,23 @@ class MenuController extends Controller
|
|||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'code' => 'required|string|unique:menues,code',
|
'code' => 'required|string|unique:menues,code',
|
||||||
|
'label' => 'required|string|max:255',
|
||||||
|
'parent_menu_code' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::exists('menues', 'code'),
|
||||||
|
'different:code',
|
||||||
|
],
|
||||||
|
'content_type' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
||||||
|
],
|
||||||
|
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
||||||
'route' => 'required|string',
|
'route' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$menu = Menu::create($validated);
|
$menu = Menu::create($validated);
|
||||||
|
|
||||||
return response()->json($menu, 201);
|
return response()->json($menu, 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,17 +49,31 @@ class MenuController extends Controller
|
|||||||
public function update(Request $request, Menu $menu): JsonResponse
|
public function update(Request $request, Menu $menu): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'code' => 'sometimes|required|string|unique:menues,code,' . $menu->id,
|
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
|
||||||
|
'label' => 'sometimes|required|string|max:255',
|
||||||
|
'parent_menu_code' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::exists('menues', 'code'),
|
||||||
|
Rule::notIn([$menu->code]),
|
||||||
|
],
|
||||||
|
'content_type' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
||||||
|
],
|
||||||
|
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
||||||
'route' => 'sometimes|required|string',
|
'route' => 'sometimes|required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$menu->update($validated);
|
$menu->update($validated);
|
||||||
|
|
||||||
return response()->json($menu);
|
return response()->json($menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Menu $menu): JsonResponse
|
public function destroy(Menu $menu): JsonResponse
|
||||||
{
|
{
|
||||||
$menu->delete();
|
$menu->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/Domains/Menu/Controllers/TenantMenuController.php
Normal file
32
app/Domains/Menu/Controllers/TenantMenuController.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
|
||||||
|
use App\Domains\Menu\Services\TenantMenuService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class TenantMenuController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
StoreTenantMenuRequest $request,
|
||||||
|
string $tenantCode,
|
||||||
|
string $menuCode,
|
||||||
|
): JsonResponse {
|
||||||
|
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
|
||||||
|
|
||||||
|
$tenantMenu = $this->tenantMenuService->configure(
|
||||||
|
$tenant,
|
||||||
|
$menu,
|
||||||
|
$request->validated('static_content'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($tenantMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,28 +5,77 @@ namespace App\Domains\Menu\Models;
|
|||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class Menu extends Model
|
class Menu extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
public const CONTENT_TYPE_DYNAMIC = 'dynamic';
|
||||||
|
|
||||||
|
public const CONTENT_TYPE_STATIC = 'static';
|
||||||
|
|
||||||
protected $table = 'menues';
|
protected $table = 'menues';
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'content_type' => self::CONTENT_TYPE_DYNAMIC,
|
||||||
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'code',
|
'code',
|
||||||
|
'label',
|
||||||
|
'parent_menu_code',
|
||||||
|
'content_type',
|
||||||
|
'static_content_schema',
|
||||||
'route',
|
'route',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'static_content_schema' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (self $menu): void {
|
||||||
|
if (
|
||||||
|
$menu->content_type === self::CONTENT_TYPE_STATIC
|
||||||
|
&& empty($menu->static_content_schema)
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'static_content_schema' => __('api.menu.schema_required'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parent(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(self::class, 'parent_menu_code', 'code');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function children(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(self::class, 'parent_menu_code', 'code');
|
||||||
|
}
|
||||||
|
|
||||||
public function tenants(): BelongsToMany
|
public function tenants(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(
|
return $this->belongsToMany(
|
||||||
Tenant::class,
|
Tenant::class,
|
||||||
'tenant_menues',
|
'tenants_menues',
|
||||||
'menu_code',
|
'menu_code',
|
||||||
'tenant_codigo',
|
'tenant_code',
|
||||||
'code',
|
'code',
|
||||||
'codigo'
|
'codigo'
|
||||||
)->withTimestamps();
|
)
|
||||||
|
->using(TenantMenu::class)
|
||||||
|
->withPivot('static_content')
|
||||||
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,36 @@
|
|||||||
|
|
||||||
namespace App\Domains\Menu\Models;
|
namespace App\Domains\Menu\Models;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
|
|
||||||
class TenantMenu extends Pivot
|
class TenantMenu extends Pivot
|
||||||
{
|
{
|
||||||
protected $table = 'tenant_menues';
|
protected $table = 'tenants_menues';
|
||||||
|
|
||||||
|
public $incrementing = true;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_code',
|
||||||
|
'menu_code',
|
||||||
|
'static_content',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'static_content' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function menu(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Menu::class, 'menu_code', 'code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
app/Domains/Menu/Requests/StoreTenantMenuRequest.php
Normal file
49
app/Domains/Menu/Requests/StoreTenantMenuRequest.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class StoreTenantMenuRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?Menu $menuModel = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->menuModel = Menu::query()
|
||||||
|
->where('code', $this->route('menu_code'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $this->menuModel) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'menu_code' => __('api.menu.not_found'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
|
||||||
|
return [
|
||||||
|
'static_content' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'static_content' => ['required', 'array'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->menuModel->static_content_schema as $field => $rule) {
|
||||||
|
$rules["static_content.{$field}"] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Menu/Services/TenantMenuService.php
Normal file
26
app/Domains/Menu/Services/TenantMenuService.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Services;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Menu\Models\TenantMenu;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TenantMenuService
|
||||||
|
{
|
||||||
|
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
|
||||||
|
{
|
||||||
|
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'menu_code' => $menu->code,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
|
||||||
|
? $staticContent
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Menu\Controllers\MenuController;
|
use App\Domains\Menu\Controllers\MenuController;
|
||||||
|
use App\Domains\Menu\Controllers\TenantMenuController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::apiResource('menues', MenuController::class);
|
Route::apiResource('menues', MenuController::class);
|
||||||
|
|
||||||
|
Route::post(
|
||||||
|
'{tenant_code}/menues/{menu_code}',
|
||||||
|
[TenantMenuController::class, 'store']
|
||||||
|
);
|
||||||
|
|||||||
16
app/Domains/Notification/Events/PasswordResetRequested.php
Normal file
16
app/Domains/Notification/Events/PasswordResetRequested.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Events;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PasswordResetRequested
|
||||||
|
{
|
||||||
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $attemptId,
|
||||||
|
public readonly string $tenantCode,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Notification\Listeners;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||||
|
{
|
||||||
|
use InteractsWithQueue;
|
||||||
|
|
||||||
|
public string $queue = 'emails';
|
||||||
|
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/** @var array<int, int> */
|
||||||
|
public array $backoff = [30, 120, 300];
|
||||||
|
|
||||||
|
public function handle(PasswordResetRequested $event): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
|
$event->attemptId,
|
||||||
|
$event->tenantCode,
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::error('Failed to send password reset email.', [
|
||||||
|
'attempt_id' => $event->attemptId,
|
||||||
|
'tenant_code' => $event->tenantCode,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Domains\Notification\Services;
|
namespace App\Domains\Notification\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Integration\Services\MailService;
|
use App\Domains\Integration\Services\MailService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class NotificationMailService
|
class NotificationMailService
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,32 @@ class NotificationMailService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function sendPasswordResetCode(int $attemptId, string $tenantCode): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$attempt = ResetPasswordAttempt::query()
|
||||||
|
->with('user')
|
||||||
|
->findOrFail($attemptId);
|
||||||
|
|
||||||
|
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||||
|
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'attempt_status' => $attempt->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailService
|
||||||
|
->forTenant($tenantCode)
|
||||||
|
->send(
|
||||||
|
$attempt->user->email,
|
||||||
|
"Código para recuperar tu contraseña - {$tenant->nombre}",
|
||||||
|
view('mail.notifications.password-reset', compact('tenant', 'attempt'))->render(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function sendPurchasePaid(int $purchaseId): void
|
public function sendPurchasePaid(int $purchaseId): void
|
||||||
{
|
{
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ namespace App\Domains\Purchase\Controllers;
|
|||||||
|
|
||||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
||||||
use App\Domains\Purchase\Requests\StorePurchaseRequest;
|
use App\Domains\Purchase\Requests\StartCheckoutRequest;
|
||||||
|
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
|
||||||
|
use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest;
|
||||||
use App\Domains\Purchase\Resources\PurchaseResource;
|
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
class PurchaseController extends Controller
|
class PurchaseController extends Controller
|
||||||
@@ -32,8 +35,11 @@ class PurchaseController extends Controller
|
|||||||
)->response();
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StorePurchaseRequest $request, Tenant $tenant, CheckoutService $checkoutService): JsonResponse
|
public function startCheckout(
|
||||||
{
|
StartCheckoutRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
CheckoutService $checkoutService,
|
||||||
|
): JsonResponse {
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
$purchase = $checkoutService->startCheckout(
|
$purchase = $checkoutService->startCheckout(
|
||||||
@@ -49,14 +55,54 @@ class PurchaseController extends Controller
|
|||||||
{
|
{
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
$compra->loadMissing(['items', 'cart.items']);
|
$compra->loadMissing('items')->loadCount('tickets');
|
||||||
$compra->items->load('imageAttachment');
|
$compra->items->load('imageAttachment');
|
||||||
|
|
||||||
if ($compra->cart !== null) {
|
return PurchaseResource::make($compra);
|
||||||
$this->loadCartCatalogEntries($compra->cart->items);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return PurchaseResource::make($compra);
|
public function updateCustomerData(
|
||||||
|
UpdatePurchaseCustomerRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
Purchase $compra,
|
||||||
|
CheckoutService $checkoutService,
|
||||||
|
): PurchaseResource {
|
||||||
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$checkoutService->updateCustomerData($compra, $request->validated()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItemQuantity(
|
||||||
|
UpdatePurchaseItemQuantityRequest $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
Purchase $compra,
|
||||||
|
PurchaseItem $item,
|
||||||
|
CheckoutService $checkoutService,
|
||||||
|
): PurchaseResource {
|
||||||
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$checkoutService->updateItemQuantity(
|
||||||
|
$compra,
|
||||||
|
$item,
|
||||||
|
(int) $request->validated('quantity'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prepareItemEditing(
|
||||||
|
Request $request,
|
||||||
|
Tenant $tenant,
|
||||||
|
Purchase $compra,
|
||||||
|
CheckoutService $checkoutService,
|
||||||
|
): PurchaseResource {
|
||||||
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$checkoutService->prepareItemEditing($compra),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
|
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
|
||||||
@@ -68,6 +114,9 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate = [
|
$purchaseUpdate = [
|
||||||
'payment_method' => $method,
|
'payment_method' => $method,
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'expires_at' => now()->addMinutes(
|
||||||
|
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
||||||
|
),
|
||||||
'total' => $totalAmount,
|
'total' => $totalAmount,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -75,7 +124,22 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
|
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$compra->update($purchaseUpdate);
|
$updated = Purchase::query()
|
||||||
|
->whereKey($compra->getKey())
|
||||||
|
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||||
|
->where(function ($query): void {
|
||||||
|
$query->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->update($purchaseUpdate);
|
||||||
|
|
||||||
|
if ($updated === 0) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.not_available_for_payment'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$compra->refresh();
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
@@ -95,8 +159,14 @@ class PurchaseController extends Controller
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Unable to retrieve TelePagos account information.', [
|
||||||
|
'purchase_id' => $compra->id,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Error getting account info: '.$e->getMessage(),
|
'code' => 'integration.account_info_failed',
|
||||||
|
'message' => __('api.integration.account_info_failed'),
|
||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,8 +188,14 @@ class PurchaseController extends Controller
|
|||||||
'qr_code' => $qrResponse['qr_code'] ?? '',
|
'qr_code' => $qrResponse['qr_code'] ?? '',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Unable to generate TelePagos QR code.', [
|
||||||
|
'purchase_id' => $compra->id,
|
||||||
|
'exception' => $e,
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Error generating QR: '.$e->getMessage(),
|
'code' => 'integration.qr_generation_failed',
|
||||||
|
'message' => __('api.integration.qr_generation_failed'),
|
||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +209,8 @@ class PurchaseController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Invalid payment method.',
|
'code' => 'integration.invalid_payment_method',
|
||||||
|
'message' => __('api.integration.invalid_payment_method'),
|
||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,25 +223,34 @@ 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);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$checkoutService->cancelPurchase($compra)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
|
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;
|
return $purchase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
protected function loadCartCatalogEntries(Collection $items): void
|
|
||||||
{
|
|
||||||
$items->load([
|
|
||||||
'catalogItem.attachments',
|
|
||||||
'variant.attachments',
|
|
||||||
'variant.catalogItem.attachments',
|
|
||||||
'variant.definitions.itemAttribute.attribute',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
|
|||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -20,6 +21,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
'user_id',
|
'user_id',
|
||||||
'status',
|
'status',
|
||||||
'payment_method',
|
'payment_method',
|
||||||
|
'expires_at',
|
||||||
'total',
|
'total',
|
||||||
'dni',
|
'dni',
|
||||||
'transfer_payer_dni',
|
'transfer_payer_dni',
|
||||||
@@ -35,12 +37,16 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||||
|
|
||||||
|
public const STATUS_IN_REVIEW = 'in_review';
|
||||||
|
|
||||||
public const STATUS_PAID = 'paid';
|
public const STATUS_PAID = 'paid';
|
||||||
|
|
||||||
public const STATUS_CANCELLED = 'cancelled';
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
public const STATUS_REJECTED = 'rejected';
|
public const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -48,6 +54,7 @@ class Purchase extends Model
|
|||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
'total' => 'decimal:2',
|
'total' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -84,6 +91,14 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
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>
|
* @return HasOne<TelepagosQr, $this>
|
||||||
*/
|
*/
|
||||||
@@ -111,25 +126,13 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public function calculateCurrentTotalAmount(): float
|
public function calculateCurrentTotalAmount(): float
|
||||||
{
|
{
|
||||||
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
|
if ($this->relationLoaded('items')) {
|
||||||
return (float) $this->getRelation('items')->sum('total');
|
return (float) $this->getRelation('items')->sum('total');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->items()->exists()) {
|
|
||||||
return (float) $this->items()->sum('total');
|
return (float) $this->items()->sum('total');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->relationLoaded('cart')
|
|
||||||
? $this->getRelation('cart')
|
|
||||||
: $this->cart()->with(['items.catalogItem', 'items.variant'])->first();
|
|
||||||
|
|
||||||
if (! $cart) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cart->getTotalAmount();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function markAsPendingPayment(): void
|
public function markAsPendingPayment(): void
|
||||||
{
|
{
|
||||||
$this->update([
|
$this->update([
|
||||||
|
|||||||
@@ -23,11 +23,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'discount_total',
|
'discount_total',
|
||||||
'tax_total',
|
'tax_total',
|
||||||
'total',
|
'total',
|
||||||
|
'reservation_status',
|
||||||
])]
|
])]
|
||||||
class PurchaseItem extends Model
|
class PurchaseItem extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
public const RESERVATION_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const RESERVATION_COMMITTED = 'committed';
|
||||||
|
|
||||||
|
public const RESERVATION_RELEASED = 'released';
|
||||||
|
|
||||||
protected $table = 'compra_items';
|
protected $table = 'compra_items';
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
51
app/Domains/Purchase/Requests/StartCheckoutRequest.php
Normal file
51
app/Domains/Purchase/Requests/StartCheckoutRequest.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StartCheckoutRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'cart_id' => [
|
||||||
|
'required_without:direct_item',
|
||||||
|
Rule::prohibitedIf(fn (): bool => $this->has('direct_item')),
|
||||||
|
'integer',
|
||||||
|
'exists:carritos,id',
|
||||||
|
],
|
||||||
|
'direct_item' => [
|
||||||
|
'required_without:cart_id',
|
||||||
|
Rule::prohibitedIf(fn (): bool => $this->has('cart_id')),
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
'direct_item.catalog_item_id' => [
|
||||||
|
'required_with:direct_item',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
|
'direct_item.variant_id' => [
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
],
|
||||||
|
'direct_item.cantidad' => [
|
||||||
|
'required_with:direct_item',
|
||||||
|
'integer',
|
||||||
|
'min:1',
|
||||||
|
],
|
||||||
|
'dni' => ['prohibited'],
|
||||||
|
'telefono' => ['prohibited'],
|
||||||
|
'nombre_apellido' => ['prohibited'],
|
||||||
|
'email' => ['prohibited'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ namespace App\Domains\Purchase\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class StorePurchaseRequest extends FormRequest
|
class UpdatePurchaseCustomerRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,6 @@ class StorePurchaseRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cart_id' => ['required', 'integer', 'exists:carritos,id'],
|
|
||||||
'dni' => ['required', 'string'],
|
'dni' => ['required', 'string'],
|
||||||
'telefono' => ['required', 'string'],
|
'telefono' => ['required', 'string'],
|
||||||
'nombre_apellido' => ['required', 'string'],
|
'nombre_apellido' => ['required', 'string'],
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdatePurchaseItemQuantityRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Resources;
|
namespace App\Domains\Purchase\Resources;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin Purchase
|
* @mixin Purchase
|
||||||
@@ -19,18 +17,23 @@ class PurchaseResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
[$items, $itemsSource] = $this->resolveItems();
|
$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()
|
$subtotal = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $items->reduce(
|
||||||
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
|
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
|
||||||
0.0,
|
0.0,
|
||||||
)
|
)
|
||||||
: (float) ($this->total ?? 0);
|
: (float) ($this->total ?? 0);
|
||||||
|
|
||||||
$total = $items->isNotEmpty()
|
$total = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $items->reduce(
|
||||||
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
|
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
|
||||||
0.0,
|
0.0,
|
||||||
)
|
)
|
||||||
: (float) ($this->total ?? 0);
|
: (float) ($this->total ?? 0);
|
||||||
@@ -43,64 +46,31 @@ class PurchaseResource extends JsonResource
|
|||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'payment_method' => $this->payment_method,
|
'payment_method' => $this->payment_method,
|
||||||
|
'expires_at' => $this->expires_at,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
'nombre_apellido' => $this->nombre_apellido,
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'items_source' => $itemsSource,
|
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
||||||
'items' => PurchaseItemResource::collection($items),
|
'items' => PurchaseItemResource::collection($items),
|
||||||
|
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||||
|
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||||
'subtotal' => $this->formatMoney($subtotal),
|
'subtotal' => $this->formatMoney($subtotal),
|
||||||
'total' => $this->formatMoney($total),
|
'total' => $this->formatMoney($total),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function resolveItemSubtotal(PurchaseItem $item): float
|
||||||
* @return array{0: Collection<int, PurchaseItem|CartItem>, 1: string|null}
|
|
||||||
*/
|
|
||||||
protected function resolveItems(): array
|
|
||||||
{
|
{
|
||||||
if (! $this->resource->relationLoaded('items')) {
|
|
||||||
return [collect(), null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$purchaseItems = $this->resource->getRelation('items');
|
|
||||||
|
|
||||||
if ($purchaseItems->isNotEmpty()) {
|
|
||||||
return [$purchaseItems, 'purchase'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->resource->relationLoaded('cart')) {
|
|
||||||
return [collect(), null];
|
|
||||||
}
|
|
||||||
|
|
||||||
$cart = $this->resource->getRelation('cart');
|
|
||||||
|
|
||||||
if ($cart === null || ! $cart->relationLoaded('items')) {
|
|
||||||
return [collect(), null];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$cart->getRelation('items'), 'cart'];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
|
|
||||||
{
|
|
||||||
if ($item instanceof PurchaseItem) {
|
|
||||||
return (float) $item->precio_unitario * $item->cantidad;
|
return (float) $item->precio_unitario * $item->cantidad;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
|
protected function resolveItemTotal(PurchaseItem $item): float
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
|
|
||||||
{
|
{
|
||||||
if ($item instanceof PurchaseItem) {
|
|
||||||
return (float) ($item->total ?? 0);
|
return (float) ($item->total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveItemSubtotal($item);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatMoney(float|int|string|null $amount): string
|
protected function formatMoney(float|int|string|null $amount): string
|
||||||
{
|
{
|
||||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
|
|||||||
@@ -3,77 +3,54 @@
|
|||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Attachable\Services\AttachmentService;
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class CheckoutService
|
class CheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly AttachmentService $attachmentService,
|
|
||||||
private readonly CatalogInventoryService $catalogInventoryService,
|
private readonly CatalogInventoryService $catalogInventoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
||||||
{
|
{
|
||||||
$cartId = (int) $purchaseData['cart_id'];
|
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
|
||||||
unset($purchaseData['cart_id']);
|
$directItem = $purchaseData['direct_item'] ?? null;
|
||||||
|
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
|
||||||
|
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $userId, $purchaseData, $cartId): Purchase {
|
if (is_array($directItem)) {
|
||||||
$cart = $this->resolveCheckoutCart($tenant, $userId, $cartId);
|
return $this->startDirectCheckout(
|
||||||
$cartItems = $cart->items()->lockForUpdate()->get();
|
$tenant,
|
||||||
|
$userId,
|
||||||
|
$purchaseData,
|
||||||
|
$directItem,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ($cartItems->isEmpty()) {
|
if ($cartId === null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The selected cart does not contain items.',
|
'cart_id' => __('api.purchase.source_required'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->loadCartItems($cartItems);
|
return $this->startCartCheckout(
|
||||||
$cart->setRelation('items', $cartItems);
|
$tenant,
|
||||||
$totalAmount = $cart->getTotalAmount();
|
$userId,
|
||||||
|
$purchaseData,
|
||||||
/** @var Purchase|null $purchase */
|
$cartId,
|
||||||
$purchase = Purchase::query()
|
);
|
||||||
->where('cart_id', $cart->getKey())
|
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('user_id', $userId)
|
|
||||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($purchase === null) {
|
|
||||||
/** @var Purchase $purchase */
|
|
||||||
$purchase = Purchase::query()->create([
|
|
||||||
...$purchaseData,
|
|
||||||
'cart_id' => $cart->getKey(),
|
|
||||||
'tenant_codigo' => $tenant->codigo,
|
|
||||||
'user_id' => $userId,
|
|
||||||
'status' => Purchase::STATUS_CREATED,
|
|
||||||
'payment_method' => null,
|
|
||||||
'total' => $totalAmount,
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
$purchase->fill([
|
|
||||||
...$purchaseData,
|
|
||||||
'status' => Purchase::STATUS_CREATED,
|
|
||||||
'payment_method' => null,
|
|
||||||
'total' => $totalAmount,
|
|
||||||
]);
|
|
||||||
$purchase->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +64,17 @@ class CheckoutService
|
|||||||
|
|
||||||
if ($purchase->payment_method === null) {
|
if ($purchase->payment_method === null) {
|
||||||
throw ValidationException::withMessages([
|
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_CANCELLED, Purchase::STATUS_REJECTED], true)) {
|
if (in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_PAID,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
Purchase::STATUS_CANCELLED,
|
||||||
|
Purchase::STATUS_REJECTED,
|
||||||
|
Purchase::STATUS_EXPIRED,
|
||||||
|
], true)) {
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,76 +87,564 @@ class CheckoutService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function confirmPurchase(Purchase $purchase): void
|
public function submitForReview(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
$snapshotPaths = [];
|
return DB::transaction(function () use ($purchase): Purchase {
|
||||||
|
|
||||||
try {
|
|
||||||
DB::transaction(function () use ($purchase, &$snapshotPaths): void {
|
|
||||||
/** @var Purchase $purchase */
|
/** @var Purchase $purchase */
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($purchase->getKey());
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
if ($purchase->items()->exists()) {
|
if (in_array($purchase->status, [
|
||||||
return;
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
Purchase::STATUS_PAID,
|
||||||
|
], true)) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Cart|null $cart */
|
if (
|
||||||
$cart = $purchase->cart()->lockForUpdate()->first();
|
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||||
|
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||||
if ($cart === null) {
|
) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The purchase cart is no longer available.',
|
'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
|
||||||
|
*/
|
||||||
|
public function updateCustomerData(Purchase $purchase, array $customerData): Purchase
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($purchase, $customerData): Purchase {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if (
|
||||||
|
! in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)
|
||||||
|
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.not_editable'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update($customerData);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateItemQuantity(
|
||||||
|
Purchase $purchase,
|
||||||
|
PurchaseItem $purchaseItem,
|
||||||
|
int $quantity,
|
||||||
|
): Purchase {
|
||||||
|
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if (
|
||||||
|
$purchase->status !== Purchase::STATUS_CREATED
|
||||||
|
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.not_editable'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var PurchaseItem|null $purchaseItem */
|
||||||
|
$purchaseItem = $purchase->items()
|
||||||
|
->whereKey($purchaseItem->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($purchaseItem === null) {
|
||||||
|
throw new NotFoundHttpException('Purchase item not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'item' => __('api.purchase.item_not_editable'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentQuantity = (int) $purchaseItem->cantidad;
|
||||||
|
$difference = $quantity - $currentQuantity;
|
||||||
|
|
||||||
|
if ($difference !== 0) {
|
||||||
|
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $purchaseItem);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($difference > 0) {
|
||||||
|
$this->catalogInventoryService->reserve($selection, $difference);
|
||||||
|
} else {
|
||||||
|
$this->catalogInventoryService->release($selection, abs($difference));
|
||||||
|
}
|
||||||
|
} catch (\InvalidArgumentException $exception) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'quantity' => __('api.purchase.insufficient_stock'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchaseItem->update([
|
||||||
|
'cantidad' => $quantity,
|
||||||
|
'total' => (float) $purchaseItem->precio_unitario * $quantity,
|
||||||
|
]);
|
||||||
|
$this->syncSourceCartItemQuantity($purchase, $purchaseItem, $quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update([
|
||||||
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function prepareItemEditing(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_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)
|
||||||
|
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||||
|
) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.not_editable'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->telepagosQr()->delete();
|
||||||
|
$purchase->update([
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
'payment_method' => null,
|
||||||
|
'transfer_payer_dni' => null,
|
||||||
|
'expires_at' => now()->addMinutes(
|
||||||
|
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmPurchase(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_PAID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_CANCELLED,
|
||||||
|
Purchase::STATUS_REJECTED,
|
||||||
|
Purchase::STATUS_EXPIRED,
|
||||||
|
], true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.cannot_confirm'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $purchase->items()
|
||||||
|
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->catalogInventoryService->commit($selection, (int) $item->cantidad);
|
||||||
|
} catch (\InvalidArgumentException $exception) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->update([
|
||||||
|
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->finalizeSourceCart($purchase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancelPurchase(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return $this->releasePurchase($purchase, Purchase::STATUS_CANCELLED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expirePurchase(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return $this->releasePurchase($purchase, Purchase::STATUS_EXPIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expireOverduePurchases(): int
|
||||||
|
{
|
||||||
|
$expiredCount = 0;
|
||||||
|
|
||||||
|
Purchase::query()
|
||||||
|
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->orderBy('id')
|
||||||
|
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
|
||||||
|
$purchase = $this->expirePurchase($purchase);
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||||
|
$expiredCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $expiredCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function releasePurchase(Purchase $purchase, string $targetStatus): Purchase
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_PAID) {
|
||||||
|
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'purchase' => __('api.purchase.paid_cannot_cancel'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_CANCELLED,
|
||||||
|
Purchase::STATUS_REJECTED,
|
||||||
|
Purchase::STATUS_EXPIRED,
|
||||||
|
], true)) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$targetStatus === Purchase::STATUS_EXPIRED
|
||||||
|
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
|
||||||
|
) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = $purchase->items()
|
||||||
|
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
$reservationReturnedToCart = $this->restoreSourceCart($purchase);
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
if (! $reservationReturnedToCart) {
|
||||||
|
$selection = $this->resolvePurchaseItemSelection($purchase->tenant, $item);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->catalogInventoryService->release($selection, (int) $item->cantidad);
|
||||||
|
} catch (\InvalidArgumentException $exception) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->update([
|
||||||
|
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase->update([
|
||||||
|
'status' => $targetStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $purchaseData
|
||||||
|
* @param array<string, mixed> $directItem
|
||||||
|
*/
|
||||||
|
private function startDirectCheckout(
|
||||||
|
Tenant $tenant,
|
||||||
|
int $userId,
|
||||||
|
array $purchaseData,
|
||||||
|
array $directItem,
|
||||||
|
): Purchase {
|
||||||
|
$catalogItemId = (int) $directItem['catalog_item_id'];
|
||||||
|
$variantId = isset($directItem['variant_id']) ? (int) $directItem['variant_id'] : null;
|
||||||
|
$quantity = (int) $directItem['cantidad'];
|
||||||
|
$selection = $this->resolveSelection($tenant, $catalogItemId, $variantId);
|
||||||
|
$availableQuantity = $this->catalogInventoryService->availableQuantity($selection);
|
||||||
|
|
||||||
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->catalogInventoryService->reserve($selection, $quantity);
|
||||||
|
} catch (\InvalidArgumentException $exception) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase = $this->createPurchase(
|
||||||
|
$tenant,
|
||||||
|
$userId,
|
||||||
|
$purchaseData,
|
||||||
|
$selection->getPrice() * $quantity,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
$cartItem = $this->makeDirectCartItem($selection, $catalogItemId, $variantId, $quantity);
|
||||||
|
$purchase->items()->createMany(
|
||||||
|
$this->buildPurchaseItemsPayload(collect([$cartItem])),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $purchaseData
|
||||||
|
*/
|
||||||
|
private function startCartCheckout(
|
||||||
|
Tenant $tenant,
|
||||||
|
int $userId,
|
||||||
|
array $purchaseData,
|
||||||
|
int $cartId,
|
||||||
|
): Purchase {
|
||||||
|
$cart = $this->resolveCheckoutCart($tenant, $userId, $cartId);
|
||||||
$cartItems = $cart->items()->lockForUpdate()->get();
|
$cartItems = $cart->items()->lockForUpdate()->get();
|
||||||
|
|
||||||
if ($cartItems->isEmpty()) {
|
if ($cartItems->isEmpty()) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The purchase cart does not contain items.',
|
'cart_id' => __('api.purchase.empty_cart'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->loadCartItems($cartItems);
|
$this->loadCartItems($cartItems);
|
||||||
$this->verifyTenantItems($purchase->tenant, $cartItems);
|
$this->verifyTenantItems($tenant, $cartItems);
|
||||||
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($purchase, $cartItems, $snapshotPaths);
|
$cart->setRelation('items', $cartItems);
|
||||||
|
|
||||||
$purchase->items()->createMany($purchaseItemsPayload);
|
$purchase = $this->createPurchase(
|
||||||
$this->completeCartConversion($cart, $cartItems);
|
$tenant,
|
||||||
});
|
$userId,
|
||||||
} catch (Throwable $throwable) {
|
$purchaseData,
|
||||||
foreach ($snapshotPaths as $snapshotPath) {
|
$cart->getTotalAmount(),
|
||||||
Storage::disk('s3')->delete($snapshotPath);
|
$cart->getKey(),
|
||||||
|
);
|
||||||
|
$purchase->items()->createMany(
|
||||||
|
$this->buildPurchaseItemsPayload($cartItems),
|
||||||
|
);
|
||||||
|
|
||||||
|
// PurchaseItem owns the reservation during checkout. The source cart is
|
||||||
|
// kept with its owner so it can be restored if the purchase is cancelled
|
||||||
|
// or expires. Only active carts participate in the identity constraint.
|
||||||
|
$cart->update([
|
||||||
|
'status' => 'checkout',
|
||||||
|
'guest_token' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw $throwable;
|
private function restoreSourceCart(Purchase $purchase): bool
|
||||||
|
{
|
||||||
|
if ($purchase->cart_id === null) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var Cart|null $sourceCart */
|
||||||
|
$sourceCart = Cart::withTrashed()
|
||||||
|
->whereKey($purchase->cart_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($sourceCart === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Cart|null $activeCart */
|
||||||
|
$activeCart = Cart::query()
|
||||||
|
->where('tenant_codigo', $purchase->tenant_codigo)
|
||||||
|
->where('user_id', $purchase->user_id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->where('id', '!=', $sourceCart->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($activeCart !== null) {
|
||||||
|
$sourceItems = $sourceCart->items()->lockForUpdate()->get();
|
||||||
|
|
||||||
|
foreach ($sourceItems as $sourceItem) {
|
||||||
|
/** @var CartItem|null $activeItem */
|
||||||
|
$activeItem = $activeCart->items()
|
||||||
|
->where('catalog_item_id', $sourceItem->catalog_item_id)
|
||||||
|
->where('variant_id', $sourceItem->variant_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($activeItem === null) {
|
||||||
|
$activeCart->items()->create([
|
||||||
|
'catalog_item_id' => $sourceItem->catalog_item_id,
|
||||||
|
'variant_id' => $sourceItem->variant_id,
|
||||||
|
'cantidad' => $sourceItem->cantidad,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceCart->update([
|
||||||
|
'status' => 'converted',
|
||||||
|
'guest_token' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $sourceCart->trashed()) {
|
||||||
|
$sourceCart->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceCart->trashed()) {
|
||||||
|
$sourceCart->restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceCart->update([
|
||||||
|
'status' => 'active',
|
||||||
|
'user_id' => $purchase->user_id,
|
||||||
|
'guest_token' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncSourceCartItemQuantity(
|
||||||
|
Purchase $purchase,
|
||||||
|
PurchaseItem $purchaseItem,
|
||||||
|
int $quantity,
|
||||||
|
): void {
|
||||||
|
if ($purchase->cart_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceCart = Cart::withTrashed()
|
||||||
|
->whereKey($purchase->cart_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($sourceCart === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceCart->items()
|
||||||
|
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
|
||||||
|
->where('variant_id', $purchaseItem->source_variant_id)
|
||||||
|
->update([
|
||||||
|
'cantidad' => $quantity,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeSourceCart(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
if ($purchase->cart_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Cart|null $sourceCart */
|
||||||
|
$sourceCart = Cart::withTrashed()
|
||||||
|
->whereKey($purchase->cart_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($sourceCart === null || $sourceCart->trashed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceCart->update([
|
||||||
|
'status' => 'converted',
|
||||||
|
'guest_token' => null,
|
||||||
|
]);
|
||||||
|
$sourceCart->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $purchaseData
|
||||||
|
*/
|
||||||
|
private function createPurchase(
|
||||||
|
Tenant $tenant,
|
||||||
|
int $userId,
|
||||||
|
array $purchaseData,
|
||||||
|
float $total,
|
||||||
|
?int $cartId,
|
||||||
|
): Purchase {
|
||||||
|
return Purchase::query()->create([
|
||||||
|
...$purchaseData,
|
||||||
|
'cart_id' => $cartId,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
'payment_method' => null,
|
||||||
|
'expires_at' => now()->addMinutes(
|
||||||
|
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||||
|
),
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
|
protected function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
|
||||||
{
|
{
|
||||||
foreach ($cartItems as $item) {
|
foreach ($cartItems as $item) {
|
||||||
$selectedItem = $item->selectedItem();
|
if ($item->selectedItem() === null) {
|
||||||
if ($selectedItem === null) {
|
|
||||||
throw ValidationException::withMessages([
|
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) {
|
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'One or more catalog items do not belong to the tenant.',
|
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, CartItem> $cartItems
|
|
||||||
* @return Collection<int, CartItem>
|
|
||||||
*/
|
|
||||||
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
|
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
|
||||||
{
|
{
|
||||||
/** @var Cart|null $cart */
|
/** @var Cart|null $cart */
|
||||||
@@ -187,7 +658,7 @@ class CheckoutService
|
|||||||
|
|
||||||
if ($cart->status !== 'active') {
|
if ($cart->status !== 'active') {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The selected cart is no longer active.',
|
'cart_id' => __('api.purchase.inactive_cart'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,21 +669,14 @@ class CheckoutService
|
|||||||
* @param Collection<int, CartItem> $cartItems
|
* @param Collection<int, CartItem> $cartItems
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
protected function buildPurchaseItemsPayload(
|
protected function buildPurchaseItemsPayload(Collection $cartItems): array
|
||||||
Purchase $purchase,
|
{
|
||||||
Collection $cartItems,
|
|
||||||
array &$snapshotPaths = [],
|
|
||||||
): array {
|
|
||||||
return $cartItems
|
return $cartItems
|
||||||
->map(function (CartItem $item) use ($purchase, &$snapshotPaths): array {
|
->map(function (CartItem $item): array {
|
||||||
$selectedItem = $item->selectedItem();
|
$selectedItem = $item->selectedItem();
|
||||||
$quantity = (int) $item['cantidad'];
|
$quantity = (int) $item->cantidad;
|
||||||
$unitPrice = $selectedItem?->getPrice() ?? 0;
|
$unitPrice = $selectedItem?->getPrice() ?? 0;
|
||||||
$imageAttachment = $this->snapshotFirstImage($purchase, $item);
|
$imageAttachment = $this->firstImageAttachment($item);
|
||||||
|
|
||||||
if ($imageAttachment !== null) {
|
|
||||||
$snapshotPaths[] = $imageAttachment->path;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'source_catalog_item_id' => $item->catalog_item_id,
|
'source_catalog_item_id' => $item->catalog_item_id,
|
||||||
@@ -230,37 +694,117 @@ class CheckoutService
|
|||||||
'discount_total' => null,
|
'discount_total' => null,
|
||||||
'tax_total' => null,
|
'tax_total' => null,
|
||||||
'total' => $unitPrice * $quantity,
|
'total' => $unitPrice * $quantity,
|
||||||
|
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function resolveSelection(
|
||||||
* @param Collection<int, CartItem> $cartItems
|
Tenant $tenant,
|
||||||
*/
|
int $catalogItemId,
|
||||||
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
|
?int $variantId,
|
||||||
{
|
): CatalogItem|Variant {
|
||||||
foreach ($cartItems as $item) {
|
/** @var CatalogItem|null $catalogItem */
|
||||||
$selectedItem = $item->selectedItem();
|
$catalogItem = CatalogItem::query()
|
||||||
$quantity = (int) $item->cantidad;
|
->whereKey($catalogItemId)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
try {
|
if ($catalogItem === null) {
|
||||||
$this->catalogInventoryService->commit(
|
throw new NotFoundHttpException('Catalog item not found for tenant.');
|
||||||
$selectedItem,
|
}
|
||||||
$quantity,
|
|
||||||
);
|
if ($catalogItem->isBundle()) {
|
||||||
} catch (\InvalidArgumentException $exception) {
|
if ($variantId !== null) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => 'The selected cart has inconsistent stock state.',
|
'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $catalogItem->bundleComponents()->exists()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart->status = 'converted';
|
return $catalogItem;
|
||||||
$cart->user_id = null;
|
}
|
||||||
$cart->guest_token = null;
|
|
||||||
$cart->save();
|
if ($variantId === null) {
|
||||||
$cart->delete();
|
if ($catalogItem->inventory_id === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'direct_item.variant_id' => __('api.cart.variant_required'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem->setRelation(
|
||||||
|
'inventory',
|
||||||
|
Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $catalogItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var Variant|null $variant */
|
||||||
|
$variant = Variant::query()
|
||||||
|
->whereKey($variantId)
|
||||||
|
->where('catalog_item_id', $catalogItem->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
|
$variant->setRelation(
|
||||||
|
'inventory',
|
||||||
|
Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePurchaseItemSelection(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
|
||||||
|
{
|
||||||
|
return $this->resolveSelection(
|
||||||
|
$tenant,
|
||||||
|
(int) $item->source_catalog_item_id,
|
||||||
|
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeDirectCartItem(
|
||||||
|
CatalogItem|Variant $selection,
|
||||||
|
int $catalogItemId,
|
||||||
|
?int $variantId,
|
||||||
|
int $quantity,
|
||||||
|
): CartItem {
|
||||||
|
$catalogItem = $selection instanceof Variant
|
||||||
|
? $selection->catalogItem
|
||||||
|
: $selection;
|
||||||
|
$catalogItem->loadMissing(['inventory', 'attachments']);
|
||||||
|
|
||||||
|
if ($selection instanceof Variant) {
|
||||||
|
$selection->loadMissing([
|
||||||
|
'inventory',
|
||||||
|
'attachments',
|
||||||
|
'catalogItem',
|
||||||
|
'definitions.itemAttribute.attribute',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = new CartItem([
|
||||||
|
'catalog_item_id' => $catalogItemId,
|
||||||
|
'variant_id' => $variantId,
|
||||||
|
'cantidad' => $quantity,
|
||||||
|
]);
|
||||||
|
$item->setRelation('catalogItem', $catalogItem);
|
||||||
|
$item->setRelation('variant', $selection instanceof Variant ? $selection : null);
|
||||||
|
|
||||||
|
return $item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param Collection<int, CartItem> $cartItems */
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
@@ -283,19 +827,10 @@ class CheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function snapshotFirstImage(Purchase $purchase, CartItem $item): ?Attachment
|
private function firstImageAttachment(CartItem $item): ?Attachment
|
||||||
{
|
{
|
||||||
$source = $item->variant?->attachments->first()
|
return $item->variant?->attachments->first()
|
||||||
?? $item->catalogItem?->attachments->first();
|
?? $item->catalogItem?->attachments->first();
|
||||||
|
|
||||||
if ($source === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->attachmentService->copy(
|
|
||||||
$source,
|
|
||||||
"purchase/{$purchase->id}",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<int, array{name: string, value: mixed}> */
|
/** @return array<int, array{name: string, value: mixed}> */
|
||||||
|
|||||||
@@ -3,8 +3,15 @@
|
|||||||
use App\Domains\Purchase\Controllers\PurchaseController;
|
use App\Domains\Purchase\Controllers\PurchaseController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('tenants/{tenant:codigo}')->middleware(['auth:sanctum', \App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class])->group(function (): void {
|
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
|
||||||
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
|
Route::get('compras', [PurchaseController::class, 'index']);
|
||||||
|
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
|
||||||
|
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
||||||
|
Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']);
|
||||||
|
Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItemQuantity']);
|
||||||
|
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||||
|
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||||
|
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,17 @@ class BootstrapTenantController extends Controller
|
|||||||
$dominio = $request->validated('dominio');
|
$dominio = $request->validated('dominio');
|
||||||
|
|
||||||
return TenantResource::make(
|
return TenantResource::make(
|
||||||
Tenant::query()->with(['headerLogo', 'footerLogo', 'menues'])->where('dominio', $dominio)->firstOrFail()
|
Tenant::query()
|
||||||
|
->with([
|
||||||
|
'headerLogo',
|
||||||
|
'footerLogo',
|
||||||
|
'mainCarouselImages',
|
||||||
|
'menues',
|
||||||
|
'socialMedia',
|
||||||
|
'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||||
|
])
|
||||||
|
->where('dominio', $dominio)
|
||||||
|
->firstOrFail()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,32 +13,41 @@ use Illuminate\Http\Response;
|
|||||||
|
|
||||||
class TenantController extends Controller
|
class TenantController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(protected TenantService $tenantService)
|
public function __construct(protected TenantService $tenantService) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
return TenantResource::collection(Tenant::query()->with(['headerLogo', 'footerLogo'])->latest()->paginateFromRequest())->response();
|
return TenantResource::collection(
|
||||||
|
Tenant::query()
|
||||||
|
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||||
|
->latest()
|
||||||
|
->paginateFromRequest()
|
||||||
|
)->response();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(StoreTenantRequest $request): JsonResponse
|
public function store(StoreTenantRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$tenant = $this->tenantService->create($request->validated());
|
$tenant = $this->tenantService->create($request->validated());
|
||||||
|
|
||||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']))->response()->setStatusCode(201);
|
return TenantResource::make(
|
||||||
|
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||||
|
)->response()->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Tenant $tenant): TenantResource
|
public function show(Tenant $tenant): TenantResource
|
||||||
{
|
{
|
||||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
return TenantResource::make(
|
||||||
|
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||||
{
|
{
|
||||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||||
|
|
||||||
return TenantResource::make($tenant->loadMissing(['headerLogo', 'footerLogo']));
|
return TenantResource::make(
|
||||||
|
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Tenant $tenant): Response
|
public function destroy(Tenant $tenant): Response
|
||||||
|
|||||||
35
app/Domains/Tenant/Models/SocialMedia.php
Normal file
35
app/Domains/Tenant/Models/SocialMedia.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class SocialMedia extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'social_media';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'icon',
|
||||||
|
'name',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Tenant, $this>
|
||||||
|
*/
|
||||||
|
public function tenants(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Tenant::class,
|
||||||
|
'tenant_social_media',
|
||||||
|
'social_media_code',
|
||||||
|
'tenant_code',
|
||||||
|
'code',
|
||||||
|
'codigo'
|
||||||
|
)
|
||||||
|
->withPivot(['url', 'orden'])
|
||||||
|
->withTimestamps()
|
||||||
|
->orderByPivot('orden');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@
|
|||||||
namespace App\Domains\Tenant\Models;
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
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\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Menu\Models\Menu;
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Menu\Models\TenantMenu;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -26,11 +30,20 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'footer_logo_id',
|
'footer_logo_id',
|
||||||
'hero_config',
|
'hero_config',
|
||||||
'event_config',
|
'event_config',
|
||||||
|
'search_product_layout',
|
||||||
|
'search_group_layout',
|
||||||
|
'search_items_per_page',
|
||||||
])]
|
])]
|
||||||
class Tenant extends Model
|
class Tenant extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'search_product_layout' => ProductLayout::ColumnWithImage->value,
|
||||||
|
'search_group_layout' => GroupLayout::Paginated->value,
|
||||||
|
'search_items_per_page' => 12,
|
||||||
|
];
|
||||||
|
|
||||||
public function getRouteKeyName(): string
|
public function getRouteKeyName(): string
|
||||||
{
|
{
|
||||||
return 'codigo';
|
return 'codigo';
|
||||||
@@ -46,6 +59,9 @@ class Tenant extends Model
|
|||||||
return [
|
return [
|
||||||
'hero_config' => 'array',
|
'hero_config' => 'array',
|
||||||
'event_config' => 'array',
|
'event_config' => 'array',
|
||||||
|
'search_product_layout' => ProductLayout::class,
|
||||||
|
'search_group_layout' => GroupLayout::class,
|
||||||
|
'search_items_per_page' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,20 +89,65 @@ class Tenant extends Model
|
|||||||
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
|
return $this->belongsTo(Attachment::class, 'hero_bg_image_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Attachment, $this>
|
||||||
|
*/
|
||||||
|
public function mainCarouselImages(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Attachment::class,
|
||||||
|
'tenant_main_carousel_images',
|
||||||
|
'tenant_id',
|
||||||
|
'attachment_id'
|
||||||
|
)
|
||||||
|
->withPivot('orden')
|
||||||
|
->withTimestamps()
|
||||||
|
->orderByPivot('orden');
|
||||||
|
}
|
||||||
|
|
||||||
public function catalogItems(): HasMany
|
public function catalogItems(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
|
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>
|
||||||
|
*/
|
||||||
|
public function socialMedia(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
SocialMedia::class,
|
||||||
|
'tenant_social_media',
|
||||||
|
'tenant_code',
|
||||||
|
'social_media_code',
|
||||||
|
'codigo',
|
||||||
|
'code'
|
||||||
|
)
|
||||||
|
->withPivot(['url', 'orden'])
|
||||||
|
->withTimestamps()
|
||||||
|
->orderByPivot('orden');
|
||||||
|
}
|
||||||
|
|
||||||
public function menues(): BelongsToMany
|
public function menues(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(
|
return $this->belongsToMany(
|
||||||
Menu::class,
|
Menu::class,
|
||||||
'tenant_menues',
|
'tenants_menues',
|
||||||
'tenant_codigo',
|
'tenant_code',
|
||||||
'menu_code',
|
'menu_code',
|
||||||
'codigo',
|
'codigo',
|
||||||
'code'
|
'code'
|
||||||
)->withTimestamps();
|
)
|
||||||
|
->using(TenantMenu::class)
|
||||||
|
->withPivot('static_content')
|
||||||
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -35,8 +37,7 @@ class StoreTenantRequest extends FormRequest
|
|||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$logoRule = ['required', new ImageOrBase64Rule()];
|
$logoRule = ['required', new ImageOrBase64Rule];
|
||||||
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||||
@@ -61,7 +62,18 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
'header_logo' => $logoRule,
|
'header_logo' => $logoRule,
|
||||||
'footer_logo' => $logoRule,
|
'footer_logo' => $logoRule,
|
||||||
'hero_bg_image' => ['nullable', new ImageOrBase64Rule()],
|
'hero_bg_image' => ['nullable', new ImageOrBase64Rule],
|
||||||
|
'main_carousel_images' => ['sometimes', 'array'],
|
||||||
|
'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule],
|
||||||
|
'social_media' => ['sometimes', 'array'],
|
||||||
|
'social_media.*.code' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('social_media', 'code'),
|
||||||
|
],
|
||||||
|
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||||
|
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||||
'hero_config' => ['nullable', 'array'],
|
'hero_config' => ['nullable', 'array'],
|
||||||
'hero_config.title_html' => ['nullable', 'string'],
|
'hero_config.title_html' => ['nullable', 'string'],
|
||||||
'hero_config.description_html' => ['nullable', 'string'],
|
'hero_config.description_html' => ['nullable', 'string'],
|
||||||
@@ -72,6 +84,9 @@ class StoreTenantRequest extends FormRequest
|
|||||||
'event_config.location' => ['nullable', 'string'],
|
'event_config.location' => ['nullable', 'string'],
|
||||||
'event_config.dates' => ['nullable', 'array'],
|
'event_config.dates' => ['nullable', 'array'],
|
||||||
'event_config.dates.*' => ['required', 'string'],
|
'event_config.dates.*' => ['required', 'string'],
|
||||||
|
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||||
|
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||||
|
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Tenant\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
@@ -41,8 +43,7 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $this->route('tenant');
|
$tenant = $this->route('tenant');
|
||||||
|
|
||||||
$logoRule = ['nullable', new ImageOrBase64Rule()];
|
$logoRule = ['nullable', new ImageOrBase64Rule];
|
||||||
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'codigo' => [
|
'codigo' => [
|
||||||
@@ -72,7 +73,18 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||||
'header_logo' => $logoRule,
|
'header_logo' => $logoRule,
|
||||||
'footer_logo' => $logoRule,
|
'footer_logo' => $logoRule,
|
||||||
'hero_bg_image' => ['nullable', new ImageOrBase64Rule()],
|
'hero_bg_image' => ['nullable', new ImageOrBase64Rule],
|
||||||
|
'main_carousel_images' => ['sometimes', 'array'],
|
||||||
|
'main_carousel_images.*' => ['required', 'distinct', new ImageOrBase64Rule],
|
||||||
|
'social_media' => ['sometimes', 'array'],
|
||||||
|
'social_media.*.code' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('social_media', 'code'),
|
||||||
|
],
|
||||||
|
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||||
|
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||||
'hero_config' => ['nullable', 'array'],
|
'hero_config' => ['nullable', 'array'],
|
||||||
'hero_config.title_html' => ['nullable', 'string'],
|
'hero_config.title_html' => ['nullable', 'string'],
|
||||||
'hero_config.description_html' => ['nullable', 'string'],
|
'hero_config.description_html' => ['nullable', 'string'],
|
||||||
@@ -83,6 +95,9 @@ class UpdateTenantRequest extends FormRequest
|
|||||||
'event_config.location' => ['nullable', 'string'],
|
'event_config.location' => ['nullable', 'string'],
|
||||||
'event_config.dates' => ['nullable', 'array'],
|
'event_config.dates' => ['nullable', 'array'],
|
||||||
'event_config.dates.*' => ['required', 'string'],
|
'event_config.dates.*' => ['required', 'string'],
|
||||||
|
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||||
|
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||||
|
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Resources;
|
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;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin \App\Domains\Tenant\Models\Tenant
|
* @mixin Tenant
|
||||||
*/
|
*/
|
||||||
class TenantResource extends JsonResource
|
class TenantResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -34,10 +38,107 @@ class TenantResource extends JsonResource
|
|||||||
'footer_bg_color' => $this->footer_bg_color,
|
'footer_bg_color' => $this->footer_bg_color,
|
||||||
// 1 day
|
// 1 day
|
||||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440 ),
|
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
||||||
'hero_config' => $heroConfig,
|
'hero_config' => $heroConfig,
|
||||||
'event_config' => $this->event_config,
|
'event_config' => $this->event_config,
|
||||||
'menues' => $this->whenLoaded('menues'),
|
'search_product_layout' => $this->search_product_layout->value,
|
||||||
|
'search_group_layout' => $this->search_group_layout->value,
|
||||||
|
'search_items_per_page' => $this->search_items_per_page,
|
||||||
|
'main_carousel_images' => $this->whenLoaded(
|
||||||
|
'mainCarouselImages',
|
||||||
|
fn () => $this->mainCarouselImages
|
||||||
|
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||||
|
->values()
|
||||||
|
),
|
||||||
|
'social_media' => $this->whenLoaded(
|
||||||
|
'socialMedia',
|
||||||
|
fn () => $this->socialMedia
|
||||||
|
->map(fn ($socialMedia) => [
|
||||||
|
'code' => $socialMedia->code,
|
||||||
|
'icon' => $socialMedia->icon,
|
||||||
|
'name' => $socialMedia->name,
|
||||||
|
'url' => $socialMedia->pivot->url,
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
),
|
||||||
|
'menues' => $this->whenLoaded(
|
||||||
|
'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>>
|
||||||
|
*/
|
||||||
|
private function menuTree(Collection $menus): Collection
|
||||||
|
{
|
||||||
|
$menuCodes = $menus->pluck('code')->flip();
|
||||||
|
$childrenByParent = $menus
|
||||||
|
->filter(fn (Menu $menu) => $menu->parent_menu_code !== null)
|
||||||
|
->groupBy('parent_menu_code');
|
||||||
|
|
||||||
|
$formatMenu = function (Menu $menu) use (&$formatMenu, $childrenByParent): array {
|
||||||
|
$formatted = [
|
||||||
|
'id' => $menu->id,
|
||||||
|
'code' => $menu->code,
|
||||||
|
'label' => $menu->label,
|
||||||
|
'parent_menu_code' => $menu->parent_menu_code,
|
||||||
|
'content_type' => $menu->content_type,
|
||||||
|
'route' => $menu->route,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($menu->pivot?->static_content !== null) {
|
||||||
|
$formatted['static_content'] = $menu->pivot->static_content;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formatted['submenues'] = $childrenByParent
|
||||||
|
->get($menu->code, collect())
|
||||||
|
->map($formatMenu)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $formatted;
|
||||||
|
};
|
||||||
|
|
||||||
|
return $menus
|
||||||
|
->filter(fn (Menu $menu) => $menu->parent_menu_code === null
|
||||||
|
|| ! $menuCodes->has($menu->parent_menu_code))
|
||||||
|
->map($formatMenu)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,22 @@
|
|||||||
|
|
||||||
namespace App\Domains\Tenant\Services;
|
namespace App\Domains\Tenant\Services;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Attachable\Services\AttachmentService;
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class TenantService
|
class TenantService
|
||||||
{
|
{
|
||||||
public function __construct(protected AttachmentService $attachmentService)
|
public function __construct(protected AttachmentService $attachmentService) {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new tenant and store its logos.
|
* Create a new tenant and store its logos.
|
||||||
*
|
*
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @return Tenant
|
|
||||||
*/
|
*/
|
||||||
public function create(array $data): Tenant
|
public function create(array $data): Tenant
|
||||||
{
|
{
|
||||||
@@ -25,13 +25,21 @@ class TenantService
|
|||||||
$headerLogo = $data['header_logo'] ?? null;
|
$headerLogo = $data['header_logo'] ?? null;
|
||||||
$footerLogo = $data['footer_logo'] ?? null;
|
$footerLogo = $data['footer_logo'] ?? null;
|
||||||
$heroBgImage = $data['hero_bg_image'] ?? null;
|
$heroBgImage = $data['hero_bg_image'] ?? null;
|
||||||
|
$mainCarouselImages = $data['main_carousel_images'] ?? [];
|
||||||
|
$socialMedia = $data['social_media'] ?? [];
|
||||||
|
|
||||||
unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']);
|
unset(
|
||||||
|
$data['header_logo'],
|
||||||
|
$data['footer_logo'],
|
||||||
|
$data['hero_bg_image'],
|
||||||
|
$data['main_carousel_images'],
|
||||||
|
$data['social_media']
|
||||||
|
);
|
||||||
|
|
||||||
$headerAttachmentId = null;
|
$headerAttachmentId = null;
|
||||||
if ($headerLogo) {
|
if ($headerLogo) {
|
||||||
$attachment = Str::isUuid($headerLogo)
|
$attachment = Str::isUuid($headerLogo)
|
||||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
|
? Attachment::query()->where('key', $headerLogo)->first()
|
||||||
: $this->attachmentService->store($headerLogo, 'tenants');
|
: $this->attachmentService->store($headerLogo, 'tenants');
|
||||||
|
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
@@ -42,7 +50,7 @@ class TenantService
|
|||||||
$footerAttachmentId = null;
|
$footerAttachmentId = null;
|
||||||
if ($footerLogo) {
|
if ($footerLogo) {
|
||||||
$attachment = Str::isUuid($footerLogo)
|
$attachment = Str::isUuid($footerLogo)
|
||||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
|
? Attachment::query()->where('key', $footerLogo)->first()
|
||||||
: $this->attachmentService->store($footerLogo, 'tenants');
|
: $this->attachmentService->store($footerLogo, 'tenants');
|
||||||
|
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
@@ -55,7 +63,7 @@ class TenantService
|
|||||||
|
|
||||||
if ($heroBgImage) {
|
if ($heroBgImage) {
|
||||||
$attachment = Str::isUuid($heroBgImage)
|
$attachment = Str::isUuid($heroBgImage)
|
||||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $heroBgImage)->first()
|
? Attachment::query()->where('key', $heroBgImage)->first()
|
||||||
: $this->attachmentService->store($heroBgImage, 'tenants');
|
: $this->attachmentService->store($heroBgImage, 'tenants');
|
||||||
|
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
@@ -67,6 +75,8 @@ class TenantService
|
|||||||
|
|
||||||
/** @var Tenant $tenant */
|
/** @var Tenant $tenant */
|
||||||
$tenant = Tenant::query()->create($data);
|
$tenant = Tenant::query()->create($data);
|
||||||
|
$this->syncMainCarouselImages($tenant, $mainCarouselImages);
|
||||||
|
$this->syncSocialMedia($tenant, $socialMedia);
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
});
|
});
|
||||||
@@ -75,9 +85,7 @@ class TenantService
|
|||||||
/**
|
/**
|
||||||
* Update an existing tenant and store new logos if uploaded.
|
* Update an existing tenant and store new logos if uploaded.
|
||||||
*
|
*
|
||||||
* @param Tenant $tenant
|
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
* @return Tenant
|
|
||||||
*/
|
*/
|
||||||
public function update(Tenant $tenant, array $data): Tenant
|
public function update(Tenant $tenant, array $data): Tenant
|
||||||
{
|
{
|
||||||
@@ -85,11 +93,21 @@ class TenantService
|
|||||||
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
|
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
|
||||||
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
|
$hasFooterLogoKey = array_key_exists('footer_logo', $data);
|
||||||
$hasHeroBgImageKey = array_key_exists('hero_bg_image', $data);
|
$hasHeroBgImageKey = array_key_exists('hero_bg_image', $data);
|
||||||
|
$hasMainCarouselImagesKey = array_key_exists('main_carousel_images', $data);
|
||||||
|
$hasSocialMediaKey = array_key_exists('social_media', $data);
|
||||||
$headerLogo = $data['header_logo'] ?? null;
|
$headerLogo = $data['header_logo'] ?? null;
|
||||||
$footerLogo = $data['footer_logo'] ?? null;
|
$footerLogo = $data['footer_logo'] ?? null;
|
||||||
$heroBgImage = $data['hero_bg_image'] ?? null;
|
$heroBgImage = $data['hero_bg_image'] ?? null;
|
||||||
|
$mainCarouselImages = $data['main_carousel_images'] ?? [];
|
||||||
|
$socialMedia = $data['social_media'] ?? [];
|
||||||
|
|
||||||
unset($data['header_logo'], $data['footer_logo'], $data['hero_bg_image']);
|
unset(
|
||||||
|
$data['header_logo'],
|
||||||
|
$data['footer_logo'],
|
||||||
|
$data['hero_bg_image'],
|
||||||
|
$data['main_carousel_images'],
|
||||||
|
$data['social_media']
|
||||||
|
);
|
||||||
|
|
||||||
$oldHeroBgId = $tenant->hero_bg_image_id;
|
$oldHeroBgId = $tenant->hero_bg_image_id;
|
||||||
|
|
||||||
@@ -98,7 +116,7 @@ class TenantService
|
|||||||
if ($hasHeaderLogoKey) {
|
if ($hasHeaderLogoKey) {
|
||||||
if ($headerLogo) {
|
if ($headerLogo) {
|
||||||
$attachment = Str::isUuid($headerLogo)
|
$attachment = Str::isUuid($headerLogo)
|
||||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $headerLogo)->first()
|
? Attachment::query()->where('key', $headerLogo)->first()
|
||||||
: $this->attachmentService->store($headerLogo, 'tenants');
|
: $this->attachmentService->store($headerLogo, 'tenants');
|
||||||
|
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
@@ -114,7 +132,7 @@ class TenantService
|
|||||||
if ($hasFooterLogoKey) {
|
if ($hasFooterLogoKey) {
|
||||||
if ($footerLogo) {
|
if ($footerLogo) {
|
||||||
$attachment = Str::isUuid($footerLogo)
|
$attachment = Str::isUuid($footerLogo)
|
||||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $footerLogo)->first()
|
? Attachment::query()->where('key', $footerLogo)->first()
|
||||||
: $this->attachmentService->store($footerLogo, 'tenants');
|
: $this->attachmentService->store($footerLogo, 'tenants');
|
||||||
|
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
@@ -131,7 +149,7 @@ class TenantService
|
|||||||
if ($hasHeroBgImageKey) {
|
if ($hasHeroBgImageKey) {
|
||||||
if ($heroBgImage) {
|
if ($heroBgImage) {
|
||||||
$attachment = Str::isUuid($heroBgImage)
|
$attachment = Str::isUuid($heroBgImage)
|
||||||
? \App\Domains\Attachable\Models\Attachment::query()->where('key', $heroBgImage)->first()
|
? Attachment::query()->where('key', $heroBgImage)->first()
|
||||||
: $this->attachmentService->store($heroBgImage, 'tenants');
|
: $this->attachmentService->store($heroBgImage, 'tenants');
|
||||||
|
|
||||||
if ($attachment) {
|
if ($attachment) {
|
||||||
@@ -151,7 +169,70 @@ class TenantService
|
|||||||
|
|
||||||
$tenant->save();
|
$tenant->save();
|
||||||
|
|
||||||
|
if ($hasMainCarouselImagesKey) {
|
||||||
|
$this->syncMainCarouselImages($tenant, $mainCarouselImages);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasSocialMediaKey) {
|
||||||
|
$this->syncSocialMedia($tenant, $socialMedia);
|
||||||
|
}
|
||||||
|
|
||||||
return $tenant;
|
return $tenant;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $images
|
||||||
|
*/
|
||||||
|
private function syncMainCarouselImages(Tenant $tenant, array $images): void
|
||||||
|
{
|
||||||
|
$attachments = [];
|
||||||
|
|
||||||
|
foreach (array_values($images) as $order => $image) {
|
||||||
|
$attachment = $this->resolveMainCarouselImage($image, $order);
|
||||||
|
$attachments[$attachment->id] = ['orden' => $order];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->mainCarouselImages()->sync($attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{code: string, url: string, orden?: int}> $socialMedia
|
||||||
|
*/
|
||||||
|
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
|
||||||
|
{
|
||||||
|
$associations = [];
|
||||||
|
|
||||||
|
foreach (array_values($socialMedia) as $index => $item) {
|
||||||
|
$associations[$item['code']] = [
|
||||||
|
'url' => $item['url'],
|
||||||
|
'orden' => $item['orden'] ?? $index,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->socialMedia()->sync($associations);
|
||||||
|
$tenant->unsetRelation('socialMedia');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveMainCarouselImage(mixed $image, int $order): Attachment
|
||||||
|
{
|
||||||
|
if (is_string($image) && Str::isUuid($image)) {
|
||||||
|
$attachment = Attachment::query()
|
||||||
|
->where('key', $image)
|
||||||
|
->where('type', AttachmentType::Image->value)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($attachment === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"main_carousel_images.{$order}" => [
|
||||||
|
__('api.tenant.invalid_carousel_image'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->attachmentService->store($image, 'tenants/main-carousel');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,20 @@
|
|||||||
namespace App\Domains\Ticket\Controllers;
|
namespace App\Domains\Ticket\Controllers;
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
|
||||||
use App\Domains\Ticket\Resources\TicketResource;
|
use App\Domains\Ticket\Resources\TicketResource;
|
||||||
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class TicketController extends Controller
|
class TicketController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly TicketPdfService $ticketPdfService) {}
|
||||||
|
|
||||||
public function index(Request $request, Tenant $tenant): JsonResponse
|
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
$tickets = Ticket::query()
|
$tickets = Ticket::query()
|
||||||
@@ -21,4 +27,21 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
return TicketResource::collection($tickets)->response();
|
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,31 +11,43 @@ class TicketGenerationException extends RuntimeException
|
|||||||
{
|
{
|
||||||
public static function invalidQuantity(): self
|
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
|
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
|
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
|
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(
|
||||||
|
CatalogItem $catalogItem,
|
||||||
|
int $variantId,
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
__('api.ticket.invalid_variant', [
|
||||||
|
'variant' => $variantId,
|
||||||
|
'product' => $catalogItem->id,
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function purchaseWithoutUser(Purchase $purchase): self
|
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
|
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,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
|
$purchase->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Ticket\Models;
|
namespace App\Domains\Ticket\Models;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
'ticket',
|
'ticket',
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
|
'source_purchase_id',
|
||||||
'source_catalog_item_id',
|
'source_catalog_item_id',
|
||||||
'source_variant_id',
|
'source_variant_id',
|
||||||
'starts_at',
|
'starts_at',
|
||||||
@@ -38,6 +40,7 @@ class Ticket extends Model
|
|||||||
return [
|
return [
|
||||||
'source_catalog_item_id' => 'integer',
|
'source_catalog_item_id' => 'integer',
|
||||||
'source_variant_id' => 'integer',
|
'source_variant_id' => 'integer',
|
||||||
|
'source_purchase_id' => 'integer',
|
||||||
'starts_at' => 'datetime',
|
'starts_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'used_at' => 'datetime',
|
'used_at' => 'datetime',
|
||||||
@@ -57,6 +60,12 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(User::class);
|
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
|
public function isValid(): bool
|
||||||
{
|
{
|
||||||
$now = now();
|
$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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ namespace App\Domains\Ticket\Services;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -21,72 +21,127 @@ class TicketGeneratorService
|
|||||||
User $user,
|
User $user,
|
||||||
int $quantity = 1,
|
int $quantity = 1,
|
||||||
?int $sourceVariantId = null,
|
?int $sourceVariantId = null,
|
||||||
|
?int $sourcePurchaseId = null,
|
||||||
): Collection {
|
): Collection {
|
||||||
if ($quantity < 1) {
|
if ($quantity < 1) {
|
||||||
throw TicketGenerationException::invalidQuantity();
|
throw TicketGenerationException::invalidQuantity();
|
||||||
}
|
}
|
||||||
|
|
||||||
$now = now();
|
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection {
|
||||||
|
$targets = $this->resolveTargets(
|
||||||
|
$catalogItem,
|
||||||
|
$quantity,
|
||||||
|
$sourceVariantId,
|
||||||
|
);
|
||||||
|
|
||||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $now): Collection {
|
return $targets->map(function (array $target) use (
|
||||||
$catalogItems = $this->resolveCatalogItems($catalogItem, $quantity, $now);
|
$catalogItem,
|
||||||
|
$sourceVariantId,
|
||||||
|
$sourcePurchaseId,
|
||||||
|
$user,
|
||||||
|
): Ticket {
|
||||||
|
$item = $target['catalog_item'];
|
||||||
|
$selectedItem = $target['variant'] ?? $item;
|
||||||
|
|
||||||
return $catalogItems->map(fn (CatalogItem $item): Ticket => Ticket::query()->create([
|
return Ticket::query()->create([
|
||||||
'tenant_code' => $item->tenant_code,
|
'tenant_code' => $item->tenant_code,
|
||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'name' => $item->nombre,
|
'name' => $item->nombre,
|
||||||
'description' => (string) ($item->descripcion ?? ''),
|
'description' => (string) ($item->descripcion ?? ''),
|
||||||
|
'source_purchase_id' => $sourcePurchaseId,
|
||||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||||
'source_variant_id' => $sourceVariantId,
|
'source_variant_id' => $sourceVariantId,
|
||||||
'starts_at' => $item->minimum_use_date,
|
'starts_at' => $selectedItem->getMinimumUseDate(),
|
||||||
'expires_at' => $item->maximum_use_date,
|
'expires_at' => $selectedItem->getMaximumUseDate(),
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
'user_id' => $user->getKey(),
|
'user_id' => $user->getKey(),
|
||||||
]));
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, CatalogItem>
|
* @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}>
|
||||||
*/
|
*/
|
||||||
private function resolveCatalogItems(
|
private function resolveTargets(
|
||||||
CatalogItem $catalogItem,
|
CatalogItem $catalogItem,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
CarbonInterface $now,
|
?int $sourceVariantId,
|
||||||
): Collection {
|
): Collection {
|
||||||
if (! $catalogItem->isBundle()) {
|
if (! $catalogItem->isBundle()) {
|
||||||
$this->validateCatalogItem($catalogItem, $now);
|
$variant = $this->resolveVariant($catalogItem, $sourceVariantId);
|
||||||
|
$this->validateTarget($catalogItem, $variant);
|
||||||
|
|
||||||
return Collection::times($quantity, fn (): CatalogItem => $catalogItem);
|
return Collection::times($quantity, fn (): array => [
|
||||||
|
'catalog_item' => $catalogItem,
|
||||||
|
'variant' => $variant,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$catalogItem->loadMissing('bundleComponents.catalogItem');
|
$catalogItem->loadMissing([
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
|
||||||
if ($catalogItem->bundleComponents->isEmpty()) {
|
if ($catalogItem->bundleComponents->isEmpty()) {
|
||||||
throw TicketGenerationException::emptyBundle($catalogItem);
|
throw TicketGenerationException::emptyBundle($catalogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $catalogItem->bundleComponents
|
return $catalogItem->bundleComponents
|
||||||
->flatMap(function ($component) use ($quantity, $now): Collection {
|
->flatMap(function ($component) use ($quantity): Collection {
|
||||||
$componentItem = $component->catalogItem;
|
$componentItem = $component->catalogItem;
|
||||||
$this->validateCatalogItem($componentItem, $now);
|
$variant = $component->variant;
|
||||||
|
$this->validateTarget($componentItem, $variant);
|
||||||
|
|
||||||
return Collection::times(
|
return Collection::times(
|
||||||
$quantity * $component->quantity,
|
$quantity * $component->quantity,
|
||||||
fn (): CatalogItem => $componentItem,
|
fn (): array => [
|
||||||
|
'catalog_item' => $componentItem,
|
||||||
|
'variant' => $variant,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
->values();
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validateCatalogItem(CatalogItem $catalogItem, CarbonInterface $now): void
|
private function resolveVariant(
|
||||||
{
|
CatalogItem $catalogItem,
|
||||||
|
?int $sourceVariantId,
|
||||||
|
): ?Variant {
|
||||||
|
if ($sourceVariantId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = $catalogItem->variants()
|
||||||
|
->whereKey($sourceVariantId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
throw TicketGenerationException::variantNotFound(
|
||||||
|
$catalogItem,
|
||||||
|
$sourceVariantId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
|
|
||||||
|
return $variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateTarget(
|
||||||
|
CatalogItem $catalogItem,
|
||||||
|
?Variant $variant,
|
||||||
|
): void {
|
||||||
if (! $catalogItem->has_tickets) {
|
if (! $catalogItem->has_tickets) {
|
||||||
throw TicketGenerationException::ticketsDisabled($catalogItem);
|
throw TicketGenerationException::ticketsDisabled($catalogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($catalogItem->maximum_use_date?->lessThanOrEqualTo($now)) {
|
// TODO: Reactivar esta validación cuando los pagos con productos vencidos
|
||||||
throw TicketGenerationException::maximumUseDateReached($catalogItem);
|
// deban rechazarse nuevamente. Se deja deshabilitada temporalmente.
|
||||||
}
|
// $selectedItem = $variant ?? $catalogItem;
|
||||||
|
//
|
||||||
|
// if ($selectedItem->getMaximumUseDate()?->lessThanOrEqualTo(now())) {
|
||||||
|
// throw TicketGenerationException::maximumUseDateReached($catalogItem);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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')
|
->middleware('auth:sanctum')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('tickets', [TicketController::class, 'index']);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
use App\Domains\Notification\Events\TicketsAvailable;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
||||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||||
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -32,6 +37,26 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
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) {
|
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||||
/** @var Builder $this */
|
/** @var Builder $this */
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
<?php
|
<?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\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -16,9 +24,86 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
$middleware->encryptCookies(except: [
|
$middleware->encryptCookies(except: [
|
||||||
'guest_token',
|
'guest_token',
|
||||||
]);
|
]);
|
||||||
|
$middleware->prepend(SetApiLocale::class);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(
|
$exceptions->shouldRenderJsonWhen(
|
||||||
fn (Request $request) => $request->is('api/*'),
|
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();
|
})->create();
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
|
"endroid/qr-code": "^6.1",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/socialite": "^5.29",
|
"laravel/socialite": "^5.29",
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
],
|
],
|
||||||
"dev": [
|
"dev": [
|
||||||
"Composer\\Config::disableProcessTimeout",
|
"Composer\\Config::disableProcessTimeout",
|
||||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
"npx concurrently -c \"#93c5fd,#c4b5fd,#a7f3d0,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --queue=emails,default --tries=1 --timeout=0\" \"php artisan schedule:work\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,scheduler,logs,vite --kill-others"
|
||||||
],
|
],
|
||||||
"test": [
|
"test": [
|
||||||
"@php artisan config:clear --ansi @no_additional_args",
|
"@php artisan config:clear --ansi @no_additional_args",
|
||||||
|
|||||||
1143
composer.lock
generated
1143
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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'),
|
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
$frontendUrls = array_values(array_filter(array_map(
|
||||||
|
'trim',
|
||||||
|
explode(',', (string) env(
|
||||||
|
'FRONTEND_URLS',
|
||||||
|
env('FRONTEND_URL', 'http://localhost:4200')
|
||||||
|
))
|
||||||
|
)));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -19,9 +27,11 @@ return [
|
|||||||
|
|
||||||
'allowed_methods' => ['*'],
|
'allowed_methods' => ['*'],
|
||||||
|
|
||||||
'allowed_origins' => [env('FRONTEND_URL', 'http://localhost:4200')],
|
'allowed_origins' => $frontendUrls,
|
||||||
|
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [
|
||||||
|
'#^https://[a-z0-9-]+\.shopit\.com\.ar$#',
|
||||||
|
],
|
||||||
|
|
||||||
'allowed_headers' => ['*'],
|
'allowed_headers' => ['*'],
|
||||||
|
|
||||||
|
|||||||
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),
|
||||||
|
];
|
||||||
11
config/purchase.php
Normal file
11
config/purchase.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
||||||
|
|
||||||
|
'payment_expiration_minutes' => [
|
||||||
|
'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15),
|
||||||
|
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
|
||||||
|
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
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('featured_groups', function (Blueprint $table): void {
|
||||||
|
$table->enum('group_layout', GroupLayout::values())
|
||||||
|
->default(GroupLayout::Paginated->value)
|
||||||
|
->after('product_layout');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('featured_groups', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('group_layout');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?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('tenant_main_carousel_images', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('tenant_id')
|
||||||
|
->constrained('tenants')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreignId('attachment_id')
|
||||||
|
->constrained('attachments')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unsignedInteger('orden')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['tenant_id', 'attachment_id']);
|
||||||
|
$table->index(['tenant_id', 'orden']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenant_main_carousel_images');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?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('social_media', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code')->unique();
|
||||||
|
$table->string('icon');
|
||||||
|
$table->string('name');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('tenant_social_media', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('social_media_code');
|
||||||
|
$table->string('url');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreign('social_media_code')
|
||||||
|
->references('code')
|
||||||
|
->on('social_media')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unique(['tenant_code', 'social_media_code']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tenant_social_media');
|
||||||
|
Schema::dropIfExists('social_media');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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('tenant_social_media', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('orden')->default(0)->after('url');
|
||||||
|
$table->index(['tenant_code', 'orden']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenant_social_media', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['tenant_code', 'orden']);
|
||||||
|
$table->dropColumn('orden');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?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('tenant_menues', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['tenant_codigo']);
|
||||||
|
$table->dropForeign(['menu_code']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::rename('tenant_menues', 'tenants_menues');
|
||||||
|
|
||||||
|
Schema::table('tenants_menues', function (Blueprint $table) {
|
||||||
|
$table->renameColumn('tenant_codigo', 'tenant_code');
|
||||||
|
$table->json('static_content')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('menues', function (Blueprint $table) {
|
||||||
|
$table->string('parent_menu_code')->nullable()->after('code');
|
||||||
|
$table->enum('content_type', ['static', 'dynamic'])
|
||||||
|
->default('dynamic')
|
||||||
|
->after('parent_menu_code');
|
||||||
|
$table->json('static_content_schema')->nullable()->after('content_type');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('menues', function (Blueprint $table) {
|
||||||
|
$table->foreign('parent_menu_code')
|
||||||
|
->references('code')
|
||||||
|
->on('menues')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tenants_menues', function (Blueprint $table) {
|
||||||
|
$table->foreign('tenant_code')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreign('menu_code')
|
||||||
|
->references('code')
|
||||||
|
->on('menues')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants_menues', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['tenant_code']);
|
||||||
|
$table->dropForeign(['menu_code']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('menues', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['parent_menu_code']);
|
||||||
|
$table->dropColumn([
|
||||||
|
'parent_menu_code',
|
||||||
|
'content_type',
|
||||||
|
'static_content_schema',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tenants_menues', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('static_content');
|
||||||
|
$table->renameColumn('tenant_code', 'tenant_codigo');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::rename('tenants_menues', 'tenant_menues');
|
||||||
|
|
||||||
|
Schema::table('tenant_menues', function (Blueprint $table) {
|
||||||
|
$table->foreign('tenant_codigo')
|
||||||
|
->references('codigo')
|
||||||
|
->on('tenants')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->foreign('menu_code')
|
||||||
|
->references('code')
|
||||||
|
->on('menues')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::table('menues')->updateOrInsert(
|
||||||
|
['code' => 'account'],
|
||||||
|
[
|
||||||
|
'parent_menu_code' => null,
|
||||||
|
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
||||||
|
'static_content_schema' => null,
|
||||||
|
'route' => '/mi-cuenta',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$menus = [
|
||||||
|
'profile' => [
|
||||||
|
'code' => 'account.profile',
|
||||||
|
'route' => '/mi-cuenta/datos-personales',
|
||||||
|
],
|
||||||
|
'purchases' => [
|
||||||
|
'code' => 'account.purchases',
|
||||||
|
'route' => '/mi-cuenta/compras',
|
||||||
|
],
|
||||||
|
'tickets' => [
|
||||||
|
'code' => 'account.tickets',
|
||||||
|
'route' => '/mi-cuenta/tickets',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($menus as $legacyCode => $menuData) {
|
||||||
|
$legacyMenu = DB::table('menues')->where('code', $legacyCode)->first();
|
||||||
|
|
||||||
|
DB::table('menues')->updateOrInsert(
|
||||||
|
['code' => $menuData['code']],
|
||||||
|
[
|
||||||
|
'parent_menu_code' => 'account',
|
||||||
|
'content_type' => $legacyMenu?->content_type ?? Menu::CONTENT_TYPE_DYNAMIC,
|
||||||
|
'static_content_schema' => $legacyMenu?->static_content_schema,
|
||||||
|
'route' => $menuData['route'],
|
||||||
|
'created_at' => $legacyMenu?->created_at ?? $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $legacyMenu) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignments = DB::table('tenants_menues')
|
||||||
|
->where('menu_code', $legacyCode)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
$alreadyAssigned = DB::table('tenants_menues')
|
||||||
|
->where('tenant_code', $assignment->tenant_code)
|
||||||
|
->where('menu_code', $menuData['code'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $alreadyAssigned) {
|
||||||
|
DB::table('tenants_menues')->insert([
|
||||||
|
'tenant_code' => $assignment->tenant_code,
|
||||||
|
'menu_code' => $menuData['code'],
|
||||||
|
'static_content' => $assignment->static_content,
|
||||||
|
'created_at' => $assignment->created_at,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('tenants_menues')->where('menu_code', $legacyCode)->delete();
|
||||||
|
DB::table('menues')->where('code', $legacyCode)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantCodes = DB::table('tenants_menues')
|
||||||
|
->whereIn('menu_code', array_column($menus, 'code'))
|
||||||
|
->distinct()
|
||||||
|
->pluck('tenant_code');
|
||||||
|
|
||||||
|
foreach ($tenantCodes as $tenantCode) {
|
||||||
|
$alreadyAssigned = DB::table('tenants_menues')
|
||||||
|
->where('tenant_code', $tenantCode)
|
||||||
|
->where('menu_code', 'account')
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyAssigned) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('tenants_menues')->insert([
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'menu_code' => 'account',
|
||||||
|
'static_content' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
$menus = [
|
||||||
|
'account.profile' => [
|
||||||
|
'code' => 'profile',
|
||||||
|
'route' => '/profile',
|
||||||
|
],
|
||||||
|
'account.purchases' => [
|
||||||
|
'code' => 'purchases',
|
||||||
|
'route' => '/purchases',
|
||||||
|
],
|
||||||
|
'account.tickets' => [
|
||||||
|
'code' => 'tickets',
|
||||||
|
'route' => '/tickets',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($menus as $accountCode => $menuData) {
|
||||||
|
$accountMenu = DB::table('menues')->where('code', $accountCode)->first();
|
||||||
|
|
||||||
|
DB::table('menues')->updateOrInsert(
|
||||||
|
['code' => $menuData['code']],
|
||||||
|
[
|
||||||
|
'parent_menu_code' => null,
|
||||||
|
'content_type' => $accountMenu?->content_type ?? Menu::CONTENT_TYPE_DYNAMIC,
|
||||||
|
'static_content_schema' => $accountMenu?->static_content_schema,
|
||||||
|
'route' => $menuData['route'],
|
||||||
|
'created_at' => $accountMenu?->created_at ?? $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $accountMenu) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assignments = DB::table('tenants_menues')
|
||||||
|
->where('menu_code', $accountCode)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($assignments as $assignment) {
|
||||||
|
$alreadyAssigned = DB::table('tenants_menues')
|
||||||
|
->where('tenant_code', $assignment->tenant_code)
|
||||||
|
->where('menu_code', $menuData['code'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $alreadyAssigned) {
|
||||||
|
DB::table('tenants_menues')->insert([
|
||||||
|
'tenant_code' => $assignment->tenant_code,
|
||||||
|
'menu_code' => $menuData['code'],
|
||||||
|
'static_content' => $assignment->static_content,
|
||||||
|
'created_at' => $assignment->created_at,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('tenants_menues')->where('menu_code', $accountCode)->delete();
|
||||||
|
DB::table('menues')->where('code', $accountCode)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('tenants_menues')->where('menu_code', 'account')->delete();
|
||||||
|
DB::table('menues')->where('code', 'account')->delete();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('menues', 'label')) {
|
||||||
|
Schema::table('menues', function (Blueprint $table) {
|
||||||
|
$table->string('label')->nullable()->after('code');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = [
|
||||||
|
'index' => 'Inicio',
|
||||||
|
'product.detail' => 'Detalle de producto',
|
||||||
|
'checkout' => 'Finalizar compra',
|
||||||
|
'account' => 'Mi cuenta',
|
||||||
|
'account.profile' => 'Datos personales',
|
||||||
|
'account.purchases' => 'Mis compras',
|
||||||
|
'account.tickets' => 'Mis tickets',
|
||||||
|
'help' => 'Ayuda',
|
||||||
|
'help.faq' => 'Preguntas frecuentes',
|
||||||
|
'help.contact' => 'Contacto',
|
||||||
|
'help.payment-methods' => 'Medios de pago',
|
||||||
|
'help.shipping' => 'Envíos',
|
||||||
|
'help.terms-and-conditions' => 'Términos y condiciones',
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::table('menues')
|
||||||
|
->select(['id', 'code'])
|
||||||
|
->orderBy('id')
|
||||||
|
->eachById(function ($menu) use ($labels): void {
|
||||||
|
$fallback = Str::headline(Str::afterLast($menu->code, '.'));
|
||||||
|
|
||||||
|
DB::table('menues')
|
||||||
|
->where('id', $menu->id)
|
||||||
|
->update(['label' => $labels[$menu->code] ?? $fallback]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('menues', function (Blueprint $table) {
|
||||||
|
$table->string('label')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('menues', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('label');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
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('tenants', function (Blueprint $table): void {
|
||||||
|
$table->enum('search_product_layout', ProductLayout::values())
|
||||||
|
->default(ProductLayout::ColumnWithImage->value);
|
||||||
|
$table->enum('search_group_layout', GroupLayout::values())
|
||||||
|
->default(GroupLayout::Paginated->value);
|
||||||
|
$table->unsignedTinyInteger('search_items_per_page')->default(12);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'search_product_layout',
|
||||||
|
'search_group_layout',
|
||||||
|
'search_items_per_page',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->string('reservation_status')
|
||||||
|
->default('active')
|
||||||
|
->after('total');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('compra_items')
|
||||||
|
->whereIn('compra_id', DB::table('compras')->where('status', 'paid')->select('id'))
|
||||||
|
->update(['reservation_status' => 'committed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('reservation_status');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user