Compare commits
11 Commits
ff9d7728e8
...
homo
| Author | SHA1 | Date | |
|---|---|---|---|
| d02b0c5551 | |||
| ee911171be | |||
| 7c9ebc6d4d | |||
| 45553dc514 | |||
| 1c870d9cfa | |||
| 8ab94ec61f | |||
| 4eacff86bc | |||
| 5c457db1c9 | |||
| 8dc80411d7 | |||
| e16d91f3f9 | |||
| 89f6dab0c4 |
@@ -25,6 +25,11 @@ GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
|
||||
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
AUTH_MAX_LOGIN_ATTEMPTS=5
|
||||
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||
AUTH_LOGIN_LOCK_MINUTES=15
|
||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
|
||||
@@ -2,35 +2,31 @@
|
||||
|
||||
namespace App\Domains\Auth\Controllers;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Auth\Requests\LoginUserRequest;
|
||||
use App\Domains\Auth\Resources\UserResource;
|
||||
use App\Domains\Auth\Services\PasswordLoginService;
|
||||
use App\Domains\Cart\Services\GuestCartMergeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Cookie;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GuestCartMergeService $guestCartMergeService,
|
||||
private readonly PasswordLoginService $passwordLoginService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(LoginUserRequest $request): JsonResponse
|
||||
{
|
||||
$credentials = $request->validated();
|
||||
$user = User::query()->where('email', $credentials['email'])->first();
|
||||
|
||||
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('api.auth.invalid_credentials'),
|
||||
]);
|
||||
}
|
||||
$user = $this->passwordLoginService->authenticate(
|
||||
$credentials['email'],
|
||||
$credentials['password'],
|
||||
$credentials['tenant_codigo'],
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
);
|
||||
|
||||
$expirationMinutes = (int) config('sanctum.expiration');
|
||||
$token = $user->createToken(
|
||||
|
||||
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
20
app/Domains/Auth/Exceptions/AccountLockedException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Exceptions;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use RuntimeException;
|
||||
|
||||
class AccountLockedException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly CarbonImmutable $lockedUntil,
|
||||
) {
|
||||
parent::__construct('The account is temporarily locked.');
|
||||
}
|
||||
|
||||
public function retryAfterSeconds(): int
|
||||
{
|
||||
return max(1, (int) now()->diffInSeconds($this->lockedUntil, false));
|
||||
}
|
||||
}
|
||||
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
40
app/Domains/Auth/Models/LoginAttempt.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'user_id',
|
||||
'email_fingerprint',
|
||||
'tenant_codigo',
|
||||
'outcome',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
])]
|
||||
class LoginAttempt extends Model
|
||||
{
|
||||
public const OUTCOME_SUCCESS = 'success';
|
||||
|
||||
public const OUTCOME_INVALID_CREDENTIALS = 'invalid_credentials';
|
||||
|
||||
public const OUTCOME_ACCOUNT_LOCKED = 'account_locked';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'codigo', 'status'])]
|
||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||
#[Hidden(['codigo'])]
|
||||
class ResetPasswordAttempt extends Model
|
||||
{
|
||||
|
||||
@@ -29,6 +29,12 @@ class User extends Authenticatable
|
||||
return $this->hasMany(ResetPasswordAttempt::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<LoginAttempt, $this> */
|
||||
public function loginAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(LoginAttempt::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
@@ -37,6 +43,9 @@ class User extends Authenticatable
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'failed_login_attempts' => 'integer',
|
||||
'last_failed_login_at' => 'datetime',
|
||||
'locked_until' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LoginUserRequest extends FormRequest
|
||||
{
|
||||
@@ -11,6 +12,17 @@ class LoginUserRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$email = $this->input('email');
|
||||
|
||||
if (is_string($email)) {
|
||||
$this->merge([
|
||||
'email' => Str::lower(trim($email)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
191
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
191
app/Domains/Auth/Services/PasswordLoginService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PasswordLoginService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AccountLockedException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function authenticate(
|
||||
string $email,
|
||||
string $password,
|
||||
string $tenantCode,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
): User {
|
||||
$normalizedEmail = mb_strtolower(trim($email));
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
/** @var array{outcome: string, user: User|null, locked_until: CarbonImmutable|null} $result */
|
||||
$result = DB::transaction(function () use (
|
||||
$normalizedEmail,
|
||||
$password,
|
||||
$tenantCode,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
$now,
|
||||
): array {
|
||||
$user = User::query()
|
||||
->where('email', $normalizedEmail)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($user?->locked_until?->isFuture()) {
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
'user' => $user,
|
||||
'locked_until' => CarbonImmutable::instance($user->locked_until),
|
||||
];
|
||||
}
|
||||
|
||||
if ($user !== null && $user->locked_until !== null) {
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 0,
|
||||
'last_failed_login_at' => null,
|
||||
'locked_until' => null,
|
||||
])->save();
|
||||
}
|
||||
|
||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||
if ($user !== null) {
|
||||
$this->registerFailure($user, $now, $tenantCode);
|
||||
}
|
||||
|
||||
$outcome = $user?->locked_until?->isFuture()
|
||||
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
|
||||
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
$outcome,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => $outcome,
|
||||
'user' => $user,
|
||||
'locked_until' => $user?->locked_until === null
|
||||
? null
|
||||
: CarbonImmutable::instance($user->locked_until),
|
||||
];
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 0,
|
||||
'last_failed_login_at' => null,
|
||||
'locked_until' => null,
|
||||
])->save();
|
||||
|
||||
$this->recordAttempt(
|
||||
$user,
|
||||
$normalizedEmail,
|
||||
$tenantCode,
|
||||
LoginAttempt::OUTCOME_SUCCESS,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
);
|
||||
|
||||
return [
|
||||
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||
'user' => $user,
|
||||
'locked_until' => null,
|
||||
];
|
||||
});
|
||||
|
||||
if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) {
|
||||
throw new AccountLockedException($result['locked_until']);
|
||||
}
|
||||
|
||||
if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('api.auth.invalid_credentials'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $result['user'];
|
||||
}
|
||||
|
||||
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
|
||||
{
|
||||
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
||||
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
||||
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
|
||||
|
||||
$withinAttemptWindow = $user->last_failed_login_at !== null
|
||||
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
|
||||
$attempts = $withinAttemptWindow
|
||||
? $user->failed_login_attempts + 1
|
||||
: 1;
|
||||
|
||||
$previousAttempts = $user->failed_login_attempts;
|
||||
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => $attempts,
|
||||
'last_failed_login_at' => $now,
|
||||
'locked_until' => $attempts >= $maxAttempts
|
||||
? $now->addMinutes($lockMinutes)
|
||||
: null,
|
||||
])->save();
|
||||
|
||||
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
|
||||
try {
|
||||
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to trigger reset password on account lock', [
|
||||
'user_id' => $user->id,
|
||||
'exception' => $e
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function recordAttempt(
|
||||
?User $user,
|
||||
string $normalizedEmail,
|
||||
string $tenantCode,
|
||||
string $outcome,
|
||||
?string $ipAddress,
|
||||
?string $userAgent,
|
||||
): void {
|
||||
LoginAttempt::query()->create([
|
||||
'user_id' => $user?->getKey(),
|
||||
'email_fingerprint' => hash_hmac(
|
||||
'sha256',
|
||||
$normalizedEmail,
|
||||
(string) config('app.key'),
|
||||
),
|
||||
'tenant_codigo' => $tenantCode,
|
||||
'outcome' => $outcome,
|
||||
'ip_address' => $ipAddress,
|
||||
'user_agent' => $userAgent === null
|
||||
? null
|
||||
: mb_substr($userAgent, 0, 1024),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@ use Throwable;
|
||||
|
||||
class ResetPasswordAttemptService
|
||||
{
|
||||
public function createForEmail(string $email, string $tenantCode): void
|
||||
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||
{
|
||||
$emailFingerprint = $this->emailFingerprint($email);
|
||||
|
||||
try {
|
||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
|
||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->lockForUpdate()
|
||||
@@ -39,6 +39,7 @@ class ResetPasswordAttemptService
|
||||
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => $this->generateCode(),
|
||||
'reason' => $reason,
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
@@ -138,6 +139,9 @@ class ResetPasswordAttemptService
|
||||
}
|
||||
|
||||
$user->password = $password;
|
||||
$user->failed_login_attempts = 0;
|
||||
$user->last_failed_login_at = null;
|
||||
$user->locked_until = null;
|
||||
$user->save();
|
||||
$user->tokens()->delete();
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('/register', RegisterController::class);
|
||||
Route::post('/login', LoginController::class);
|
||||
Route::post('/login', LoginController::class)->middleware('throttle:login');
|
||||
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||
|
||||
@@ -4,9 +4,11 @@ namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\FeaturedItem;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||
@@ -57,6 +59,32 @@ class CatalogController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function category(
|
||||
CategoryPageRequest $request,
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
CatalogService $catalogService,
|
||||
): AnonymousResourceCollection {
|
||||
abort_unless($category->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
return CatalogSearchItemResource::collection(
|
||||
$catalogService->categoryItems(
|
||||
$tenant,
|
||||
$category,
|
||||
$tenant->search_items_per_page,
|
||||
(int) $request->validated('page', 1),
|
||||
)
|
||||
)->additional([
|
||||
'category' => [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'categoria_id' => $category->categoria_id,
|
||||
],
|
||||
'layout' => $tenant->search_product_layout->value,
|
||||
'group_layout' => $tenant->search_group_layout->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function featuredGroupItems(
|
||||
FeaturedGroupPageRequest $request,
|
||||
Tenant $tenant,
|
||||
|
||||
@@ -79,20 +79,7 @@ class Variant extends Model
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
$name = $this->catalogItem->nombre;
|
||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
||||
$definitions = $this->definitions
|
||||
->map(function (VariantDefinition $definition): ?string {
|
||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
||||
|
||||
return $attributeName
|
||||
? "{$attributeName}: {$definition->value}"
|
||||
: $definition->value;
|
||||
})
|
||||
->filter()
|
||||
->implode(', ');
|
||||
|
||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
||||
return $this->catalogItem->nombre;
|
||||
}
|
||||
|
||||
public function getMinimumUseDate(): ?CarbonInterface
|
||||
|
||||
21
app/Domains/Catalog/Requests/CategoryPageRequest.php
Normal file
21
app/Domains/Catalog/Requests/CategoryPageRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CategoryPageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
@@ -210,6 +211,29 @@ class CatalogService
|
||||
]));
|
||||
}
|
||||
|
||||
/** @return LengthAwarePaginator<CatalogItem> */
|
||||
public function categoryItems(
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
->orderBy('nombre')
|
||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||
}
|
||||
|
||||
public function delete(CatalogItem $catalogItem): void
|
||||
{
|
||||
DB::transaction(function () use ($catalogItem): void {
|
||||
|
||||
@@ -9,6 +9,8 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
->name('catalog.featured-groups.items.index');
|
||||
Route::get('catalog-items', [CatalogController::class, 'search'])
|
||||
->name('catalog-items.index');
|
||||
Route::get('categories/{category}', [CatalogController::class, 'category'])
|
||||
->name('categories.show');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TelepagosWebhookService
|
||||
@@ -19,16 +20,13 @@ class TelepagosWebhookService
|
||||
/**
|
||||
* Handle the Telepagos webhook notification.
|
||||
*
|
||||
* @param string $tenantCodigo
|
||||
* @param string $cashinId
|
||||
* @return void
|
||||
* @throws Exception
|
||||
*/
|
||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
||||
{
|
||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
||||
|
||||
$telepagosService = new TelepagosIntegrationService();
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
$telepagosService->forTenant($tenant->codigo);
|
||||
|
||||
try {
|
||||
@@ -48,26 +46,36 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $cuit) {
|
||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->where('transfer_payer_dni', $dni)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('total', $amount)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||
if (! $qrOrderId) {
|
||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,6 +83,7 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $telepagosQr) {
|
||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,11 +91,16 @@ class TelepagosWebhookService
|
||||
|
||||
if (! $compra) {
|
||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($compra->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
if (! in_array($compra->status, [
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
], true)) {
|
||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,10 +108,12 @@ class TelepagosWebhookService
|
||||
|
||||
if ($amount !== $totalAmount) {
|
||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
||||
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +130,7 @@ class TelepagosWebhookService
|
||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||
];
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($compra, $paymentData) {
|
||||
DB::transaction(function () use ($compra, $paymentData) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$this->checkoutService->confirmPurchase($compra);
|
||||
$compra->markAsPaid();
|
||||
@@ -122,7 +138,7 @@ class TelepagosWebhookService
|
||||
|
||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
||||
} catch (Exception $e) {
|
||||
Log::error("Telepagos webhook error: " . $e->getMessage());
|
||||
Log::error('Telepagos webhook error: '.$e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ class PurchaseController extends Controller
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
$compra->loadMissing('items');
|
||||
$compra->loadMissing('items')->loadCount('tickets');
|
||||
$compra->items->load('imageAttachment');
|
||||
|
||||
return PurchaseResource::make($compra);
|
||||
@@ -223,6 +223,19 @@ class PurchaseController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function submitForReview(
|
||||
Request $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
CheckoutService $checkoutService,
|
||||
): PurchaseResource {
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$checkoutService->submitForReview($compra),
|
||||
);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||
{
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -36,6 +37,8 @@ class Purchase extends Model
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
|
||||
public const STATUS_IN_REVIEW = 'in_review';
|
||||
|
||||
public const STATUS_PAID = 'paid';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
@@ -88,6 +91,14 @@ class Purchase extends Model
|
||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Ticket, $this>
|
||||
*/
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasOne<TelepagosQr, $this>
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,9 @@ class PurchaseResource extends JsonResource
|
||||
$items = $this->resource->relationLoaded('items')
|
||||
? $this->resource->getRelation('items')
|
||||
: collect();
|
||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||
? (int) $this->resource->getAttribute('tickets_count')
|
||||
: null;
|
||||
|
||||
$subtotal = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
@@ -51,6 +54,8 @@ class PurchaseResource extends JsonResource
|
||||
'email' => $this->email,
|
||||
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
|
||||
@@ -70,6 +70,7 @@ class CheckoutService
|
||||
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_PAID,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
@@ -86,6 +87,39 @@ class CheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
public function submitForReview(Purchase $purchase): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($purchase): Purchase {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if (in_array($purchase->status, [
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_PAID,
|
||||
], true)) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $customerData
|
||||
*/
|
||||
@@ -98,7 +132,10 @@ class CheckoutService
|
||||
->findOrFail($purchase->getKey());
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_CREATED
|
||||
! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
|
||||
@@ -12,5 +12,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,14 @@ class BootstrapTenantController extends Controller
|
||||
|
||||
return TenantResource::make(
|
||||
Tenant::query()
|
||||
->with(['headerLogo', 'footerLogo', 'mainCarouselImages', 'menues', 'socialMedia'])
|
||||
->with([
|
||||
'headerLogo',
|
||||
'footerLogo',
|
||||
'mainCarouselImages',
|
||||
'menues',
|
||||
'socialMedia',
|
||||
'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||
])
|
||||
->where('dominio', $dominio)
|
||||
->firstOrFail()
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
@@ -109,6 +110,14 @@ class Tenant extends Model
|
||||
return $this->hasMany(CatalogItem::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
public function categories(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SocialMedia, $this>
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Tenant\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -64,9 +65,43 @@ class TenantResource extends JsonResource
|
||||
'menues',
|
||||
fn () => $this->menuTree($this->menues)
|
||||
),
|
||||
'categories' => $this->whenLoaded(
|
||||
'categories',
|
||||
fn () => $this->categoryTree($this->categories)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Category> $categories
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function categoryTree(Collection $categories): Collection
|
||||
{
|
||||
$categoryIds = $categories->pluck('id')->flip();
|
||||
$childrenByParent = $categories
|
||||
->filter(fn (Category $category) => $category->categoria_id !== null
|
||||
&& $categoryIds->has($category->categoria_id))
|
||||
->groupBy('categoria_id');
|
||||
|
||||
$formatCategory = function (Category $category) use (&$formatCategory, $childrenByParent): array {
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'subcategories' => $childrenByParent
|
||||
->get($category->id, collect())
|
||||
->map($formatCategory)
|
||||
->values(),
|
||||
];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->filter(fn (Category $category) => $category->categoria_id === null
|
||||
|| ! $categoryIds->has($category->categoria_id))
|
||||
->map($formatCategory)
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Menu> $menus
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
|
||||
@@ -45,6 +45,7 @@ class GenerateTicketsForPaidPurchase
|
||||
$user,
|
||||
$purchaseItem->cantidad,
|
||||
$purchaseItem->source_variant_id,
|
||||
$purchase->getKey(),
|
||||
);
|
||||
|
||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Ticket\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
'ticket',
|
||||
'name',
|
||||
'description',
|
||||
'source_purchase_id',
|
||||
'source_catalog_item_id',
|
||||
'source_variant_id',
|
||||
'starts_at',
|
||||
@@ -38,6 +40,7 @@ class Ticket extends Model
|
||||
return [
|
||||
'source_catalog_item_id' => 'integer',
|
||||
'source_variant_id' => 'integer',
|
||||
'source_purchase_id' => 'integer',
|
||||
'starts_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'used_at' => 'datetime',
|
||||
@@ -57,6 +60,12 @@ class Ticket extends Model
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function sourcePurchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'source_purchase_id');
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
$now = now();
|
||||
|
||||
@@ -21,12 +21,13 @@ class TicketGeneratorService
|
||||
User $user,
|
||||
int $quantity = 1,
|
||||
?int $sourceVariantId = null,
|
||||
?int $sourcePurchaseId = null,
|
||||
): Collection {
|
||||
if ($quantity < 1) {
|
||||
throw TicketGenerationException::invalidQuantity();
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId): Collection {
|
||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection {
|
||||
$targets = $this->resolveTargets(
|
||||
$catalogItem,
|
||||
$quantity,
|
||||
@@ -36,6 +37,7 @@ class TicketGeneratorService
|
||||
return $targets->map(function (array $target) use (
|
||||
$catalogItem,
|
||||
$sourceVariantId,
|
||||
$sourcePurchaseId,
|
||||
$user,
|
||||
): Ticket {
|
||||
$item = $target['catalog_item'];
|
||||
@@ -46,6 +48,7 @@ class TicketGeneratorService
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'name' => $item->nombre,
|
||||
'description' => (string) ($item->descripcion ?? ''),
|
||||
'source_purchase_id' => $sourcePurchaseId,
|
||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||
'source_variant_id' => $sourceVariantId,
|
||||
'starts_at' => $selectedItem->getMinimumUseDate(),
|
||||
|
||||
@@ -11,8 +11,11 @@ use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -36,6 +39,25 @@ class AppServiceProvider extends ServiceProvider
|
||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||
|
||||
RateLimiter::for('login', function (Request $request): array {
|
||||
$normalizedEmail = mb_strtolower(trim((string) $request->input('email')));
|
||||
$emailFingerprint = hash_hmac(
|
||||
'sha256',
|
||||
$normalizedEmail,
|
||||
(string) config('app.key'),
|
||||
);
|
||||
$ipAddress = $request->ip() ?? 'unknown';
|
||||
|
||||
return [
|
||||
Limit::perMinute(
|
||||
max(1, (int) config('login-security.rate_limit_per_minute'))
|
||||
)->by("login:identity:{$emailFingerprint}:{$ipAddress}"),
|
||||
Limit::perMinute(
|
||||
max(1, (int) config('login-security.ip_rate_limit_per_minute'))
|
||||
)->by("login:ip:{$ipAddress}"),
|
||||
];
|
||||
});
|
||||
|
||||
Builder::macro('paginateFromRequest', function (int $defaultPerPage = 15, int $maxPerPage = 100, ?int $page = null) {
|
||||
/** @var Builder $this */
|
||||
$perPage = (int) request()->query('per_page', $defaultPerPage);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
|
||||
use App\Http\Middleware\SetApiLocale;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -39,6 +40,22 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'message' => __('api.auth.unauthenticated'),
|
||||
], 401);
|
||||
});
|
||||
$exceptions->render(function (AccountLockedException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$retryAfter = $exception->retryAfterSeconds();
|
||||
|
||||
return response()->json([
|
||||
'code' => 'auth.account_locked',
|
||||
'message' => __('api.auth.account_locked'),
|
||||
'retry_after' => $retryAfter,
|
||||
'locked_until' => $exception->lockedUntil->toIso8601String(),
|
||||
], 429, [
|
||||
'Retry-After' => (string) $retryAfter,
|
||||
]);
|
||||
});
|
||||
$exceptions->render(function (AuthorizationException $exception, Request $request) {
|
||||
if (! $request->is('api/*')) {
|
||||
return null;
|
||||
|
||||
9
config/login-security.php
Normal file
9
config/login-security.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'max_attempts' => (int) env('AUTH_MAX_LOGIN_ATTEMPTS', 3),
|
||||
'attempt_window_minutes' => (int) env('AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES', 30),
|
||||
'lock_minutes' => (int) env('AUTH_LOGIN_LOCK_MINUTES', 15),
|
||||
'rate_limit_per_minute' => (int) env('AUTH_LOGIN_RATE_LIMIT_PER_MINUTE', 10),
|
||||
'ip_rate_limit_per_minute' => (int) env('AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE', 30),
|
||||
];
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->foreignId('source_purchase_id')
|
||||
->nullable()
|
||||
->after('description')
|
||||
->constrained('compras')
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tickets', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('source_purchase_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->unsignedSmallInteger('failed_login_attempts')->default(0);
|
||||
$table->timestamp('last_failed_login_at')->nullable();
|
||||
$table->timestamp('locked_until')->nullable()->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
$table->dropIndex(['locked_until']);
|
||||
$table->dropColumn([
|
||||
'failed_login_attempts',
|
||||
'last_failed_login_at',
|
||||
'locked_until',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('login_attempts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')
|
||||
->nullable()
|
||||
->constrained()
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
$table->string('email_fingerprint', 64);
|
||||
$table->string('tenant_codigo')->nullable();
|
||||
$table->string('outcome', 32);
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('user_agent', 1024)->nullable();
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['email_fingerprint', 'created_at']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['ip_address', 'created_at']);
|
||||
$table->index(['outcome', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('login_attempts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->string('reason')->default('manual')->after('codigo');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->dropColumn('reason');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
@@ -12,6 +13,12 @@ class CategorySeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tenant = Tenant::query()->where('codigo', 'sonder')->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$categories = [
|
||||
'Remeras',
|
||||
'Pantalones',
|
||||
@@ -23,8 +30,21 @@ class CategorySeeder extends Seeder
|
||||
foreach ($categories as $nombre) {
|
||||
Category::firstOrCreate([
|
||||
'nombre' => $nombre,
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
|
||||
$indumentaria = Category::updateOrCreate(
|
||||
[
|
||||
'nombre' => 'Indumentaria',
|
||||
'tenant_code' => $tenant->codigo,
|
||||
],
|
||||
['categoria_id' => null],
|
||||
);
|
||||
|
||||
Category::query()
|
||||
->where('nombre', 'Remeras')
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->update(['categoria_id' => $indumentaria->id]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,15 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
|
||||
$ticketCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Entradas',
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
$foodCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Gastronomía',
|
||||
'tenant_code' => null,
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
$parkingCategory = Category::query()->firstOrCreate([
|
||||
'nombre' => 'Estacionamiento',
|
||||
'tenant_code' => $tenant->codigo,
|
||||
]);
|
||||
|
||||
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
|
||||
@@ -69,8 +73,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
['slug' => 'pancho', 'nombre' => 'Pancho', 'precio' => 4000, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'coca-cola-500ml', 'nombre' => 'Coca Cola 500ml', 'precio' => 3000, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'agua-mineral-1l', 'nombre' => 'Agua Mineral 1L', 'precio' => 2500, 'category_id' => $foodCategory->id],
|
||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $ticketCategory->id],
|
||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $ticketCategory->id],
|
||||
['slug' => 'estacionamiento-auto', 'nombre' => 'Estacionamiento Auto', 'precio' => 5000, 'category_id' => $parkingCategory->id],
|
||||
['slug' => 'estacionamiento-moto', 'nombre' => 'Estacionamiento Moto', 'precio' => 2000, 'category_id' => $parkingCategory->id],
|
||||
];
|
||||
|
||||
$createdItems = [];
|
||||
|
||||
@@ -236,11 +236,11 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
|
||||
$category = Category::query()
|
||||
->where('nombre', $metadata['category_name'])
|
||||
->whereNull('tenant_code')
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->first();
|
||||
|
||||
if (! $category instanceof Category) {
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found.");
|
||||
throw new RuntimeException("Category '{$metadata['category_name']}' not found for tenant '{$tenant->codigo}'.");
|
||||
}
|
||||
|
||||
$attributeCodes = Attribute::query()
|
||||
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
'auth' => [
|
||||
'unauthenticated' => 'Unauthenticated.',
|
||||
'invalid_credentials' => 'Email or password is incorrect.',
|
||||
'account_locked' => 'The account is temporarily locked. Please try again later.',
|
||||
'login_success' => 'Signed in successfully.',
|
||||
'logout_success' => 'Signed out successfully.',
|
||||
'register_success' => 'User registered successfully.',
|
||||
@@ -46,6 +47,7 @@ return [
|
||||
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
|
||||
'inactive_cart' => 'The selected cart is no longer active.',
|
||||
'not_available_for_payment' => 'The purchase is no longer available for payment.',
|
||||
'not_available_for_review' => 'The purchase is no longer available for review.',
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'One or more tickets are not available.',
|
||||
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
'auth' => [
|
||||
'unauthenticated' => 'No autenticado.',
|
||||
'invalid_credentials' => 'Email o contraseña incorrectos.',
|
||||
'account_locked' => 'La cuenta está bloqueada temporalmente. Intenta nuevamente más tarde.',
|
||||
'login_success' => 'Sesión iniciada correctamente.',
|
||||
'logout_success' => 'Sesión cerrada correctamente.',
|
||||
'register_success' => 'Usuario registrado correctamente.',
|
||||
@@ -46,6 +47,7 @@ return [
|
||||
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
|
||||
'inactive_cart' => 'El carrito seleccionado ya no está activo.',
|
||||
'not_available_for_payment' => 'La compra ya no está disponible para el pago.',
|
||||
'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.",
|
||||
],
|
||||
'ticket' => [
|
||||
'not_available' => 'Uno o más tickets no están disponibles.',
|
||||
|
||||
@@ -37,7 +37,7 @@ return [
|
||||
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
|
||||
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
|
||||
'string' => ':Attribute debe ser texto.',
|
||||
'unique' => 'El valor de :attribute ya está en uso.',
|
||||
'unique' => 'El :attribute ya está en uso.',
|
||||
'url' => ':Attribute debe ser una URL válida.',
|
||||
'uuid' => ':Attribute debe ser un UUID válido.',
|
||||
'attributes' => [
|
||||
|
||||
@@ -2,10 +2,16 @@
|
||||
Recuperá tu contraseña
|
||||
</h1>
|
||||
|
||||
@if($attempt->reason === 'account_locked')
|
||||
<p>
|
||||
Hola {{ $attempt->user->nombre_apellido }}, registramos varios intentos fallidos de inicio de sesión en tu cuenta. Por seguridad, hemos bloqueado el acceso temporalmente. Puedes utilizar este código para cambiar tu contraseña y desbloquearla inmediatamente.
|
||||
</p>
|
||||
@else
|
||||
<p>
|
||||
Hola {{ $attempt->user->nombre_apellido }}, recibimos una solicitud para restablecer
|
||||
la contraseña de tu cuenta.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||
|
||||
@@ -15,6 +21,21 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$recoveryUrl = 'https://' . $tenant->dominio . '/recuperar-contrasena/codigo?email=' . urlencode($attempt->user->email);
|
||||
@endphp
|
||||
|
||||
<div style="text-align: center; margin-bottom: 28px;">
|
||||
<a href="{{ $recoveryUrl }}"
|
||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||
Ingresar código ahora
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="color: #64748b; font-size: 14px;">
|
||||
@if($attempt->reason === 'account_locked')
|
||||
Si no fuiste vos, por favor desestimá y borrá este correo. Tu cuenta seguirá protegida.
|
||||
@else
|
||||
Si no solicitaste recuperar tu contraseña, podés ignorar este mensaje.
|
||||
@endif
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\LoginAttempt;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
@@ -102,11 +103,190 @@ class LoginControllerTest extends TestCase
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
$this->withHeader('User-Agent', 'Shopit login test')
|
||||
->postJson('/api/login', [
|
||||
'email' => 'grace@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
|
||||
$user = User::query()->where('email', 'grace@example.com')->sole();
|
||||
|
||||
$this->assertSame(1, $user->failed_login_attempts);
|
||||
$this->assertNotNull($user->last_failed_login_at);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'Shopit login test',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_locks_an_account_after_the_maximum_failed_attempts(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 3,
|
||||
'login-security.lock_minutes' => 15,
|
||||
'login-security.rate_limit_per_minute' => 100,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
$this->travelTo(now()->startOfSecond());
|
||||
|
||||
$tenant = $this->createTenant('locked');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'locked@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$payload = [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable()->assertJsonValidationErrors(['email']);
|
||||
];
|
||||
|
||||
for ($attempt = 0; $attempt < 2; $attempt++) {
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
}
|
||||
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertJsonPath('code', 'auth.account_locked');
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(3, $user->failed_login_attempts);
|
||||
$this->assertTrue($user->locked_until->equalTo(now()->addMinutes(15)));
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
...$payload,
|
||||
'password' => 'secret123',
|
||||
])
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After', '900')
|
||||
->assertJsonPath('code', 'auth.account_locked')
|
||||
->assertJsonPath('retry_after', 900);
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 4);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_a_successful_login_resets_failures_and_is_audited(): void
|
||||
{
|
||||
$tenant = $this->createTenant('successful');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'successful@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 2,
|
||||
'last_failed_login_at' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
$this->assertDatabaseHas('login_attempts', [
|
||||
'user_id' => $user->id,
|
||||
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_an_expired_lock_allows_login_again(): void
|
||||
{
|
||||
$tenant = $this->createTenant('expired-lock');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'expired@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now()->subMinutes(20),
|
||||
'locked_until' => now()->subMinute(),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'secret123',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_failures_outside_the_attempt_window_start_a_new_count(): void
|
||||
{
|
||||
config(['login-security.attempt_window_minutes' => 30]);
|
||||
|
||||
$tenant = $this->createTenant('attempt-window');
|
||||
$user = User::factory()->create([
|
||||
'email' => 'window@example.com',
|
||||
'password' => Hash::make('secret123'),
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 4,
|
||||
'last_failed_login_at' => now()->subMinutes(31),
|
||||
])->save();
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$this->assertSame(1, $user->refresh()->failed_login_attempts);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_unknown_emails_are_audited_without_storing_the_email(): void
|
||||
{
|
||||
$tenant = $this->createTenant('unknown');
|
||||
|
||||
$this->postJson('/api/login', [
|
||||
'email' => 'missing@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
])->assertUnprocessable();
|
||||
|
||||
$attempt = LoginAttempt::query()->sole();
|
||||
$this->assertNull($attempt->user_id);
|
||||
$this->assertSame(LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $attempt->outcome);
|
||||
$this->assertSame(64, strlen($attempt->email_fingerprint));
|
||||
$this->assertStringNotContainsString('missing@example.com', $attempt->email_fingerprint);
|
||||
}
|
||||
|
||||
public function test_login_is_rate_limited_by_email_and_ip(): void
|
||||
{
|
||||
config([
|
||||
'login-security.max_attempts' => 100,
|
||||
'login-security.rate_limit_per_minute' => 2,
|
||||
'login-security.ip_rate_limit_per_minute' => 100,
|
||||
]);
|
||||
|
||||
$tenant = $this->createTenant('rate-limit');
|
||||
$payload = [
|
||||
'email' => 'rate-limited@example.com',
|
||||
'password' => 'wrong-password',
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
];
|
||||
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)->assertUnprocessable();
|
||||
$this->postJson('/api/login', $payload)
|
||||
->assertTooManyRequests()
|
||||
->assertHeader('Retry-After');
|
||||
|
||||
$this->assertDatabaseCount('login_attempts', 2);
|
||||
}
|
||||
|
||||
public function test_it_validates_required_login_fields(): void
|
||||
|
||||
@@ -18,6 +18,11 @@ class ResetPasswordControllerTest extends TestCase
|
||||
'email' => 'ada@example.com',
|
||||
'password' => 'OldSecret!123',
|
||||
]);
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 5,
|
||||
'last_failed_login_at' => now(),
|
||||
'locked_until' => now()->addMinutes(15),
|
||||
])->save();
|
||||
$user->createToken('existing-session');
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
@@ -38,6 +43,9 @@ class ResetPasswordControllerTest extends TestCase
|
||||
$this->assertFalse(Hash::check('OldSecret!123', $user->password));
|
||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||
$this->assertDatabaseCount('personal_access_tokens', 0);
|
||||
$this->assertSame(0, $user->failed_login_attempts);
|
||||
$this->assertNull($user->last_failed_login_at);
|
||||
$this->assertNull($user->locked_until);
|
||||
}
|
||||
|
||||
public function test_it_rejects_a_pending_expired_or_used_attempt(): void
|
||||
|
||||
123
tests/Feature/Catalog/CategoryDetailTest.php
Normal file
123
tests/Feature/Catalog/CategoryDetailTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Catalog;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CategoryDetailTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_returns_category_products_paginated_with_the_tenant_search_layout(): void
|
||||
{
|
||||
$tenant = $this->createTenant('category-detail');
|
||||
$tenant->update([
|
||||
'search_product_layout' => ProductLayout::Row->value,
|
||||
'search_group_layout' => GroupLayout::SimpleVertical->value,
|
||||
'search_items_per_page' => 2,
|
||||
]);
|
||||
$category = $this->createCategory($tenant, 'Remeras');
|
||||
$otherCategory = $this->createCategory($tenant, 'Pantalones');
|
||||
|
||||
$this->createCatalogItem($tenant, $category, 'Remera C');
|
||||
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
|
||||
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
|
||||
$this->createCatalogItem($tenant, $otherCategory, 'Pantalón');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('category.id', $category->id)
|
||||
->assertJsonPath('category.nombre', 'Remeras')
|
||||
->assertJsonPath('category.categoria_id', null)
|
||||
->assertJsonPath('layout', ProductLayout::Row->value)
|
||||
->assertJsonPath('group_layout', GroupLayout::SimpleVertical->value)
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonPath('meta.per_page', 2)
|
||||
->assertJsonPath('meta.total', 3)
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $firstItem->id)
|
||||
->assertJsonPath('data.1.id', $secondItem->id)
|
||||
->assertJsonMissing(['nombre' => 'Pantalón']);
|
||||
}
|
||||
|
||||
public function test_it_returns_an_empty_paginated_response_for_a_category_without_products(): void
|
||||
{
|
||||
$tenant = $this->createTenant('empty-category');
|
||||
$category = $this->createCategory($tenant, 'Sin productos');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('category.id', $category->id)
|
||||
->assertJsonPath('meta.total', 0)
|
||||
->assertJsonPath('meta.current_page', 1)
|
||||
->assertJsonCount(0, 'data');
|
||||
}
|
||||
|
||||
public function test_it_rejects_categories_from_another_tenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('category-owner');
|
||||
$otherTenant = $this->createTenant('category-foreign');
|
||||
$foreignCategory = $this->createCategory($otherTenant, 'Ajena');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$foreignCategory->id}")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_validates_the_page(): void
|
||||
{
|
||||
$tenant = $this->createTenant('category-page');
|
||||
$category = $this->createCategory($tenant, 'Remeras');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}?page=0")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('page');
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.local",
|
||||
'primary_color' => '#000000',
|
||||
'secondary_color' => '#000000',
|
||||
'danger_color' => '#000000',
|
||||
'success_color' => '#000000',
|
||||
'header_bg_color' => '#000000',
|
||||
'footer_bg_color' => '#000000',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCategory(Tenant $tenant, string $name): Category
|
||||
{
|
||||
return Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => $name,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createCatalogItem(
|
||||
Tenant $tenant,
|
||||
Category $category,
|
||||
string $name,
|
||||
): CatalogItem {
|
||||
$inventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
|
||||
return CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'category_id' => $category->id,
|
||||
'inventory_id' => $inventory->id,
|
||||
'slug' => str($name)->slug()->toString(),
|
||||
'nombre' => $name,
|
||||
'descripcion' => "{$name} description",
|
||||
'precio' => 100,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class TelepagosWebhookTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
||||
public function test_transfer_webhook_matches_purchase_in_review_by_dni_and_total_amount(): void
|
||||
{
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
@@ -122,6 +122,8 @@ class TelepagosWebhookTest extends TestCase
|
||||
'12345678'
|
||||
);
|
||||
|
||||
$matchingPurchase->update(['status' => Purchase::STATUS_IN_REVIEW]);
|
||||
|
||||
$newerPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
$newerUser->id,
|
||||
|
||||
@@ -304,6 +304,32 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
|
||||
'dni' => '987654321',
|
||||
'telefono' => '+54 9 341 555-4321',
|
||||
'nombre_apellido' => 'Juan Perez',
|
||||
'email' => 'juan.perez@example.com',
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||
->assertJsonPath('data.dni', '987654321');
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'dni' => '987654321',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_updates_a_created_purchase_item_quantity_and_its_stock_reservation(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
@@ -448,6 +474,62 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_submits_a_pending_purchase_for_review_idempotently(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||
|
||||
$purchase->update([
|
||||
'payment_method' => 'transfer',
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
|
||||
$url = "/api/tenants/sonder/compras/{$purchase->id}/review";
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson($url)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW)
|
||||
->assertJsonPath('data.expires_at', null);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson($url)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||
}
|
||||
|
||||
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||
|
||||
$this->actingAs($user, 'sanctum')
|
||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/review")
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['purchase']);
|
||||
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'status' => Purchase::STATUS_CREATED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_expires_an_abandoned_purchase_and_restores_its_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
@@ -155,6 +156,24 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
);
|
||||
$this->assertSame(9, CatalogItem::query()->where('tenant_code', $tenant->codigo)->count());
|
||||
$this->assertSame(10, Inventory::query()->count());
|
||||
$this->assertSame(
|
||||
['Entradas', 'Estacionamiento', 'Gastronomía'],
|
||||
Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orderBy('nombre')
|
||||
->pluck('nombre')
|
||||
->all(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'Estacionamiento',
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'estacionamiento-auto')
|
||||
->with('category')
|
||||
->sole()
|
||||
->category
|
||||
->nombre,
|
||||
);
|
||||
|
||||
$featuredGroups = FeaturedGroup::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Database\Seeders\AttributeSeeder;
|
||||
@@ -91,5 +92,19 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
||||
->pluck('catalog_item_id')
|
||||
->diff($catalogItemIds),
|
||||
);
|
||||
$this->assertSame(
|
||||
['Accesorios', 'Buzos', 'Pantalones', 'Remeras', 'Zapatillas'],
|
||||
Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orderBy('nombre')
|
||||
->pluck('nombre')
|
||||
->all(),
|
||||
);
|
||||
$this->assertFalse(
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('category', fn ($query) => $query->where('tenant_code', '!=', $tenant->codigo))
|
||||
->exists(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -95,6 +96,45 @@ class BootstrapTenantControllerTest extends TestCase
|
||||
->assertJsonPath('data.dominio', 'acme.com');
|
||||
}
|
||||
|
||||
public function test_it_returns_only_the_tenant_categories_in_the_bootstrap(): void
|
||||
{
|
||||
$tenant = $this->createTenant();
|
||||
$otherTenant = $this->createTenant([
|
||||
'codigo' => 'globex',
|
||||
'nombre' => 'Globex',
|
||||
'dominio' => 'globex.com',
|
||||
]);
|
||||
|
||||
$parent = Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => 'Remeras',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'categoria_id' => $parent->id,
|
||||
'nombre' => 'Manga corta',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'tenant_code' => $otherTenant->codigo,
|
||||
'nombre' => 'Otra categoría',
|
||||
]);
|
||||
Category::query()->create([
|
||||
'tenant_code' => null,
|
||||
'nombre' => 'Global',
|
||||
]);
|
||||
|
||||
$this->getJson('/api/tenants/bootstrap/acme.com')
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data.categories')
|
||||
->assertJsonPath('data.categories.0.nombre', 'Remeras')
|
||||
->assertJsonCount(1, 'data.categories.0.subcategories')
|
||||
->assertJsonPath('data.categories.0.subcategories.0.nombre', 'Manga corta')
|
||||
->assertJsonPath('data.categories.0.subcategories.0.subcategories', [])
|
||||
->assertJsonMissingPath('data.categories.0.categoria_id')
|
||||
->assertJsonMissing(['nombre' => 'Otra categoría'])
|
||||
->assertJsonMissing(['nombre' => 'Global']);
|
||||
}
|
||||
|
||||
public function test_it_returns_not_found_when_the_domain_does_not_exist(): void
|
||||
{
|
||||
$response = $this->getJson('/api/tenants/bootstrap/missing.example');
|
||||
|
||||
@@ -193,6 +193,12 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
||||
|
||||
$this->actingAs($this->user, 'sanctum')
|
||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.tickets_count', 2)
|
||||
->assertJsonPath('data.has_generated_tickets', true);
|
||||
|
||||
$purchase->markAsPaid();
|
||||
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
@@ -209,6 +215,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$purchase->markAsPaid();
|
||||
|
||||
$this->assertDatabaseHas('tickets', [
|
||||
'source_purchase_id' => $purchase->id,
|
||||
'source_catalog_item_id' => $item->id,
|
||||
'source_variant_id' => $variant->id,
|
||||
]);
|
||||
@@ -226,6 +233,12 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
Event::assertNotDispatched(TicketsAvailable::class);
|
||||
|
||||
$this->actingAs($this->user, 'sanctum')
|
||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.tickets_count', 0)
|
||||
->assertJsonPath('data.has_generated_tickets', false);
|
||||
}
|
||||
|
||||
public function test_paid_status_is_confirmed_when_ticket_maximum_use_date_was_reached(): void
|
||||
|
||||
@@ -155,6 +155,28 @@ class CatalogModelsTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_event_date_identifies_a_variant_without_catalog_attributes(): void
|
||||
{
|
||||
$item = new CatalogItem;
|
||||
$item->nombre = 'Entrada General';
|
||||
|
||||
$eventDate = new EventDate;
|
||||
$eventDate->date = '2026-10-09';
|
||||
$eventDate->time_start = '09:00:00';
|
||||
$eventDate->time_end = '18:00:00';
|
||||
|
||||
$variant = new Variant;
|
||||
$variant->minimum_use_date = Carbon::parse('2026-10-09 08:00:00');
|
||||
$variant->maximum_use_date = Carbon::parse('2026-10-09 20:00:00');
|
||||
$variant->setRelation('catalogItem', $item);
|
||||
$variant->setRelation('eventDate', $eventDate);
|
||||
$variant->setRelation('definitions', new EloquentCollection);
|
||||
|
||||
$this->assertSame('Entrada General', $variant->getName());
|
||||
$this->assertSame('2026-10-09 09:00:00', $variant->getMinimumUseDate()->format('Y-m-d H:i:s'));
|
||||
$this->assertSame('2026-10-09 18:00:00', $variant->getMaximumUseDate()->format('Y-m-d H:i:s'));
|
||||
}
|
||||
|
||||
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
|
||||
{
|
||||
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);
|
||||
|
||||
Reference in New Issue
Block a user