Compare commits
44 Commits
5ada399adf
...
authorizat
| Author | SHA1 | Date | |
|---|---|---|---|
| 40a7dece11 | |||
| 5ec65ce3d3 | |||
| b7e1332b8c | |||
| a087a511e0 | |||
| a20b1713d3 | |||
| d56d387933 | |||
| cba32e90e7 | |||
| a674bcfabc | |||
| ace3f136e4 | |||
| 2cc72be850 | |||
| 8e3ee7eff6 | |||
| 27d4f9fac6 | |||
| 9e3163b74f | |||
| b9ad2e589f | |||
| 5416c0fd61 | |||
| 04bf03fc2e | |||
| 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 |
58
.env.example
58
.env.example
@@ -3,11 +3,15 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
FRONTEND_URL=http://localhost:4200
|
||||
INTEGRATION_SECRET=
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||
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_MAINTENANCE_DRIVER=file
|
||||
@@ -15,27 +19,38 @@ APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
||||
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||
AUTH_LOGIN_LOCK_MINUTES=15
|
||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=laravel
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
INTEGRATION_SECRET=change-me
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
|
||||
SANCTUM_EXPIRATION=720
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
@@ -51,27 +66,20 @@ REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
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_SCHEME=smtp
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
|
||||
AWS_ENDPOINT=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=garage
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=
|
||||
AWS_HTTP_VERIFY=
|
||||
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
AWS_HTTP_VERIFY=true
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Tenant (JSON)",
|
||||
"name": "Create Tenant OnTicket (JSON)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
@@ -51,7 +51,7 @@
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme\",\n \"dominio\": \"acme.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
|
||||
"raw": "{\n \"codigo\": \"festival-demo\",\n \"nombre\": \"Festival Demo\",\n \"dominio\": \"festival-demo.test\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#ff0000\",\n \"success_color\": \"#00aa55\",\n \"header_bg_color\": \"#333333\",\n \"footer_bg_color\": \"#333333\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"website_type_code\": \"onticket\",\n \"extras\": {\n \"heroConfig\": {\n \"title_html\": \"<h1>Festival Demo</h1>\",\n \"description_html\": \"<p>Una experiencia inolvidable</p>\",\n \"button_text\": \"Comprar entradas\",\n \"button_href\": \"/tickets\",\n \"background_image_id\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n },\n \"eventConfig\": {\n \"title\": \"Festival Demo\",\n \"location\": \"Buenos Aires\",\n \"dates_text\": \"10 y 11 de octubre de 2026\",\n \"dates\": [\n \"2026-10-10\",\n \"2026-10-11\"\n ]\n }\n }\n}"
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{base_url}}/api/tenants",
|
||||
@@ -67,7 +67,7 @@
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Create Tenant (Form Data)",
|
||||
"name": "Create Tenant ShopIt (Form Data)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
@@ -110,6 +110,11 @@
|
||||
"value": "#ff0000",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "success_color",
|
||||
"value": "#00aa55",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "header_bg_color",
|
||||
"value": "#333333",
|
||||
@@ -129,6 +134,32 @@
|
||||
"key": "footer_logo",
|
||||
"type": "file",
|
||||
"src": []
|
||||
},
|
||||
{
|
||||
"key": "search_product_layout",
|
||||
"value": "column_with_image",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "search_group_layout",
|
||||
"value": "paginated",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "search_items_per_page",
|
||||
"value": "12",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "website_type_code",
|
||||
"value": "shopit",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "extras[carousel][]",
|
||||
"type": "file",
|
||||
"src": [],
|
||||
"description": "Se puede repetir esta key hasta 10 veces. También acepta una imagen base64 o el UUID de un attachment."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\GoogleAuthService;
|
||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class GoogleTokenExchangeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
|
||||
public function __construct(
|
||||
private readonly GoogleAuthService $googleAuthService,
|
||||
private readonly GuestCartMergeService $guestCartMergeService,
|
||||
) {}
|
||||
|
||||
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
|
||||
{
|
||||
$authentication = $this->googleAuthService->exchange($request->validated('oauth_code'));
|
||||
$authentication = $this->googleAuthService->exchange(
|
||||
$request->validated('oauth_code'),
|
||||
$request->validated('tenant_codigo'),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
$guestTokenCookie = $request->cookie('guest_token');
|
||||
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||
? $guestTokenCookie
|
||||
: null;
|
||||
$this->guestCartMergeService->merge(
|
||||
$authentication['tenant_codigo'],
|
||||
$authentication['user'],
|
||||
$guestToken,
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'code' => 'auth.login_success',
|
||||
'message' => __('api.auth.login_success'),
|
||||
'token' => $authentication['token'],
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($authentication['user']),
|
||||
]);
|
||||
|
||||
if ($guestToken !== null) {
|
||||
$response->withCookie(Cookie::forget('guest_token'));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,30 @@
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\PasswordLoginService;
|
||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly GuestCartMergeService $guestCartMergeService,
|
||||
private readonly PasswordLoginService $passwordLoginService,
|
||||
) {}
|
||||
|
||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()->where('email', $credentials['email'])->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => 'Email o Contraseña incorrectos.',
|
||||
]);
|
||||
}
|
||||
$user = $this->passwordLoginService->authenticate(
|
||||
$credentials['email'],
|
||||
$credentials['password'],
|
||||
$credentials['tenant_codigo'],
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
);
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
@@ -33,11 +35,28 @@ class LoginController extends Controller
|
||||
now()->addMinutes($expirationMinutes),
|
||||
)->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
$guestTokenCookie = $request->cookie('guest_token');
|
||||
$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_type' => 'Bearer',
|
||||
'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([
|
||||
'message' => 'Sesion cerrada correctamente.',
|
||||
'code' => 'auth.logout_success',
|
||||
'message' => __('api.auth.logout_success'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,17 @@ class RegisterController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RegisterUserService $registerUserService,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function __invoke(RegisterUserRequest $request): JsonResponse
|
||||
{
|
||||
$user = $this->registerUserService->register($request->validated());
|
||||
|
||||
return UserResource::make($user)
|
||||
->additional(['message' => 'Usuario registrado correctamente.'])
|
||||
->additional([
|
||||
'code' => 'auth.register_success',
|
||||
'message' => __('api.auth.register_success'),
|
||||
])
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
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', '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);
|
||||
}
|
||||
}
|
||||
@@ -2,26 +2,63 @@
|
||||
|
||||
namespace App\Domains\Auth\Models;
|
||||
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
|
||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $attributes = [
|
||||
'rol_codigo' => RoleCode::User->value,
|
||||
];
|
||||
|
||||
protected static function newFactory(): UserFactory
|
||||
{
|
||||
return UserFactory::new();
|
||||
}
|
||||
|
||||
/** @return HasMany<ResetPasswordAttempt, $this> */
|
||||
public function resetPasswordAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ResetPasswordAttempt::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<LoginAttempt, $this> */
|
||||
public function loginAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(LoginAttempt::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Role, $this>
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -30,6 +67,9 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'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 [
|
||||
'oauth_code' => ['required', 'uuid'],
|
||||
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LoginUserRequest extends FormRequest
|
||||
{
|
||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
|
||||
if (is_string($email)) {
|
||||
$this->merge([
|
||||
'email' => Str::lower(trim($email)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
@@ -19,6 +31,7 @@ class LoginUserRequest extends FormRequest
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'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}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
namespace App\Domains\Auth\Resources;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin \App\Domains\Auth\Models\User
|
||||
* @mixin User
|
||||
*/
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
@@ -21,6 +22,8 @@ class UserResource extends JsonResource
|
||||
'email' => $this->email,
|
||||
'dni' => $this->dni,
|
||||
'telefono' => $this->telefono,
|
||||
'rol_codigo' => $this->rol_codigo,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class GoogleAuthService
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant' => 'El tenant o la URL de retorno no son validos.',
|
||||
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -50,13 +50,13 @@ class GoogleAuthService
|
||||
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
|
||||
|
||||
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
|
||||
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.');
|
||||
abort(400, __('api.auth.request_expired'));
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
|
||||
|
||||
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
|
||||
abort(400, 'La URL de retorno no es valida.');
|
||||
abort(400, __('api.auth.invalid_return_url'));
|
||||
}
|
||||
|
||||
/** @var SocialiteUser $googleUser */
|
||||
@@ -72,6 +72,7 @@ class GoogleAuthService
|
||||
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
||||
'user_id' => $user->id,
|
||||
'token' => $token,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
return redirect()->to($context['return_url'].'/login?'.http_build_query([
|
||||
@@ -79,21 +80,28 @@ class GoogleAuthService
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return array{user: User, token: string} */
|
||||
public function exchange(string $exchangeCode): array
|
||||
/** @return array{user: User, token: string, tenant_codigo: string} */
|
||||
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}");
|
||||
|
||||
if (! $authentication) {
|
||||
throw ValidationException::withMessages([
|
||||
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.',
|
||||
'oauth_code' => __('api.auth.oauth_code_expired'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_codigo' => __('api.auth.tenant_mismatch'),
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => User::query()->findOrFail($authentication['user_id']),
|
||||
'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)) {
|
||||
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;
|
||||
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'google' => 'La cuenta de Google debe tener el email verificado.',
|
||||
'google' => __('api.auth.google_email_unverified'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
173
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
173
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?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\Validation\ValidationException;
|
||||
|
||||
class PasswordLoginService
|
||||
{
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
|
||||
$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): 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;
|
||||
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => $attempts,
|
||||
'last_failed_login_at' => $now,
|
||||
'locked_until' => $attempts >= $maxAttempts
|
||||
? $now->addMinutes($lockMinutes)
|
||||
: null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
180
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
180
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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): void
|
||||
{
|
||||
$emailFingerprint = $this->emailFingerprint($email);
|
||||
|
||||
try {
|
||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?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(),
|
||||
'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
|
||||
|
||||
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
|
||||
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
|
||||
use App\Domains\Auth\Controllers\LoginController;
|
||||
use App\Domains\Auth\Controllers\LogoutController;
|
||||
use App\Domains\Auth\Controllers\MeController;
|
||||
use App\Domains\Auth\Controllers\RegisterController;
|
||||
use App\Domains\Auth\Controllers\ResetPasswordController;
|
||||
use App\Domains\Auth\Controllers\UpdateProfileController;
|
||||
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||
->middleware('throttle:10,1');
|
||||
Route::post('/password/reset', ResetPasswordController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||
|
||||
10
app/Domains/Authorization/Enums/RoleCode.php
Normal file
10
app/Domains/Authorization/Enums/RoleCode.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Authorization\Enums;
|
||||
|
||||
enum RoleCode: string
|
||||
{
|
||||
case Admin = 'admin';
|
||||
case AdminApp = 'adminapp';
|
||||
case User = 'user';
|
||||
}
|
||||
32
app/Domains/Authorization/Models/Permission.php
Normal file
32
app/Domains/Authorization/Models/Permission.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Authorization\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class Permission extends Model
|
||||
{
|
||||
protected $table = 'permisos';
|
||||
|
||||
protected $fillable = [
|
||||
'codigo',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Role, $this>
|
||||
*/
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Role::class,
|
||||
'roles_permisos',
|
||||
'codigo_permiso',
|
||||
'rol_codigo',
|
||||
'codigo',
|
||||
'codigo'
|
||||
)->withTimestamps();
|
||||
}
|
||||
}
|
||||
59
app/Domains/Authorization/Models/Role.php
Normal file
59
app/Domains/Authorization/Models/Role.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Authorization\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\MenuRole;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Role extends Model
|
||||
{
|
||||
protected $table = 'roles';
|
||||
|
||||
protected $fillable = [
|
||||
'codigo',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Permission, $this>
|
||||
*/
|
||||
public function permissions(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Permission::class,
|
||||
'roles_permisos',
|
||||
'rol_codigo',
|
||||
'codigo_permiso',
|
||||
'codigo',
|
||||
'codigo'
|
||||
)->withTimestamps();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<User, $this>
|
||||
*/
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class, 'rol_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Menu, $this>
|
||||
*/
|
||||
public function menus(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Menu::class,
|
||||
'roles_menues',
|
||||
'rol_codigo',
|
||||
'menu_codigo',
|
||||
'codigo',
|
||||
'code'
|
||||
)->using(MenuRole::class);
|
||||
}
|
||||
}
|
||||
32
app/Domains/Authorization/Models/RolePermission.php
Normal file
32
app/Domains/Authorization/Models/RolePermission.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Authorization\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RolePermission extends Model
|
||||
{
|
||||
protected $table = 'roles_permisos';
|
||||
|
||||
protected $fillable = [
|
||||
'codigo_permiso',
|
||||
'rol_codigo',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Permission, $this>
|
||||
*/
|
||||
public function permission(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Permission::class, 'codigo_permiso', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Role, $this>
|
||||
*/
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,10 @@ class CartController extends Controller
|
||||
);
|
||||
|
||||
$response = CartResource::make($result['cart'])
|
||||
->additional(['message' => 'Producto agregado al carrito.'])
|
||||
->additional([
|
||||
'code' => 'cart.item_added',
|
||||
'message' => __('api.cart.item_added'),
|
||||
])
|
||||
->response();
|
||||
|
||||
if ($result['guest_token'] !== null) {
|
||||
@@ -58,13 +61,19 @@ class CartController extends Controller
|
||||
$cartItem->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
)
|
||||
)->additional(['message' => 'Cantidad de producto actualizada.']);
|
||||
)->additional([
|
||||
'code' => 'cart.quantity_updated',
|
||||
'message' => __('api.cart.quantity_updated'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
|
||||
{
|
||||
return CartResource::make(
|
||||
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
|
||||
)->additional(['message' => 'Producto eliminado del carrito.']);
|
||||
)->additional([
|
||||
'code' => 'cart.item_removed',
|
||||
'message' => __('api.cart.item_removed'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class Cart extends Model
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.",
|
||||
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ class Cart extends Model
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => 'La cantidad debe ser mayor a cero.',
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ class Cart extends Model
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
|
||||
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -209,13 +209,13 @@ class Cart extends Model
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Un bundle no admite una variante.',
|
||||
'variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
'catalog_item_id' => __('api.cart.empty_bundle'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ class Cart extends Model
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => 'Debe seleccionar una variante para este ítem.',
|
||||
'variant_id' => __('api.cart.variant_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ class CartService
|
||||
{
|
||||
return Cart::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('status', 'active')
|
||||
->when(
|
||||
$identity['user_id'] !== null,
|
||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||
@@ -199,7 +200,10 @@ class CartService
|
||||
*/
|
||||
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) {
|
||||
$attributes['user_id'] = $identity['user_id'];
|
||||
@@ -207,6 +211,6 @@ class CartService
|
||||
$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;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')
|
||||
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
|
||||
->group(function (): void {
|
||||
Route::get('cart', [CartController::class, 'show']);
|
||||
Route::post('cart/items', [CartController::class, 'addItem']);
|
||||
|
||||
@@ -4,20 +4,25 @@ namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\FeaturedItem;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
class CatalogController extends Controller
|
||||
@@ -39,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(
|
||||
FeaturedGroupPageRequest $request,
|
||||
Tenant $tenant,
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -163,6 +164,16 @@ class CatalogItem extends Model
|
||||
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
|
||||
{
|
||||
return $this->type === CatalogItemType::Bundle;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
#[Fillable([
|
||||
'catalog_item_id',
|
||||
'inventory_id',
|
||||
'minimum_use_date',
|
||||
'maximum_use_date',
|
||||
])]
|
||||
class Variant extends Model
|
||||
{
|
||||
@@ -27,6 +30,8 @@ class Variant extends Model
|
||||
return [
|
||||
'catalog_item_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'minimum_use_date' => 'datetime',
|
||||
'maximum_use_date' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -89,4 +94,16 @@ class Variant extends Model
|
||||
|
||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
||||
}
|
||||
|
||||
public function getMinimumUseDate(): ?CarbonInterface
|
||||
{
|
||||
return $this->minimum_use_date
|
||||
?? $this->catalogItem->getMinimumUseDate();
|
||||
}
|
||||
|
||||
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.*.reserved_stock' => ['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.*' => ['nullable', 'string'],
|
||||
'variants.*.images' => ['sometimes', 'array'],
|
||||
|
||||
@@ -111,6 +111,12 @@ class CatalogItemDetailResource extends JsonResource
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'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
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||
|
||||
@@ -33,6 +33,12 @@ class CatalogItemResource extends JsonResource
|
||||
->map(fn ($variant) => [
|
||||
'id' => $variant->id,
|
||||
'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
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$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\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -42,7 +46,7 @@ class CatalogService
|
||||
} else {
|
||||
if (array_key_exists('components', $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un item standard no puede tener componentes.'],
|
||||
'components' => [__('api.catalog.standard_with_components')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -158,6 +162,78 @@ class CatalogService
|
||||
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
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
@@ -213,7 +289,7 @@ class CatalogService
|
||||
|
||||
if (isset($seen[$key])) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}" => ['El componente esta duplicado.'],
|
||||
"components.{$index}" => [__('api.catalog.duplicate_component')],
|
||||
]);
|
||||
}
|
||||
$seen[$key] = true;
|
||||
@@ -226,7 +302,7 @@ class CatalogService
|
||||
if ($componentItem === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El item no pertenece al tenant del bundle.',
|
||||
__('api.catalog.component_wrong_tenant'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -234,7 +310,7 @@ class CatalogService
|
||||
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.catalog_item_id" => [
|
||||
'El componente debe ser un item standard distinto del bundle.',
|
||||
__('api.catalog.invalid_component'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -243,7 +319,7 @@ class CatalogService
|
||||
if ($hasVariants && $variantId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'Debe seleccionar una variante para este componente.',
|
||||
__('api.catalog.component_variant_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -251,7 +327,7 @@ class CatalogService
|
||||
if (! $hasVariants && $variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'El componente con inventario directo no admite una variante.',
|
||||
__('api.catalog.component_variant_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -259,7 +335,7 @@ class CatalogService
|
||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
'La variante no pertenece al componente indicado.',
|
||||
__('api.catalog.component_variant_invalid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -281,7 +357,7 @@ class CatalogService
|
||||
{
|
||||
if ($components === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'components' => ['Un bundle debe tener al menos un componente.'],
|
||||
'components' => [__('api.catalog.bundle_component_required')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -296,7 +372,7 @@ class CatalogService
|
||||
] as $field) {
|
||||
if (array_key_exists($field, $data)) {
|
||||
throw ValidationException::withMessages([
|
||||
$field => ["{$field} no se admite para un bundle."],
|
||||
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -324,7 +400,7 @@ class CatalogService
|
||||
|
||||
if ($attachment === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$validationKey => ['El attachment indicado no existe.'],
|
||||
$validationKey => [__('api.catalog.attachment_not_found')],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -356,7 +432,7 @@ class CatalogService
|
||||
if ($attribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'attribute_codes' => [
|
||||
"El atributo {$attributeCode} no existe para el tenant del ítem.",
|
||||
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -390,7 +466,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.inventory" => [
|
||||
'inventory_id, reserved_stock y sold_units son administrados internamente.',
|
||||
__('api.catalog.managed_inventory_fields'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -398,7 +474,12 @@ class CatalogService
|
||||
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||
$variant = $catalogItem->variants()->create([
|
||||
'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) {
|
||||
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||
@@ -406,7 +487,7 @@ class CatalogService
|
||||
if ($itemAttribute === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.values.{$attributeCode}" => [
|
||||
'El atributo no pertenece al ítem de catálogo.',
|
||||
__('api.catalog.attribute_not_on_item'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -420,6 +501,24 @@ class CatalogService
|
||||
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<int, array<string, mixed>> $variants
|
||||
@@ -433,7 +532,7 @@ class CatalogService
|
||||
if ($hasVariants && $variants === []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem con attribute_codes debe tener variantes.',
|
||||
__('api.catalog.variants_required'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -441,7 +540,7 @@ class CatalogService
|
||||
if (! $hasVariants && $variants !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'variants' => [
|
||||
'Un ítem sin attribute_codes no puede tener variantes.',
|
||||
__('api.catalog.variants_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -449,7 +548,7 @@ class CatalogService
|
||||
if ($hasVariants && $hasDirectStock) {
|
||||
throw ValidationException::withMessages([
|
||||
'real_stock' => [
|
||||
'Un ítem con variantes no puede tener inventario directo.',
|
||||
__('api.catalog.direct_inventory_forbidden'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -461,7 +560,7 @@ class CatalogService
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'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/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
|
||||
->name('catalog.featured-groups.items.index');
|
||||
Route::get('catalog-items', [CatalogController::class, 'search'])
|
||||
->name('catalog-items.index');
|
||||
Route::get('categories/{category}', [CatalogController::class, 'category'])
|
||||
->name('categories.show');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TelepagosWebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle the incoming Telepagos webhook.
|
||||
*
|
||||
* @param TelepagosWebhookRequest $request
|
||||
* @param string $tenantCodigo
|
||||
* @param TelepagosWebhookService $service
|
||||
* @return JsonResponse
|
||||
*/
|
||||
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$cashinId = $request->validated('id');
|
||||
|
||||
|
||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
||||
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500);
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'code' => 'integration.webhook_failed',
|
||||
'message' => __('api.integration.webhook_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller
|
||||
public function show(string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
||||
|
||||
if (!$integration) {
|
||||
return response()->json(['message' => 'Integration not configured for this tenant'], 404);
|
||||
|
||||
if (! $integration) {
|
||||
return response()->json([
|
||||
'code' => 'integration.not_configured',
|
||||
'message' => __('api.integration.not_configured'),
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json($integration);
|
||||
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
|
||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
||||
{
|
||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
||||
|
||||
|
||||
try {
|
||||
$this->tenantIntegrationService->updateOrCreateIntegration(
|
||||
$tenantCode,
|
||||
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'integration configured correctly',
|
||||
'code' => 'integration.configured',
|
||||
'message' => __('api.integration.configured'),
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'message' => 'Error validando la configuración: ' . $e->getMessage()
|
||||
'code' => 'integration.validation_failed',
|
||||
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
$integrationCode = $this->route('integration_code');
|
||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
||||
|
||||
if (!$this->integrationModel) {
|
||||
if (! $this->integrationModel) {
|
||||
throw ValidationException::withMessages([
|
||||
'integration_code' => 'Integration not found.'
|
||||
'integration_code' => __('api.integration.not_configured'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ class StoreTenantIntegrationRequest extends FormRequest
|
||||
// Dynamic validation rules based on the integration data schema
|
||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
||||
$rules['integration_data.' . $field] = $rule;
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
@@ -19,16 +20,13 @@ class TelepagosWebhookService
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @param string $tenantCodigo
|
||||
* @param string $cashinId
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService();
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
@@ -48,26 +46,36 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $cuit) {
|
||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +83,7 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,11 +91,16 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
], true)) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,10 +108,12 @@ class TelepagosWebhookService
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +130,7 @@ class TelepagosWebhookService
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||
DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPurchase($compra);
|
||||
$compra->markAsPaid();
|
||||
@@ -122,7 +138,7 @@ class TelepagosWebhookService
|
||||
|
||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||
} catch (Exception $e) {
|
||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||
Log::error('Telepagos webhook error: '.$e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ class MailTestService
|
||||
);
|
||||
|
||||
return [
|
||||
'message' => 'Correo de prueba enviado correctamente.',
|
||||
'code' => 'mail.test_sent',
|
||||
'message' => __('api.mail.test_sent'),
|
||||
'recipient' => $recipient,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'mailer' => $mailService->mailerName(),
|
||||
|
||||
@@ -21,6 +21,7 @@ class MenuController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|unique:menues,code',
|
||||
'label' => 'required|string|max:255',
|
||||
'parent_menu_code' => [
|
||||
'nullable',
|
||||
'string',
|
||||
@@ -49,6 +50,7 @@ class MenuController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
|
||||
'label' => 'sometimes|required|string|max:255',
|
||||
'parent_menu_code' => [
|
||||
'nullable',
|
||||
'string',
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Menu\Models;
|
||||
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -26,6 +27,7 @@ class Menu extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'label',
|
||||
'parent_menu_code',
|
||||
'content_type',
|
||||
'static_content_schema',
|
||||
@@ -47,7 +49,7 @@ class Menu extends Model
|
||||
&& empty($menu->static_content_schema)
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'static_content_schema' => 'El schema es obligatorio para los menús estáticos.',
|
||||
'static_content_schema' => __('api.menu.schema_required'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -77,4 +79,16 @@ class Menu extends Model
|
||||
->withPivot('static_content')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function roles(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Role::class,
|
||||
'roles_menues',
|
||||
'menu_codigo',
|
||||
'rol_codigo',
|
||||
'code',
|
||||
'codigo'
|
||||
)->using(MenuRole::class);
|
||||
}
|
||||
}
|
||||
|
||||
31
app/Domains/Menu/Models/MenuRole.php
Normal file
31
app/Domains/Menu/Models/MenuRole.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Menu\Models;
|
||||
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class MenuRole extends Pivot
|
||||
{
|
||||
protected $table = 'roles_menues';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'rol_codigo',
|
||||
'menu_codigo',
|
||||
];
|
||||
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
|
||||
}
|
||||
|
||||
public function menu(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Menu::class, 'menu_codigo', 'code');
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
use App\Domains\Menu\Controllers\MenuController;
|
||||
use App\Domains\Menu\Controllers\TenantMenuController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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;
|
||||
|
||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Integration\Services\MailService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
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
|
||||
{
|
||||
$purchase = Purchase::query()
|
||||
|
||||
@@ -4,16 +4,19 @@ namespace App\Domains\Purchase\Controllers;
|
||||
|
||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
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\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
@@ -32,8 +35,11 @@ class PurchaseController extends Controller
|
||||
)->response();
|
||||
}
|
||||
|
||||
public function store(StorePurchaseRequest $request, Tenant $tenant, CheckoutService $checkoutService): JsonResponse
|
||||
{
|
||||
public function startCheckout(
|
||||
StartCheckoutRequest $request,
|
||||
Tenant $tenant,
|
||||
CheckoutService $checkoutService,
|
||||
): JsonResponse {
|
||||
$data = $request->validated();
|
||||
|
||||
$purchase = $checkoutService->startCheckout(
|
||||
@@ -49,16 +55,56 @@ class PurchaseController extends Controller
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
$compra->loadMissing(['items', 'cart.items']);
|
||||
$compra->loadMissing('items')->loadCount('tickets');
|
||||
$compra->items->load('imageAttachment');
|
||||
|
||||
if ($compra->cart !== null) {
|
||||
$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
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
@@ -68,6 +114,9 @@ class PurchaseController extends Controller
|
||||
$purchaseUpdate = [
|
||||
'payment_method' => $method,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
||||
),
|
||||
'total' => $totalAmount,
|
||||
];
|
||||
|
||||
@@ -75,7 +124,22 @@ class PurchaseController extends Controller
|
||||
$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') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
@@ -95,8 +159,14 @@ class PurchaseController extends Controller
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Unable to retrieve TelePagos account information.', [
|
||||
'purchase_id' => $compra->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Error getting account info: '.$e->getMessage(),
|
||||
'code' => 'integration.account_info_failed',
|
||||
'message' => __('api.integration.account_info_failed'),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -118,8 +188,14 @@ class PurchaseController extends Controller
|
||||
'qr_code' => $qrResponse['qr_code'] ?? '',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Unable to generate TelePagos QR code.', [
|
||||
'purchase_id' => $compra->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Error generating QR: '.$e->getMessage(),
|
||||
'code' => 'integration.qr_generation_failed',
|
||||
'message' => __('api.integration.qr_generation_failed'),
|
||||
], 500);
|
||||
}
|
||||
|
||||
@@ -133,7 +209,8 @@ class PurchaseController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invalid payment method.',
|
||||
'code' => 'integration.invalid_payment_method',
|
||||
'message' => __('api.integration.invalid_payment_method'),
|
||||
], 400);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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 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\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -20,6 +21,7 @@ use Illuminate\Support\Facades\DB;
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'expires_at',
|
||||
'total',
|
||||
'dni',
|
||||
'transfer_payer_dni',
|
||||
@@ -35,12 +37,16 @@ class Purchase extends Model
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
|
||||
public const STATUS_IN_REVIEW = 'in_review';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $table = 'compras';
|
||||
|
||||
protected function casts(): array
|
||||
@@ -48,6 +54,7 @@ class Purchase extends Model
|
||||
return [
|
||||
'cart_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -84,6 +91,14 @@ class Purchase extends Model
|
||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Ticket, $this>
|
||||
*/
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<TelepagosQr, $this>
|
||||
*/
|
||||
@@ -111,23 +126,11 @@ class Purchase extends Model
|
||||
|
||||
public function calculateCurrentTotalAmount(): float
|
||||
{
|
||||
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
|
||||
if ($this->relationLoaded('items')) {
|
||||
return (float) $this->getRelation('items')->sum('total');
|
||||
}
|
||||
|
||||
if ($this->items()->exists()) {
|
||||
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();
|
||||
return (float) $this->items()->sum('total');
|
||||
}
|
||||
|
||||
public function markAsPendingPayment(): void
|
||||
|
||||
@@ -23,11 +23,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'discount_total',
|
||||
'tax_total',
|
||||
'total',
|
||||
'reservation_status',
|
||||
])]
|
||||
class PurchaseItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const RESERVATION_ACTIVE = 'active';
|
||||
|
||||
public const RESERVATION_COMMITTED = 'committed';
|
||||
|
||||
public const RESERVATION_RELEASED = 'released';
|
||||
|
||||
protected $table = 'compra_items';
|
||||
|
||||
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;
|
||||
|
||||
class StorePurchaseRequest extends FormRequest
|
||||
class UpdatePurchaseCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
@@ -17,7 +17,6 @@ class StorePurchaseRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'cart_id' => ['required', 'integer', 'exists:carritos,id'],
|
||||
'dni' => ['required', 'string'],
|
||||
'telefono' => ['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;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* @mixin Purchase
|
||||
@@ -19,18 +17,23 @@ class PurchaseResource extends JsonResource
|
||||
*/
|
||||
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()
|
||||
? $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,
|
||||
)
|
||||
: (float) ($this->total ?? 0);
|
||||
|
||||
$total = $items->isNotEmpty()
|
||||
? $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,
|
||||
)
|
||||
: (float) ($this->total ?? 0);
|
||||
@@ -43,62 +46,29 @@ class PurchaseResource extends JsonResource
|
||||
'created_at' => $this->created_at,
|
||||
'status' => $this->status,
|
||||
'payment_method' => $this->payment_method,
|
||||
'expires_at' => $this->expires_at,
|
||||
'dni' => $this->dni,
|
||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||
'telefono' => $this->telefono,
|
||||
'nombre_apellido' => $this->nombre_apellido,
|
||||
'email' => $this->email,
|
||||
'items_source' => $itemsSource,
|
||||
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Collection<int, PurchaseItem|CartItem>, 1: string|null}
|
||||
*/
|
||||
protected function resolveItems(): array
|
||||
protected function resolveItemSubtotal(PurchaseItem $item): float
|
||||
{
|
||||
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'];
|
||||
return (float) $item->precio_unitario * $item->cantidad;
|
||||
}
|
||||
|
||||
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
|
||||
protected function resolveItemTotal(PurchaseItem $item): float
|
||||
{
|
||||
if ($item instanceof PurchaseItem) {
|
||||
return (float) $item->precio_unitario * $item->cantidad;
|
||||
}
|
||||
|
||||
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
|
||||
}
|
||||
|
||||
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
|
||||
{
|
||||
if ($item instanceof PurchaseItem) {
|
||||
return (float) ($item->total ?? 0);
|
||||
}
|
||||
|
||||
return $this->resolveItemSubtotal($item);
|
||||
return (float) ($item->total ?? 0);
|
||||
}
|
||||
|
||||
protected function formatMoney(float|int|string|null $amount): string
|
||||
|
||||
@@ -3,77 +3,54 @@
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
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\Services\CatalogInventoryService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Throwable;
|
||||
|
||||
class CheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AttachmentService $attachmentService,
|
||||
private readonly CatalogInventoryService $catalogInventoryService,
|
||||
) {}
|
||||
|
||||
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
|
||||
{
|
||||
$cartId = (int) $purchaseData['cart_id'];
|
||||
unset($purchaseData['cart_id']);
|
||||
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
|
||||
$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 {
|
||||
$cart = $this->resolveCheckoutCart($tenant, $userId, $cartId);
|
||||
$cartItems = $cart->items()->lockForUpdate()->get();
|
||||
if (is_array($directItem)) {
|
||||
return $this->startDirectCheckout(
|
||||
$tenant,
|
||||
$userId,
|
||||
$purchaseData,
|
||||
$directItem,
|
||||
);
|
||||
}
|
||||
|
||||
if ($cartItems->isEmpty()) {
|
||||
if ($cartId === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart does not contain items.',
|
||||
'cart_id' => __('api.purchase.source_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->loadCartItems($cartItems);
|
||||
$cart->setRelation('items', $cartItems);
|
||||
$totalAmount = $cart->getTotalAmount();
|
||||
|
||||
/** @var Purchase|null $purchase */
|
||||
$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);
|
||||
return $this->startCartCheckout(
|
||||
$tenant,
|
||||
$userId,
|
||||
$purchaseData,
|
||||
$cartId,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,11 +64,17 @@ class CheckoutService
|
||||
|
||||
if ($purchase->payment_method === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'payment_method' => 'The purchase payment method must be selected before finalizing.',
|
||||
'payment_method' => __('api.purchase.payment_method_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($purchase->status, [Purchase::STATUS_PAID, Purchase::STATUS_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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($purchase, &$snapshotPaths): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if ($purchase->items()->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Cart|null $cart */
|
||||
$cart = $purchase->cart()->lockForUpdate()->first();
|
||||
|
||||
if ($cart === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The purchase cart is no longer available.',
|
||||
]);
|
||||
}
|
||||
|
||||
$cartItems = $cart->items()->lockForUpdate()->get();
|
||||
|
||||
if ($cartItems->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The purchase cart does not contain items.',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->loadCartItems($cartItems);
|
||||
$this->verifyTenantItems($purchase->tenant, $cartItems);
|
||||
$purchaseItemsPayload = $this->buildPurchaseItemsPayload($purchase, $cartItems, $snapshotPaths);
|
||||
|
||||
$purchase->items()->createMany($purchaseItemsPayload);
|
||||
$this->completeCartConversion($cart, $cartItems);
|
||||
});
|
||||
} catch (Throwable $throwable) {
|
||||
foreach ($snapshotPaths as $snapshotPath) {
|
||||
Storage::disk('s3')->delete($snapshotPath);
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_PAID,
|
||||
], true)) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
throw $throwable;
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $customerData
|
||||
*/
|
||||
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();
|
||||
|
||||
if ($cartItems->isEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.empty_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->loadCartItems($cartItems);
|
||||
$this->verifyTenantItems($tenant, $cartItems);
|
||||
$cart->setRelation('items', $cartItems);
|
||||
|
||||
$purchase = $this->createPurchase(
|
||||
$tenant,
|
||||
$userId,
|
||||
$purchaseData,
|
||||
$cart->getTotalAmount(),
|
||||
$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);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
foreach ($cartItems as $item) {
|
||||
$selectedItem = $item->selectedItem();
|
||||
if ($selectedItem === null) {
|
||||
if ($item->selectedItem() === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'One or more catalog items could not be loaded.',
|
||||
'cart_id' => __('api.purchase.catalog_item_missing'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'One or more catalog items do not belong to the tenant.',
|
||||
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CartItem> $cartItems
|
||||
* @return Collection<int, CartItem>
|
||||
*/
|
||||
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
|
||||
{
|
||||
/** @var Cart|null $cart */
|
||||
@@ -187,7 +658,7 @@ class CheckoutService
|
||||
|
||||
if ($cart->status !== 'active') {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart is no longer active.',
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -198,21 +669,14 @@ class CheckoutService
|
||||
* @param Collection<int, CartItem> $cartItems
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
protected function buildPurchaseItemsPayload(
|
||||
Purchase $purchase,
|
||||
Collection $cartItems,
|
||||
array &$snapshotPaths = [],
|
||||
): array {
|
||||
protected function buildPurchaseItemsPayload(Collection $cartItems): array
|
||||
{
|
||||
return $cartItems
|
||||
->map(function (CartItem $item) use ($purchase, &$snapshotPaths): array {
|
||||
->map(function (CartItem $item): array {
|
||||
$selectedItem = $item->selectedItem();
|
||||
$quantity = (int) $item['cantidad'];
|
||||
$quantity = (int) $item->cantidad;
|
||||
$unitPrice = $selectedItem?->getPrice() ?? 0;
|
||||
$imageAttachment = $this->snapshotFirstImage($purchase, $item);
|
||||
|
||||
if ($imageAttachment !== null) {
|
||||
$snapshotPaths[] = $imageAttachment->path;
|
||||
}
|
||||
$imageAttachment = $this->firstImageAttachment($item);
|
||||
|
||||
return [
|
||||
'source_catalog_item_id' => $item->catalog_item_id,
|
||||
@@ -230,37 +694,117 @@ class CheckoutService
|
||||
'discount_total' => null,
|
||||
'tax_total' => null,
|
||||
'total' => $unitPrice * $quantity,
|
||||
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
|
||||
];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CartItem> $cartItems
|
||||
*/
|
||||
protected function completeCartConversion(Cart $cart, Collection $cartItems): void
|
||||
{
|
||||
foreach ($cartItems as $item) {
|
||||
$selectedItem = $item->selectedItem();
|
||||
$quantity = (int) $item->cantidad;
|
||||
private function resolveSelection(
|
||||
Tenant $tenant,
|
||||
int $catalogItemId,
|
||||
?int $variantId,
|
||||
): CatalogItem|Variant {
|
||||
/** @var CatalogItem|null $catalogItem */
|
||||
$catalogItem = CatalogItem::query()
|
||||
->whereKey($catalogItemId)
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
try {
|
||||
$this->catalogInventoryService->commit(
|
||||
$selectedItem,
|
||||
$quantity,
|
||||
);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => 'The selected cart has inconsistent stock state.',
|
||||
]);
|
||||
}
|
||||
if ($catalogItem === null) {
|
||||
throw new NotFoundHttpException('Catalog item not found for tenant.');
|
||||
}
|
||||
|
||||
$cart->status = 'converted';
|
||||
$cart->user_id = null;
|
||||
$cart->guest_token = null;
|
||||
$cart->save();
|
||||
$cart->delete();
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'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'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
if ($variantId === null) {
|
||||
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 */
|
||||
@@ -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();
|
||||
|
||||
if ($source === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->attachmentService->copy(
|
||||
$source,
|
||||
"purchase/{$purchase->id}",
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<int, array{name: string, value: mixed}> */
|
||||
|
||||
@@ -3,8 +3,15 @@
|
||||
use App\Domains\Purchase\Controllers\PurchaseController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('tenants/{tenant:codigo}')->middleware(['auth:sanctum', \App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class])->group(function (): void {
|
||||
Route::apiResource('compras', PurchaseController::class)->only(['index', 'store', 'show']);
|
||||
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
|
||||
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}/complete', [PurchaseController::class, 'complete']);
|
||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtrasRequest;
|
||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebsiteExtraController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected WebsiteExtraService $websiteExtraService,
|
||||
protected TenantInformationService $tenantInformationService,
|
||||
) {}
|
||||
|
||||
public function show(Request $request): WebsiteExtrasResource
|
||||
{
|
||||
return WebsiteExtrasResource::make(
|
||||
$this->loadTenant($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateWebsiteExtrasRequest $request): WebsiteExtrasResource
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
$this->websiteExtraService->replaceForTenant(
|
||||
$tenant,
|
||||
$request->validated('extras', [])
|
||||
);
|
||||
|
||||
return WebsiteExtrasResource::make(
|
||||
$this->loadTenant($request->user())
|
||||
);
|
||||
}
|
||||
|
||||
private function loadTenant(User $user): Tenant
|
||||
{
|
||||
$tenant = $user->tenant()->firstOrFail();
|
||||
|
||||
return $this->tenantInformationService->load($tenant, [
|
||||
'websiteType.extras',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,23 +2,37 @@
|
||||
|
||||
namespace App\Domains\Tenant\Controllers;
|
||||
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\BootstrapTenantRequest;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class BootstrapTenantController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TenantInformationService $tenantInformationService
|
||||
) {}
|
||||
|
||||
public function __invoke(BootstrapTenantRequest $request): TenantResource
|
||||
{
|
||||
/** @var string $dominio */
|
||||
$dominio = $request->validated('dominio');
|
||||
|
||||
return TenantResource::make(
|
||||
Tenant::query()
|
||||
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues', 'socialMedia'])
|
||||
->where('dominio', $dominio)
|
||||
->firstOrFail()
|
||||
$this->tenantInformationService->load(
|
||||
Tenant::query()
|
||||
->where('dominio', $dominio)
|
||||
->firstOrFail(),
|
||||
[
|
||||
'menues' => fn ($query) => $query->whereHas(
|
||||
'roles',
|
||||
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||
),
|
||||
'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -13,16 +14,20 @@ use Illuminate\Http\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function __construct(protected TenantService $tenantService) {}
|
||||
public function __construct(
|
||||
protected TenantService $tenantService,
|
||||
protected TenantInformationService $tenantInformationService,
|
||||
) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return TenantResource::collection(
|
||||
Tenant::query()
|
||||
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||
->latest()
|
||||
->paginateFromRequest()
|
||||
)->response();
|
||||
$tenants = Tenant::query()
|
||||
->latest()
|
||||
->paginateFromRequest();
|
||||
|
||||
$this->tenantInformationService->loadMany($tenants->getCollection());
|
||||
|
||||
return TenantResource::collection($tenants)->response();
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request): JsonResponse
|
||||
@@ -30,14 +35,14 @@ class TenantController extends Controller
|
||||
$tenant = $this->tenantService->create($request->validated());
|
||||
|
||||
return TenantResource::make(
|
||||
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||
$this->tenantInformationService->load($tenant)
|
||||
)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant): TenantResource
|
||||
{
|
||||
return TenantResource::make(
|
||||
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||
$this->tenantInformationService->load($tenant)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +51,7 @@ class TenantController extends Controller
|
||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||
|
||||
return TenantResource::make(
|
||||
$tenant->loadMissing(['headerLogo', 'footerLogo', 'mainCarouselImages', 'socialMedia'])
|
||||
$this->tenantInformationService->load($tenant)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
@@ -25,13 +28,21 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'footer_bg_color',
|
||||
'header_logo_id',
|
||||
'footer_logo_id',
|
||||
'hero_config',
|
||||
'event_config',
|
||||
'website_type_code',
|
||||
'search_product_layout',
|
||||
'search_group_layout',
|
||||
'search_items_per_page',
|
||||
])]
|
||||
class Tenant extends Model
|
||||
{
|
||||
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
|
||||
{
|
||||
return 'codigo';
|
||||
@@ -45,8 +56,9 @@ class Tenant extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'hero_config' => 'array',
|
||||
'event_config' => 'array',
|
||||
'search_product_layout' => ProductLayout::class,
|
||||
'search_group_layout' => GroupLayout::class,
|
||||
'search_items_per_page' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,27 +79,11 @@ class Tenant extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Attachment, $this>
|
||||
* @return BelongsTo<WebsiteType, $this>
|
||||
*/
|
||||
public function heroBgImage(): BelongsTo
|
||||
public function websiteType(): BelongsTo
|
||||
{
|
||||
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');
|
||||
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
|
||||
}
|
||||
|
||||
public function catalogItems(): HasMany
|
||||
@@ -95,6 +91,14 @@ class Tenant extends Model
|
||||
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SocialMedia, $this>
|
||||
*/
|
||||
@@ -113,6 +117,14 @@ class Tenant extends Model
|
||||
->orderByPivot('orden');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<WebsiteExtra, $this>
|
||||
*/
|
||||
public function websiteExtras(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo');
|
||||
}
|
||||
|
||||
public function menues(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
|
||||
64
app/Domains/Tenant/Models/WebsiteExtra.php
Normal file
64
app/Domains/Tenant/Models/WebsiteExtra.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'website_code',
|
||||
'website_type_extra_id',
|
||||
'config',
|
||||
])]
|
||||
class WebsiteExtra extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'websites_extras';
|
||||
|
||||
private mixed $resolvedConfig = null;
|
||||
|
||||
private bool $hasResolvedConfig = false;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'website_type_extra_id' => 'integer',
|
||||
'config' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'website_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<WebsiteTypeExtra, $this>
|
||||
*/
|
||||
public function websiteTypeExtra(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WebsiteTypeExtra::class, 'website_type_extra_id');
|
||||
}
|
||||
|
||||
public function setResolvedConfig(mixed $config): self
|
||||
{
|
||||
$this->resolvedConfig = $config;
|
||||
$this->hasResolvedConfig = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function resolvedConfig(): mixed
|
||||
{
|
||||
return $this->hasResolvedConfig ? $this->resolvedConfig : $this->config;
|
||||
}
|
||||
}
|
||||
35
app/Domains/Tenant/Models/WebsiteType.php
Normal file
35
app/Domains/Tenant/Models/WebsiteType.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'codigo',
|
||||
'nombre',
|
||||
])]
|
||||
class WebsiteType extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'website_type';
|
||||
|
||||
/**
|
||||
* @return HasMany<WebsiteTypeExtra, $this>
|
||||
*/
|
||||
public function extras(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteTypeExtra::class, 'website_type_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Tenant, $this>
|
||||
*/
|
||||
public function tenants(): HasMany
|
||||
{
|
||||
return $this->hasMany(Tenant::class, 'website_type_code', 'codigo');
|
||||
}
|
||||
}
|
||||
50
app/Domains/Tenant/Models/WebsiteTypeExtra.php
Normal file
50
app/Domains/Tenant/Models/WebsiteTypeExtra.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'website_type_code',
|
||||
'nombre',
|
||||
'descripcion',
|
||||
'is_required',
|
||||
'config_schema',
|
||||
])]
|
||||
class WebsiteTypeExtra extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'website_type_extras';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_required' => 'boolean',
|
||||
'config_schema' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<WebsiteType, $this>
|
||||
*/
|
||||
public function websiteType(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<WebsiteExtra, $this>
|
||||
*/
|
||||
public function websiteExtras(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteExtra::class, 'website_type_extra_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests\AdminApp;
|
||||
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateWebsiteExtrasRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return app(WebsiteExtraService::class)->requestRules(
|
||||
$this->user()?->tenant?->website_type_code
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
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\Tenant\Services\WebsiteExtraService;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -37,7 +40,7 @@ class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
$logoRule = ['required', new ImageOrBase64Rule];
|
||||
|
||||
return [
|
||||
return array_merge([
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
@@ -60,9 +63,6 @@ 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})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'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',
|
||||
@@ -72,16 +72,15 @@ class StoreTenantRequest extends FormRequest
|
||||
],
|
||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||
'hero_config' => ['nullable', 'array'],
|
||||
'hero_config.title_html' => ['nullable', 'string'],
|
||||
'hero_config.description_html' => ['nullable', 'string'],
|
||||
'hero_config.button_text' => ['nullable', 'string'],
|
||||
'hero_config.button_href' => ['nullable', 'string'],
|
||||
'event_config' => ['nullable', 'array'],
|
||||
'event_config.title' => ['nullable', 'string'],
|
||||
'event_config.location' => ['nullable', 'string'],
|
||||
'event_config.dates' => ['nullable', 'array'],
|
||||
'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'],
|
||||
'website_type_code' => [
|
||||
'required_with:extras',
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::exists('website_type', 'codigo'),
|
||||
],
|
||||
], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
@@ -71,9 +73,6 @@ 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})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'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',
|
||||
@@ -83,16 +82,9 @@ class UpdateTenantRequest extends FormRequest
|
||||
],
|
||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||
'hero_config' => ['nullable', 'array'],
|
||||
'hero_config.title_html' => ['nullable', 'string'],
|
||||
'hero_config.description_html' => ['nullable', 'string'],
|
||||
'hero_config.button_text' => ['nullable', 'string'],
|
||||
'hero_config.button_href' => ['nullable', 'string'],
|
||||
'event_config' => ['nullable', 'array'],
|
||||
'event_config.title' => ['nullable', 'string'],
|
||||
'event_config.location' => ['nullable', 'string'],
|
||||
'event_config.dates' => ['nullable', 'array'],
|
||||
'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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Tenant
|
||||
*/
|
||||
class WebsiteExtrasResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$websiteExtras = $this->websiteExtras->keyBy(
|
||||
fn ($extra) => $extra->websiteTypeExtra->nombre
|
||||
);
|
||||
|
||||
return [
|
||||
'website_type' => $this->websiteType ? [
|
||||
'codigo' => $this->websiteType->codigo,
|
||||
'nombre' => $this->websiteType->nombre,
|
||||
] : null,
|
||||
'definitions' => $this->websiteType?->extras
|
||||
->mapWithKeys(fn ($definition) => [
|
||||
$definition->nombre => [
|
||||
'descripcion' => $definition->descripcion,
|
||||
'is_required' => $definition->is_required,
|
||||
'request_rules' => $definition->config_schema['request_rules'] ?? [],
|
||||
],
|
||||
]) ?? [],
|
||||
'extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $this->formatConfig(
|
||||
$extra->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->key
|
||||
),
|
||||
]),
|
||||
'resolved_extras' => $websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $this->formatConfig(
|
||||
$extra->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
|
||||
),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatConfig(mixed $value, callable $formatAttachment): mixed
|
||||
{
|
||||
if ($value instanceof Attachment) {
|
||||
return $formatAttachment($value);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
namespace App\Domains\Tenant\Resources;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* @mixin Tenant
|
||||
@@ -16,12 +20,6 @@ class TenantResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$heroConfig = $this->hero_config;
|
||||
if (is_array($heroConfig)) {
|
||||
$heroConfig['background_image'] = $this->heroBgImage?->getTemporaryUrl(1440);
|
||||
unset($heroConfig['background_image_id']);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'codigo' => $this->codigo,
|
||||
@@ -33,17 +31,28 @@ class TenantResource extends JsonResource
|
||||
'success_color' => $this->success_color,
|
||||
'header_bg_color' => $this->header_bg_color,
|
||||
'footer_bg_color' => $this->footer_bg_color,
|
||||
'website_type_code' => $this->website_type_code,
|
||||
'website_type' => $this->whenLoaded(
|
||||
'websiteType',
|
||||
fn () => $this->websiteType ? [
|
||||
'codigo' => $this->websiteType->codigo,
|
||||
'nombre' => $this->websiteType->nombre,
|
||||
] : null
|
||||
),
|
||||
'extras' => $this->whenLoaded(
|
||||
'websiteExtras',
|
||||
fn () => $this->websiteExtras->mapWithKeys(fn ($extra) => [
|
||||
$extra->websiteTypeExtra->nombre => $this->formatExtraConfig(
|
||||
$extra->resolvedConfig()
|
||||
),
|
||||
])
|
||||
),
|
||||
// 1 day
|
||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
||||
'hero_config' => $heroConfig,
|
||||
'event_config' => $this->event_config,
|
||||
'main_carousel_images' => $this->whenLoaded(
|
||||
'mainCarouselImages',
|
||||
fn () => $this->mainCarouselImages
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values()
|
||||
),
|
||||
'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,
|
||||
'social_media' => $this->whenLoaded(
|
||||
'socialMedia',
|
||||
fn () => $this->socialMedia
|
||||
@@ -55,7 +64,101 @@ class TenantResource extends JsonResource
|
||||
])
|
||||
->values()
|
||||
),
|
||||
'menues' => $this->whenLoaded('menues'),
|
||||
'menues' => $this->whenLoaded(
|
||||
'menues',
|
||||
fn () => $this->menuTree($this->menues)
|
||||
),
|
||||
'categories' => $this->whenLoaded(
|
||||
'categories',
|
||||
fn () => $this->categoryTree($this->categories)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatExtraConfig(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof Attachment) {
|
||||
return $value->getTemporaryUrl(1440);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->formatExtraConfig($item);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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();
|
||||
}
|
||||
}
|
||||
|
||||
177
app/Domains/Tenant/Services/TenantInformationService.php
Normal file
177
app/Domains/Tenant/Services/TenantInformationService.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Services;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteExtra;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class TenantInformationService
|
||||
{
|
||||
private const DEFAULT_RELATIONS = [
|
||||
'headerLogo',
|
||||
'footerLogo',
|
||||
'socialMedia',
|
||||
'websiteType',
|
||||
'websiteExtras.websiteTypeExtra',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<int|string, mixed> $relations
|
||||
*/
|
||||
public function load(Tenant $tenant, array $relations = []): Tenant
|
||||
{
|
||||
$this->loadMany(new EloquentCollection([$tenant]), $relations);
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param EloquentCollection<int, Tenant> $tenants
|
||||
* @param array<int|string, mixed> $relations
|
||||
* @return EloquentCollection<int, Tenant>
|
||||
*/
|
||||
public function loadMany(
|
||||
EloquentCollection $tenants,
|
||||
array $relations = []
|
||||
): EloquentCollection {
|
||||
$tenants->loadMissing([
|
||||
...self::DEFAULT_RELATIONS,
|
||||
...$relations,
|
||||
]);
|
||||
|
||||
$websiteExtras = $tenants
|
||||
->flatMap(fn (Tenant $tenant): Collection => $tenant->websiteExtras);
|
||||
|
||||
$this->resolveWebsiteExtraAttachments($websiteExtras);
|
||||
|
||||
return $tenants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace attachment IDs with their models in each extra's resolved config.
|
||||
*
|
||||
* @param Collection<int, WebsiteExtra> $websiteExtras
|
||||
*/
|
||||
private function resolveWebsiteExtraAttachments(Collection $websiteExtras): void
|
||||
{
|
||||
$attachmentIds = $websiteExtras
|
||||
->flatMap(function (WebsiteExtra $extra): array {
|
||||
$ids = [];
|
||||
|
||||
foreach ($this->attachmentTransformPaths($extra) as $path) {
|
||||
$ids = [
|
||||
...$ids,
|
||||
...$this->valuesAtPath($extra->config, $this->pathSegments($path)),
|
||||
];
|
||||
}
|
||||
|
||||
return $ids;
|
||||
})
|
||||
->filter(fn (mixed $id): bool => is_int($id) || ctype_digit((string) $id))
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$attachments = Attachment::query()
|
||||
->whereIn('id', $attachmentIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($websiteExtras as $extra) {
|
||||
$config = $extra->config;
|
||||
|
||||
foreach ($this->attachmentTransformPaths($extra) as $path) {
|
||||
$config = $this->replaceAtPath(
|
||||
$config,
|
||||
$this->pathSegments($path),
|
||||
fn (mixed $attachmentId): ?Attachment => $attachments->get((int) $attachmentId)
|
||||
);
|
||||
}
|
||||
|
||||
$extra->setResolvedConfig($config);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function attachmentTransformPaths(WebsiteExtra $extra): array
|
||||
{
|
||||
return collect($extra->websiteTypeExtra->config_schema['transforms'] ?? [])
|
||||
->filter(
|
||||
fn (mixed $transform): bool => is_array($transform)
|
||||
&& ($transform['handler'] ?? null) === 'attachment'
|
||||
)
|
||||
->keys()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function pathSegments(string $path): array
|
||||
{
|
||||
$path = ltrim($path, '$');
|
||||
$path = ltrim($path, '.');
|
||||
|
||||
return $path === '' ? [] : explode('.', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function valuesAtPath(mixed $value, array $segments): array
|
||||
{
|
||||
if ($segments === []) {
|
||||
return [$value];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$segment = array_shift($segments);
|
||||
|
||||
if ($segment === '*') {
|
||||
return collect($value)
|
||||
->flatMap(fn (mixed $item): array => $this->valuesAtPath($item, $segments))
|
||||
->all();
|
||||
}
|
||||
|
||||
if (! array_key_exists($segment, $value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->valuesAtPath($value[$segment], $segments);
|
||||
}
|
||||
|
||||
private function replaceAtPath(mixed $value, array $segments, callable $replace): mixed
|
||||
{
|
||||
if ($segments === []) {
|
||||
return $value === null ? null : $replace($value);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$segment = array_shift($segments);
|
||||
|
||||
if ($segment === '*') {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->replaceAtPath($item, $segments, $replace);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (array_key_exists($segment, $value)) {
|
||||
$value[$segment] = $this->replaceAtPath($value[$segment], $segments, $replace);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
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\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TenantService
|
||||
{
|
||||
public function __construct(protected AttachmentService $attachmentService) {}
|
||||
public function __construct(
|
||||
protected AttachmentService $attachmentService,
|
||||
protected WebsiteExtraService $websiteExtraService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new tenant and store its logos.
|
||||
@@ -24,16 +25,14 @@ class TenantService
|
||||
return DB::transaction(function () use ($data): Tenant {
|
||||
$headerLogo = $data['header_logo'] ?? null;
|
||||
$footerLogo = $data['footer_logo'] ?? null;
|
||||
$heroBgImage = $data['hero_bg_image'] ?? null;
|
||||
$mainCarouselImages = $data['main_carousel_images'] ?? [];
|
||||
$socialMedia = $data['social_media'] ?? [];
|
||||
$extras = $data['extras'] ?? [];
|
||||
|
||||
unset(
|
||||
$data['header_logo'],
|
||||
$data['footer_logo'],
|
||||
$data['hero_bg_image'],
|
||||
$data['main_carousel_images'],
|
||||
$data['social_media']
|
||||
$data['social_media'],
|
||||
$data['extras'],
|
||||
);
|
||||
|
||||
$headerAttachmentId = null;
|
||||
@@ -61,22 +60,10 @@ class TenantService
|
||||
$data['header_logo_id'] = $headerAttachmentId;
|
||||
$data['footer_logo_id'] = $footerAttachmentId;
|
||||
|
||||
if ($heroBgImage) {
|
||||
$attachment = Str::isUuid($heroBgImage)
|
||||
? Attachment::query()->where('key', $heroBgImage)->first()
|
||||
: $this->attachmentService->store($heroBgImage, 'tenants');
|
||||
|
||||
if ($attachment) {
|
||||
$heroConfig = $data['hero_config'] ?? [];
|
||||
$heroConfig['background_image_id'] = $attachment->id;
|
||||
$data['hero_config'] = $heroConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->create($data);
|
||||
$this->syncMainCarouselImages($tenant, $mainCarouselImages);
|
||||
$this->syncSocialMedia($tenant, $socialMedia);
|
||||
$this->websiteExtraService->createForTenant($tenant, $extras);
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
@@ -92,25 +79,17 @@ class TenantService
|
||||
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||
$hasHeaderLogoKey = array_key_exists('header_logo', $data);
|
||||
$hasFooterLogoKey = array_key_exists('footer_logo', $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;
|
||||
$footerLogo = $data['footer_logo'] ?? 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'],
|
||||
$data['main_carousel_images'],
|
||||
$data['social_media']
|
||||
);
|
||||
|
||||
$oldHeroBgId = $tenant->hero_bg_image_id;
|
||||
|
||||
$tenant->fill($data);
|
||||
|
||||
if ($hasHeaderLogoKey) {
|
||||
@@ -145,34 +124,8 @@ class TenantService
|
||||
}
|
||||
}
|
||||
|
||||
$currentHeroConfig = $tenant->hero_config ?? [];
|
||||
if ($hasHeroBgImageKey) {
|
||||
if ($heroBgImage) {
|
||||
$attachment = Str::isUuid($heroBgImage)
|
||||
? Attachment::query()->where('key', $heroBgImage)->first()
|
||||
: $this->attachmentService->store($heroBgImage, 'tenants');
|
||||
|
||||
if ($attachment) {
|
||||
$currentHeroConfig['background_image_id'] = $attachment->id;
|
||||
} else {
|
||||
unset($currentHeroConfig['background_image_id']);
|
||||
}
|
||||
} else {
|
||||
unset($currentHeroConfig['background_image_id']);
|
||||
}
|
||||
} else {
|
||||
if ($oldHeroBgId) {
|
||||
$currentHeroConfig['background_image_id'] = $oldHeroBgId;
|
||||
}
|
||||
}
|
||||
$tenant->hero_config = empty($currentHeroConfig) ? null : $currentHeroConfig;
|
||||
|
||||
$tenant->save();
|
||||
|
||||
if ($hasMainCarouselImagesKey) {
|
||||
$this->syncMainCarouselImages($tenant, $mainCarouselImages);
|
||||
}
|
||||
|
||||
if ($hasSocialMediaKey) {
|
||||
$this->syncSocialMedia($tenant, $socialMedia);
|
||||
}
|
||||
@@ -181,21 +134,6 @@ class TenantService
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
@@ -213,26 +151,4 @@ class TenantService
|
||||
$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}" => [
|
||||
'La imagen indicada no existe o no es un attachment de tipo imagen.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
return $this->attachmentService->store($image, 'tenants/main-carousel');
|
||||
}
|
||||
}
|
||||
|
||||
321
app/Domains/Tenant/Services/WebsiteExtraService.php
Normal file
321
app/Domains/Tenant/Services/WebsiteExtraService.php
Normal file
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
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\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use App\Domains\Tenant\Models\WebsiteTypeExtra;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class WebsiteExtraService
|
||||
{
|
||||
public function __construct(protected AttachmentService $attachmentService) {}
|
||||
|
||||
/**
|
||||
* Build the request rules declared by the selected website type.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function requestRules(?string $websiteTypeCode): array
|
||||
{
|
||||
if (! is_string($websiteTypeCode) || $websiteTypeCode === '') {
|
||||
return [
|
||||
'extras' => ['prohibited'],
|
||||
];
|
||||
}
|
||||
|
||||
$definitions = $this->definitionsFor($websiteTypeCode);
|
||||
$allowedNames = $definitions->pluck('nombre')->all();
|
||||
$hasRequiredExtras = $definitions->contains(
|
||||
fn (WebsiteTypeExtra $definition): bool => $definition->is_required
|
||||
);
|
||||
|
||||
$rules = [
|
||||
'extras' => [
|
||||
$hasRequiredExtras ? 'required' : 'sometimes',
|
||||
'array',
|
||||
function (string $attribute, mixed $value, \Closure $fail) use ($allowedNames): void {
|
||||
if (! is_array($value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$unknownNames = array_diff(array_keys($value), $allowedNames);
|
||||
|
||||
if ($unknownNames !== []) {
|
||||
$fail(
|
||||
'The '.$attribute.' field contains extras not supported by the website type: '
|
||||
.implode(', ', $unknownNames).'.'
|
||||
);
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
$schemaRules = $definition->config_schema['request_rules'] ?? [];
|
||||
$rootRules = $this->compileRules($schemaRules['$'] ?? []);
|
||||
$rootRules = array_values(array_filter(
|
||||
$rootRules,
|
||||
fn (mixed $rule): bool => ! in_array($rule, ['required', 'sometimes'], true)
|
||||
));
|
||||
array_unshift($rootRules, $definition->is_required ? 'required' : 'sometimes');
|
||||
|
||||
$rules["extras.{$definition->nombre}"] = $rootRules;
|
||||
|
||||
foreach ($schemaRules as $path => $pathRules) {
|
||||
if ($path === '$') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rules[$this->requestAttribute($definition->nombre, $path)] = $this->compileRules($pathRules);
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform and persist each extra selected for a tenant.
|
||||
*
|
||||
* @param array<string, mixed> $extras
|
||||
*/
|
||||
public function createForTenant(Tenant $tenant, array $extras): void
|
||||
{
|
||||
if ($extras === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definitions = $this->definitionsFor((string) $tenant->website_type_code)
|
||||
->keyBy('nombre');
|
||||
|
||||
foreach ($extras as $name => $config) {
|
||||
/** @var WebsiteTypeExtra|null $definition */
|
||||
$definition = $definitions->get($name);
|
||||
|
||||
if (! $definition) {
|
||||
throw ValidationException::withMessages([
|
||||
'extras' => ["The extra {$name} is not supported by the selected website type."],
|
||||
]);
|
||||
}
|
||||
|
||||
$transformedConfig = $this->applyTransforms($tenant, $definition, $config);
|
||||
$this->validateDatabaseConfig($definition, $transformedConfig);
|
||||
|
||||
$tenant->websiteExtras()->create([
|
||||
'website_type_extra_id' => $definition->id,
|
||||
'config' => $transformedConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
$tenant->unsetRelation('websiteExtras');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace all configured extras for a tenant.
|
||||
*
|
||||
* @param array<string, mixed> $extras
|
||||
*/
|
||||
public function replaceForTenant(Tenant $tenant, array $extras): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $extras): void {
|
||||
$tenant->websiteExtras()->delete();
|
||||
$this->createForTenant($tenant, $extras);
|
||||
});
|
||||
|
||||
$tenant->unsetRelation('websiteExtras');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, WebsiteTypeExtra>
|
||||
*/
|
||||
private function definitionsFor(string $websiteTypeCode): Collection
|
||||
{
|
||||
$websiteType = WebsiteType::query()
|
||||
->where('codigo', $websiteTypeCode)
|
||||
->with('extras')
|
||||
->first();
|
||||
|
||||
return $websiteType?->extras ?? collect();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array<int, mixed> $rules
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private function compileRules(string|array $rules): array
|
||||
{
|
||||
$compiled = is_string($rules) ? explode('|', $rules) : $rules;
|
||||
|
||||
return array_map(
|
||||
fn (mixed $rule): mixed => $rule === 'image_or_base64'
|
||||
? new ImageOrBase64Rule
|
||||
: $rule,
|
||||
$compiled
|
||||
);
|
||||
}
|
||||
|
||||
private function requestAttribute(string $extraName, string $path): string
|
||||
{
|
||||
if ($path === '$') {
|
||||
return "extras.{$extraName}";
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '$.')) {
|
||||
$path = substr($path, 2);
|
||||
}
|
||||
|
||||
return "extras.{$extraName}.{$path}";
|
||||
}
|
||||
|
||||
private function applyTransforms(
|
||||
Tenant $tenant,
|
||||
WebsiteTypeExtra $definition,
|
||||
mixed $config
|
||||
): mixed {
|
||||
foreach ($definition->config_schema['transforms'] ?? [] as $path => $transform) {
|
||||
$segments = $this->pathSegments($path);
|
||||
$config = $this->transformAtPath(
|
||||
$config,
|
||||
$segments,
|
||||
fn (mixed $value): mixed => $this->transformValue(
|
||||
$tenant,
|
||||
$definition,
|
||||
$path,
|
||||
$value,
|
||||
$transform
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function pathSegments(string $path): array
|
||||
{
|
||||
$path = ltrim($path, '$');
|
||||
$path = ltrim($path, '.');
|
||||
|
||||
return $path === '' ? [] : explode('.', $path);
|
||||
}
|
||||
|
||||
private function transformAtPath(mixed $value, array $segments, callable $transform): mixed
|
||||
{
|
||||
if ($segments === []) {
|
||||
return $transform($value);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$segment = array_shift($segments);
|
||||
|
||||
if ($segment === '*') {
|
||||
foreach ($value as $key => $item) {
|
||||
$value[$key] = $this->transformAtPath($item, $segments, $transform);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (array_key_exists($segment, $value)) {
|
||||
$value[$segment] = $this->transformAtPath($value[$segment], $segments, $transform);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $transform
|
||||
*/
|
||||
private function transformValue(
|
||||
Tenant $tenant,
|
||||
WebsiteTypeExtra $definition,
|
||||
string $path,
|
||||
mixed $value,
|
||||
array $transform
|
||||
): mixed {
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (($transform['handler'] ?? null) !== 'attachment') {
|
||||
throw new InvalidArgumentException(
|
||||
"Unsupported transform handler for {$definition->nombre}: ".($transform['handler'] ?? 'null')
|
||||
);
|
||||
}
|
||||
|
||||
if (is_string($value) && Str::isUuid($value)) {
|
||||
$attachment = Attachment::query()->where('key', $value)->first();
|
||||
|
||||
if (! $attachment) {
|
||||
throw ValidationException::withMessages([
|
||||
$this->requestAttribute($definition->nombre, $path) => [
|
||||
'The selected attachment does not exist.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$attachment = $this->attachmentService->store(
|
||||
$value,
|
||||
"tenants/{$tenant->codigo}/extras/{$definition->nombre}"
|
||||
);
|
||||
}
|
||||
|
||||
$expectedType = $transform['attachment_type'] ?? null;
|
||||
|
||||
if (
|
||||
is_string($expectedType)
|
||||
&& $attachment->type instanceof AttachmentType
|
||||
&& $attachment->type->value !== $expectedType
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
$this->requestAttribute($definition->nombre, $path) => [
|
||||
"The attachment must be of type {$expectedType}.",
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attachment->id;
|
||||
}
|
||||
|
||||
private function validateDatabaseConfig(
|
||||
WebsiteTypeExtra $definition,
|
||||
mixed $config
|
||||
): void {
|
||||
$schemaRules = $definition->config_schema['database_rules'] ?? [];
|
||||
$rules = [];
|
||||
|
||||
foreach ($schemaRules as $path => $pathRules) {
|
||||
$attribute = $path === '$'
|
||||
? 'config'
|
||||
: 'config.'.ltrim(str_starts_with($path, '$.') ? substr($path, 2) : $path, '.');
|
||||
$rules[$attribute] = $this->compileRules($pathRules);
|
||||
}
|
||||
|
||||
$validator = Validator::make(['config' => $config], $rules);
|
||||
|
||||
if ($validator->fails()) {
|
||||
$messages = [];
|
||||
|
||||
foreach ($validator->errors()->toArray() as $attribute => $errors) {
|
||||
$suffix = $attribute === 'config' ? '' : substr($attribute, strlen('config'));
|
||||
$messages["extras.{$definition->nombre}{$suffix}"] = $errors;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages($messages);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
app/Domains/Tenant/routes/adminapp.php
Normal file
13
app/Domains/Tenant/routes/adminapp.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('website-extras', [WebsiteExtraController::class, 'show'])
|
||||
->name('adminapp.tenant.website-extras.show');
|
||||
Route::put('website-extras', [WebsiteExtraController::class, 'update'])
|
||||
->name('adminapp.tenant.website-extras.update');
|
||||
});
|
||||
@@ -8,3 +8,5 @@ Route::get('tenants/bootstrap/{dominio}', BootstrapTenantController::class)
|
||||
->where('dominio', '.*');
|
||||
|
||||
Route::apiResource('tenants', TenantController::class);
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
namespace App\Domains\Ticket\Controllers;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
|
||||
use App\Domains\Ticket\Resources\TicketResource;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct(private readonly TicketPdfService $ticketPdfService) {}
|
||||
|
||||
public function index(Request $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$tickets = Ticket::query()
|
||||
@@ -21,4 +27,21 @@ class TicketController extends Controller
|
||||
|
||||
return TicketResource::collection($tickets)->response();
|
||||
}
|
||||
|
||||
public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response
|
||||
{
|
||||
$ticketIds = $request->validated('ticket_ids');
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('user_id', $request->user()->getKey())
|
||||
->whereIn('id', $ticketIds)
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
if ($tickets->count() !== count($ticketIds)) {
|
||||
throw new TicketNotAvailableException(__('api.ticket.not_available'));
|
||||
}
|
||||
|
||||
return $this->ticketPdfService->download($tenant, $tickets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,31 +11,43 @@ class TicketGenerationException extends RuntimeException
|
||||
{
|
||||
public static function invalidQuantity(): self
|
||||
{
|
||||
return new self('La cantidad de tickets a generar debe ser mayor a cero.');
|
||||
return new self(__('api.ticket.positive_quantity'));
|
||||
}
|
||||
|
||||
public static function emptyBundle(CatalogItem $bundle): self
|
||||
{
|
||||
return new self("El bundle {$bundle->id} no tiene componentes.");
|
||||
return new self(__('api.ticket.empty_bundle', ['bundle' => $bundle->id]));
|
||||
}
|
||||
|
||||
public static function ticketsDisabled(CatalogItem $catalogItem): self
|
||||
{
|
||||
return new self("El producto {$catalogItem->id} no tiene tickets habilitados.");
|
||||
return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
|
||||
}
|
||||
|
||||
public static function maximumUseDateReached(CatalogItem $catalogItem): self
|
||||
{
|
||||
return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso.");
|
||||
return new self(__('api.ticket.expired', ['product' => $catalogItem->id]));
|
||||
}
|
||||
|
||||
public static function variantNotFound(
|
||||
CatalogItem $catalogItem,
|
||||
int $variantId,
|
||||
): self {
|
||||
return new self(
|
||||
__('api.ticket.invalid_variant', [
|
||||
'variant' => $variantId,
|
||||
'product' => $catalogItem->id,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public static function purchaseWithoutUser(Purchase $purchase): self
|
||||
{
|
||||
return new self("La compra {$purchase->id} no tiene un usuario asociado.");
|
||||
return new self(__('api.ticket.purchase_without_user', ['purchase' => $purchase->id]));
|
||||
}
|
||||
|
||||
public static function catalogItemNotFound(PurchaseItem $purchaseItem): self
|
||||
{
|
||||
return new self("No se encontró el producto de la línea de compra {$purchaseItem->id}.");
|
||||
return new self(__('api.ticket.product_not_found', ['purchase_item' => $purchaseItem->id]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TicketNotAvailableException extends NotFoundHttpException {}
|
||||
@@ -45,6 +45,7 @@ class GenerateTicketsForPaidPurchase
|
||||
$user,
|
||||
$purchaseItem->cantidad,
|
||||
$purchaseItem->source_variant_id,
|
||||
$purchase->getKey(),
|
||||
);
|
||||
|
||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Ticket\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'ticket',
|
||||
'name',
|
||||
'description',
|
||||
'source_purchase_id',
|
||||
'source_catalog_item_id',
|
||||
'source_variant_id',
|
||||
'starts_at',
|
||||
@@ -38,6 +40,7 @@ class Ticket extends Model
|
||||
return [
|
||||
'source_catalog_item_id' => 'integer',
|
||||
'source_variant_id' => 'integer',
|
||||
'source_purchase_id' => 'integer',
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
@@ -57,6 +60,12 @@ class Ticket extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function sourcePurchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
22
app/Domains/Ticket/Requests/DownloadTicketsPdfRequest.php
Normal file
22
app/Domains/Ticket/Requests/DownloadTicketsPdfRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticket\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class DownloadTicketsPdfRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, array<int, string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ticket_ids' => ['required', 'array', 'min:1', 'max:25'],
|
||||
'ticket_ids.*' => ['required', 'integer', 'distinct'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,9 @@ namespace App\Domains\Ticket\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -21,72 +21,127 @@ class TicketGeneratorService
|
||||
User $user,
|
||||
int $quantity = 1,
|
||||
?int $sourceVariantId = null,
|
||||
?int $sourcePurchaseId = null,
|
||||
): Collection {
|
||||
if ($quantity < 1) {
|
||||
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 {
|
||||
$catalogItems = $this->resolveCatalogItems($catalogItem, $quantity, $now);
|
||||
return $targets->map(function (array $target) use (
|
||||
$catalogItem,
|
||||
$sourceVariantId,
|
||||
$sourcePurchaseId,
|
||||
$user,
|
||||
): Ticket {
|
||||
$item = $target['catalog_item'];
|
||||
$selectedItem = $target['variant'] ?? $item;
|
||||
|
||||
return $catalogItems->map(fn (CatalogItem $item): Ticket => Ticket::query()->create([
|
||||
'tenant_code' => $item->tenant_code,
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'name' => $item->nombre,
|
||||
'description' => (string) ($item->descripcion ?? ''),
|
||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||
'source_variant_id' => $sourceVariantId,
|
||||
'starts_at' => $item->minimum_use_date,
|
||||
'expires_at' => $item->maximum_use_date,
|
||||
'used_at' => null,
|
||||
'user_id' => $user->getKey(),
|
||||
]));
|
||||
return Ticket::query()->create([
|
||||
'tenant_code' => $item->tenant_code,
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'name' => $item->nombre,
|
||||
'description' => (string) ($item->descripcion ?? ''),
|
||||
'source_purchase_id' => $sourcePurchaseId,
|
||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||
'source_variant_id' => $sourceVariantId,
|
||||
'starts_at' => $selectedItem->getMinimumUseDate(),
|
||||
'expires_at' => $selectedItem->getMaximumUseDate(),
|
||||
'used_at' => null,
|
||||
'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,
|
||||
int $quantity,
|
||||
CarbonInterface $now,
|
||||
?int $sourceVariantId,
|
||||
): Collection {
|
||||
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()) {
|
||||
throw TicketGenerationException::emptyBundle($catalogItem);
|
||||
}
|
||||
|
||||
return $catalogItem->bundleComponents
|
||||
->flatMap(function ($component) use ($quantity, $now): Collection {
|
||||
->flatMap(function ($component) use ($quantity): Collection {
|
||||
$componentItem = $component->catalogItem;
|
||||
$this->validateCatalogItem($componentItem, $now);
|
||||
$variant = $component->variant;
|
||||
$this->validateTarget($componentItem, $variant);
|
||||
|
||||
return Collection::times(
|
||||
$quantity * $component->quantity,
|
||||
fn (): CatalogItem => $componentItem,
|
||||
fn (): array => [
|
||||
'catalog_item' => $componentItem,
|
||||
'variant' => $variant,
|
||||
],
|
||||
);
|
||||
})
|
||||
->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) {
|
||||
throw TicketGenerationException::ticketsDisabled($catalogItem);
|
||||
}
|
||||
|
||||
if ($catalogItem->maximum_use_date?->lessThanOrEqualTo($now)) {
|
||||
throw TicketGenerationException::maximumUseDateReached($catalogItem);
|
||||
}
|
||||
// TODO: Reactivar esta validación cuando los pagos con productos vencidos
|
||||
// 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')
|
||||
->group(function (): void {
|
||||
Route::get('tickets', [TicketController::class, 'index']);
|
||||
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
|
||||
});
|
||||
|
||||
30
app/Http/Middleware/EnsureAdminAppTenant.php
Normal file
30
app/Http/Middleware/EnsureAdminAppTenant.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureAdminAppTenant
|
||||
{
|
||||
/**
|
||||
* Ensure the authenticated user is an AdminApp user bound to a tenant.
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (
|
||||
! $user
|
||||
|| $user->rol_codigo !== RoleCode::AdminApp->value
|
||||
|| ! $user->tenant_codigo
|
||||
) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Events\TicketsAvailable;
|
||||
use App\Domains\Notification\Events\UserRegistered;
|
||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -32,6 +37,26 @@ class AppServiceProvider extends ServiceProvider
|
||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::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) {
|
||||
/** @var Builder $this */
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Http\Middleware\EnsureAdminAppTenant;
|
||||
use App\Http\Middleware\SetApiLocale;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Auth\AuthenticationException;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -13,12 +22,92 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'adminapp.tenant' => EnsureAdminAppTenant::class,
|
||||
]);
|
||||
$middleware->encryptCookies(except: [
|
||||
'guest_token',
|
||||
]);
|
||||
$middleware->prepend(SetApiLocale::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*'),
|
||||
);
|
||||
$exceptions->render(function (AuthenticationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.unauthenticated',
|
||||
'message' => __('api.auth.unauthenticated'),
|
||||
], 401);
|
||||
});
|
||||
$exceptions->render(function (AccountLockedException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$retryAfter = $exception->retryAfterSeconds();
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.account_locked',
|
||||
'message' => __('api.auth.account_locked'),
|
||||
'retry_after' => $retryAfter,
|
||||
'locked_until' => $exception->lockedUntil->toIso8601String(),
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
]);
|
||||
});
|
||||
$exceptions->render(function (AuthorizationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.forbidden',
|
||||
'message' => __('api.errors.forbidden'),
|
||||
], 403);
|
||||
});
|
||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => __('api.errors.not_found'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (TicketNotAvailableException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'ticket.not_available',
|
||||
'message' => __('api.ticket.not_available'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (NotFoundHttpException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.not_found',
|
||||
'message' => __('api.errors.not_found'),
|
||||
], 404);
|
||||
});
|
||||
$exceptions->render(function (MethodNotAllowedHttpException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'code' => 'errors.method_not_allowed',
|
||||
'message' => __('api.errors.method_not_allowed'),
|
||||
], 405, $exception->getHeaders());
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"endroid/qr-code": "^6.1",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/socialite": "^5.29",
|
||||
@@ -45,7 +47,7 @@
|
||||
],
|
||||
"dev": [
|
||||
"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": [
|
||||
"@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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user