Compare commits
140 Commits
feature/al
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9521e65d0 | |||
| d0625f25e5 | |||
| 1fdcfc1547 | |||
| ca4ea0aa6c | |||
| bb1f9c8e91 | |||
| 99fe94fa6a | |||
| a4b5c2eb19 | |||
| 2feca2bed5 | |||
| a35ff69140 | |||
| 75152b53a4 | |||
| 99594b17e7 | |||
| 85edea0661 | |||
| ab5ea7dd33 | |||
| c0057c237a | |||
| e4146288f1 | |||
| f11ba5a470 | |||
| e4db36e650 | |||
| ffa3f10b18 | |||
| 7acef66ee7 | |||
| 18f1217daa | |||
| ac44e82454 | |||
| 1e7a9b6876 | |||
| deea4fd4af | |||
| c4ff695f2e | |||
| 703158bd06 | |||
| eda2343380 | |||
| 0baad641d5 | |||
| ddb8c3743f | |||
| c2233389d0 | |||
| 334ffe92e7 | |||
| a19ace3115 | |||
| acc0a68736 | |||
| dcf105c316 | |||
| f74bf41931 | |||
| 34217be630 | |||
| ee906d2d2e | |||
| 836aa1afd7 | |||
| 6896646c54 | |||
| 5e541f7cd0 | |||
| 80b8707771 | |||
| 58b15f3026 | |||
| 2e9355c85c | |||
| fa1e11a69f | |||
| 6a00d8d09c | |||
| fbfcb5cf63 | |||
| adce4ab59e | |||
| 5250d1a818 | |||
| 457eba68a9 | |||
| 52b0b339d6 | |||
| 9175df1d7f | |||
| 38341b2bda | |||
| 34a0a14404 | |||
| af516ce9e3 | |||
| dca35e7b0c | |||
| df853de843 | |||
| 4ba464bf85 | |||
| 208e929262 | |||
| ffe47d903e | |||
| 24983439b1 | |||
| d179a0f74e | |||
| 636e39e98e | |||
| bda94d02af | |||
| bfb16ef60e | |||
| 1b6303237a | |||
| 9da92c880a | |||
| 63012838a4 | |||
| 27544a23be | |||
| a71c80248c | |||
| 1ca8fb20af | |||
| ba167559a6 | |||
| 495515b1f3 | |||
| 781e282f16 | |||
| 7fb4c9674a | |||
| 0d85c3df19 | |||
| a93b260043 | |||
| c209e716e3 | |||
| 900ef6cf98 | |||
| 71c914a389 | |||
| 705b1247f5 | |||
| b1f42775d6 | |||
| 0f6f39cce7 | |||
| 1ec7ab3e35 | |||
| ba0e2dd00c | |||
| c792e7d306 | |||
| 6ee3f22e41 | |||
| d4ec34db1a | |||
| 351178d9da | |||
| b617285148 | |||
| 9bca41bfa5 | |||
| 5601285369 | |||
| 6fb60c9a73 | |||
| c3f1c320a4 | |||
| 899bf12457 | |||
| a460743ae0 | |||
| a4d7b1b789 | |||
| 10655b8c07 | |||
| adc7b21ab8 | |||
| ba1355448a | |||
| 1c76e56f2d | |||
| daa74845b7 | |||
| 05944cec9c | |||
| c820741e5f | |||
| adc4cc9595 | |||
| c67dfcc1a1 | |||
| f61b7e4dee | |||
| e53fa949cf | |||
| c51e24311f | |||
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 1f7bb35f63 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 | |||
| e2ad78b1fb | |||
| 8ed8a11b7b | |||
| dc33e5dc09 | |||
| 414df97e9d | |||
| d0424c5589 | |||
| 28d09dedab | |||
| 8220fb5aaf | |||
| bf02d37a33 | |||
| 78ce263849 | |||
| 0c8419a5eb | |||
| 843659583e | |||
| c2921166bd | |||
| 0f67e66b6b | |||
| 725abed06a | |||
| 743939fc63 | |||
| eb50e65fef | |||
| 1cf13501d8 | |||
| 2eeef8d392 | |||
| 87a4fa6288 | |||
| 07d2129410 |
@@ -8,6 +8,7 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
|||||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||||
|
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
|
||||||
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
|||||||
AUTH_LOGIN_LOCK_MINUTES=15
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
|
||||||
|
|
||||||
LOG_CHANNEL=daily
|
LOG_CHANNEL=daily
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
@@ -41,6 +43,8 @@ TELEPAGOS_LOG_LEVEL=info
|
|||||||
TELEPAGOS_LOG_DAYS=30
|
TELEPAGOS_LOG_DAYS=30
|
||||||
COMMANDS_LOG_LEVEL=info
|
COMMANDS_LOG_LEVEL=info
|
||||||
COMMANDS_LOG_DAYS=30
|
COMMANDS_LOG_DAYS=30
|
||||||
|
EMAILS_LOG_LEVEL=info
|
||||||
|
EMAILS_LOG_DAYS=30
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Requests\StoreAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Requests\UpdateAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Resources\AdministratorResource;
|
||||||
|
use App\Domains\Administrator\Services\AdministratorService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AdminAppAdministratorController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdministratorService $administratorService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return AdministratorResource::collection($this->administratorService->list(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreAdministratorRequest $request): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->create(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateAdministratorRequest $request, int $administrator): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->update(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$administrator,
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $administrator): Response
|
||||||
|
{
|
||||||
|
$this->administratorService->delete($request->user()->tenant()->firstOrFail(), $administrator, $request->user());
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$administratorId = (int) $this->route('administrator');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($administratorId),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class AdministratorResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
|
'dni' => $this->dni,
|
||||||
|
'email' => $this->email,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
|
'codigo' => $this->role?->codigo,
|
||||||
|
'nombre' => $this->role?->nombre,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AdministratorService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
public function list(Tenant $tenant, ?string $search = null): Collection
|
||||||
|
{
|
||||||
|
return $this->query($tenant)->with('role')
|
||||||
|
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||||
|
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||||
|
->orWhere('dni', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
}))
|
||||||
|
->orderBy('nombre_apellido')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function create(Tenant $tenant, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): User {
|
||||||
|
$administrator = User::query()->create([
|
||||||
|
...$this->attributes($data),
|
||||||
|
'password' => Str::random(64),
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||||
|
$administrator->email,
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, int $administratorId, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
$administrator->update($this->attributes($data));
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $administratorId, User $actor): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($tenant, $administratorId, $actor): void {
|
||||||
|
// Serialize deletions for this tenant, including requests already authenticated
|
||||||
|
// when another administrator removes their account.
|
||||||
|
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
if ($administrator->is($actor)) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||||
|
}
|
||||||
|
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
||||||
|
if ($activeAdministrators->count() <= 1) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||||
|
}
|
||||||
|
abort_unless($activeAdministrators->contains('id', $actor->id), 403);
|
||||||
|
$administrator->tokens()->delete();
|
||||||
|
$administrator->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function query(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('rol_codigo', RoleCode::AdminApp->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function attributes(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...Arr::only($data, ['nombre_apellido', 'dni']),
|
||||||
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Domains/Administrator/documentacion/README.md
Normal file
63
app/Domains/Administrator/documentacion/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Administradores de AdminApp
|
||||||
|
|
||||||
|
CRUD de usuarios con rol `adminapp`, limitado al tenant del usuario autenticado.
|
||||||
|
Todos los administradores del tenant pueden gestionar esta sección.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Base: `/api/v1/adminapp/tenant/administrators`.
|
||||||
|
Requieren `auth:sanctum` y `adminapp.tenant`.
|
||||||
|
|
||||||
|
- `GET /`: listado ordenado por nombre; acepta `search` por nombre, DNI o email.
|
||||||
|
- `POST /`: alta; responde `201` con `data`.
|
||||||
|
- `PUT /{administrator}`: actualización de los tres campos; responde `200` con `data`.
|
||||||
|
- `DELETE /{administrator}`: baja lógica; responde `204`.
|
||||||
|
|
||||||
|
Alta y actualización reciben:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre_apellido": "Ada Lovelace",
|
||||||
|
"dni": "12345678",
|
||||||
|
"email": "ada@example.test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nombre (hasta 255 caracteres), DNI (hasta 50) y email (hasta 255) son obligatorios.
|
||||||
|
El email se normaliza a minúsculas antes de validar y debe ser único entre
|
||||||
|
usuarios activos, sin importar su tenant o rol. Se permite reutilizar el email
|
||||||
|
de un usuario eliminado. Rol y tenant no son editables desde esta API.
|
||||||
|
|
||||||
|
Las respuestas incluyen `id`, `nombre_apellido`, `dni`, `email`, `rol_codigo`
|
||||||
|
y `role` (`codigo`, `nombre`). Nunca incluyen contraseña ni datos de escaneo.
|
||||||
|
|
||||||
|
## Alta y acceso
|
||||||
|
|
||||||
|
Se genera una contraseña aleatoria y un intento de establecimiento de contraseña
|
||||||
|
con motivo `administrator_created`, reutilizando `createForAdminAppEmail`.
|
||||||
|
El evento usa el canal `adminapp`; el listener existente envía el email después
|
||||||
|
del commit mediante la cola `emails`. Requiere la configuración de correo,
|
||||||
|
dominio AdminApp y worker existentes. No se envían contraseñas en texto plano.
|
||||||
|
|
||||||
|
## Eliminación y aislamiento
|
||||||
|
|
||||||
|
Las consultas de usuarios se limitan por tenant y rol `adminapp`. IDs ajenos,
|
||||||
|
usuarios eliminados y usuarios de otros roles devuelven `404`.
|
||||||
|
La validación de campos devuelve `422`; falta de autenticación, `401`, y rol
|
||||||
|
no autorizado, `403`.
|
||||||
|
|
||||||
|
No se permite eliminar al propio usuario ni dejar al tenant sin administradores
|
||||||
|
(`422`, error `administrator`). La eliminación bloquea la fila del tenant dentro
|
||||||
|
de una transacción para serializar bajas concurrentes. También verifica que el
|
||||||
|
actor siga activo, revoca tokens y aplica el borrado lógico existente en `users`.
|
||||||
|
|
||||||
|
No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el frontend.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||||
|
|
||||||
|
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||||
|
contraseña, restricciones de rol y tenant, baja lógica, tokens y protecciones de
|
||||||
|
eliminación. El caso de petición autenticada antes de la baja del actor se simula;
|
||||||
|
no es una prueba con conexiones concurrentes reales.
|
||||||
10
app/Domains/Administrator/routes/api.php
Normal file
10
app/Domains/Administrator/routes/api.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Controllers\AdminAppAdministratorController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::apiResource('administrators', AdminAppAdministratorController::class)->except('show');
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
|
|||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
$data['password'],
|
$data['password'],
|
||||||
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
)) {
|
)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.password_reset_invalid'),
|
'codigo' => __('api.auth.password_reset_invalid'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -22,10 +23,19 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
if (! $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
)) {
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_expired'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.reset_code_invalid'),
|
'codigo' => __('api.auth.reset_code_invalid'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model
|
|||||||
|
|
||||||
public const REASON_STAFF_CREATED = 'staff_created';
|
public const REASON_STAFF_CREATED = 'staff_created';
|
||||||
|
|
||||||
|
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
public const STATUS_VALIDATED = 'validated';
|
public const STATUS_VALIDATED = 'validated';
|
||||||
@@ -31,6 +33,7 @@ class ResetPasswordAttempt extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'rol_codigo' => RoleCode::User->value,
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function prepareForValidation(): void
|
||||||
* @return array<string, mixed>
|
{
|
||||||
*/
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
||||||
'dni' => ['nullable', 'string', 'max:255'],
|
'dni' => ['nullable', 'string', 'max:255'],
|
||||||
'telefono' => ['nullable', 'string', 'max:255'],
|
'telefono' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class UpdateProfileRequest extends FormRequest
|
class UpdateProfileRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -19,11 +27,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminCredentialVerifier
|
|||||||
public function verify(string $email, string $password): bool
|
public function verify(string $email, string $password): bool
|
||||||
{
|
{
|
||||||
$admin = User::query()
|
$admin = User::query()
|
||||||
->where('email', mb_strtolower(trim($email)))
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Admin->value)
|
->where('rol_codigo', RoleCode::Admin->value)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Services;
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
@@ -124,12 +125,12 @@ class GoogleAuthService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('google_id', $googleId)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$user->forceFill(['google_id' => $googleId])->save();
|
$user->forceFill(['google_id' => $googleId])->save();
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class PasswordLoginService
|
|||||||
null,
|
null,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
null,
|
RoleCode::Scanner,
|
||||||
true,
|
true,
|
||||||
PermissionCode::ScanTickets->value,
|
PermissionCode::ScanTickets->value,
|
||||||
PasswordResetRequested::CHANNEL_SCANNER,
|
PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
@@ -120,7 +120,7 @@ class PasswordLoginService
|
|||||||
$passwordResetChannel,
|
$passwordResetChannel,
|
||||||
): array {
|
): array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $normalizedEmail)
|
->where('active_email', $normalizedEmail)
|
||||||
->when(
|
->when(
|
||||||
$requiredRole !== null,
|
$requiredRole !== null,
|
||||||
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
|
public const CODE_VALID = 'valid';
|
||||||
|
|
||||||
|
public const CODE_INVALID = 'invalid';
|
||||||
|
|
||||||
|
public const CODE_EXPIRED = 'expired';
|
||||||
|
|
||||||
public function createForEmail(
|
public function createForEmail(
|
||||||
string $email,
|
string $email,
|
||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
@@ -22,7 +28,8 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', RoleCode::User->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -59,7 +66,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::AdminApp->value)
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -107,7 +114,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Scanner->value)
|
->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -146,14 +153,15 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): bool
|
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -169,14 +177,27 @@ class ResetPasswordAttemptService
|
|||||||
'email_fingerprint' => $emailFingerprint,
|
'email_fingerprint' => $emailFingerprint,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return self::CODE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset code validation failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::CODE_EXPIRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attempt->update([
|
$attempt->update([
|
||||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return self::CODE_VALID;
|
||||||
});
|
});
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Failed to validate password reset code.', [
|
Log::error('Failed to validate password reset code.', [
|
||||||
@@ -188,14 +209,15 @@ class ResetPasswordAttemptService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetPassword(string $email, string $code, string $password): bool
|
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -214,6 +236,19 @@ class ResetPasswordAttemptService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$user->password = $password;
|
$user->password = $password;
|
||||||
$user->failed_login_attempts = 0;
|
$user->failed_login_attempts = 0;
|
||||||
$user->last_failed_login_at = null;
|
$user->last_failed_login_at = null;
|
||||||
@@ -270,6 +305,7 @@ class ResetPasswordAttemptService
|
|||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $attempt->getKey();
|
return $attempt->getKey();
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->get('me', AdminAppMeController::class);
|
->get('me', AdminAppMeController::class);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
->get('me', ScannerMeController::class);
|
->get('me', ScannerMeController::class);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
|
|||||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('dominio', $domain)
|
->where('dominio', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('scanner_domain', $domain)
|
->where('scanner_domain', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
@@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
'status',
|
'status',
|
||||||
'origin',
|
'origin',
|
||||||
'current_purchase_id',
|
'current_purchase_id',
|
||||||
|
'current_stock_reservation_id',
|
||||||
])]
|
])]
|
||||||
class Cart extends Model
|
class Cart extends Model
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,16 @@ class Cart extends Model
|
|||||||
|
|
||||||
protected $table = 'carritos';
|
protected $table = 'carritos';
|
||||||
|
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_CHECKOUT = 'checkout';
|
||||||
|
|
||||||
|
public const STATUS_CONVERTED = 'converted';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public const STATUS_ABANDONED = 'abandoned';
|
||||||
|
|
||||||
public const ORIGIN_USER = 'user';
|
public const ORIGIN_USER = 'user';
|
||||||
|
|
||||||
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
||||||
@@ -45,6 +57,7 @@ class Cart extends Model
|
|||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'current_purchase_id' => 'integer',
|
'current_purchase_id' => 'integer',
|
||||||
|
'current_stock_reservation_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +97,15 @@ class Cart extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'current_purchase_id');
|
return $this->belongsTo(Purchase::class, 'current_purchase_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
|
public function currentStockReservation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(
|
||||||
|
StockReservation::class,
|
||||||
|
'current_stock_reservation_id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
@@ -107,6 +129,7 @@ class Cart extends Model
|
|||||||
|
|
||||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||||
$cartQuantity = (int) $this->items()
|
$cartQuantity = (int) $this->items()
|
||||||
->where('catalog_item_id', $catalogItemId)
|
->where('catalog_item_id', $catalogItemId)
|
||||||
@@ -140,12 +163,11 @@ class Cart extends Model
|
|||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
app(StockReservationService::class)->ensure($item, $selectedItem);
|
|
||||||
$item->cantidad += $quantity;
|
$item->cantidad += $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -172,6 +194,7 @@ class Cart extends Model
|
|||||||
$excludedPurchaseId,
|
$excludedPurchaseId,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -205,7 +228,6 @@ class Cart extends Model
|
|||||||
$nextAvailableQuantity,
|
$nextAvailableQuantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
|
|
||||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
@@ -222,11 +244,10 @@ class Cart extends Model
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($targetItem !== null) {
|
if ($targetItem !== null) {
|
||||||
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
|
|
||||||
$targetItem->cantidad += $quantity;
|
$targetItem->cantidad += $quantity;
|
||||||
$targetItem->save();
|
$targetItem->save();
|
||||||
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
|
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $targetItem->fresh();
|
return $targetItem->fresh();
|
||||||
}
|
}
|
||||||
@@ -234,7 +255,7 @@ class Cart extends Model
|
|||||||
$item->variant_id = $variantId;
|
$item->variant_id = $variantId;
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
}
|
}
|
||||||
@@ -263,16 +284,9 @@ class Cart extends Model
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta > 0) {
|
|
||||||
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($delta < 0) {
|
|
||||||
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -282,6 +296,7 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($cartItemId): void {
|
DB::transaction(function () use ($cartItemId): void {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -289,17 +304,8 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$selectedItem = $this->resolveScopedItem(
|
|
||||||
$item->catalog_item_id,
|
|
||||||
$item->variant_id,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
app(StockReservationService::class)->release(
|
|
||||||
$item,
|
|
||||||
$selectedItem,
|
|
||||||
$item->cantidad,
|
|
||||||
);
|
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,13 +347,20 @@ class Cart extends Model
|
|||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)) {
|
], true)) {
|
||||||
|
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
|
||||||
$currentPurchase->update([
|
$currentPurchase->update([
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->current_purchase_id = null;
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
app(StockReservationService::class)->detachFromPurchase($currentPurchase);
|
app(StockReservationService::class)->releaseForPurchase(
|
||||||
|
$currentPurchase,
|
||||||
|
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
|
||||||
|
);
|
||||||
self::query()
|
self::query()
|
||||||
->whereKey($cart->getKey())
|
->whereKey($cart->getKey())
|
||||||
->where('current_purchase_id', $currentPurchase->getKey())
|
->where('current_purchase_id', $currentPurchase->getKey())
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
@@ -57,10 +55,4 @@ class CartItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->variant ?? $this->catalogItem;
|
return $this->variant ?? $this->catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
|
||||||
public function stockReservations(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(StockReservation::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
@@ -22,7 +25,7 @@ class CartService
|
|||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
@@ -119,7 +122,7 @@ class CartService
|
|||||||
{
|
{
|
||||||
$cart = new Cart([
|
$cart = new Cart([
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cart->setRelation('items', collect());
|
$cart->setRelation('items', collect());
|
||||||
@@ -220,12 +223,14 @@ class CartService
|
|||||||
{
|
{
|
||||||
return Cart::query()
|
return Cart::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('status', 'active')
|
->where('origin', Cart::ORIGIN_USER)
|
||||||
|
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||||
->when(
|
->when(
|
||||||
$identity['user_id'] !== null,
|
$identity['user_id'] !== null,
|
||||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||||
)
|
)
|
||||||
|
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +239,7 @@ class CartService
|
|||||||
*/
|
*/
|
||||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$cart = $this->findCart($tenant, $identity);
|
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
throw new NotFoundHttpException('Cart not found.');
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
@@ -247,10 +252,73 @@ class CartService
|
|||||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||||
|
{
|
||||||
|
return $this->resolveCart($tenant, $identity)
|
||||||
|
?? $this->createCart($tenant, $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function resolveCart(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $identity,
|
||||||
|
bool $replaceExpired = true,
|
||||||
|
): ?Cart {
|
||||||
|
$cart = $this->findCart($tenant, $identity);
|
||||||
|
|
||||||
|
if ($cart?->status === Cart::STATUS_ACTIVE
|
||||||
|
&& $cart->current_stock_reservation_id !== null
|
||||||
|
&& app(ExpireStockReservationsService::class)
|
||||||
|
->expireIfOverdue($cart->current_stock_reservation_id)) {
|
||||||
|
$cart = $this->findCart($tenant, $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart?->status === Cart::STATUS_EXPIRED) {
|
||||||
|
if (! $replaceExpired) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->replaceExpiredCart($cart, $tenant, $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart !== null) {
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
|
||||||
|
/** @var Cart|null $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
|
||||||
|
|
||||||
|
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
|
||||||
|
$lockedCart->update([
|
||||||
|
'status' => Cart::STATUS_ABANDONED,
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findCart($tenant, $identity)
|
||||||
|
?? $this->createCart($tenant, $identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function createCart(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$attributes = [
|
$attributes = [
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
'origin' => Cart::ORIGIN_USER,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($identity['user_id'] !== null) {
|
if ($identity['user_id'] !== null) {
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Cart\Services;
|
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ExpireCartReservationsService
|
|
||||||
{
|
|
||||||
public function expireOverdue(): int
|
|
||||||
{
|
|
||||||
$expiredItems = 0;
|
|
||||||
$lastCartItemId = 0;
|
|
||||||
|
|
||||||
do {
|
|
||||||
$cartItemIds = StockReservation::query()
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->whereNull('purchase_id')
|
|
||||||
->whereNotNull('cart_item_id')
|
|
||||||
->whereNotNull('expires_at')
|
|
||||||
->where('expires_at', '<=', now())
|
|
||||||
->where('cart_item_id', '>', $lastCartItemId)
|
|
||||||
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
|
|
||||||
->select('cart_item_id')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('cart_item_id')
|
|
||||||
->limit(500)
|
|
||||||
->pluck('cart_item_id');
|
|
||||||
|
|
||||||
foreach ($cartItemIds as $cartItemId) {
|
|
||||||
$lastCartItemId = (int) $cartItemId;
|
|
||||||
|
|
||||||
if ($this->expireCartItem($lastCartItemId)) {
|
|
||||||
$expiredItems++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while ($cartItemIds->count() === 500);
|
|
||||||
|
|
||||||
return $expiredItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function expireCartItem(int $cartItemId): bool
|
|
||||||
{
|
|
||||||
/** @var CartItem|null $candidate */
|
|
||||||
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
|
|
||||||
if ($candidate === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($candidate, $cartItemId): bool {
|
|
||||||
/** @var Cart|null $cart */
|
|
||||||
$cart = Cart::query()
|
|
||||||
->whereKey($candidate->cart_id)
|
|
||||||
->where('status', 'active')
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($cart === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CartItem|null $cartItem */
|
|
||||||
$cartItem = $cart->items()
|
|
||||||
->whereKey($cartItemId)
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($cartItem === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reservations = StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->orderBy('inventory_id')
|
|
||||||
->lockForUpdate()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if (
|
|
||||||
$reservations->isEmpty()
|
|
||||||
|| $reservations->contains(
|
|
||||||
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
|
|
||||||
|| $reservation->expires_at === null
|
|
||||||
|| $reservation->expires_at->isFuture(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inventories = Inventory::query()
|
|
||||||
->whereKey($reservations->pluck('inventory_id'))
|
|
||||||
->orderBy('id')
|
|
||||||
->lockForUpdate()
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
foreach ($reservations as $reservation) {
|
|
||||||
$inventory = $inventories->get($reservation->inventory_id)
|
|
||||||
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
|
|
||||||
|
|
||||||
$inventory->release((int) $reservation->quantity);
|
|
||||||
$reservation->update([
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => StockReservation::STATUS_EXPIRED,
|
|
||||||
'expires_at' => null,
|
|
||||||
'released_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cartItem->delete();
|
|
||||||
|
|
||||||
if (! $cart->items()->exists()) {
|
|
||||||
$cart->update(['status' => 'expired']);
|
|
||||||
$cart->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
|
|||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
|
||||||
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
||||||
|
|
||||||
## Servicios
|
## Servicios
|
||||||
|
|
||||||
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
||||||
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
|
|
||||||
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
|
|||||||
|
|
||||||
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
||||||
|
|
||||||
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.
|
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
|
||||||
|
|
||||||
|
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.
|
||||||
|
|||||||
@@ -123,10 +123,6 @@ class CatalogController extends Controller
|
|||||||
$variantId === null ? null : (int) $variantId,
|
$variantId === null ? null : (int) $variantId,
|
||||||
);
|
);
|
||||||
$allowances->attach(collect([$item]), $this->userId($request));
|
$allowances->attach(collect([$item]), $this->userId($request));
|
||||||
abort_unless($allowances->availability(
|
|
||||||
$item->availableStock(),
|
|
||||||
$item->getAttribute('remaining_user_quota'),
|
|
||||||
)->isVisible(), 404);
|
|
||||||
|
|
||||||
return CatalogItemDetailResource::make($item);
|
return CatalogItemDetailResource::make($item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Enums;
|
|
||||||
|
|
||||||
enum AvailabilityEffect: string
|
|
||||||
{
|
|
||||||
case Hide = 'hide';
|
|
||||||
case Restrict = 'restrict';
|
|
||||||
case Notice = 'notice';
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Enums;
|
|
||||||
|
|
||||||
enum CatalogAction: string
|
|
||||||
{
|
|
||||||
case SelectVariant = 'select_variant';
|
|
||||||
case ChangeQuantity = 'change_quantity';
|
|
||||||
case AddToCart = 'add_to_cart';
|
|
||||||
case BuyNow = 'buy_now';
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class StockReservationExpiredException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(__('api.cart.reservation_expired'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
|
|||||||
'type',
|
'type',
|
||||||
'slug',
|
'slug',
|
||||||
'nombre',
|
'nombre',
|
||||||
|
'group_order',
|
||||||
'descripcion',
|
'descripcion',
|
||||||
'precio',
|
'precio',
|
||||||
'inventory_policy',
|
'inventory_policy',
|
||||||
@@ -47,6 +48,7 @@ class CatalogItem extends Model
|
|||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
'inventory_subject' => InventorySubject::Product->value,
|
'inventory_subject' => InventorySubject::Product->value,
|
||||||
'has_tickets' => false,
|
'has_tickets' => false,
|
||||||
|
'group_order' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -56,6 +58,7 @@ class CatalogItem extends Model
|
|||||||
'brand_id' => 'integer',
|
'brand_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
'type' => CatalogItemType::class,
|
'type' => CatalogItemType::class,
|
||||||
|
'group_order' => 'integer',
|
||||||
'precio' => 'decimal:2',
|
'precio' => 'decimal:2',
|
||||||
'inventory_policy' => InventoryPolicy::class,
|
'inventory_policy' => InventoryPolicy::class,
|
||||||
'inventory_subject' => InventorySubject::class,
|
'inventory_subject' => InventorySubject::class,
|
||||||
@@ -172,17 +175,29 @@ class CatalogItem extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
/** @param Builder<CatalogItem> $query */
|
||||||
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
public function scopeWhereAvailable(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('variants')
|
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||||
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
|
||||||
->orWhereHas(
|
->orWhereHas(
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||||
);
|
)
|
||||||
|
->orWhere(function (Builder $directItemQuery): void {
|
||||||
|
$directItemQuery
|
||||||
|
->whereDoesntHave('variants')
|
||||||
|
->where(function (Builder $inventoryQuery): void {
|
||||||
|
$inventoryQuery
|
||||||
|
->whereNull('catalog_items.inventory_id')
|
||||||
|
->orWhereHas(
|
||||||
|
'inventory',
|
||||||
|
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||||
|
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ class Inventory extends Model
|
|||||||
return $this->hasOne(Variant::class);
|
return $this->hasOne(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
public function stockReservations(): HasMany
|
public function stockReservationLines(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(StockReservation::class);
|
return $this->hasMany(StockReservationLine::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function availableStock(): int
|
public function availableStock(): int
|
||||||
|
|||||||
@@ -2,21 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'inventory_id',
|
|
||||||
'cart_item_id',
|
|
||||||
'purchase_id',
|
|
||||||
'quantity',
|
|
||||||
'status',
|
'status',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'committed_at',
|
'committed_at',
|
||||||
'released_at',
|
'released_at',
|
||||||
|
'expired_at',
|
||||||
|
'release_reason',
|
||||||
])]
|
])]
|
||||||
class StockReservation extends Model
|
class StockReservation extends Model
|
||||||
{
|
{
|
||||||
@@ -31,31 +30,28 @@ class StockReservation extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'inventory_id' => 'integer',
|
|
||||||
'cart_item_id' => 'integer',
|
|
||||||
'purchase_id' => 'integer',
|
|
||||||
'quantity' => 'integer',
|
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'committed_at' => 'datetime',
|
'committed_at' => 'datetime',
|
||||||
'released_at' => 'datetime',
|
'released_at' => 'datetime',
|
||||||
|
'expired_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Inventory, $this> */
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
public function inventory(): BelongsTo
|
public function lines(): HasMany
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Inventory::class);
|
return $this->hasMany(StockReservationLine::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<CartItem, $this> */
|
/** @return HasOne<Cart, $this> */
|
||||||
public function cartItem(): BelongsTo
|
public function currentCart(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(CartItem::class);
|
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Purchase, $this> */
|
/** @return HasOne<Purchase, $this> */
|
||||||
public function purchase(): BelongsTo
|
public function purchase(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Purchase::class);
|
return $this->hasOne(Purchase::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'stock_reservation_id',
|
||||||
|
'inventory_id',
|
||||||
|
'quantity',
|
||||||
|
'tracks_inventory',
|
||||||
|
])]
|
||||||
|
class StockReservationLine extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stock_reservation_id' => 'integer',
|
||||||
|
'inventory_id' => 'integer',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'tracks_inventory' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
|
public function reservation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Inventory, $this> */
|
||||||
|
public function inventory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Inventory::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'group_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
'maximum_addable_quantity' => $this->maximumAddable(
|
||||||
'variants' => $catalogItem->variants
|
$availableStock,
|
||||||
->map(function (Variant $variant) use ($catalogItem): array {
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->unavailableMessage(
|
||||||
|
$availableStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'variants' => $catalogItem->visibleVariants()
|
||||||
|
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
||||||
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory->availableStock();
|
: $variant->inventory->availableStock();
|
||||||
$availability = $this->availability($variantStock, null);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -54,11 +60,17 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $availability,
|
'maximum_addable_quantity' => $this->maximumAddable(
|
||||||
|
$variantStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->unavailableMessage(
|
||||||
|
$variantStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
|
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,7 +90,8 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'image' => $this->firstImageUrl($catalogItem),
|
'image' => $this->firstImageUrl($catalogItem),
|
||||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,27 +107,30 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'image' => $this->firstImageUrl($catalogItem),
|
'image' => $this->firstImageUrl($catalogItem),
|
||||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
||||||
{
|
{
|
||||||
$attachment = $catalogItem->attachments->first()
|
$attachment = $catalogItem->attachments->first()
|
||||||
?? $catalogItem->variants
|
?? $catalogItem->visibleVariants()
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $attachment?->getTemporaryUrl(1440);
|
return $attachment?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
|
||||||
private function availability(
|
{
|
||||||
?int $stock,
|
|
||||||
?int $remainingUserQuota,
|
|
||||||
): array {
|
|
||||||
return app(CatalogItemAllowanceService::class)
|
return app(CatalogItemAllowanceService::class)
|
||||||
->availability($stock, $remainingUserQuota)
|
->maximumAddableQuantity($stock, $remainingUserQuota);
|
||||||
->toArray();
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)
|
||||||
|
->unavailableMessage($stock, $remainingUserQuota);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,20 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'max_units_per_user' => $this->max_units_per_user,
|
'max_units_per_user' => $this->max_units_per_user,
|
||||||
'has_tickets' => $this->has_tickets,
|
'has_tickets' => $this->has_tickets,
|
||||||
'attributes' => $this->attributesData(),
|
'attributes' => $this->attributesData(),
|
||||||
'availability' => $this->availability($this->availableStock()),
|
'maximum_addable_quantity' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->maximumAddable($this->availableStock()),
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->unavailableMessage($this->availableStock()),
|
||||||
|
),
|
||||||
'images' => $this->when(
|
'images' => $this->when(
|
||||||
$selectedVariant === null,
|
$selectedVariant === null,
|
||||||
fn () => $this->imageUrls($this->attachments),
|
fn () => $this->imageUrls($this->attachments),
|
||||||
),
|
),
|
||||||
'variants' => $this->variants
|
'variants' => $this->variants
|
||||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||||
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
|
|
||||||
->values(),
|
->values(),
|
||||||
'selected_variant' => $this->when(
|
'selected_variant' => $this->when(
|
||||||
$selectedVariant !== null,
|
$selectedVariant !== null,
|
||||||
@@ -158,10 +164,6 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
$values = $variant->selectionOptions($this->itemAttributes);
|
$values = $variant->selectionOptions($this->itemAttributes);
|
||||||
$eventDates = $variant->selectedEventDates();
|
$eventDates = $variant->selectedEventDates();
|
||||||
$variantStock = $this->variantStock($variant);
|
$variantStock = $this->variantStock($variant);
|
||||||
$availability = $this->availability(
|
|
||||||
$variantStock,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -171,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $availability,
|
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($variantStock),
|
||||||
'values' => $values,
|
'values' => $values,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -191,14 +194,19 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
: $variant->inventory->availableStock();
|
: $variant->inventory->availableStock();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
private function maximumAddable(?int $stock): ?int
|
||||||
private function availability(
|
{
|
||||||
?int $stock,
|
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
|
||||||
bool $includeUserQuota = true,
|
|
||||||
): array {
|
|
||||||
return app(CatalogItemAllowanceService::class)->availability(
|
|
||||||
$stock,
|
$stock,
|
||||||
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
|
$this->getAttribute('remaining_user_quota'),
|
||||||
)->toArray();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->unavailableMessage(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$availableStock = $this->availableStock();
|
$availableStock = $this->availableStock();
|
||||||
|
$visibleVariants = $this->visibleVariants();
|
||||||
$attachment = $this->attachments->first()
|
$attachment = $this->attachments->first()
|
||||||
?? $this->variants
|
?? $visibleVariants
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -28,16 +29,13 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'descripcion' => $this->descripcion,
|
'descripcion' => $this->descripcion,
|
||||||
'precio' => $this->precio,
|
'precio' => $this->precio,
|
||||||
'image' => $attachment?->getTemporaryUrl(1440),
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
'availability' => $this->availability($availableStock),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
|
||||||
'variants' => $this->variants
|
'unavailable_message' => $this->unavailableMessage($availableStock),
|
||||||
|
'variants' => $visibleVariants
|
||||||
->map(function (Variant $variant): array {
|
->map(function (Variant $variant): array {
|
||||||
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory?->availableStock();
|
: $variant->inventory?->availableStock();
|
||||||
$availability = $this->availability(
|
|
||||||
$variantStock,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -47,23 +45,28 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $availability,
|
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($variantStock),
|
||||||
'values' => $variant->selectorOptions($this->itemAttributes),
|
'values' => $variant->selectorOptions($this->itemAttributes),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
|
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
private function maximumAddable(?int $stock): ?int
|
||||||
private function availability(
|
{
|
||||||
?int $stock,
|
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
|
||||||
bool $includeUserQuota = true,
|
|
||||||
): array {
|
|
||||||
return app(CatalogItemAllowanceService::class)->availability(
|
|
||||||
$stock,
|
$stock,
|
||||||
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
|
$this->getAttribute('remaining_user_quota'),
|
||||||
)->toArray();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->unavailableMessage(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\CatalogAction;
|
|
||||||
|
|
||||||
final readonly class AvailabilityDecision
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<CatalogAction> $allowedActions
|
|
||||||
* @param list<array{code: string, message: string}> $reasons
|
|
||||||
*/
|
|
||||||
private function __construct(
|
|
||||||
private bool $visible,
|
|
||||||
private ?int $maximumQuantity,
|
|
||||||
private array $allowedActions,
|
|
||||||
private array $reasons,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param list<array{code: string, message: string}> $reasons */
|
|
||||||
public static function hidden(array $reasons): self
|
|
||||||
{
|
|
||||||
return new self(false, null, [], $reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<CatalogAction> $allowedActions
|
|
||||||
* @param list<array{code: string, message: string}> $reasons
|
|
||||||
*/
|
|
||||||
public static function visible(
|
|
||||||
?int $maximumQuantity,
|
|
||||||
array $allowedActions,
|
|
||||||
array $reasons,
|
|
||||||
): self {
|
|
||||||
return new self(true, $maximumQuantity, $allowedActions, $reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isVisible(): bool
|
|
||||||
{
|
|
||||||
return $this->visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function toArray(): array
|
|
||||||
{
|
|
||||||
if (! $this->visible) {
|
|
||||||
return [
|
|
||||||
'state' => 'hidden',
|
|
||||||
'reasons' => $this->reasons,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'state' => 'visible',
|
|
||||||
'maximum_quantity' => $this->maximumQuantity,
|
|
||||||
'allowed_actions' => array_map(
|
|
||||||
fn (CatalogAction $action): string => $action->value,
|
|
||||||
$this->allowedActions,
|
|
||||||
),
|
|
||||||
'reasons' => $this->reasons,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\AvailabilityEffect;
|
|
||||||
use App\Domains\Catalog\Enums\CatalogAction;
|
|
||||||
|
|
||||||
final class AvailabilityPolicyResolver
|
|
||||||
{
|
|
||||||
/** @return array{effect: AvailabilityEffect, denied_actions: list<CatalogAction>} */
|
|
||||||
public function resolve(string $restrictionCode): array
|
|
||||||
{
|
|
||||||
/** @var array{effect?: string, denied_actions?: list<string>} $configured */
|
|
||||||
$configured = config("catalog.availability.rules.{$restrictionCode}", []);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'effect' => AvailabilityEffect::from(
|
|
||||||
$configured['effect'] ?? AvailabilityEffect::Notice->value,
|
|
||||||
),
|
|
||||||
'denied_actions' => array_map(
|
|
||||||
fn (string $action): CatalogAction => CatalogAction::from($action),
|
|
||||||
$configured['denied_actions'] ?? [],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,24 @@ class CatalogInventoryService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (array $requirement): array => [
|
||||||
|
...$requirement,
|
||||||
|
'quantity' => $requirement['quantity'] * $quantity,
|
||||||
|
],
|
||||||
|
$this->inventoryRequirements($selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||||
{
|
{
|
||||||
if ($selection instanceof CatalogItem
|
if ($selection instanceof CatalogItem
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\AvailabilityEffect;
|
|
||||||
use App\Domains\Catalog\Enums\CatalogAction;
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -16,7 +14,6 @@ class CatalogItemAllowanceService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||||
private readonly AvailabilityPolicyResolver $policies,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param Collection<int, CatalogItem> $catalogItems */
|
/** @param Collection<int, CatalogItem> $catalogItems */
|
||||||
@@ -45,82 +42,16 @@ class CatalogItemAllowanceService
|
|||||||
return min($availableStock, $remainingUserQuota);
|
return min($availableStock, $remainingUserQuota);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function availability(
|
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
|
||||||
?int $availableStock,
|
{
|
||||||
?int $remainingUserQuota,
|
|
||||||
): AvailabilityDecision {
|
|
||||||
$reasons = [];
|
|
||||||
|
|
||||||
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
|
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
|
||||||
$reasons[] = [
|
return self::USER_QUOTA_REACHED_MESSAGE;
|
||||||
'code' => 'user_quota_reached',
|
|
||||||
'message' => self::USER_QUOTA_REACHED_MESSAGE,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($availableStock !== null && $availableStock <= 0) {
|
if ($availableStock !== null && $availableStock <= 0) {
|
||||||
$reasons[] = [
|
return self::OUT_OF_STOCK_MESSAGE;
|
||||||
'code' => 'out_of_stock',
|
|
||||||
'message' => self::OUT_OF_STOCK_MESSAGE,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->decision(
|
return null;
|
||||||
$this->maximumAddableQuantity($availableStock, $remainingUserQuota),
|
|
||||||
$reasons,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purchaseLimitExceededAvailability(
|
|
||||||
int $maximumQuantity,
|
|
||||||
string $message,
|
|
||||||
): AvailabilityDecision {
|
|
||||||
$reasons = [];
|
|
||||||
|
|
||||||
if ($maximumQuantity <= 0) {
|
|
||||||
$reasons[] = [
|
|
||||||
'code' => 'user_quota_reached',
|
|
||||||
'message' => self::USER_QUOTA_REACHED_MESSAGE,
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$reasons[] = [
|
|
||||||
'code' => 'requested_quantity_exceeds_user_quota',
|
|
||||||
'message' => $message,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->decision($maximumQuantity, $reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<array{code: string, message: string}> $reasons
|
|
||||||
*/
|
|
||||||
private function decision(?int $maximumQuantity, array $reasons): AvailabilityDecision
|
|
||||||
{
|
|
||||||
/** @var list<string> $configuredActions */
|
|
||||||
$configuredActions = config('catalog.availability.default_actions', []);
|
|
||||||
$allowedActions = collect($configuredActions)
|
|
||||||
->map(fn (string $action): CatalogAction => CatalogAction::from($action));
|
|
||||||
|
|
||||||
foreach ($reasons as $reason) {
|
|
||||||
$policy = $this->policies->resolve($reason['code']);
|
|
||||||
|
|
||||||
if ($policy['effect'] === AvailabilityEffect::Hide) {
|
|
||||||
return AvailabilityDecision::hidden($reasons);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($policy['effect'] === AvailabilityEffect::Restrict) {
|
|
||||||
$deniedActions = $policy['denied_actions'];
|
|
||||||
$allowedActions = $allowedActions->reject(
|
|
||||||
fn (CatalogAction $action): bool => in_array($action, $deniedActions, true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AvailabilityDecision::visible(
|
|
||||||
$maximumQuantity,
|
|
||||||
$allowedActions->values()->all(),
|
|
||||||
$reasons,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
|
|
||||||
class CatalogService
|
class CatalogService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(protected AttachmentService $attachmentService) {}
|
||||||
protected AttachmentService $attachmentService,
|
|
||||||
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
@@ -208,6 +205,13 @@ class CatalogService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$visibleVariants = $catalogItem->visibleVariants();
|
$visibleVariants = $catalogItem->visibleVariants();
|
||||||
|
if ($catalogItem->type === CatalogItemType::Standard
|
||||||
|
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
||||||
|
&& ! $catalogItem->isAvailable()) {
|
||||||
|
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem->setRelation('variants', $visibleVariants);
|
||||||
$selectedVariant = $variantId === null
|
$selectedVariant = $variantId === null
|
||||||
? $visibleVariants->first()
|
? $visibleVariants->first()
|
||||||
: $visibleVariants->firstWhere('id', $variantId);
|
: $visibleVariants->firstWhere('id', $variantId);
|
||||||
@@ -232,8 +236,9 @@ class CatalogService
|
|||||||
$containsPattern = "%{$normalizedTerm}%";
|
$containsPattern = "%{$normalizedTerm}%";
|
||||||
$startsWithPattern = "{$normalizedTerm}%";
|
$startsWithPattern = "{$normalizedTerm}%";
|
||||||
|
|
||||||
$query = CatalogItem::query()
|
$paginator = CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereAvailable()
|
||||||
->where(function (Builder $query) use ($containsPattern): void {
|
->where(function (Builder $query) use ($containsPattern): void {
|
||||||
$query
|
$query
|
||||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
@@ -260,10 +265,7 @@ class CatalogService
|
|||||||
'variants.definitions.itemAttribute.attribute.options',
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
])
|
||||||
|
|
||||||
$paginator = $this->visibleCatalogItems
|
|
||||||
->apply($query)
|
|
||||||
->orderByRaw(
|
->orderByRaw(
|
||||||
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
||||||
[$normalizedTerm, $startsWithPattern],
|
[$normalizedTerm, $startsWithPattern],
|
||||||
@@ -283,9 +285,10 @@ class CatalogService
|
|||||||
int $perPage,
|
int $perPage,
|
||||||
int $page,
|
int $page,
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
$query = CatalogItem::query()
|
return CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('category_id', $category->id)
|
->where('category_id', $category->id)
|
||||||
|
->whereAvailable()
|
||||||
->with([
|
->with([
|
||||||
'attachments',
|
'attachments',
|
||||||
'inventory',
|
'inventory',
|
||||||
@@ -297,10 +300,7 @@ class CatalogService
|
|||||||
'variants.definitions.itemAttribute.attribute.options',
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
])
|
||||||
|
|
||||||
return $this->visibleCatalogItems
|
|
||||||
->apply($query)
|
|
||||||
->orderBy('nombre')
|
->orderBy('nombre')
|
||||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,50 +2,184 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ExpireStockReservationsService
|
class ExpireStockReservationsService
|
||||||
{
|
{
|
||||||
|
private const BATCH_SIZE = 500;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutService $checkout,
|
private readonly ReleaseCheckoutService $purchases,
|
||||||
private readonly ExpireCartReservationsService $carts,
|
private readonly StockReservationService $reservations,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{purchases: int, cart_items: int}
|
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||||
*/
|
*/
|
||||||
public function expireOverdue(): array
|
public function expireOverdue(): array
|
||||||
{
|
{
|
||||||
$expiredPurchases = null;
|
$summary = [
|
||||||
$expiredCartItems = null;
|
'purchases' => 0,
|
||||||
|
'cart_reservations' => 0,
|
||||||
|
'orphan_reservations' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
$lastReservationId = 0;
|
||||||
|
|
||||||
try {
|
do {
|
||||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
$reservationIds = StockReservation::query()
|
||||||
$expiredCartItems = $this->carts->expireOverdue();
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->where('id', '>', $lastReservationId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::BATCH_SIZE)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
foreach ($reservationIds as $reservationId) {
|
||||||
'command' => 'reservations:expire',
|
$lastReservationId = (int) $reservationId;
|
||||||
'expired_purchases' => $expiredPurchases,
|
|
||||||
'expired_cart_items' => $expiredCartItems,
|
|
||||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
try {
|
||||||
'purchases' => $expiredPurchases,
|
$owner = $this->expireReservation($lastReservationId);
|
||||||
'cart_items' => $expiredCartItems,
|
if ($owner !== null) {
|
||||||
];
|
$summary[$owner]++;
|
||||||
} catch (Throwable $exception) {
|
}
|
||||||
Log::channel('commands')->error('Stock reservation cleanup failed.', [
|
} catch (Throwable $exception) {
|
||||||
'command' => 'reservations:expire',
|
$summary['failed']++;
|
||||||
'expired_purchases' => $expiredPurchases,
|
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||||
'expired_cart_items' => $expiredCartItems,
|
'command' => 'reservations:expire',
|
||||||
'exception' => $exception,
|
'stock_reservation_id' => $lastReservationId,
|
||||||
]);
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||||
|
|
||||||
throw $exception;
|
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||||
|
'command' => 'reservations:expire',
|
||||||
|
'expired_purchases' => $summary['purchases'],
|
||||||
|
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||||
|
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||||
|
'failed_reservations' => $summary['failed'],
|
||||||
|
'total_expired' => $summary['purchases']
|
||||||
|
+ $summary['cart_reservations']
|
||||||
|
+ $summary['orphan_reservations'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expireIfOverdue(int $reservationId): bool
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->find($reservationId);
|
||||||
|
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expireReservation($reservationId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
||||||
|
private function expireReservation(int $reservationId): ?string
|
||||||
|
{
|
||||||
|
$purchaseId = Purchase::query()
|
||||||
|
->where('stock_reservation_id', $reservationId)
|
||||||
|
->value('id');
|
||||||
|
if ($purchaseId !== null) {
|
||||||
|
return $this->expirePurchase((int) $purchaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartId = Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservationId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->value('id');
|
||||||
|
if ($cartId !== null) {
|
||||||
|
return $this->expireCart((int) $cartId, $reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expireOrphan($reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'purchases'|null */
|
||||||
|
private function expirePurchase(int $purchaseId): ?string
|
||||||
|
{
|
||||||
|
/** @var Purchase|null $purchase */
|
||||||
|
$purchase = Purchase::query()->find($purchaseId);
|
||||||
|
if ($purchase === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase = $this->purchases->expire($purchase);
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||||
|
return 'purchases';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation = $purchase->stockReservation;
|
||||||
|
if ($this->isOverdue($reservation)) {
|
||||||
|
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'cart_reservations'|null */
|
||||||
|
private function expireCart(int $cartId, int $reservationId): ?string
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
||||||
|
/** @var Cart|null $cart */
|
||||||
|
$cart = Cart::query()
|
||||||
|
->whereKey($cartId)
|
||||||
|
->where('current_stock_reservation_id', $reservationId)
|
||||||
|
->where('status', Cart::STATUS_ACTIVE)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
if ($cart === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reservations->expire($reservation);
|
||||||
|
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
return 'cart_reservations';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'orphan_reservations'|null */
|
||||||
|
private function expireOrphan(int $reservationId): ?string
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->find($reservationId);
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reservations->expire($reservation);
|
||||||
|
|
||||||
|
return 'orphan_reservations';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isOverdue(?StockReservation $reservation): bool
|
||||||
|
{
|
||||||
|
return $reservation !== null
|
||||||
|
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||||
|
&& $reservation->expires_at !== null
|
||||||
|
&& ! $reservation->expires_at->isFuture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class FeaturedGroupService
|
|||||||
/** @return array<array-key, mixed> */
|
/** @return array<array-key, mixed> */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogItemAllowanceService $allowances,
|
private readonly CatalogItemAllowanceService $allowances,
|
||||||
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
|
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
|
||||||
@@ -50,6 +49,7 @@ class FeaturedGroupService
|
|||||||
{
|
{
|
||||||
$query = CatalogItem::query()
|
$query = CatalogItem::query()
|
||||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->whereAvailable()
|
||||||
->where(function (Builder $query): void {
|
->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('category')
|
->whereDoesntHave('category')
|
||||||
@@ -72,8 +72,6 @@ class FeaturedGroupService
|
|||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = $this->visibleCatalogItems->apply($query);
|
|
||||||
|
|
||||||
return match ($featuredGroup->source_type) {
|
return match ($featuredGroup->source_type) {
|
||||||
FeaturedGroupSource::Manual => $query
|
FeaturedGroupSource::Manual => $query
|
||||||
->select('catalog_items.*')
|
->select('catalog_items.*')
|
||||||
@@ -84,7 +82,9 @@ class FeaturedGroupService
|
|||||||
FeaturedGroupSource::Category => $query
|
FeaturedGroupSource::Category => $query
|
||||||
->where('catalog_items.category_id', $featuredGroup->category_id)
|
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||||
->orderBy('catalog_items.id'),
|
->orderBy('catalog_items.id'),
|
||||||
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
FeaturedGroupSource::All => $query
|
||||||
|
->orderBy('catalog_items.group_order')
|
||||||
|
->orderBy('catalog_items.id'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,262 +2,486 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class StockReservationService
|
class StockReservationService
|
||||||
{
|
{
|
||||||
|
public const REASON_CART_EMPTY = 'cart_empty';
|
||||||
|
|
||||||
|
public const REASON_CART_CHANGED = 'cart_changed';
|
||||||
|
|
||||||
|
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
|
||||||
|
|
||||||
|
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
|
||||||
|
|
||||||
|
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
|
||||||
|
|
||||||
|
public const REASON_MANUAL_RELEASE = 'manual_release';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
public function syncCart(Cart $cart): ?StockReservation
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
|
return DB::transaction(function () use ($cart): ?StockReservation {
|
||||||
$this->inventory->reserve($selection, $quantity);
|
/** @var Cart $lockedCart */
|
||||||
$this->recordIncrease($cartItem, $selection, $quantity);
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
});
|
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
}
|
$this->loadSelections($items);
|
||||||
|
$requirements = $this->requirementsForItems($items);
|
||||||
|
|
||||||
public function release(
|
$reservation = $lockedCart->current_stock_reservation_id === null
|
||||||
CartItem $cartItem,
|
? null
|
||||||
CatalogItem|Variant $selection,
|
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||||
int $quantity,
|
|
||||||
string $releasedStatus = StockReservation::STATUS_RELEASED,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
$this->inventory->release($selection, $quantity);
|
|
||||||
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function commit(
|
if ($reservation !== null) {
|
||||||
CartItem $cartItem,
|
$this->assertUsableCartReservation($reservation);
|
||||||
CatalogItem|Variant $selection,
|
}
|
||||||
Purchase $purchase,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
|
||||||
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
if ($requirements === []) {
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
$this->finalizeLocked(
|
||||||
if (
|
$reservation,
|
||||||
$reservation === null
|
StockReservation::STATUS_RELEASED,
|
||||||
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|
self::REASON_CART_EMPTY,
|
||||||
|| $reservation->purchase_id !== $purchase->getKey()
|
);
|
||||||
|| $reservation->quantity !== $quantity
|
|
||||||
) {
|
|
||||||
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$reservation->update([
|
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||||
'status' => StockReservation::STATUS_COMMITTED,
|
$cart->current_stock_reservation_id = null;
|
||||||
'committed_at' => now(),
|
|
||||||
'expires_at' => null,
|
return null;
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
|
|
||||||
{
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
|
||||||
|
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
|
||||||
|
|
||||||
if ($reservation === null) {
|
if ($reservation === null) {
|
||||||
StockReservation::query()->create([
|
$reservation = StockReservation::query()->create([
|
||||||
'inventory_id' => $inventoryId,
|
|
||||||
'cart_item_id' => $cartItem->getKey(),
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
'expires_at' => $this->expiration(),
|
'expires_at' => $this->expiration(),
|
||||||
]);
|
]);
|
||||||
|
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
|
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
|
||||||
$reservation->update([
|
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
|
||||||
'quantity' => $quantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'committed_at' => null,
|
|
||||||
'released_at' => null,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attachToPurchase(
|
$currentLines = StockReservationLine::query()
|
||||||
CartItem $cartItem,
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
CatalogItem|Variant $selection,
|
->orderBy('inventory_id')
|
||||||
Purchase $purchase,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->update([
|
|
||||||
'purchase_id' => $purchase->getKey(),
|
|
||||||
'expires_at' => $purchase->expires_at,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function detachFromPurchase(Purchase $purchase): void
|
|
||||||
{
|
|
||||||
StockReservation::query()
|
|
||||||
->where('purchase_id', $purchase->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->update([
|
|
||||||
'purchase_id' => null,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($cartItem, $selection): void {
|
|
||||||
$requirements = $this->inventory->requirementsFor(
|
|
||||||
$selection,
|
|
||||||
(int) $cartItem->cantidad,
|
|
||||||
);
|
|
||||||
$activeReservations = StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get()
|
->get()
|
||||||
->keyBy('inventory_id');
|
->keyBy('inventory_id');
|
||||||
|
$inventoryIds = collect(array_keys($requirements))
|
||||||
$hasCompleteReservation = collect($requirements)->every(
|
->merge($currentLines->keys())
|
||||||
fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity,
|
->map(fn ($id): int => (int) $id)
|
||||||
);
|
->unique()
|
||||||
|
->sort()
|
||||||
if ($hasCompleteReservation) {
|
->values();
|
||||||
return;
|
$inventories = Inventory::query()
|
||||||
}
|
->whereKey($inventoryIds)
|
||||||
|
->orderBy('id')
|
||||||
if ($activeReservations->isNotEmpty()) {
|
|
||||||
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->inventory->reserve($selection, (int) $cartItem->cantidad);
|
|
||||||
$this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function syncPurchaseExpiration(Purchase $purchase): void
|
|
||||||
{
|
|
||||||
StockReservation::query()
|
|
||||||
->where('purchase_id', $purchase->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->update(['expires_at' => $purchase->expires_at]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transfer(CartItem $source, CartItem $target): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($source, $target): void {
|
|
||||||
$sourceReservations = StockReservation::query()
|
|
||||||
->where('cart_item_id', $source->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->orderBy('inventory_id')
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get();
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
foreach ($sourceReservations as $sourceReservation) {
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
|
$inventory = $inventories->get($inventoryId)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
|
||||||
|
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
if ($targetReservation === null) {
|
if ($delta > 0
|
||||||
$sourceItemQuantity = (int) $source->cantidad;
|
&& $requirements[$inventoryId]['tracks_inventory']
|
||||||
$targetItemQuantity = (int) $target->fresh()->cantidad;
|
&& $inventory->availableStock() < $delta) {
|
||||||
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
|
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
|
||||||
$sourceReservation->update([
|
}
|
||||||
'cart_item_id' => $target->getKey(),
|
|
||||||
'purchase_id' => null,
|
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
|
||||||
'quantity' => $perItemQuantity * $targetItemQuantity,
|
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
||||||
'expires_at' => $this->expiration(),
|
}
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
|
/** @var Inventory $inventory */
|
||||||
|
$inventory = $inventories->get($inventoryId);
|
||||||
|
$line = $currentLines->get($inventoryId);
|
||||||
|
$previous = (int) ($line?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
|
if ($delta > 0) {
|
||||||
|
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
|
||||||
|
} elseif ($delta < 0) {
|
||||||
|
$inventory->release(abs($delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($required === 0) {
|
||||||
|
$line?->delete();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetReservation->update([
|
StockReservationLine::query()->updateOrCreate(
|
||||||
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
[
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
'stock_reservation_id' => $reservation->getKey(),
|
||||||
'expires_at' => $this->expiration(),
|
'inventory_id' => $inventoryId,
|
||||||
]);
|
],
|
||||||
$sourceReservation->delete();
|
[
|
||||||
|
'quantity' => $required,
|
||||||
|
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachToPurchase(
|
||||||
|
Cart $cart,
|
||||||
|
Purchase $purchase,
|
||||||
|
Carbon $expiresAt,
|
||||||
|
): StockReservation {
|
||||||
|
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||||
|
/** @var Cart $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
/** @var Purchase $lockedPurchase */
|
||||||
|
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($lockedCart->current_stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
|
||||||
|
$linkedPurchase = Purchase::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->whereKeyNot($lockedPurchase->getKey())
|
||||||
|
->exists();
|
||||||
|
if ($linkedPurchase) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||||
|
$reservation->update(['expires_at' => $expiresAt]);
|
||||||
|
$purchase->stock_reservation_id = $reservation->getKey();
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
if ($lines->isEmpty()) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
if ($inventory->reserved_stock < $line->quantity
|
||||||
|
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventories->get($line->inventory_id)->buy(
|
||||||
|
(int) $line->quantity,
|
||||||
|
(bool) $line->tracks_inventory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'status' => StockReservation::STATUS_COMMITTED,
|
||||||
|
'expires_at' => null,
|
||||||
|
'committed_at' => now(),
|
||||||
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseForPurchase(
|
||||||
|
Purchase $purchase,
|
||||||
|
string $status = StockReservation::STATUS_RELEASED,
|
||||||
|
?string $reason = null,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($purchase, $status, $reason): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->finalizeLocked($reservation, $status, $reason);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||||
{
|
{
|
||||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
return DB::transaction(function () use ($purchase, $cart): StockReservation {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
|
||||||
if ($reservation === null) {
|
if ($purchase->stock_reservation_id === null
|
||||||
StockReservation::query()->create([
|
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
|
||||||
'inventory_id' => $inventoryId,
|
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
|
||||||
'cart_item_id' => $cartItem->getKey(),
|
|
||||||
'quantity' => $requiredQuantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$reservation->update([
|
/** @var StockReservation $reservation */
|
||||||
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
|
$reservation = StockReservation::query()
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
->lockForUpdate()
|
||||||
'committed_at' => null,
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
'released_at' => null,
|
$this->assertUsableCartReservation($reservation);
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
$purchase->update(['stock_reservation_id' => null]);
|
||||||
}
|
$cart->update(['current_purchase_id' => null]);
|
||||||
|
$reservation->update(['expires_at' => $this->expiration()]);
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordDecrease(
|
public function assertCartReservationUsable(Cart $cart): void
|
||||||
CartItem $cartItem,
|
{
|
||||||
CatalogItem|Variant $selection,
|
DB::transaction(function () use ($cart): void {
|
||||||
int $quantity,
|
/** @var Cart $cart */
|
||||||
string $releasedStatus,
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($cart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseCurrentCartReservation(
|
||||||
|
Cart $cart,
|
||||||
|
string $reason = self::REASON_CART_CHANGED,
|
||||||
): void {
|
): void {
|
||||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
DB::transaction(function () use ($cart, $reason): void {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
/** @var Cart $cart */
|
||||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remaining = $reservation->quantity - $requiredQuantity;
|
/** @var StockReservation|null $reservation */
|
||||||
$reservation->update([
|
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
|
||||||
'quantity' => $remaining,
|
if ($reservation !== null) {
|
||||||
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
|
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
|
||||||
'released_at' => $remaining === 0 ? now() : null,
|
}
|
||||||
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
|
$cart->update(['current_stock_reservation_id' => null]);
|
||||||
]);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expire(StockReservation $reservation): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($reservation): void {
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null
|
||||||
|
|| $reservation->expires_at->isFuture()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearExpirationForReview(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update(['expires_at' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CartItem> $items
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
private function requirementsForItems(Collection $items): array
|
||||||
|
{
|
||||||
|
$requirements = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$selection = $item->selectedItem();
|
||||||
|
if ($selection === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
|
||||||
|
if (isset($requirements[$inventoryId])) {
|
||||||
|
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] =
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirements[$inventoryId] = $requirement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($requirements);
|
||||||
|
|
||||||
|
return $requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, CartItem> $items */
|
||||||
|
private function loadSelections(Collection $items): void
|
||||||
|
{
|
||||||
|
$items->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.catalogItem',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.catalogItem',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, StockReservationLine> */
|
||||||
|
private function lockLines(StockReservation $reservation): Collection
|
||||||
|
{
|
||||||
|
return StockReservationLine::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, StockReservationLine> $lines
|
||||||
|
* @return Collection<int, Inventory>
|
||||||
|
*/
|
||||||
|
private function lockInventories(Collection $lines): Collection
|
||||||
|
{
|
||||||
|
return Inventory::query()
|
||||||
|
->whereKey($lines->pluck('inventory_id'))
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeLocked(
|
||||||
|
StockReservation $reservation,
|
||||||
|
string $status,
|
||||||
|
?string $reason,
|
||||||
|
): void {
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
|
||||||
|
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
$inventory->release((int) $line->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$reservation->update([
|
||||||
|
'status' => $status,
|
||||||
|
'expires_at' => null,
|
||||||
|
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
|
||||||
|
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
|
||||||
|
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === StockReservation::STATUS_RELEASED) {
|
||||||
|
Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservation->getKey())
|
||||||
|
->update(['current_stock_reservation_id' => null]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
|
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||||
{
|
{
|
||||||
return StockReservation::query()
|
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||||
->where('inventory_id', $inventoryId)
|
throw new StockReservationExpiredException;
|
||||||
->lockForUpdate()
|
}
|
||||||
->first();
|
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function expiration(): Carbon
|
private function expiration(): Carbon
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ use Illuminate\Support\Collection;
|
|||||||
|
|
||||||
class VariantSelectionService
|
class VariantSelectionService
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly CatalogItemAllowanceService $allowances,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $selectedValues
|
* @param array<string, mixed> $selectedValues
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@@ -192,17 +188,13 @@ class VariantSelectionService
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
private function variantData(CatalogItem $catalogItem, Variant $variant): array
|
private function variantData(CatalogItem $catalogItem, Variant $variant): array
|
||||||
{
|
{
|
||||||
$availableStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
|
||||||
? null
|
|
||||||
: $variant->inventory?->availableStock();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $this->allowances
|
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
->availability($availableStock, null)
|
? null
|
||||||
->toArray(),
|
: $variant->inventory?->availableStock(),
|
||||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\AvailabilityEffect;
|
|
||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
final class VisibleCatalogItemsQuery
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AvailabilityPolicyResolver $policies,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
|
||||||
public function apply(Builder $query): Builder
|
|
||||||
{
|
|
||||||
if ($this->policies->resolve('out_of_stock')['effect'] !== AvailabilityEffect::Hide) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->where(function (Builder $query): void {
|
|
||||||
$query
|
|
||||||
->where(fn (Builder $query) => $this->applyStandardItemAvailability($query))
|
|
||||||
->orWhere(fn (Builder $query) => $this->applyBundleAvailability($query));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
|
||||||
private function applyStandardItemAvailability(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->where('catalog_items.type', CatalogItemType::Standard->value)
|
|
||||||
->where(function (Builder $query): void {
|
|
||||||
$query
|
|
||||||
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
|
||||||
->orWhereHas(
|
|
||||||
'inventory',
|
|
||||||
fn (Builder $query): Builder => $query
|
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'),
|
|
||||||
)
|
|
||||||
->orWhereHas(
|
|
||||||
'variants.inventory',
|
|
||||||
fn (Builder $query): Builder => $query
|
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
|
||||||
private function applyBundleAvailability(Builder $query): Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->where('catalog_items.type', CatalogItemType::Bundle->value)
|
|
||||||
->whereRaw(<<<'SQL'
|
|
||||||
NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM bundle_components AS availability_components
|
|
||||||
INNER JOIN catalog_items AS availability_items
|
|
||||||
ON availability_items.id = availability_components.component_catalog_item_id
|
|
||||||
LEFT JOIN variantes AS availability_variants
|
|
||||||
ON availability_variants.id = availability_components.component_variant_id
|
|
||||||
INNER JOIN inventories AS availability_inventories
|
|
||||||
ON availability_inventories.id = COALESCE(
|
|
||||||
availability_variants.inventory_id,
|
|
||||||
availability_items.inventory_id
|
|
||||||
)
|
|
||||||
WHERE availability_components.bundle_catalog_item_id = catalog_items.id
|
|
||||||
AND availability_items.inventory_policy = ?
|
|
||||||
GROUP BY availability_inventories.id,
|
|
||||||
availability_inventories.real_stock,
|
|
||||||
availability_inventories.reserved_stock
|
|
||||||
HAVING availability_inventories.real_stock
|
|
||||||
- availability_inventories.reserved_stock
|
|
||||||
< SUM(availability_components.quantity)
|
|
||||||
)
|
|
||||||
SQL, [InventoryPolicy::Tracked->value]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||||
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||||
- `Inventory` administra stock disponible, reservado y comprado.
|
- `Inventory` administra stock disponible, reservado y comprado.
|
||||||
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
|
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
|
||||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||||
- `BundleComponent` representa los componentes de un paquete.
|
- `BundleComponent` representa los componentes de un paquete.
|
||||||
@@ -18,7 +18,8 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
|
|
||||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||||
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
|
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||||
|
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Desfile\Services;
|
|||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ class InvitationPurchaseProvisioner
|
|||||||
private const ALLOCATIONS = [
|
private const ALLOCATIONS = [
|
||||||
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
|
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
|
||||||
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
|
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
|
||||||
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'VIP + LUNCH'],
|
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
|
||||||
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
|
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ class InvitationPurchaseProvisioner
|
|||||||
$allocation['type'],
|
$allocation['type'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->createPurchaseItem(
|
$purchaseItemId = $this->createPurchaseItem(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -70,6 +71,7 @@ class InvitationPurchaseProvisioner
|
|||||||
);
|
);
|
||||||
$this->createTicketAndCommitStock(
|
$this->createTicketAndCommitStock(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
|
$purchaseItemId,
|
||||||
$userId,
|
$userId,
|
||||||
(int) $catalogItem->id,
|
(int) $catalogItem->id,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -102,7 +104,11 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function userId(DateTimeInterface $now): int
|
private function userId(DateTimeInterface $now): int
|
||||||
{
|
{
|
||||||
$user = DB::table('users')->where('email', self::USER_EMAIL)->first();
|
$user = DB::table('users')
|
||||||
|
->where('active_email', self::USER_EMAIL)
|
||||||
|
->where('rol_codigo', 'user')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
||||||
@@ -144,7 +150,6 @@ class InvitationPurchaseProvisioner
|
|||||||
if ($purchaseId !== null) {
|
if ($purchaseId !== null) {
|
||||||
DB::table('compras')->where('id', $purchaseId)->update([
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
'status' => 'paid',
|
'status' => 'paid',
|
||||||
'expires_at' => null,
|
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -158,7 +163,6 @@ class InvitationPurchaseProvisioner
|
|||||||
'cart_id' => null,
|
'cart_id' => null,
|
||||||
'status' => 'paid',
|
'status' => 'paid',
|
||||||
'payment_method' => self::PAYMENT_METHOD,
|
'payment_method' => self::PAYMENT_METHOD,
|
||||||
'expires_at' => null,
|
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'dni' => null,
|
'dni' => null,
|
||||||
'transfer_payer_dni' => null,
|
'transfer_payer_dni' => null,
|
||||||
@@ -328,12 +332,13 @@ class InvitationPurchaseProvisioner
|
|||||||
int $seat,
|
int $seat,
|
||||||
string $type,
|
string $type,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): int {
|
||||||
if (DB::table('compra_items')
|
$existingId = DB::table('compra_items')
|
||||||
->where('compra_id', $purchaseId)
|
->where('compra_id', $purchaseId)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->value('id');
|
||||||
return;
|
if ($existingId !== null) {
|
||||||
|
return (int) $existingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = [
|
$attributes = [
|
||||||
@@ -343,7 +348,7 @@ class InvitationPurchaseProvisioner
|
|||||||
['name' => 'Asiento', 'value' => (string) $seat],
|
['name' => 'Asiento', 'value' => (string) $seat],
|
||||||
];
|
];
|
||||||
|
|
||||||
DB::table('compra_items')->insert([
|
return DB::table('compra_items')->insertGetId([
|
||||||
'compra_id' => $purchaseId,
|
'compra_id' => $purchaseId,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
@@ -369,13 +374,18 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function createTicketAndCommitStock(
|
private function createTicketAndCommitStock(
|
||||||
int $purchaseId,
|
int $purchaseId,
|
||||||
|
int $purchaseItemId,
|
||||||
int $userId,
|
int $userId,
|
||||||
int $catalogItemId,
|
int $catalogItemId,
|
||||||
object $variant,
|
object $variant,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): void {
|
||||||
|
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
|
||||||
|
? ['source_purchase_item_id' => $purchaseItemId]
|
||||||
|
: ['source_purchase_id' => $purchaseId];
|
||||||
|
|
||||||
if (DB::table('tickets')
|
if (DB::table('tickets')
|
||||||
->where('source_purchase_id', $purchaseId)
|
->where($purchaseReference)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->exists()) {
|
||||||
return;
|
return;
|
||||||
@@ -392,15 +402,27 @@ class InvitationPurchaseProvisioner
|
|||||||
'sold_units' => $inventory->sold_units + 1,
|
'sold_units' => $inventory->sold_units + 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::table('stock_reservations')->insert([
|
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
|
||||||
|
if ($reservationId === null) {
|
||||||
|
$reservationId = DB::table('stock_reservations')->insertGetId([
|
||||||
|
'status' => 'committed',
|
||||||
|
'committed_at' => $now,
|
||||||
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('stock_reservation_lines')->insert([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
'cart_item_id' => null,
|
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'status' => 'committed',
|
'tracks_inventory' => true,
|
||||||
'expires_at' => null,
|
|
||||||
'committed_at' => $now,
|
|
||||||
'released_at' => null,
|
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -410,7 +432,7 @@ class InvitationPurchaseProvisioner
|
|||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'source_purchase_id' => $purchaseId,
|
...$purchaseReference,
|
||||||
'source_catalog_item_id' => $catalogItemId,
|
'source_catalog_item_id' => $catalogItemId,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\TicketFilterFormResource;
|
||||||
|
use App\Domains\Forms\Services\TicketFilterFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketFilterFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TicketFilterFormService $formService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): TicketFilterFormResource
|
||||||
|
{
|
||||||
|
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return TicketFilterFormResource::make($this->formService->get($tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\TicketFormResource;
|
||||||
|
use App\Domains\Forms\Services\TicketFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected TicketFormService $ticketFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): TicketFormResource
|
||||||
|
{
|
||||||
|
return TicketFormResource::make(
|
||||||
|
$this->ticketFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Domains/Forms/Resources/TicketFilterFormResource.php
Normal file
21
app/Domains/Forms/Resources/TicketFilterFormResource.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TicketFilterFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => $this->resource['code'],
|
||||||
|
'action' => $this->resource['action'],
|
||||||
|
'method' => $this->resource['method'],
|
||||||
|
'fields' => $this->resource['fields'],
|
||||||
|
'columns' => $this->resource['columns'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Domains/Forms/Resources/TicketFormResource.php
Normal file
18
app/Domains/Forms/Resources/TicketFormResource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TicketFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
'categories' => $this->resource['categories'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,27 +6,18 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
|
|
||||||
class SaleFormService
|
class SaleFormService
|
||||||
{
|
{
|
||||||
/** @return array{statuses: list<array{code: string, name: string}>} */
|
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
|
||||||
public function get(): array
|
public function get(): array
|
||||||
{
|
{
|
||||||
$names = [
|
|
||||||
Purchase::STATUS_CREATED => 'Creada',
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
|
||||||
Purchase::STATUS_IN_REVIEW => 'En revisión',
|
|
||||||
Purchase::STATUS_PAID => 'Confirmada',
|
|
||||||
Purchase::STATUS_CANCELLED => 'Cancelada',
|
|
||||||
Purchase::STATUS_REJECTED => 'Rechazada',
|
|
||||||
Purchase::STATUS_EXPIRED => 'Vencida',
|
|
||||||
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'statuses' => array_map(
|
'statuses' => array_map(
|
||||||
fn (string $status): array => [
|
fn (string $code, array $definition): array => [
|
||||||
'code' => $status,
|
'value' => $code,
|
||||||
'name' => $names[$status],
|
'label' => $definition['name'],
|
||||||
|
'real_statuses' => $definition['statuses'],
|
||||||
],
|
],
|
||||||
Purchase::statuses(),
|
array_keys(Purchase::adminStatuses()),
|
||||||
|
array_values(Purchase::adminStatuses()),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
152
app/Domains/Forms/Services/TicketFilterFormService.php
Normal file
152
app/Domains/Forms/Services/TicketFilterFormService.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
|
|
||||||
|
class TicketFilterFormService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TicketFormService $ticketFormService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$fields = $this->commonFields();
|
||||||
|
|
||||||
|
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
$fields = [
|
||||||
|
...$this->fiestaFutbolInfantilFields($tenant),
|
||||||
|
...$this->commonFields(includeDate: false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'tickets_filter',
|
||||||
|
'action' => '/api/v1/adminapp/tenant/tickets',
|
||||||
|
'method' => 'GET',
|
||||||
|
'fields' => $fields,
|
||||||
|
'columns' => $this->columnService->publicColumns($tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function fiestaFutbolInfantilFields(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$form = $this->ticketFormService->getForFilters($tenant);
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'name' => 'category',
|
||||||
|
'query_param' => 'category',
|
||||||
|
'label' => 'Categoría',
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => 'Categoría',
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $category): array => [
|
||||||
|
'value' => $category['value'],
|
||||||
|
'label' => $category['label'],
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'field' => 'product',
|
||||||
|
'disabled' => $category['products'] === [],
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'field' => 'type',
|
||||||
|
'disabled' => $product['types'] === [],
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $type): array => [
|
||||||
|
'value' => $type['value'],
|
||||||
|
'label' => $type['label'],
|
||||||
|
'children' => [[
|
||||||
|
'field' => 'size',
|
||||||
|
'disabled' => ($type['sizes'] ?? []) === [],
|
||||||
|
'options' => $type['sizes'] ?? [],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
$product['types'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$category['products'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'field' => 'date',
|
||||||
|
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$form['categories'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
$this->dependentSelect('product', 'Producto', 'category'),
|
||||||
|
$this->dependentSelect('type', 'Tipo', 'product'),
|
||||||
|
$this->dependentSelect('size', 'Talle', 'type'),
|
||||||
|
[
|
||||||
|
...$this->dependentSelect('date', 'Fecha', 'category'),
|
||||||
|
'type' => 'date',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function commonFields(bool $includeDate = true): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...($includeDate ? [[
|
||||||
|
'name' => 'date',
|
||||||
|
'query_param' => 'date',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
]] : []),
|
||||||
|
[
|
||||||
|
'name' => 'status',
|
||||||
|
'query_param' => 'status',
|
||||||
|
'label' => 'Estado',
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => 'Estado',
|
||||||
|
'options' => [
|
||||||
|
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||||
|
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||||
|
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function dependentSelect(string $name, string $label, string $dependency): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'query_param' => $name,
|
||||||
|
'label' => $label,
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => $label,
|
||||||
|
'depends_on' => $dependency,
|
||||||
|
'disabled' => true,
|
||||||
|
'options' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
400
app/Domains/Forms/Services/TicketFormService.php
Normal file
400
app/Domains/Forms/Services/TicketFormService.php
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class TicketFormService
|
||||||
|
{
|
||||||
|
private const PRODUCT = 'product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
|
||||||
|
*/
|
||||||
|
private const CATEGORY_PRESENTATIONS = [
|
||||||
|
'entradas' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => null,
|
||||||
|
'order' => 1,
|
||||||
|
],
|
||||||
|
'alojamientos' => [
|
||||||
|
'label' => 'Camping',
|
||||||
|
'product' => 'tipo_alojamiento',
|
||||||
|
'type' => null,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
'camping' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => 'tipo_alojamiento',
|
||||||
|
'type' => null,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
'comidas' => [
|
||||||
|
'label' => 'Comida',
|
||||||
|
'product' => 'event_date',
|
||||||
|
'type' => 'horario',
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
'comida' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => 'event_date',
|
||||||
|
'type' => 'horario',
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
'merchandising' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => 'color',
|
||||||
|
'order' => 4,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
|
||||||
|
*/
|
||||||
|
private const FILTER_CATEGORY_PRESENTATIONS = [
|
||||||
|
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
|
||||||
|
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
||||||
|
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
||||||
|
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
||||||
|
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
||||||
|
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$items = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('has_tickets', true)
|
||||||
|
->whereHas('category')
|
||||||
|
->with($this->relations())
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->build($items, self::CATEGORY_PRESENTATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return active catalog options plus soft-deleted sources still referenced by
|
||||||
|
* tickets, so historical tickets never become impossible to filter.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getForFilters(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$historicalVariantIds = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereNotNull('source_variant_id')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source_variant_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
$historicalCatalogItemIds = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereNotNull('source_catalog_item_id')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source_catalog_item_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->merge(
|
||||||
|
Variant::withTrashed()
|
||||||
|
->whereKey($historicalVariantIds)
|
||||||
|
->pluck('catalog_item_id')
|
||||||
|
->map(fn ($id): int => (int) $id),
|
||||||
|
)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$items = CatalogItem::withTrashed()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category')
|
||||||
|
->where(function ($query) use ($historicalCatalogItemIds): void {
|
||||||
|
$query
|
||||||
|
->where(function ($activeQuery): void {
|
||||||
|
$activeQuery
|
||||||
|
->whereNull('catalog_items.deleted_at')
|
||||||
|
->where('has_tickets', true);
|
||||||
|
})
|
||||||
|
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'category',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants' => fn ($query) => $query
|
||||||
|
->withTrashed()
|
||||||
|
->where(function ($variantQuery) use ($historicalVariantIds): void {
|
||||||
|
$variantQuery
|
||||||
|
->whereNull('variantes.deleted_at')
|
||||||
|
->orWhereIn('variantes.id', $historicalVariantIds);
|
||||||
|
}),
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.eventDate',
|
||||||
|
])
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CatalogItem> $items
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
|
||||||
|
{
|
||||||
|
$categories = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$sourceCategory = trim((string) $item->category?->nombre);
|
||||||
|
$categoryValue = mb_strtolower($sourceCategory);
|
||||||
|
$presentation = $presentations[$categoryValue] ?? [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => null,
|
||||||
|
'size' => null,
|
||||||
|
'order' => PHP_INT_MAX,
|
||||||
|
];
|
||||||
|
|
||||||
|
$categories[$categoryValue] ??= [
|
||||||
|
'value' => $categoryValue,
|
||||||
|
'label' => $presentation['label'] ?? $sourceCategory,
|
||||||
|
'order' => $presentation['order'],
|
||||||
|
'products' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->products(
|
||||||
|
$item,
|
||||||
|
$presentation['product'],
|
||||||
|
$presentation['type'],
|
||||||
|
$presentation['size'] ?? null,
|
||||||
|
) as $product) {
|
||||||
|
$productValue = $product['value'];
|
||||||
|
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
|
||||||
|
'value' => $productValue,
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => [],
|
||||||
|
'sizes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($product['types'] as $type) {
|
||||||
|
$existingProduct['types'][$type['value']] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($product['sizes'] as $size) {
|
||||||
|
$existingProduct['sizes'][$size['value']] = $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
|
||||||
|
?: $left['label'] <=> $right['label']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => [
|
||||||
|
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||||
|
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||||
|
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||||
|
],
|
||||||
|
'categories' => array_values(array_map(
|
||||||
|
fn (array $category): array => [
|
||||||
|
'value' => $category['value'],
|
||||||
|
'label' => $category['label'],
|
||||||
|
'products' => array_values(array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => array_values($product['types']),
|
||||||
|
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
|
||||||
|
],
|
||||||
|
$category['products'],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
$categories,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function relations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.eventDate',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>,
|
||||||
|
* sizes: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
|
||||||
|
{
|
||||||
|
if ($productCode === self::PRODUCT) {
|
||||||
|
return [[
|
||||||
|
'value' => $item->slug,
|
||||||
|
'label' => $item->nombre,
|
||||||
|
'types' => $this->types($item, $typeCode, $sizeCode),
|
||||||
|
'sizes' => $this->types($item, $sizeCode),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
foreach ($item->variants as $variant) {
|
||||||
|
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
|
||||||
|
$productValue = $productOption['value'];
|
||||||
|
$products[$productValue] ??= [
|
||||||
|
'value' => $productValue,
|
||||||
|
'label' => $this->optionLabel($productOption['label'], $productCode),
|
||||||
|
'types' => [],
|
||||||
|
'sizes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
||||||
|
$this->mergeTypeOption(
|
||||||
|
$products[$productValue]['types'],
|
||||||
|
$typeOption,
|
||||||
|
$variant,
|
||||||
|
$sizeCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
||||||
|
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => array_values($product['types']),
|
||||||
|
'sizes' => array_values($product['sizes']),
|
||||||
|
],
|
||||||
|
$products,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
|
||||||
|
foreach ($item->variants as $variant) {
|
||||||
|
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
||||||
|
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(function (array $type) use ($sizeCode): array {
|
||||||
|
if ($sizeCode !== null) {
|
||||||
|
$type['sizes'] = array_values($type['sizes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}, $types));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $types
|
||||||
|
* @param array{value: string, label: string} $typeOption
|
||||||
|
*/
|
||||||
|
private function mergeTypeOption(
|
||||||
|
array &$types,
|
||||||
|
array $typeOption,
|
||||||
|
Variant $variant,
|
||||||
|
?string $sizeCode,
|
||||||
|
): void {
|
||||||
|
$typeValue = $typeOption['value'];
|
||||||
|
$types[$typeValue] ??= [
|
||||||
|
...$typeOption,
|
||||||
|
...($sizeCode !== null ? ['sizes' => []] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
||||||
|
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{value: string, label: string}> */
|
||||||
|
private function variantOptions(Variant $variant, ?string $attributeCode): array
|
||||||
|
{
|
||||||
|
if ($attributeCode === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
|
||||||
|
->get($attributeCode);
|
||||||
|
|
||||||
|
if ($selection === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_is_list($selection) ? $selection : [$selection];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionLabel(string $label, string $attributeCode): string
|
||||||
|
{
|
||||||
|
if ($attributeCode !== 'event_date') {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
||||||
|
|
||||||
|
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm
|
|||||||
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
||||||
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
||||||
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
||||||
|
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
|
||||||
|
|
||||||
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
|
|||||||
- `GET /event`.
|
- `GET /event`.
|
||||||
- `GET /sale`.
|
- `GET /sale`.
|
||||||
- `GET /staff`.
|
- `GET /staff`.
|
||||||
|
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
|
||||||
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
||||||
|
|
||||||
## Dependencias
|
## Dependencias
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
|
|||||||
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
@@ -13,6 +15,13 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
Route::get('sale', SaleFormController::class);
|
Route::get('sale', SaleFormController::class);
|
||||||
Route::get('staff', StaffFormController::class);
|
Route::get('staff', StaffFormController::class);
|
||||||
|
Route::get('tickets-filter', TicketFilterFormController::class)
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.forms.tickets-filter');
|
||||||
|
Route::get(
|
||||||
|
'fiesta-futbol-infantil/ticket',
|
||||||
|
TicketFormController::class
|
||||||
|
);
|
||||||
Route::get(
|
Route::get(
|
||||||
'fiesta-futbol-infantil/merchandise',
|
'fiesta-futbol-infantil/merchandise',
|
||||||
MerchandiseFormController::class
|
MerchandiseFormController::class
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers;
|
|||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
use App\Domains\Integration\Services\ClientIntegrationService;
|
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function store(
|
public function store(
|
||||||
StoreClientIntegrationRequest $request,
|
ConfigureIntegrationRequest $request,
|
||||||
Client $client,
|
Client $client,
|
||||||
string $integrationCode,
|
string $integrationCode,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
@@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
|
||||||
|
{
|
||||||
|
$service->detach($client, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class IntegrationController extends Controller
|
|||||||
|
|
||||||
public function destroy(Integration $integration)
|
public function destroy(Integration $integration)
|
||||||
{
|
{
|
||||||
|
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
|
||||||
$integration->delete();
|
$integration->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
|
use App\Domains\Integration\Resources\IntegrationAssociationResource;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegrationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly IntegrationAssociationService $service) {}
|
||||||
|
|
||||||
|
public function index(WebsiteType $websiteType)
|
||||||
|
{
|
||||||
|
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
|
return new IntegrationAssociationResource($this->service->configure(
|
||||||
|
$websiteType,
|
||||||
|
$integration,
|
||||||
|
$request->validated('integration_data'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$this->service->detach($websiteType, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,15 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class ClientIntegration extends Model
|
class ClientIntegration extends Model
|
||||||
{
|
{
|
||||||
protected $hidden = ['integration_data'];
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'client_id',
|
'client_id',
|
||||||
'integration_code',
|
'integration_code',
|
||||||
'integration_data',
|
'integration_instance_id',
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'integration_data' => EncryptedIntegrationData::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @return BelongsTo<Client, $this> */
|
/** @return BelongsTo<Client, $this> */
|
||||||
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Integration extends Model
|
class Integration extends Model
|
||||||
{
|
{
|
||||||
@@ -13,16 +14,29 @@ class Integration extends Model
|
|||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
'integration_data_schema',
|
'integration_data_schema',
|
||||||
'requires_client_configuration',
|
'requires_configuration',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'integration_data_schema' => 'array',
|
'integration_data_schema' => 'array',
|
||||||
'requires_client_configuration' => 'boolean',
|
'requires_configuration' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function clientIntegrations()
|
/** @return HasMany<IntegrationInstance, $this> */
|
||||||
|
public function instances(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class IntegrationInstance extends Model
|
||||||
|
{
|
||||||
|
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
|
||||||
|
public function tokenCacheKey(): string
|
||||||
|
{
|
||||||
|
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = ['integration_code', 'name', 'integration_data'];
|
||||||
|
|
||||||
|
protected $hidden = ['integration_data'];
|
||||||
|
|
||||||
|
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClientIntegration::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegration extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
|
||||||
|
|
||||||
|
/** @return BelongsTo<WebsiteType, $this> */
|
||||||
|
public function websiteType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Policies;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class IntegrationPolicy
|
||||||
|
{
|
||||||
|
public function manage(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->rol_codigo === RoleCode::Admin->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration;
|
|||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StoreClientIntegrationRequest extends FormRequest
|
class ConfigureIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
protected ?Integration $integrationModel = null;
|
protected ?Integration $integrationModel = null;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return $this->user()?->can('manage', Integration::class) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$integrationCode = $this->route('integration_code');
|
|
||||||
$this->integrationModel = Integration::query()
|
$this->integrationModel = Integration::query()
|
||||||
->where('integration_code', $integrationCode)
|
->where('integration_code', $this->route('integration_code'))
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $this->integrationModel) {
|
if (! $this->integrationModel) {
|
||||||
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
|
|||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$rules = [];
|
$rules = ['integration_data' => ['present', 'array']];
|
||||||
|
|
||||||
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||||
$rules['integration_data.'.$field] = $rule;
|
$rules['integration_data.'.$field] = $rule;
|
||||||
@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Requests;
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateIntegrationRequest extends FormRequest
|
class UpdateIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
|
|||||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
// the code shouldn't ideally be updatable, but if it is:
|
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
|
||||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationAssociationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'client_id' => $this->when(isset($this->client_id), $this->client_id),
|
||||||
|
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'integration_instance_id' => $this->integration_instance_id,
|
||||||
|
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationInstanceResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ namespace App\Domains\Integration\Services;
|
|||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Models\ClientIntegration;
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
@@ -32,9 +34,9 @@ abstract class BaseIntegrationService
|
|||||||
protected ?Integration $integration = null;
|
protected ?Integration $integration = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client-owned integration configuration.
|
* The effective integration instance.
|
||||||
*/
|
*/
|
||||||
protected ?ClientIntegration $clientIntegration = null;
|
protected ?IntegrationInstance $integrationInstance = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the integration code.
|
* Set the integration code.
|
||||||
@@ -85,7 +87,7 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the integration definition and its client-owned configuration.
|
* Load the integration definition and its effective instance configuration.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -95,6 +97,7 @@ abstract class BaseIntegrationService
|
|||||||
throw new Exception('Integration code is not set.');
|
throw new Exception('Integration code is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->integrationInstance = null;
|
||||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||||
if (! $this->integration) {
|
if (! $this->integration) {
|
||||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||||
@@ -104,11 +107,18 @@ abstract class BaseIntegrationService
|
|||||||
throw new Exception('Client context is not set.');
|
throw new Exception('Client context is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
|
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
|
||||||
->where('integration_code', $this->integrationCode)
|
->where('integration_code', $this->integrationCode)
|
||||||
->first();
|
->first()?->integrationInstance;
|
||||||
|
|
||||||
if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
|
if (! $this->integrationInstance && $this->tenant?->website_type_code) {
|
||||||
|
$this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance')
|
||||||
|
->where('website_type_code', $this->tenant->website_type_code)
|
||||||
|
->where('integration_code', $this->integrationCode)
|
||||||
|
->first()?->integrationInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->integrationInstance && $this->integration->requires_configuration) {
|
||||||
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,15 +141,15 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an integration setting from the client-owned configuration.
|
* Get an integration setting from the effective instance configuration.
|
||||||
*/
|
*/
|
||||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
|
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
|
||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->clientIntegration->integration_data[$key] ?? $default;
|
return $this->integrationInstance->integration_data[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ClientIntegrationService
|
|||||||
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
||||||
{
|
{
|
||||||
return $client->integrations()
|
return $client->integrations()
|
||||||
|
->with(['integration', 'integrationInstance'])
|
||||||
->where('integration_code', $integrationCode)
|
->where('integration_code', $integrationCode)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
@@ -20,7 +21,7 @@ class ClientIntegrationService
|
|||||||
/** @return Collection<int, ClientIntegration> */
|
/** @return Collection<int, ClientIntegration> */
|
||||||
public function getAllForClient(Client $client): Collection
|
public function getAllForClient(Client $client): Collection
|
||||||
{
|
{
|
||||||
return $client->integrations()->with('integration')->get();
|
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateOrCreateIntegration(
|
public function updateOrCreateIntegration(
|
||||||
@@ -29,13 +30,7 @@ class ClientIntegrationService
|
|||||||
array $data,
|
array $data,
|
||||||
): ClientIntegration {
|
): ClientIntegration {
|
||||||
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
||||||
$clientIntegration = ClientIntegration::query()->updateOrCreate(
|
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
|
||||||
[
|
|
||||||
'client_id' => $client->id,
|
|
||||||
'integration_code' => $integration->integration_code,
|
|
||||||
],
|
|
||||||
['integration_data' => $data],
|
|
||||||
);
|
|
||||||
|
|
||||||
$service = $this->resolveService($integration->integration_code);
|
$service = $this->resolveService($integration->integration_code);
|
||||||
$service?->forClient($client)->onSetup();
|
$service?->forClient($client)->onSetup();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationAssociationService
|
||||||
|
{
|
||||||
|
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$instance = IntegrationInstance::create([
|
||||||
|
'integration_code' => $integration->integration_code,
|
||||||
|
'name' => $integration->name.' / '.$this->ownerName($owner),
|
||||||
|
'integration_data' => $integrationData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->associate($owner, $integration->integration_code, $instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$previousInstanceId = $association?->integration_instance_id;
|
||||||
|
|
||||||
|
$instanceIds = array_values(array_unique(array_filter([
|
||||||
|
$previousInstanceId,
|
||||||
|
$instance->id,
|
||||||
|
])));
|
||||||
|
sort($instanceIds);
|
||||||
|
|
||||||
|
$instances = IntegrationInstance::query()
|
||||||
|
->whereKey($instanceIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
|
||||||
|
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
|
||||||
|
|
||||||
|
$association = $owner->integrations()->updateOrCreate(
|
||||||
|
['integration_code' => $code],
|
||||||
|
['integration_instance_id' => $instance->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
|
||||||
|
$this->deleteIfUnused($previousInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $association->load('integrationInstance');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detach(Client|WebsiteType $owner, string $code): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($owner, $code): void {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $association) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instanceId = $association->integration_instance_id;
|
||||||
|
$association->delete();
|
||||||
|
$this->deleteIfUnused($instanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteIfUnused(int $instanceId): void
|
||||||
|
{
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
|
||||||
|
|
||||||
|
if ($instance
|
||||||
|
&& ! $instance->clientIntegrations()->exists()
|
||||||
|
&& ! $instance->websiteTypeIntegrations()->exists()) {
|
||||||
|
$instance->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownerName(Client|WebsiteType $owner): string
|
||||||
|
{
|
||||||
|
return $owner instanceof Client ? $owner->name : $owner->nombre;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationInstanceService
|
||||||
|
{
|
||||||
|
public function create(array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return IntegrationInstance::create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->update($data);
|
||||||
|
if (array_key_exists('integration_data', $data)) {
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(IntegrationInstance $instance): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($instance): void {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->delete();
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||||
use Illuminate\Contracts\Mail\Mailer;
|
use Illuminate\Contracts\Mail\Mailer;
|
||||||
@@ -27,7 +29,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private ?Mailer $mailer = null;
|
private ?Mailer $mailer = null;
|
||||||
|
|
||||||
private bool $usesClientMailer = false;
|
private bool $usesInstanceMailer = false;
|
||||||
|
|
||||||
public function __construct(?MailFactory $mailFactory = null)
|
public function __construct(?MailFactory $mailFactory = null)
|
||||||
{
|
{
|
||||||
@@ -38,12 +40,12 @@ class MailService extends BaseIntegrationService
|
|||||||
{
|
{
|
||||||
parent::forTenant($tenantCode);
|
parent::forTenant($tenantCode);
|
||||||
|
|
||||||
if ($this->clientIntegration) {
|
if ($this->integrationInstance) {
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesClientMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesClientMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -54,12 +56,12 @@ class MailService extends BaseIntegrationService
|
|||||||
parent::forClient($client);
|
parent::forClient($client);
|
||||||
$this->tenant = $this->clientContext?->tenants()->first();
|
$this->tenant = $this->clientContext?->tenants()->first();
|
||||||
|
|
||||||
if ($this->clientIntegration) {
|
if ($this->integrationInstance) {
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesClientMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesClientMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -70,24 +72,35 @@ class MailService extends BaseIntegrationService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(string|array $recipient, string $subject, string $content): void
|
/**
|
||||||
{
|
* @param array<int, array{data: string, name: string, mime: string}> $attachments
|
||||||
|
*/
|
||||||
|
public function send(
|
||||||
|
string|array $recipient,
|
||||||
|
string $subject,
|
||||||
|
string $content,
|
||||||
|
Tenant|WebsiteType|null $brand = null,
|
||||||
|
array $attachments = [],
|
||||||
|
): void {
|
||||||
if (! $this->mailer || ! $this->tenant) {
|
if (! $this->mailer || ! $this->tenant) {
|
||||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
$brand ??= $this->tenant;
|
||||||
|
$branding = $this->brandingFor($brand);
|
||||||
|
|
||||||
$html = Blade::render(
|
$html = Blade::render(
|
||||||
<<<'BLADE'
|
<<<'BLADE'
|
||||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||||
{!! $content !!}
|
{!! $content !!}
|
||||||
</x-mail.branded-layout>
|
</x-mail.branded-layout>
|
||||||
BLADE,
|
BLADE,
|
||||||
[
|
[
|
||||||
'tenant' => $this->tenant,
|
'branding' => $branding,
|
||||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
'headerLogoUrl' => $brand instanceof WebsiteType
|
||||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||||
|
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||||
|
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -96,16 +109,54 @@ class MailService extends BaseIntegrationService
|
|||||||
->subject($subject)
|
->subject($subject)
|
||||||
->html($html);
|
->html($html);
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$mail->attachData(
|
||||||
|
$attachment['data'],
|
||||||
|
$attachment['name'],
|
||||||
|
['mime' => $attachment['mime']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->to($recipient)->send($mail);
|
$this->mailer->to($recipient)->send($mail);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mailerName(): string
|
public function mailerName(): string
|
||||||
{
|
{
|
||||||
return $this->usesClientMailer
|
return $this->usesInstanceMailer
|
||||||
? 'client-smtp'
|
? 'integration-smtp'
|
||||||
: (string) config('mail.default');
|
: (string) config('mail.default');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
|
||||||
|
private function brandingFor(Tenant|WebsiteType $brand): array
|
||||||
|
{
|
||||||
|
if ($brand instanceof WebsiteType) {
|
||||||
|
$brand->loadMissing(['siteLogo', 'footerLogo']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $brand->nombre,
|
||||||
|
'primary_color' => $brand->primary_color ?? '#FF7006',
|
||||||
|
'body_color' => $brand->body_color ?? '#666666',
|
||||||
|
'background_color' => $brand->background_color ?? '#f8f8f8',
|
||||||
|
'surface_color' => $brand->surface_color ?? '#ffffff',
|
||||||
|
'header_bg_color' => $brand->surface_color ?? '#ffffff',
|
||||||
|
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$brand->loadMissing(['headerLogo', 'footerLogo']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $brand->nombre,
|
||||||
|
'primary_color' => $brand->primary_color ?? '#6376f3',
|
||||||
|
'body_color' => '#334155',
|
||||||
|
'background_color' => '#f1f5f9',
|
||||||
|
'surface_color' => '#ffffff',
|
||||||
|
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
|
||||||
|
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function onSetup(): void
|
public function onSetup(): void
|
||||||
{
|
{
|
||||||
if (! $this->mailer || ! $this->clientContext) {
|
if (! $this->mailer || ! $this->clientContext) {
|
||||||
@@ -136,7 +187,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private function resolveMailer(): Mailer
|
private function resolveMailer(): Mailer
|
||||||
{
|
{
|
||||||
$data = $this->clientIntegration?->integration_data;
|
$data = $this->integrationInstance?->integration_data;
|
||||||
|
|
||||||
if (! is_array($data)) {
|
if (! is_array($data)) {
|
||||||
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
||||||
@@ -154,7 +205,7 @@ class MailService extends BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mailer = $this->mailFactory->build([
|
$mailer = $this->mailFactory->build([
|
||||||
'name' => 'client-smtp-'.$this->clientContext?->id,
|
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
|
||||||
'transport' => 'smtp',
|
'transport' => 'smtp',
|
||||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||||
'host' => $data['MAIL_HOST'],
|
'host' => $data['MAIL_HOST'],
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
*/
|
*/
|
||||||
public function getToken(): string
|
public function getToken(): string
|
||||||
{
|
{
|
||||||
if (! $this->clientIntegration || ! $this->clientContext) {
|
if (! $this->integrationInstance) {
|
||||||
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
|
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
// Calculate TTL and subtract a buffer of 60 seconds
|
// Calculate TTL and subtract a buffer of 60 seconds
|
||||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
*/
|
*/
|
||||||
public function clearToken(): void
|
public function clearToken(): void
|
||||||
{
|
{
|
||||||
if (! $this->clientContext) {
|
if (! $this->integrationInstance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::forget($cacheKey);
|
Cache::forget($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||||
use App\Domains\Purchase\Models\TelepagosQr;
|
use App\Domains\Purchase\Models\TelepagosQr;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
|
use App\Domains\Purchase\Services\DniDistanceService;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ class TelepagosWebhookService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutService $checkoutService,
|
private readonly CheckoutService $checkoutService,
|
||||||
|
private readonly DniDistanceService $dniDistance,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,7 +45,6 @@ class TelepagosWebhookService
|
|||||||
|
|
||||||
$paymentData = [
|
$paymentData = [
|
||||||
'compra_id' => null,
|
'compra_id' => null,
|
||||||
'matched_purchase_ids' => null,
|
|
||||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
@@ -73,24 +76,55 @@ class TelepagosWebhookService
|
|||||||
$dni = substr($cuit, 2, -1);
|
$dni = substr($cuit, 2, -1);
|
||||||
|
|
||||||
$tenantCodes = $client->tenants()->pluck('codigo');
|
$tenantCodes = $client->tenants()->pluck('codigo');
|
||||||
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
|
$eligiblePurchases = Purchase::query()
|
||||||
->where('transfer_payer_dni', $dni)
|
->whereIn('tenant_codigo', $tenantCodes)
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
Purchase::STATUS_IN_REVIEW,
|
Purchase::STATUS_IN_REVIEW,
|
||||||
])
|
])
|
||||||
->where('payment_method', 'transfer')
|
->where('payment_method', 'transfer');
|
||||||
|
|
||||||
|
$purchases = (clone $eligiblePurchases)
|
||||||
->where('total', $amount)
|
->where('total', $amount)
|
||||||
->latest()
|
->latest()
|
||||||
->get();
|
->get()
|
||||||
|
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
|
||||||
|
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
|
||||||
|
->values();
|
||||||
|
|
||||||
$paymentData['matched_purchase_ids'] = $purchases->pluck('id')->all();
|
|
||||||
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
||||||
|
|
||||||
if (! $compra) {
|
if (! $compra) {
|
||||||
if ($purchases->count() > 1) {
|
$candidatePurchases = $this->findTransferCandidates(
|
||||||
TelepagosPayment::create($paymentData);
|
$eligiblePurchases,
|
||||||
|
$dni,
|
||||||
|
$amount,
|
||||||
|
);
|
||||||
|
if ($candidatePurchases->isNotEmpty()) {
|
||||||
|
$payment = $this->storeTransferCandidates(
|
||||||
|
$paymentData,
|
||||||
|
$candidatePurchases,
|
||||||
|
$dni,
|
||||||
|
$amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'telepagos_payment_id' => $payment->id,
|
||||||
|
'amount' => $amount,
|
||||||
|
'candidate_count' => $payment->candidates->count(),
|
||||||
|
'candidates' => $payment->candidates
|
||||||
|
->map(fn ($candidate): array => [
|
||||||
|
'purchase_id' => $candidate->compra_id,
|
||||||
|
'match_reason' => $candidate->match_reason,
|
||||||
|
'dni_distance' => $candidate->dni_distance,
|
||||||
|
'amount_difference' => $candidate->amount_difference,
|
||||||
|
'confidence' => $candidate->confidence,
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
||||||
@@ -209,4 +243,91 @@ class TelepagosWebhookService
|
|||||||
{
|
{
|
||||||
return number_format((float) $amount, 2, '.', '');
|
return number_format((float) $amount, 2, '.', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<Purchase> $eligiblePurchases
|
||||||
|
* @return Collection<int, Purchase>
|
||||||
|
*/
|
||||||
|
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
|
||||||
|
{
|
||||||
|
$tolerancePercentage = max(
|
||||||
|
0,
|
||||||
|
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
|
||||||
|
);
|
||||||
|
$numericAmount = (float) $amount;
|
||||||
|
$tolerance = $numericAmount * ($tolerancePercentage / 100);
|
||||||
|
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
|
||||||
|
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
|
||||||
|
|
||||||
|
return (clone $eligiblePurchases)
|
||||||
|
->whereBetween('total', [$minimumAmount, $maximumAmount])
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
|
||||||
|
if ($purchase->transfer_payer_dni === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||||
|
$distance = $this->dniDistance->distance(
|
||||||
|
$dni,
|
||||||
|
(string) $purchase->transfer_payer_dni,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $distance === 0
|
||||||
|
|| ($purchaseAmount === $amount && $distance <= 2);
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $paymentData
|
||||||
|
* @param Collection<int, Purchase> $candidatePurchases
|
||||||
|
*/
|
||||||
|
private function storeTransferCandidates(
|
||||||
|
array $paymentData,
|
||||||
|
Collection $candidatePurchases,
|
||||||
|
string $dni,
|
||||||
|
string $amount,
|
||||||
|
): TelepagosPayment {
|
||||||
|
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
|
||||||
|
$payment = TelepagosPayment::create($paymentData);
|
||||||
|
|
||||||
|
$payment->candidates()->createMany(
|
||||||
|
$candidatePurchases
|
||||||
|
->map(function (Purchase $purchase) use ($dni, $amount): array {
|
||||||
|
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||||
|
$dniDistance = $this->dniDistance->distance(
|
||||||
|
$dni,
|
||||||
|
(string) $purchase->transfer_payer_dni,
|
||||||
|
);
|
||||||
|
$dniMatches = $dniDistance === 0;
|
||||||
|
$amountMatches = $purchaseAmount === $amount;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'compra_id' => $purchase->id,
|
||||||
|
'dni_matches' => $dniMatches,
|
||||||
|
'dni_distance' => $dniDistance,
|
||||||
|
'payment_dni' => $dni,
|
||||||
|
'purchase_dni' => $purchase->transfer_payer_dni,
|
||||||
|
'amount_matches' => $amountMatches,
|
||||||
|
'payment_amount' => $amount,
|
||||||
|
'purchase_amount' => $purchaseAmount,
|
||||||
|
'amount_difference' => $this->normalizeAmount(
|
||||||
|
abs((float) $purchaseAmount - (float) $amount),
|
||||||
|
),
|
||||||
|
'match_reason' => $amountMatches
|
||||||
|
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
|
||||||
|
: 'exact_dni_near_amount',
|
||||||
|
'confidence' => $amountMatches
|
||||||
|
? ($dniMatches ? 'exact' : 'medium')
|
||||||
|
: 'high',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $payment->load('candidates');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
# Dominio Integration
|
# Dominio Integration
|
||||||
|
|
||||||
## Propósito
|
## Modelo
|
||||||
|
|
||||||
Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos.
|
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
|
||||||
|
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
|
||||||
|
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
|
||||||
|
|
||||||
## Modelo y seguridad
|
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
|
||||||
|
|
||||||
- `Integration`: definición global de una integración.
|
## Resolución
|
||||||
- `ClientIntegration`: configuración y credenciales de una integración para un cliente.
|
|
||||||
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
|
|
||||||
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
|
|
||||||
|
|
||||||
## Servicios externos
|
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
|
||||||
|
|
||||||
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
|
||||||
- `MailService`: envío de correo usando la integración configurada.
|
|
||||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
|
||||||
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
|
||||||
|
|
||||||
## Endpoints
|
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
|
||||||
|
|
||||||
- CRUD global bajo `/integrations`.
|
## Administración
|
||||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
|
||||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
|
||||||
|
|
||||||
## Logging de Telepagos
|
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
|
||||||
|
|
||||||
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
| Método | Ruta | Operación |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
|
||||||
|
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
|
||||||
|
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
|
||||||
|
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
|
||||||
|
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
|
||||||
## Dependencias y reglas
|
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
|
||||||
|
|
||||||
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
|
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
|
||||||
|
|
||||||
|
## Webhooks y contexto operativo
|
||||||
|
|
||||||
|
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
|
||||||
|
|
||||||
|
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||||
use App\Domains\Integration\Controllers\IntegrationController;
|
|
||||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||||
|
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::group(['prefix' => 'integrations'], function () {
|
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
|
||||||
Route::get('/', [IntegrationController::class, 'index']);
|
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
|
||||||
Route::post('/', [IntegrationController::class, 'store']);
|
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
|
||||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||||
|
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||||
|
|||||||
@@ -28,10 +28,21 @@ class TestMail extends Mailable
|
|||||||
{
|
{
|
||||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||||
|
|
||||||
|
$branding = [
|
||||||
|
'name' => $this->tenant->nombre,
|
||||||
|
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
|
||||||
|
'body_color' => '#334155',
|
||||||
|
'background_color' => '#f1f5f9',
|
||||||
|
'surface_color' => '#ffffff',
|
||||||
|
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
|
||||||
|
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
|
||||||
|
];
|
||||||
|
|
||||||
return new Content(
|
return new Content(
|
||||||
view: 'mail.test',
|
view: 'mail.test',
|
||||||
with: [
|
with: [
|
||||||
'tenant' => $this->tenant,
|
'tenant' => $this->tenant,
|
||||||
|
'branding' => $branding,
|
||||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Notification\Events;
|
|
||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class TicketsAvailable
|
|
||||||
{
|
|
||||||
use Dispatchable, SerializesModels;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $ticketIds
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public readonly Purchase $purchase,
|
|
||||||
public readonly array $ticketIds,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
|||||||
use App\Domains\Notification\Services\NotificationMailService;
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PasswordResetRequested $event): void
|
public function handle(PasswordResetRequested $event): void
|
||||||
{
|
{
|
||||||
try {
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
$event->attemptId,
|
||||||
$event->attemptId,
|
$event->tenantCode,
|
||||||
$event->tenantCode,
|
$event->channel,
|
||||||
$event->channel,
|
);
|
||||||
);
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
Log::error('Failed to send password reset email.', [
|
|
||||||
'attempt_id' => $event->attemptId,
|
|
||||||
'tenant_code' => $event->tenantCode,
|
|
||||||
'channel' => $event->channel,
|
|
||||||
'exception' => $exception,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
|
||||||
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PurchasePaid $event): void
|
public function handle(PurchasePaid $event): void
|
||||||
{
|
{
|
||||||
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
|
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Notification\Listeners;
|
|
||||||
|
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Notification\Services\NotificationMailService;
|
|
||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
|
||||||
|
|
||||||
class SendTicketsAvailableEmail 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(TicketsAvailable $event): void
|
|
||||||
{
|
|
||||||
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,28 +9,44 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
|||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class NotificationMailService
|
class NotificationMailService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailService $mailService,
|
private readonly MailService $mailService,
|
||||||
|
private readonly TicketPdfService $ticketPdfService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function sendWelcome(int $userId, string $tenantCode): void
|
public function sendWelcome(int $userId, string $tenantCode): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
$this->sendLogged('welcome', [
|
||||||
$user = User::query()->findOrFail($userId);
|
'user_id' => $userId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
], function () use ($userId, $tenantCode): array {
|
||||||
|
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$user = User::query()->findOrFail($userId);
|
||||||
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$user->email,
|
$user->email,
|
||||||
"Bienvenido a {$tenant->nombre}",
|
"Bienvenido a {$brand->nombre}",
|
||||||
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
|
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
||||||
);
|
$brand,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPasswordResetCode(
|
public function sendPasswordResetCode(
|
||||||
@@ -38,95 +54,173 @@ class NotificationMailService
|
|||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||||
): void {
|
): void {
|
||||||
$tenant = Tenant::query()
|
$context = [
|
||||||
->with('websiteType')
|
'attempt_id' => $attemptId,
|
||||||
->where('codigo', $tenantCode)
|
'tenant_code' => $tenantCode,
|
||||||
->firstOrFail();
|
'channel' => $channel,
|
||||||
$attempt = ResetPasswordAttempt::query()
|
];
|
||||||
->with('user')
|
|
||||||
->findOrFail($attemptId);
|
|
||||||
|
|
||||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
|
||||||
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
$tenant = Tenant::query()
|
||||||
'attempt_id' => $attemptId,
|
->with('websiteType')
|
||||||
'tenant_code' => $tenantCode,
|
->where('codigo', $tenantCode)
|
||||||
'attempt_status' => $attempt->status,
|
->firstOrFail();
|
||||||
]);
|
$attempt = ResetPasswordAttempt::query()
|
||||||
|
->with('user')
|
||||||
|
->findOrFail($attemptId);
|
||||||
|
|
||||||
return;
|
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||||
}
|
$this->logSkipped('password_reset', array_merge($context, [
|
||||||
|
'reason' => 'attempt_not_pending',
|
||||||
|
'attempt_status' => $attempt->status,
|
||||||
|
'user_id' => $attempt->user_id,
|
||||||
|
]));
|
||||||
|
|
||||||
$recoveryDomain = match ($channel) {
|
return null;
|
||||||
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
}
|
||||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
|
||||||
default => $tenant->dominio,
|
|
||||||
};
|
|
||||||
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
|
||||||
&& $tenant->base_path !== '/'
|
|
||||||
? $tenant->base_path
|
|
||||||
: '';
|
|
||||||
$recoveryQuery = ['email' => $attempt->user->email];
|
|
||||||
if (
|
|
||||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
|
||||||
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
|
|
||||||
) {
|
|
||||||
$recoveryQuery['code'] = $attempt->codigo;
|
|
||||||
}
|
|
||||||
$recoveryUrl = $recoveryDomain === null
|
|
||||||
? null
|
|
||||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
|
||||||
|
|
||||||
$this->mailService
|
$recoveryDomain = match ($channel) {
|
||||||
->forTenant($tenantCode)
|
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||||
->send(
|
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||||
$attempt->user->email,
|
default => $tenant->dominio,
|
||||||
"Código para recuperar tu contraseña - {$tenant->nombre}",
|
};
|
||||||
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
|
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||||
);
|
&& $tenant->base_path !== '/'
|
||||||
|
? $tenant->base_path
|
||||||
|
: '';
|
||||||
|
$recoveryQuery = ['email' => $attempt->user->email];
|
||||||
|
if (
|
||||||
|
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||||
|
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
|
||||||
|
) {
|
||||||
|
$recoveryQuery['code'] = $attempt->codigo;
|
||||||
|
}
|
||||||
|
$recoveryUrl = $recoveryDomain === null
|
||||||
|
? null
|
||||||
|
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||||
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
|
||||||
|
[$subject, $template] = match ($attempt->reason) {
|
||||||
|
ResetPasswordAttempt::REASON_STAFF_CREATED => [
|
||||||
|
'Tu cuenta de escáner está lista', 'scanner-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
|
||||||
|
'Tu cuenta de administrador está lista', 'administrator-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
|
||||||
|
'Desbloqueá tu cuenta', 'account-locked',
|
||||||
|
],
|
||||||
|
default => ['Código para recuperar tu contraseña', 'password-reset'],
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->mailService
|
||||||
|
->forTenant($tenantCode)
|
||||||
|
->send(
|
||||||
|
$attempt->user->email,
|
||||||
|
"{$subject} - {$brand->nombre}",
|
||||||
|
view("mail.notifications.{$template}", [
|
||||||
|
'attempt' => $attempt,
|
||||||
|
'recoveryUrl' => $recoveryUrl,
|
||||||
|
'brand' => $brand,
|
||||||
|
])->render(),
|
||||||
|
$brand,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $attempt->user_id,
|
||||||
|
'recovery_domain_available' => $recoveryDomain !== null,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPurchasePaid(int $purchaseId): void
|
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||||
{
|
{
|
||||||
$purchase = Purchase::query()
|
$context = ['purchase_id' => $purchaseId];
|
||||||
->with(['tenant', 'user', 'items'])
|
|
||||||
->findOrFail($purchaseId);
|
|
||||||
|
|
||||||
$this->mailService
|
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||||
->forTenant($purchase->tenant_codigo)
|
$purchase = Purchase::query()
|
||||||
->send(
|
->with(['tenant', 'user', 'items'])
|
||||||
$this->recipientFor($purchase),
|
->find($purchaseId);
|
||||||
"Pago confirmado - Compra #{$purchase->getKey()}",
|
|
||||||
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int, int> $ticketIds */
|
if ($purchase === null) {
|
||||||
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
|
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||||
{
|
'reason' => 'purchase_not_found',
|
||||||
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
|
'missing_model' => Purchase::class,
|
||||||
/** @var Collection<int, Ticket> $tickets */
|
]));
|
||||||
$tickets = Ticket::query()
|
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
|
||||||
->where('user_id', $purchase->user_id)
|
|
||||||
->whereKey($ticketIds)
|
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($tickets->isEmpty()) {
|
return null;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$this->mailService
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
->forTenant($purchase->tenant_codigo)
|
$tickets = $purchase->tickets()
|
||||||
->send(
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
$this->recipientFor($purchase),
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
'Tus tickets ya están disponibles',
|
->get();
|
||||||
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
|
$attachments = $tickets->isEmpty()
|
||||||
);
|
? []
|
||||||
|
: [[
|
||||||
|
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
||||||
|
'name' => $this->ticketPdfService->filename($tickets),
|
||||||
|
'mime' => 'application/pdf',
|
||||||
|
]];
|
||||||
|
|
||||||
|
$this->mailService
|
||||||
|
->forTenant($purchase->tenant_codigo)
|
||||||
|
->send(
|
||||||
|
$this->recipientFor($purchase),
|
||||||
|
"Compra confirmada - Compra #{$purchase->getKey()}",
|
||||||
|
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
|
||||||
|
attachments: $attachments,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tenant_code' => $purchase->tenant_codigo,
|
||||||
|
'user_id' => $purchase->user_id,
|
||||||
|
'purchase_status' => $purchase->status,
|
||||||
|
'purchase_item_count' => $purchase->items->count(),
|
||||||
|
'ticket_count' => $tickets->count(),
|
||||||
|
'ticket_ids' => $tickets->modelKeys(),
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recipientFor(Purchase $purchase): string
|
private function recipientFor(Purchase $purchase): string
|
||||||
{
|
{
|
||||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
return (string) ($purchase->email ?: $purchase->user?->email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @param Closure(): (array<string, mixed>|null) $send
|
||||||
|
*/
|
||||||
|
private function sendLogged(string $emailType, array $context, Closure $send): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$resultContext = $send();
|
||||||
|
|
||||||
|
if ($resultContext === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
|
||||||
|
'email_type' => $emailType,
|
||||||
|
'mailer' => $this->mailService->mailerName(),
|
||||||
|
]));
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
|
||||||
|
'email_type' => $emailType,
|
||||||
|
'exception' => $exception,
|
||||||
|
]));
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $context */
|
||||||
|
private function logSkipped(string $emailType, array $context): void
|
||||||
|
{
|
||||||
|
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
|
||||||
|
'email_type' => $emailType,
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
|
|||||||
|
|
||||||
- `UserRegistered`: dispara el correo de bienvenida.
|
- `UserRegistered`: dispara el correo de bienvenida.
|
||||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||||
- `PurchasePaid`: envía la confirmación de pago.
|
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
|
||||||
|
|
||||||
## Componentes
|
## Componentes
|
||||||
|
|
||||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||||
|
|
||||||
## API y dependencias
|
## API y dependencias
|
||||||
|
|
||||||
@@ -23,4 +22,6 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
|
|||||||
|
|
||||||
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
||||||
- La recuperación no se envía si el intento dejó de estar pendiente.
|
- La recuperación no se envía si el intento dejó de estar pendiente.
|
||||||
|
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
|
||||||
|
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
|
||||||
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PurchaseController extends Controller
|
|||||||
|
|
||||||
return PurchaseResource::collection(
|
return PurchaseResource::collection(
|
||||||
Purchase::query()
|
Purchase::query()
|
||||||
|
->with('stockReservation')
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
||||||
@@ -92,7 +93,6 @@ class PurchaseController extends Controller
|
|||||||
PaymentIntentRequest $request,
|
PaymentIntentRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Purchase $compra,
|
Purchase $compra,
|
||||||
CheckoutService $checkoutService,
|
|
||||||
PurchaseStateGuard $purchaseState,
|
PurchaseStateGuard $purchaseState,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
@@ -101,7 +101,12 @@ class PurchaseController extends Controller
|
|||||||
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
|
$updated = DB::transaction(function () use (
|
||||||
|
$compra,
|
||||||
|
$method,
|
||||||
|
$purchaseState,
|
||||||
|
$transferPayerDni,
|
||||||
|
): bool {
|
||||||
/** @var Purchase|null $purchase */
|
/** @var Purchase|null $purchase */
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
->whereKey($compra->getKey())
|
->whereKey($compra->getKey())
|
||||||
@@ -128,9 +133,6 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate = [
|
$purchaseUpdate = [
|
||||||
'payment_method' => $method,
|
'payment_method' => $method,
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'expires_at' => now()->addMinutes(
|
|
||||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
|
||||||
),
|
|
||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -150,7 +152,6 @@ class PurchaseController extends Controller
|
|||||||
|
|
||||||
$compra->refresh();
|
$compra->refresh();
|
||||||
$totalAmount = (float) $compra->total;
|
$totalAmount = (float) $compra->total;
|
||||||
$checkoutService->syncReservationExpiration($compra);
|
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Events;
|
namespace App\Domains\Purchase\Events;
|
||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
@@ -10,5 +9,5 @@ class PurchasePaid
|
|||||||
{
|
{
|
||||||
use Dispatchable, SerializesModels;
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(public readonly Purchase $purchase) {}
|
public function __construct(public readonly int $purchaseId) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
|
'stock_reservation_id',
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
'user_id',
|
'user_id',
|
||||||
'status',
|
'status',
|
||||||
'payment_method',
|
'payment_method',
|
||||||
'expires_at',
|
|
||||||
'total',
|
'total',
|
||||||
'dni',
|
'dni',
|
||||||
'transfer_payer_dni',
|
'transfer_payer_dni',
|
||||||
@@ -51,6 +52,14 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_SUPERSEDED = 'superseded';
|
public const STATUS_SUPERSEDED = 'superseded';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_INCOMPLETE = 'incomplete';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_CONFIRMED = 'confirmed';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public static function statuses(): array
|
public static function statuses(): array
|
||||||
{
|
{
|
||||||
@@ -66,6 +75,68 @@ class Purchase extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{name: string, statuses: list<string>}>
|
||||||
|
*/
|
||||||
|
public static function adminStatuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ADMIN_STATUS_INCOMPLETE => [
|
||||||
|
'name' => 'Por completar datos',
|
||||||
|
'statuses' => [self::STATUS_CREATED],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_AWAITING_PAYMENT => [
|
||||||
|
'name' => 'Esperando pago',
|
||||||
|
'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_CONFIRMED => [
|
||||||
|
'name' => 'Confirmado',
|
||||||
|
'statuses' => [self::STATUS_PAID],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_CANCELLED => [
|
||||||
|
'name' => 'Anulado',
|
||||||
|
'statuses' => [
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_SUPERSEDED,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function adminStatusCodes(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::adminStatuses());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function realStatusesForAdminStatus(string $adminStatus): array
|
||||||
|
{
|
||||||
|
return self::adminStatuses()[$adminStatus]['statuses'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminStatusFor(string $realStatus): ?string
|
||||||
|
{
|
||||||
|
foreach (self::adminStatuses() as $adminStatus => $definition) {
|
||||||
|
if (in_array($realStatus, $definition['statuses'], true)) {
|
||||||
|
return $adminStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminStatusNameFor(string $realStatus): ?string
|
||||||
|
{
|
||||||
|
$adminStatus = self::adminStatusFor($realStatus);
|
||||||
|
|
||||||
|
return $adminStatus === null
|
||||||
|
? null
|
||||||
|
: self::adminStatuses()[$adminStatus]['name'];
|
||||||
|
}
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
@@ -77,8 +148,8 @@ class Purchase extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
|
'stock_reservation_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'total' => 'decimal:2',
|
'total' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -115,18 +186,21 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */
|
||||||
* @return HasMany<Ticket, $this>
|
public function tickets(): HasManyThrough
|
||||||
*/
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
return $this->hasManyThrough(
|
||||||
|
Ticket::class,
|
||||||
|
PurchaseItem::class,
|
||||||
|
'compra_id',
|
||||||
|
'source_purchase_item_id',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
public function stockReservations(): HasMany
|
public function stockReservation(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->hasMany(StockReservation::class);
|
return $this->belongsTo(StockReservation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +219,12 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||||
|
public function telepagosPaymentCandidates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
if ($this->total !== null) {
|
if ($this->total !== null) {
|
||||||
@@ -193,7 +273,7 @@ class Purchase extends Model
|
|||||||
'status' => self::STATUS_PAID,
|
'status' => self::STATUS_PAID,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PurchasePaid::dispatch($this);
|
PurchasePaid::dispatch($this->getKey());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ namespace App\Domains\Purchase\Models;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
@@ -56,6 +58,12 @@ class PurchaseItem extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_purchase_item_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Attachment, $this> */
|
/** @return BelongsTo<Attachment, $this> */
|
||||||
public function imageAttachment(): BelongsTo
|
public function imageAttachment(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
'matched_purchase_ids',
|
|
||||||
'cuit_buyer',
|
'cuit_buyer',
|
||||||
'cvu_buyer',
|
'cvu_buyer',
|
||||||
'amount',
|
'amount',
|
||||||
@@ -30,7 +30,6 @@ class TelepagosPayment extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'compra_id' => 'integer',
|
'compra_id' => 'integer',
|
||||||
'matched_purchase_ids' => 'array',
|
|
||||||
'amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -42,4 +41,10 @@ class TelepagosPayment extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||||
|
public function candidates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'telepagos_payment_id',
|
||||||
|
'compra_id',
|
||||||
|
'dni_matches',
|
||||||
|
'dni_distance',
|
||||||
|
'payment_dni',
|
||||||
|
'purchase_dni',
|
||||||
|
'amount_matches',
|
||||||
|
'payment_amount',
|
||||||
|
'purchase_amount',
|
||||||
|
'amount_difference',
|
||||||
|
'match_reason',
|
||||||
|
'confidence',
|
||||||
|
])]
|
||||||
|
class TelepagosPaymentCandidate extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'telepagos_payment_candidates';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'telepagos_payment_id' => 'integer',
|
||||||
|
'compra_id' => 'integer',
|
||||||
|
'dni_matches' => 'boolean',
|
||||||
|
'dni_distance' => 'integer',
|
||||||
|
'amount_matches' => 'boolean',
|
||||||
|
'payment_amount' => 'decimal:2',
|
||||||
|
'purchase_amount' => 'decimal:2',
|
||||||
|
'amount_difference' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Resources;
|
|||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -17,12 +18,15 @@ class PurchaseResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$serverTime = now();
|
||||||
|
$expiresAt = $this->stockReservation?->expires_at;
|
||||||
$items = $this->resource->relationLoaded('items')
|
$items = $this->resource->relationLoaded('items')
|
||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: collect();
|
||||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||||
? (int) $this->resource->getAttribute('tickets_count')
|
? (int) $this->resource->getAttribute('tickets_count')
|
||||||
: null;
|
: null;
|
||||||
|
$paymentVerification = $this->resolvePaymentVerification();
|
||||||
|
|
||||||
$subtotal = $items->isNotEmpty()
|
$subtotal = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $items->reduce(
|
||||||
@@ -48,7 +52,11 @@ class PurchaseResource extends JsonResource
|
|||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'payment_method' => $this->payment_method,
|
'payment_method' => $this->payment_method,
|
||||||
'expires_at' => $this->expires_at,
|
'expires_at' => $expiresAt,
|
||||||
|
'expires_in_seconds' => $expiresAt === null
|
||||||
|
? null
|
||||||
|
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
|
||||||
|
'server_time' => $serverTime,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
@@ -58,6 +66,7 @@ class PurchaseResource extends JsonResource
|
|||||||
'items' => PurchaseItemResource::collection($items),
|
'items' => PurchaseItemResource::collection($items),
|
||||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||||
|
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
|
||||||
'subtotal' => $this->formatMoney($subtotal),
|
'subtotal' => $this->formatMoney($subtotal),
|
||||||
'total' => $this->formatMoney($total),
|
'total' => $this->formatMoney($total),
|
||||||
];
|
];
|
||||||
@@ -77,4 +86,75 @@ class PurchaseResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
private function resolvePaymentVerification(): ?array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->status !== Purchase::STATUS_IN_REVIEW
|
||||||
|
|| $this->payment_method !== 'transfer'
|
||||||
|
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $this->resource
|
||||||
|
->getRelation('telepagosPaymentCandidates')
|
||||||
|
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
|
||||||
|
->values();
|
||||||
|
/** @var TelepagosPaymentCandidate|null $primary */
|
||||||
|
$primary = $candidates->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $primary === null ? 'pending' : 'candidate',
|
||||||
|
'candidate_count' => $candidates->count(),
|
||||||
|
'primary' => $primary === null ? null : [
|
||||||
|
'reason' => $primary->match_reason,
|
||||||
|
'dni_distance' => $primary->dni_distance,
|
||||||
|
'payment_amount' => $this->formatMoney($primary->payment_amount),
|
||||||
|
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
|
||||||
|
'amount_difference' => $this->formatMoney($primary->amount_difference),
|
||||||
|
'confidence' => $primary->confidence,
|
||||||
|
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
'reasons' => $candidates
|
||||||
|
->pluck('match_reason')
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function comparePaymentCandidates(
|
||||||
|
TelepagosPaymentCandidate $left,
|
||||||
|
TelepagosPaymentCandidate $right,
|
||||||
|
): int {
|
||||||
|
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
|
||||||
|
<=> $this->paymentCandidateRank($right->match_reason);
|
||||||
|
|
||||||
|
if ($reasonComparison !== 0) {
|
||||||
|
return $reasonComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
|
||||||
|
|
||||||
|
if ($differenceComparison !== 0) {
|
||||||
|
return $differenceComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
|
||||||
|
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
|
||||||
|
|
||||||
|
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paymentCandidateRank(string $reason): int
|
||||||
|
{
|
||||||
|
return match ($reason) {
|
||||||
|
'ambiguous_exact_match' => 0,
|
||||||
|
'exact_dni_near_amount' => 1,
|
||||||
|
'exact_amount_near_dni' => 2,
|
||||||
|
default => 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
$this->purchaseState->lockCurrentCart($purchase);
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
if (
|
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
|
||||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
|
||||||
) {
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'purchase' => __('api.purchase.not_available_for_review'),
|
'purchase' => __('api.purchase.not_available_for_review'),
|
||||||
]);
|
]);
|
||||||
@@ -72,9 +69,8 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
'status' => Purchase::STATUS_IN_REVIEW,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
$this->reservations->syncPurchaseExpiration($purchase);
|
$this->reservations->clearExpirationForReview($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
@@ -161,13 +157,14 @@ class CompleteCheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
}
|
||||||
$this->reservations->commit($cartItem, $selection, $purchase);
|
|
||||||
} catch (\InvalidArgumentException) {
|
try {
|
||||||
throw ValidationException::withMessages([
|
$this->reservations->commit($purchase);
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
} catch (\InvalidArgumentException) {
|
||||||
]);
|
throw ValidationException::withMessages([
|
||||||
}
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sourceCart->finalize($purchase);
|
$this->sourceCart->finalize($purchase);
|
||||||
@@ -194,7 +191,7 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function itemKey(int $catalogItemId, ?int $variantId): string
|
private function itemKey(int $catalogItemId, ?int $variantId): string
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ class PurchaseResponseLoader
|
|||||||
{
|
{
|
||||||
public function load(Purchase $purchase): Purchase
|
public function load(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['tenant', 'items.imageAttachment']);
|
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
|
||||||
|
|
||||||
|
if (
|
||||||
|
$purchase->status === Purchase::STATUS_IN_REVIEW
|
||||||
|
&& $purchase->payment_method === 'transfer'
|
||||||
|
) {
|
||||||
|
$relations[] = 'telepagosPaymentCandidates.payment';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $purchase->load($relations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user