Compare commits
2 Commits
7b565e7fa7
...
8b26a2b63e
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b26a2b63e | |||
| c37b8894e4 |
@@ -3,6 +3,11 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
|
||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||
FRONTEND_URL=http://localhost:4200
|
||||
|
||||
APP_LOCALE=en
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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([
|
||||
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
], 202);
|
||||
}
|
||||
}
|
||||
@@ -5,22 +5,46 @@ 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([
|
||||
$guestTokenCookie = $request->cookie('guest_token');
|
||||
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||
? $guestTokenCookie
|
||||
: null;
|
||||
$this->guestCartMergeService->merge(
|
||||
$authentication['tenant_codigo'],
|
||||
$authentication['user'],
|
||||
$guestToken,
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'token' => $authentication['token'],
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($authentication['user']),
|
||||
]);
|
||||
|
||||
if ($guestToken !== null) {
|
||||
$response->withCookie(Cookie::forget('guest_token'));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,22 @@
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GuestCartMergeService $guestCartMergeService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
@@ -33,11 +39,27 @@ class LoginController extends Controller
|
||||
now()->addMinutes($expirationMinutes),
|
||||
)->plainTextToken;
|
||||
|
||||
return response()->json([
|
||||
$guestTokenCookie = $request->cookie('guest_token');
|
||||
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
|
||||
? $guestTokenCookie
|
||||
: null;
|
||||
$this->guestCartMergeService->merge(
|
||||
$credentials['tenant_codigo'],
|
||||
$user,
|
||||
$guestToken,
|
||||
);
|
||||
|
||||
$response = response()->json([
|
||||
'message' => 'Sesion iniciada correctamente.',
|
||||
'token' => $token,
|
||||
'token_type' => 'Bearer',
|
||||
'user' => UserResource::make($user),
|
||||
]);
|
||||
|
||||
if ($guestToken !== null) {
|
||||
$response->withCookie(Cookie::forget('guest_token'));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
40
app/Domains/Auth/Controllers/ResetPasswordController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?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' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Contraseña modificada correctamente.',
|
||||
'status' => ResetPasswordAttempt::STATUS_USED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?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' => 'El código ingresado es inválido.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Código validado correctamente.',
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
@@ -22,6 +23,12 @@ class User extends Authenticatable
|
||||
return UserFactory::new();
|
||||
}
|
||||
|
||||
/** @return HasMany<ResetPasswordAttempt, $this> */
|
||||
public function resetPasswordAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ResetPasswordAttempt::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,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}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,10 +80,10 @@ 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) {
|
||||
@@ -91,9 +92,16 @@ class GoogleAuthService
|
||||
]);
|
||||
}
|
||||
|
||||
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
|
||||
throw ValidationException::withMessages([
|
||||
'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.',
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => User::query()->findOrFail($authentication['user_id']),
|
||||
'token' => $authentication['token'],
|
||||
'tenant_codigo' => $tenantCodigo,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
177
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
177
app/Domains/Auth/Services/ResetPasswordAttemptService.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?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->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('/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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
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');
|
||||
$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' => 'The purchase is no longer available for payment.',
|
||||
]);
|
||||
}
|
||||
|
||||
$compra->refresh();
|
||||
|
||||
if ($method === 'transfer') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
@@ -146,6 +210,15 @@ class PurchaseController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -154,17 +227,4 @@ class PurchaseController extends Controller
|
||||
|
||||
return $purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
protected function loadCartCatalogEntries(Collection $items): void
|
||||
{
|
||||
$items->load([
|
||||
'catalogItem.attachments',
|
||||
'variant.attachments',
|
||||
'variant.catalogItem.attachments',
|
||||
'variant.definitions.itemAttribute.attribute',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use Illuminate\Support\Facades\DB;
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'expires_at',
|
||||
'total',
|
||||
'dni',
|
||||
'transfer_payer_dni',
|
||||
@@ -41,6 +42,8 @@ class Purchase extends Model
|
||||
|
||||
public const STATUS_REJECTED = 'rejected';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
protected $table = 'compras';
|
||||
|
||||
protected function casts(): array
|
||||
@@ -48,6 +51,7 @@ class Purchase extends Model
|
||||
return [
|
||||
'cart_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -111,23 +115,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,20 @@ class PurchaseResource extends JsonResource
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
[$items, $itemsSource] = $this->resolveItems();
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
|
||||
$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 +43,27 @@ 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),
|
||||
'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' => 'A cart or direct item is 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,7 +68,12 @@ class CheckoutService
|
||||
]);
|
||||
}
|
||||
|
||||
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_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
], true)) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
@@ -104,59 +86,515 @@ class CheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
public function confirmPurchase(Purchase $purchase): void
|
||||
/**
|
||||
* @param array<string, string> $customerData
|
||||
*/
|
||||
public function updateCustomerData(Purchase $purchase, array $customerData): Purchase
|
||||
{
|
||||
$snapshotPaths = [];
|
||||
return DB::transaction(function () use ($purchase, $customerData): 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 (
|
||||
$purchase->status !== Purchase::STATUS_CREATED
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => 'The purchase is no longer editable.',
|
||||
]);
|
||||
}
|
||||
|
||||
throw $throwable;
|
||||
$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' => 'The purchase is no longer 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' => 'The purchase item is no longer 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' => 'No hay suficiente stock disponible.',
|
||||
]);
|
||||
}
|
||||
|
||||
$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' => 'The purchase is no longer 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' => 'A cancelled, rejected or expired purchase cannot be confirmed.',
|
||||
]);
|
||||
}
|
||||
|
||||
$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' => 'The purchase has an inconsistent stock 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' => 'A paid purchase cannot be cancelled.',
|
||||
]);
|
||||
}
|
||||
|
||||
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' => 'The purchase has an inconsistent stock 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' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.",
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->catalogInventoryService->reserve($selection, $quantity);
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.cantidad' => 'No hay suficiente stock disponible.',
|
||||
]);
|
||||
}
|
||||
|
||||
$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' => 'The selected cart does not contain items.',
|
||||
]);
|
||||
}
|
||||
|
||||
$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.',
|
||||
]);
|
||||
@@ -170,10 +608,6 @@ class CheckoutService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CartItem> $cartItems
|
||||
* @return Collection<int, CartItem>
|
||||
*/
|
||||
protected function resolveCheckoutCart(Tenant $tenant, int $userId, int $cartId): Cart
|
||||
{
|
||||
/** @var Cart|null $cart */
|
||||
@@ -198,21 +632,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 +657,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' => 'Un bundle no admite una variante.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.catalog_item_id' => 'El bundle no tiene componentes.',
|
||||
]);
|
||||
}
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'direct_item.variant_id' => 'Debe seleccionar una variante para este item.',
|
||||
]);
|
||||
}
|
||||
|
||||
$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 +790,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,14 @@
|
||||
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}/cancel', [PurchaseController::class, 'cancel']);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
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;
|
||||
@@ -32,6 +34,7 @@ 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);
|
||||
|
||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||
/** @var Builder $this */
|
||||
|
||||
@@ -45,7 +45,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",
|
||||
|
||||
11
config/purchase.php
Normal file
11
config/purchase.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
||||
|
||||
'payment_expiration_minutes' => [
|
||||
'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15),
|
||||
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
|
||||
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('compra_items', function (Blueprint $table): void {
|
||||
$table->string('reservation_status')
|
||||
->default('active')
|
||||
->after('total');
|
||||
});
|
||||
|
||||
DB::table('compra_items')
|
||||
->whereIn('compra_id', DB::table('compras')->where('status', 'paid')->select('id'))
|
||||
->update(['reservation_status' => 'committed']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('compra_items', function (Blueprint $table): void {
|
||||
$table->dropColumn('reservation_status');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')
|
||||
->constrained()
|
||||
->cascadeOnUpdate()
|
||||
->cascadeOnDelete();
|
||||
$table->string('codigo');
|
||||
$table->string('status')->default('pending');
|
||||
|
||||
$table->index(['user_id', 'codigo', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('reset_password_attempts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('compras', function (Blueprint $table): void {
|
||||
$table->timestamp('expires_at')->nullable()->index()->after('payment_method');
|
||||
});
|
||||
|
||||
DB::table('compras')
|
||||
->whereIn('status', ['created', 'pending_payment'])
|
||||
->whereNull('expires_at')
|
||||
->update([
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('compras', function (Blueprint $table): void {
|
||||
$table->dropColumn('expires_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// MySQL may use one of the old composite unique indexes to support the
|
||||
// tenant foreign key. Give that foreign key its own index before the
|
||||
// unique indexes are removed.
|
||||
Schema::whenTableDoesntHaveIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_codigo_index',
|
||||
fn (Blueprint $table) => $table->index(
|
||||
'tenant_codigo',
|
||||
'carritos_tenant_codigo_index',
|
||||
),
|
||||
);
|
||||
|
||||
Schema::whenTableHasIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_codigo_user_id_unique',
|
||||
fn (Blueprint $table) => $table->dropUnique(
|
||||
'carritos_tenant_codigo_user_id_unique',
|
||||
),
|
||||
);
|
||||
Schema::whenTableHasIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_codigo_guest_token_unique',
|
||||
fn (Blueprint $table) => $table->dropUnique(
|
||||
'carritos_tenant_codigo_guest_token_unique',
|
||||
),
|
||||
);
|
||||
|
||||
Schema::whenTableDoesntHaveColumn('carritos', 'active_user_id', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('active_user_id')
|
||||
->virtualAs("CASE WHEN status = 'active' AND deleted_at IS NULL THEN user_id ELSE NULL END");
|
||||
});
|
||||
Schema::whenTableDoesntHaveColumn('carritos', 'active_guest_token', function (Blueprint $table): void {
|
||||
$table->string('active_guest_token')
|
||||
->virtualAs("CASE WHEN status = 'active' AND deleted_at IS NULL THEN guest_token ELSE NULL END");
|
||||
});
|
||||
|
||||
Schema::whenTableDoesntHaveIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_active_user_unique',
|
||||
fn (Blueprint $table) => $table->unique(
|
||||
['tenant_codigo', 'active_user_id'],
|
||||
'carritos_tenant_active_user_unique',
|
||||
),
|
||||
);
|
||||
Schema::whenTableDoesntHaveIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_active_guest_unique',
|
||||
fn (Blueprint $table) => $table->unique(
|
||||
['tenant_codigo', 'active_guest_token'],
|
||||
'carritos_tenant_active_guest_unique',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::whenTableHasIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_active_user_unique',
|
||||
fn (Blueprint $table) => $table->dropUnique(
|
||||
'carritos_tenant_active_user_unique',
|
||||
),
|
||||
);
|
||||
Schema::whenTableHasIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_active_guest_unique',
|
||||
fn (Blueprint $table) => $table->dropUnique(
|
||||
'carritos_tenant_active_guest_unique',
|
||||
),
|
||||
);
|
||||
|
||||
Schema::whenTableHasColumn(
|
||||
'carritos',
|
||||
'active_user_id',
|
||||
fn (Blueprint $table) => $table->dropColumn('active_user_id'),
|
||||
);
|
||||
Schema::whenTableHasColumn(
|
||||
'carritos',
|
||||
'active_guest_token',
|
||||
fn (Blueprint $table) => $table->dropColumn('active_guest_token'),
|
||||
);
|
||||
|
||||
Schema::whenTableDoesntHaveIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_codigo_user_id_unique',
|
||||
fn (Blueprint $table) => $table->unique(['tenant_codigo', 'user_id']),
|
||||
);
|
||||
Schema::whenTableDoesntHaveIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_codigo_guest_token_unique',
|
||||
fn (Blueprint $table) => $table->unique(['tenant_codigo', 'guest_token']),
|
||||
);
|
||||
Schema::whenTableHasIndex(
|
||||
'carritos',
|
||||
'carritos_tenant_codigo_index',
|
||||
fn (Blueprint $table) => $table->dropIndex('carritos_tenant_codigo_index'),
|
||||
);
|
||||
}
|
||||
};
|
||||
20
resources/views/mail/notifications/password-reset.blade.php
Normal file
20
resources/views/mail/notifications/password-reset.blade.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
|
||||
Recuperá tu contraseña
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
||||
la contraseña de tu cuenta.
|
||||
</p>
|
||||
|
||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||
|
||||
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $tenant->primary_color }}; border-radius: 8px; text-align: center;">
|
||||
<span style="color: {{ $tenant->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
||||
{{ $attempt->codigo }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px;">
|
||||
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
||||
</p>
|
||||
@@ -1,8 +1,21 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Artisan::command('purchases:expire', function (): void {
|
||||
$expiredCount = app(CheckoutService::class)
|
||||
->expireOverduePurchases();
|
||||
|
||||
$this->info("Expired purchases: {$expiredCount}");
|
||||
})->purpose('Release stock reservations from expired purchases');
|
||||
|
||||
Schedule::command('purchases:expire')
|
||||
->everyMinute()
|
||||
->withoutOverlapping();
|
||||
|
||||
123
tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php
Normal file
123
tests/Feature/Auth/CreateResetPasswordAttemptControllerTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CreateResetPasswordAttemptControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Event::fake([PasswordResetRequested::class]);
|
||||
|
||||
$header = Attachment::query()->create([
|
||||
'path' => 'test/reset-header.png',
|
||||
'filename' => 'header.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
$footer = Attachment::query()->create([
|
||||
'path' => 'test/reset-footer.png',
|
||||
'filename' => 'footer.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
$this->tenant = Tenant::query()->create([
|
||||
'codigo' => 'reset-tenant',
|
||||
'nombre' => 'Reset Tenant',
|
||||
'dominio' => 'reset.local',
|
||||
'primary_color' => '#112233',
|
||||
'secondary_color' => '#000000',
|
||||
'danger_color' => '#000000',
|
||||
'success_color' => '#000000',
|
||||
'header_bg_color' => '#000000',
|
||||
'footer_bg_color' => '#000000',
|
||||
'header_logo_id' => $header->id,
|
||||
'footer_logo_id' => $footer->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_creates_a_pending_attempt_for_a_registered_email(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/password/reset-attempts', [
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'email' => ' ADA@EXAMPLE.COM ',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertAccepted()
|
||||
->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
|
||||
|
||||
$attempt = ResetPasswordAttempt::query()->sole();
|
||||
|
||||
$this->assertTrue($attempt->user->is($user));
|
||||
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||
Event::assertDispatched(
|
||||
PasswordResetRequested::class,
|
||||
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
||||
&& $event->tenantCode === $this->tenant->codigo,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_expires_previous_pending_and_validated_attempts(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
$pendingAttempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '1234',
|
||||
]);
|
||||
$validatedAttempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '5678',
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/password/reset-attempts', [
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'email' => 'ada@example.com',
|
||||
])->assertAccepted();
|
||||
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $pendingAttempt->fresh()->status);
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $validatedAttempt->fresh()->status);
|
||||
$this->assertSame(1, $user->resetPasswordAttempts()
|
||||
->where('status', ResetPasswordAttempt::STATUS_PENDING)
|
||||
->count());
|
||||
}
|
||||
|
||||
public function test_unknown_email_gets_the_same_response_without_creating_an_attempt(): void
|
||||
{
|
||||
$response = $this->postJson('/api/password/reset-attempts', [
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'email' => 'unknown@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertAccepted()
|
||||
->assertJsonPath('status', ResetPasswordAttempt::STATUS_PENDING);
|
||||
$this->assertDatabaseCount('reset_password_attempts', 0);
|
||||
Event::assertNotDispatched(PasswordResetRequested::class);
|
||||
}
|
||||
|
||||
public function test_it_validates_the_email(): void
|
||||
{
|
||||
$this->postJson('/api/password/reset-attempts', [
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'email' => 'invalid-email',
|
||||
])->assertUnprocessable()->assertJsonValidationErrors('email');
|
||||
}
|
||||
}
|
||||
114
tests/Feature/Auth/GoogleTokenExchangeControllerTest.php
Normal file
114
tests/Feature/Auth/GoogleTokenExchangeControllerTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class GoogleTokenExchangeControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_google_exchange(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$user = User::factory()->create();
|
||||
$catalogItem = $this->createCatalogItem($tenant);
|
||||
$userCart = Cart::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$guestCart = Cart::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'guest_token' => 'google-guest-token',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$guestCart->items()->create([
|
||||
'catalog_item_id' => $catalogItem->id,
|
||||
'variant_id' => null,
|
||||
'cantidad' => 1,
|
||||
]);
|
||||
|
||||
$exchangeCode = (string) Str::uuid();
|
||||
Cache::put("google-oauth-exchange:{$exchangeCode}", [
|
||||
'user_id' => $user->id,
|
||||
'token' => 'google-access-token',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
], now()->addMinutes(5));
|
||||
|
||||
$this->withCookie('guest_token', 'google-guest-token')
|
||||
->postJson('/api/auth/google/exchange', [
|
||||
'oauth_code' => $exchangeCode,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('token', 'google-access-token')
|
||||
->assertCookieExpired('guest_token');
|
||||
|
||||
$this->assertSoftDeleted('carritos', [
|
||||
'id' => $userCart->id,
|
||||
'status' => 'converted',
|
||||
]);
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $guestCart->id,
|
||||
'user_id' => $user->id,
|
||||
'guest_token' => null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCatalogItem(Tenant $tenant): CatalogItem
|
||||
{
|
||||
$inventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
|
||||
return CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'inventory_id' => $inventory->id,
|
||||
'slug' => 'google-login-item-'.$tenant->codigo,
|
||||
'nombre' => 'Google login item',
|
||||
'precio' => '10.00',
|
||||
'inventory_policy' => InventoryPolicy::Tracked,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
$headerLogo = $this->createAttachment("{$code}-header");
|
||||
$footerLogo = $this->createAttachment("{$code}-footer");
|
||||
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.local",
|
||||
'primary_color' => '#000000',
|
||||
'secondary_color' => '#000000',
|
||||
'danger_color' => '#000000',
|
||||
'success_color' => '#000000',
|
||||
'header_bg_color' => '#000000',
|
||||
'footer_bg_color' => '#000000',
|
||||
'header_logo_id' => $headerLogo->id,
|
||||
'footer_logo_id' => $footerLogo->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAttachment(string $name): Attachment
|
||||
{
|
||||
return Attachment::query()->create([
|
||||
'path' => "test/{$name}.png",
|
||||
'filename' => "{$name}.png",
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
@@ -13,6 +20,7 @@ class LoginControllerTest extends TestCase
|
||||
|
||||
public function test_it_logs_in_a_user_and_returns_a_bearer_token(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$user = User::query()->create([
|
||||
'nombre_apellido' => 'Grace Hopper',
|
||||
'email' => 'grace@example.com',
|
||||
@@ -22,6 +30,7 @@ class LoginControllerTest extends TestCase
|
||||
$response = $this->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$response
|
||||
@@ -83,6 +92,7 @@ class LoginControllerTest extends TestCase
|
||||
|
||||
public function test_it_returns_a_validation_error_for_invalid_credentials(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
User::query()->create([
|
||||
'nombre_apellido' => 'Grace Hopper',
|
||||
'email' => 'grace@example.com',
|
||||
@@ -92,6 +102,7 @@ class LoginControllerTest extends TestCase
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
}
|
||||
|
||||
@@ -103,6 +114,96 @@ class LoginControllerTest extends TestCase
|
||||
])->assertUnprocessable()->assertJsonValidationErrors([
|
||||
'email',
|
||||
'password',
|
||||
'tenant_codigo',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'grace@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$catalogItem = $this->createCatalogItem($tenant);
|
||||
$userCart = Cart::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
$guestCart = Cart::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'guest_token' => 'guest-cart-token',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$guestCart->items()->create([
|
||||
'catalog_item_id' => $catalogItem->id,
|
||||
'variant_id' => null,
|
||||
'cantidad' => 2,
|
||||
]);
|
||||
|
||||
$this->withCookie('guest_token', 'guest-cart-token')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])
|
||||
->assertOk()
|
||||
->assertCookieExpired('guest_token');
|
||||
|
||||
$this->assertSoftDeleted('carritos', [
|
||||
'id' => $userCart->id,
|
||||
'status' => 'converted',
|
||||
]);
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $guestCart->id,
|
||||
'user_id' => $user->id,
|
||||
'guest_token' => null,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCatalogItem(Tenant $tenant): CatalogItem
|
||||
{
|
||||
$inventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
|
||||
return CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'inventory_id' => $inventory->id,
|
||||
'slug' => 'login-item-'.$tenant->codigo,
|
||||
'nombre' => 'Login item',
|
||||
'precio' => '10.00',
|
||||
'inventory_policy' => InventoryPolicy::Tracked,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
$headerLogo = $this->createAttachment("{$code}-header");
|
||||
$footerLogo = $this->createAttachment("{$code}-footer");
|
||||
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.local",
|
||||
'primary_color' => '#000000',
|
||||
'secondary_color' => '#000000',
|
||||
'danger_color' => '#000000',
|
||||
'success_color' => '#000000',
|
||||
'header_bg_color' => '#000000',
|
||||
'footer_bg_color' => '#000000',
|
||||
'header_logo_id' => $headerLogo->id,
|
||||
'footer_logo_id' => $footerLogo->id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAttachment(string $name): Attachment
|
||||
{
|
||||
return Attachment::query()->create([
|
||||
'path' => "test/{$name}.png",
|
||||
'filename' => "{$name}.png",
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
54
tests/Feature/Auth/ResetPasswordAttemptTest.php
Normal file
54
tests/Feature/Auth/ResetPasswordAttemptTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResetPasswordAttemptTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_table_has_the_expected_columns(): void
|
||||
{
|
||||
$this->assertEqualsCanonicalizing([
|
||||
'id',
|
||||
'user_id',
|
||||
'codigo',
|
||||
'status',
|
||||
], Schema::getColumnListing('reset_password_attempts'));
|
||||
}
|
||||
|
||||
public function test_attempt_belongs_to_a_user_and_defaults_to_pending(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '123456',
|
||||
]);
|
||||
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||
$this->assertTrue($attempt->user->is($user));
|
||||
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
||||
$this->assertFalse($attempt->usesTimestamps());
|
||||
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
||||
}
|
||||
|
||||
public function test_attempts_are_deleted_with_their_user(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$attempt = ResetPasswordAttempt::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'codigo' => '123456',
|
||||
]);
|
||||
|
||||
$user->delete();
|
||||
|
||||
$this->assertDatabaseMissing('reset_password_attempts', [
|
||||
'id' => $attempt->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
104
tests/Feature/Auth/ResetPasswordControllerTest.php
Normal file
104
tests/Feature/Auth/ResetPasswordControllerTest.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResetPasswordControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_resets_the_password_consumes_the_attempt_and_revokes_tokens(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'OldSecret!123',
|
||||
]);
|
||||
$user->createToken('existing-session');
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/password/reset', [
|
||||
'email' => ' ADA@EXAMPLE.COM ',
|
||||
'codigo' => '0123',
|
||||
'password' => 'NewSecret!456',
|
||||
'password_confirmation' => 'NewSecret!456',
|
||||
])->assertOk()
|
||||
->assertJsonPath('status', ResetPasswordAttempt::STATUS_USED);
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertTrue(Hash::check('NewSecret!456', $user->password));
|
||||
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
||||
{
|
||||
$user = User::factory()->create([
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'OldSecret!123',
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
ResetPasswordAttempt::STATUS_PENDING,
|
||||
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||
ResetPasswordAttempt::STATUS_USED,
|
||||
] as $status) {
|
||||
$user->resetPasswordAttempts()->create([
|
||||
'codigo' => '1234',
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->postJson('/api/password/reset', [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => '1234',
|
||||
'password' => 'NewSecret!456',
|
||||
'password_confirmation' => 'NewSecret!456',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors('codigo');
|
||||
|
||||
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
|
||||
}
|
||||
|
||||
public function test_a_used_attempt_cannot_be_reused(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '1234',
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
$payload = [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => '1234',
|
||||
'password' => 'NewSecret!456',
|
||||
'password_confirmation' => 'NewSecret!456',
|
||||
];
|
||||
|
||||
$this->postJson('/api/password/reset', $payload)->assertOk();
|
||||
$this->postJson('/api/password/reset', $payload)
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('codigo');
|
||||
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||
}
|
||||
|
||||
public function test_it_validates_password_confirmation_and_strength(): void
|
||||
{
|
||||
$this->postJson('/api/password/reset', [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => '1234',
|
||||
'password' => 'weak',
|
||||
'password_confirmation' => 'different',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors('password');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ValidateResetPasswordAttemptControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_validates_a_pending_four_digit_code(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/password/reset-attempts/validate', [
|
||||
'email' => ' ADA@EXAMPLE.COM ',
|
||||
'codigo' => '0123',
|
||||
])->assertOk()
|
||||
->assertJsonPath('status', ResetPasswordAttempt::STATUS_VALIDATED);
|
||||
|
||||
$this->assertSame(
|
||||
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
$attempt->fresh()->status,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_incorrect_code_without_consuming_the_attempt(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '1234',
|
||||
]);
|
||||
|
||||
$this->postJson('/api/password/reset-attempts/validate', [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => '9999',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors('codigo');
|
||||
|
||||
$this->assertSame(
|
||||
ResetPasswordAttempt::STATUS_PENDING,
|
||||
$attempt->fresh()->status,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
|
||||
foreach ([
|
||||
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
] as $status) {
|
||||
$user->resetPasswordAttempts()->create([
|
||||
'codigo' => '1234',
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->postJson('/api/password/reset-attempts/validate', [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => '1234',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors('codigo');
|
||||
}
|
||||
|
||||
public function test_it_requires_exactly_four_numeric_digits(): void
|
||||
{
|
||||
foreach (['123', '12345', '12a4'] as $invalidCode) {
|
||||
$this->postJson('/api/password/reset-attempts/validate', [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => $invalidCode,
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors('codigo');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,8 +193,12 @@ class TelepagosWebhookTest extends TestCase
|
||||
'sold_units' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('compra_items', [
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $newerPurchase->id,
|
||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
||||
'source_variant_id' => $variant->id,
|
||||
'cantidad' => 2,
|
||||
'reservation_status' => 'active',
|
||||
]);
|
||||
|
||||
$this->assertSoftDeleted('carritos', [
|
||||
|
||||
@@ -80,6 +80,29 @@ class NotificationMailServiceTest extends TestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_sends_a_branded_password_reset_email(): void
|
||||
{
|
||||
$attempt = $this->user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
]);
|
||||
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$attempt->id,
|
||||
$this->tenant->codigo,
|
||||
);
|
||||
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||
$mail->assertTo('ada@example.com');
|
||||
$mail->assertHasSubject('Código para recuperar tu contraseña - Mail Tenant');
|
||||
$rendered = $mail->render();
|
||||
|
||||
return str_contains($rendered, '0123')
|
||||
&& str_contains($rendered, 'Ada Lovelace')
|
||||
&& str_contains($rendered, 'Mail Tenant')
|
||||
&& str_contains($rendered, '#112233');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
||||
{
|
||||
$purchase = Purchase::query()->create([
|
||||
|
||||
@@ -86,12 +86,10 @@ class PurchaseCatalogItemTest extends TestCase
|
||||
]);
|
||||
$purchaseItem = $purchase->items()->with('imageAttachment')->firstOrFail();
|
||||
$this->assertNotNull($purchaseItem->imageAttachment);
|
||||
$this->assertNotSame($productImage->id, $purchaseItem->image_attachment_id);
|
||||
$this->assertSame($productImage->id, $purchaseItem->image_attachment_id);
|
||||
$this->assertSame('original-image', Storage::disk('s3')->get($purchaseItem->imageAttachment->path));
|
||||
$this->assertStringStartsWith(
|
||||
"purchase/{$purchase->id}/",
|
||||
$purchaseItem->imageAttachment->path,
|
||||
);
|
||||
$this->assertSame($productImage->path, $purchaseItem->imageAttachment->path);
|
||||
$this->assertDatabaseCount('attachments', 3);
|
||||
$catalogItem->update([
|
||||
'nombre' => 'Changed catalog item',
|
||||
'descripcion' => 'Changed description',
|
||||
|
||||
@@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase
|
||||
Queue::fake();
|
||||
}
|
||||
|
||||
public function test_it_creates_a_purchase_from_cart_id_without_persisting_items_yet(): void
|
||||
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
@@ -74,24 +74,20 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras', [
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cartId,
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$response->assertJsonPath('data.cart_id', $cartId);
|
||||
$response->assertJsonPath('data.dni', '987654321');
|
||||
$response->assertJsonPath('data.telefono', '+54 9 341 555-4321');
|
||||
$response->assertJsonPath('data.nombre_apellido', 'Juan Perez');
|
||||
$response->assertJsonPath('data.email', 'juan.perez@example.com');
|
||||
$response->assertJsonPath('data.dni', null);
|
||||
$response->assertJsonPath('data.telefono', null);
|
||||
$response->assertJsonPath('data.nombre_apellido', null);
|
||||
$response->assertJsonPath('data.email', null);
|
||||
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
||||
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
||||
$response->assertJsonPath('data.items_source', null);
|
||||
$response->assertJsonPath('data.items', []);
|
||||
$response->assertJsonPath('data.items_source', 'purchase');
|
||||
$response->assertJsonCount(1, 'data.items');
|
||||
$response->assertJsonPath('data.subtotal', '100.00');
|
||||
$response->assertJsonPath('data.total', '100.00');
|
||||
|
||||
@@ -102,20 +98,25 @@ class StorePurchaseTest extends TestCase
|
||||
'cart_id' => $cartId,
|
||||
'tenant_codigo' => 'sonder',
|
||||
'user_id' => $user->id,
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
'dni' => null,
|
||||
'telefono' => null,
|
||||
'nombre_apellido' => null,
|
||||
'email' => null,
|
||||
'status' => Purchase::STATUS_CREATED,
|
||||
'total' => 100,
|
||||
]);
|
||||
$this->assertDatabaseMissing('compra_items', [
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $purchaseId,
|
||||
'source_catalog_item_id' => $catalogItem->id,
|
||||
'source_variant_id' => $variant->id,
|
||||
'cantidad' => 2,
|
||||
'reservation_status' => 'active',
|
||||
]);
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $cartId,
|
||||
'status' => 'active',
|
||||
'user_id' => $user->id,
|
||||
'status' => 'checkout',
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('carrito_items', [
|
||||
'cart_id' => $cartId,
|
||||
@@ -130,6 +131,281 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_creates_a_direct_purchase_without_creating_or_changing_a_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'direct_item' => [
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 3,
|
||||
],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.cart_id', null)
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonPath('data.items.0.quantity', 3)
|
||||
->assertJsonPath('data.total', '150.00');
|
||||
|
||||
$this->assertDatabaseCount('carritos', 0);
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $response->json('data.id'),
|
||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
||||
'source_variant_id' => $variant->id,
|
||||
'cantidad' => 3,
|
||||
'reservation_status' => 'active',
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'real_stock' => 10,
|
||||
'reserved_stock' => 3,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$response->json('data.id')}/cancel")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
||||
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $response->json('data.id'),
|
||||
'reservation_status' => 'released',
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'real_stock' => 10,
|
||||
'reserved_stock' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_restores_the_source_cart_when_checkout_is_cancelled(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3);
|
||||
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $purchase->cart_id,
|
||||
'status' => 'checkout',
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
||||
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $purchase->cart_id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('carrito_items', [
|
||||
'cart_id' => $purchase->cart_id,
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 3,
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'reserved_stock' => 3,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_merges_the_checkout_cart_when_the_user_created_another_active_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$activeCartId = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/cart/items', [
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 1,
|
||||
])
|
||||
->assertOk()
|
||||
->json('data.id');
|
||||
|
||||
$this->assertNotSame($purchase->cart_id, $activeCartId);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
|
||||
->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $activeCartId,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('carrito_items', [
|
||||
'cart_id' => $activeCartId,
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 3,
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'reserved_stock' => 3,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_creates_purchase_items_before_checkout_and_updates_customer_data(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
|
||||
$purchaseResponse = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'direct_item' => [
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 2,
|
||||
],
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.dni', null)
|
||||
->assertJsonPath('data.telefono', null);
|
||||
|
||||
$purchaseId = $purchaseResponse->json('data.id');
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->patchJson("/api/tenants/sonder/compras/{$purchaseId}/customer-data", [
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $purchaseId)
|
||||
->assertJsonPath('data.dni', '987654321')
|
||||
->assertJsonPath('data.telefono', '+54 9 341 555-4321')
|
||||
->assertJsonPath('data.nombre_apellido', 'Juan Perez')
|
||||
->assertJsonPath('data.email', 'juan.perez@example.com')
|
||||
->assertJsonCount(1, 'data.items');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchaseId,
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
$itemId = $purchase->items->firstOrFail()->id;
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
||||
'quantity' => 4,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.quantity', 4)
|
||||
->assertJsonPath('data.items.0.line_total', '200.00')
|
||||
->assertJsonPath('data.subtotal', '200.00')
|
||||
->assertJsonPath('data.total', '200.00');
|
||||
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'reserved_stock' => 4,
|
||||
]);
|
||||
$this->assertDatabaseHas('carrito_items', [
|
||||
'cart_id' => $purchase->cart_id,
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 4,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
||||
'quantity' => 1,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.quantity', 1)
|
||||
->assertJsonPath('data.total', '50.00');
|
||||
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'reserved_stock' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_reopens_a_pending_purchase_before_editing_items(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'payment_method' => 'qr',
|
||||
]);
|
||||
$purchase->telepagosQr()->create([
|
||||
'qr_order_id' => 'stale-order',
|
||||
'qr_code' => 'stale-qr',
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/edit-items")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
||||
->assertJsonPath('data.payment_method', null);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_CREATED,
|
||||
'payment_method' => null,
|
||||
]);
|
||||
$this->assertDatabaseMissing('telepagos_qr', [
|
||||
'compra_id' => $purchase->id,
|
||||
'qr_order_id' => 'stale-order',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_start_checkout_rejects_customer_data(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'direct_item' => [
|
||||
'catalog_item_id' => $variant->catalog_item_id,
|
||||
'variant_id' => $variant->id,
|
||||
'cantidad' => 1,
|
||||
],
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors([
|
||||
'dni',
|
||||
'telefono',
|
||||
'nombre_apellido',
|
||||
'email',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_moves_a_created_purchase_to_pending_payment_when_finalized(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
@@ -148,12 +424,8 @@ class StorePurchaseTest extends TestCase
|
||||
->json('data.id');
|
||||
|
||||
$purchaseId = $this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras', [
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cartId,
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertCreated()
|
||||
->json('data.id');
|
||||
@@ -176,7 +448,52 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_cart_items_for_created_purchase(): void
|
||||
public function test_it_expires_an_abandoned_purchase_and_restores_its_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3);
|
||||
|
||||
$this->assertNotNull($purchase->expires_at);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'reserved_stock' => 3,
|
||||
]);
|
||||
|
||||
$this->travel(31)->minutes();
|
||||
|
||||
$this->artisan('purchases:expire')
|
||||
->expectsOutput('Expired purchases: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_EXPIRED,
|
||||
]);
|
||||
$this->assertDatabaseHas('compra_items', [
|
||||
'compra_id' => $purchase->id,
|
||||
'reservation_status' => 'released',
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'real_stock' => 10,
|
||||
'reserved_stock' => 3,
|
||||
'sold_units' => 0,
|
||||
]);
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $purchase->cart_id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
|
||||
$this->artisan('purchases:expire')
|
||||
->expectsOutput('Expired purchases: 0')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_purchase_items_for_created_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
@@ -189,22 +506,22 @@ class StorePurchaseTest extends TestCase
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
||||
->assertJsonPath('data.items_source', 'cart')
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||
->assertJsonPath('data.items.0.line_total', '100.00')
|
||||
->assertJsonPath('data.items.0.product.id', $variant->catalogItem->id)
|
||||
->assertJsonPath('data.items.0.product.nombre', $variant->catalogItem->nombre)
|
||||
->assertJsonPath('data.items.0.product.slug', $variant->catalogItem->slug)
|
||||
->assertJsonPath('data.items.0.product.imagen', null)
|
||||
->assertJsonPath('data.items.0.variant.id', $variant->id)
|
||||
->assertJsonPath('data.items.0.variant.attributes', [])
|
||||
->assertJsonPath('data.items.0.source_catalog_item_id', $variant->catalogItem->id)
|
||||
->assertJsonPath('data.items.0.source_variant_id', $variant->id)
|
||||
->assertJsonPath('data.items.0.item_details.nombre', $variant->catalogItem->nombre)
|
||||
->assertJsonPath('data.items.0.item_details.slug', $variant->catalogItem->slug)
|
||||
->assertJsonPath('data.items.0.item_details.imagen', null)
|
||||
->assertJsonPath('data.items.0.item_details.attributes', [])
|
||||
->assertJsonPath('data.subtotal', '100.00')
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void
|
||||
public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
@@ -223,13 +540,13 @@ class StorePurchaseTest extends TestCase
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||
->assertJsonPath('data.items_source', 'cart')
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||
->assertJsonPath('data.items.0.line_total', '100.00')
|
||||
->assertJsonPath('data.items.0.product.imagen', null)
|
||||
->assertJsonPath('data.items.0.variant.attributes', [])
|
||||
->assertJsonPath('data.items.0.item_details.imagen', null)
|
||||
->assertJsonPath('data.items.0.item_details.attributes', [])
|
||||
->assertJsonPath('data.subtotal', '100.00')
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
@@ -280,7 +597,7 @@ class StorePurchaseTest extends TestCase
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_detail_prefers_purchase_items_when_both_sources_exist(): void
|
||||
public function test_purchase_detail_does_not_require_its_source_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create([
|
||||
@@ -289,30 +606,17 @@ class StorePurchaseTest extends TestCase
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$purchase->items()->create([
|
||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
||||
'source_variant_id' => $variant->id,
|
||||
'nombre' => $variant->catalogItem->nombre,
|
||||
'descripcion' => $variant->catalogItem->descripcion,
|
||||
'slug' => $variant->catalogItem->slug,
|
||||
'item_nombre' => $variant->getName(),
|
||||
'variant_attributes' => [],
|
||||
'cantidad' => 1,
|
||||
'precio_unitario' => '50.00',
|
||||
'discount_total' => null,
|
||||
'tax_total' => null,
|
||||
'total' => '50.00',
|
||||
]);
|
||||
$purchase->update(['cart_id' => null]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items_source', 'purchase')
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.quantity', 1)
|
||||
->assertJsonPath('data.items.0.line_total', '50.00')
|
||||
->assertJsonPath('data.subtotal', '50.00')
|
||||
->assertJsonPath('data.total', '50.00');
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.line_total', '100.00')
|
||||
->assertJsonPath('data.subtotal', '100.00')
|
||||
->assertJsonPath('data.total', '100.00');
|
||||
}
|
||||
|
||||
public function test_purchase_index_returns_empty_items_without_loaded_relations(): void
|
||||
@@ -350,12 +654,8 @@ class StorePurchaseTest extends TestCase
|
||||
->json('data.id');
|
||||
|
||||
$this->actingAs($attacker, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras', [
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cartId,
|
||||
'dni' => '12345678',
|
||||
'telefono' => '+54 9 341 555-1111',
|
||||
'nombre_apellido' => 'Intruso',
|
||||
'email' => 'intruso@example.com',
|
||||
])
|
||||
->assertNotFound();
|
||||
}
|
||||
@@ -377,12 +677,8 @@ class StorePurchaseTest extends TestCase
|
||||
->json('data.id');
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras', [
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cartId,
|
||||
'dni' => '12345678',
|
||||
'telefono' => '+54 9 341 555-1111',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertNotFound();
|
||||
}
|
||||
@@ -398,12 +694,8 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras', [
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cart->id,
|
||||
'dni' => '12345678',
|
||||
'telefono' => '+54 9 341 555-1111',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['cart_id']);
|
||||
@@ -420,12 +712,8 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson('/api/tenants/sonder/compras', [
|
||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||
'cart_id' => $cart->id,
|
||||
'dni' => '12345678',
|
||||
'telefono' => '+54 9 341 555-1111',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['cart_id']);
|
||||
@@ -510,10 +798,6 @@ class StorePurchaseTest extends TestCase
|
||||
|
||||
return app(CheckoutService::class)->startCheckout($tenant, $user->id, [
|
||||
'cart_id' => $cart->id,
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
79
tests/Unit/Auth/ResetPasswordAttemptServiceTest.php
Normal file
79
tests/Unit/Auth/ResetPasswordAttemptServiceTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Auth;
|
||||
|
||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ResetPasswordAttemptServiceTest extends TestCase
|
||||
{
|
||||
public function test_it_logs_and_rethrows_unexpected_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('Database unavailable.');
|
||||
|
||||
DB::shouldReceive('transaction')
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with(
|
||||
'Failed to create password reset attempt.',
|
||||
\Mockery::on(fn (array $context): bool => $context['email_fingerprint'] === 'b5fc85e55755'
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
(new ResetPasswordAttemptService)->createForEmail('ada@example.com', 'tenant-test');
|
||||
}
|
||||
|
||||
public function test_code_validation_logs_and_rethrows_unexpected_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('Database unavailable.');
|
||||
|
||||
DB::shouldReceive('transaction')
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with(
|
||||
'Failed to validate password reset code.',
|
||||
\Mockery::on(fn (array $context): bool => $context['email_fingerprint'] === 'b5fc85e55755'
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
(new ResetPasswordAttemptService)->validateCode('ada@example.com', '1234');
|
||||
}
|
||||
|
||||
public function test_password_reset_logs_and_rethrows_unexpected_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('Database unavailable.');
|
||||
|
||||
DB::shouldReceive('transaction')
|
||||
->once()
|
||||
->andThrow($exception);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with(
|
||||
'Failed to reset user password.',
|
||||
\Mockery::on(fn (array $context): bool => $context['email_fingerprint'] === 'b5fc85e55755'
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
(new ResetPasswordAttemptService)->resetPassword(
|
||||
'ada@example.com',
|
||||
'1234',
|
||||
'NewSecret!456',
|
||||
);
|
||||
}
|
||||
}
|
||||
39
tests/Unit/Notification/SendPasswordResetEmailTest.php
Normal file
39
tests/Unit/Notification/SendPasswordResetEmailTest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notification;
|
||||
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendPasswordResetEmailTest extends TestCase
|
||||
{
|
||||
public function test_it_logs_and_rethrows_mail_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('SMTP unavailable.');
|
||||
$mailService = \Mockery::mock(NotificationMailService::class);
|
||||
$mailService->shouldReceive('sendPasswordResetCode')
|
||||
->once()
|
||||
->with(10, 'tenant-test')
|
||||
->andThrow($exception);
|
||||
$this->app->instance(NotificationMailService::class, $mailService);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with(
|
||||
'Failed to send password reset email.',
|
||||
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
|
||||
&& $context['tenant_code'] === 'tenant-test'
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
(new SendPasswordResetEmail)->handle(
|
||||
new PasswordResetRequested(10, 'tenant-test'),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user