Compare commits

..

41 Commits

Author SHA1 Message Date
152d44cd0c Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations 2026-08-26 16:23:20 -03:00
cd99a6489b Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations 2026-08-26 10:12:44 -03:00
ebcbbb1774 fix(cart): reconcile expired cart mutations 2026-08-26 10:10:35 -03:00
3dd378ed06 docs(stock): describe associated entity expiration 2026-08-26 10:10:35 -03:00
4f705175bd test(stock): cover propagated reservation expiration 2026-08-26 10:10:35 -03:00
82570fe941 refactor(stock): propagate reservation expiration 2026-08-26 10:10:35 -03:00
d2a2b323e0 docs(stock): document terminal reservation recovery 2026-08-26 10:10:22 -03:00
ec67623b7b test(stock): cover terminal reservation lifecycle 2026-08-26 10:10:22 -03:00
f6f138e180 refactor(stock): make expired reservations terminal 2026-08-26 10:10:22 -03:00
d612b7a118 docs(stock): describe unified expiration ownership 2026-08-26 10:10:08 -03:00
49f42d4507 test(stock): cover authoritative reservation expiration 2026-08-26 10:10:00 -03:00
1881cc1d4b refactor(stock): make reservation expiration authoritative 2026-08-26 10:10:00 -03:00
00ec37d8a0 docs(stock): document centralized reservation flow 2026-08-26 10:09:34 -03:00
d9f374c718 test(stock): cover aggregate reservation lifecycle 2026-08-26 10:09:14 -03:00
bbfdf8f342 refactor(stock): centralize reservation aggregate 2026-08-26 10:08:28 -03:00
8a0f29bdae fix(cart): reconcile expired cart mutations 2026-08-26 09:45:39 -03:00
198ed400a9 docs(stock): describe associated entity expiration 2026-08-26 09:45:39 -03:00
23d83166e6 test(stock): cover propagated reservation expiration 2026-08-26 09:45:39 -03:00
cb090402d0 refactor(stock): propagate reservation expiration 2026-08-26 09:45:39 -03:00
c120b6f7c0 docs(stock): document terminal reservation recovery 2026-08-26 09:45:39 -03:00
1106fa28b4 test(stock): cover terminal reservation lifecycle 2026-08-26 09:45:39 -03:00
896fb81bcf refactor(stock): make expired reservations terminal 2026-08-26 09:45:39 -03:00
8545fbc645 docs(stock): describe unified expiration ownership 2026-08-26 09:45:39 -03:00
dd6dcb708a test(stock): cover authoritative reservation expiration 2026-08-26 09:45:38 -03:00
624b246ff5 refactor(stock): make reservation expiration authoritative 2026-08-26 09:45:38 -03:00
007b95b904 docs(stock): document centralized reservation flow 2026-08-26 09:45:38 -03:00
0b3c8c93be test(stock): cover aggregate reservation lifecycle 2026-08-26 09:45:38 -03:00
ede718e448 refactor(stock): centralize reservation aggregate 2026-08-26 09:45:38 -03:00
e63a03601a fix(cart): reconcile expired cart mutations 2026-08-25 16:33:39 -03:00
389373ad8c docs(stock): describe associated entity expiration 2026-08-25 16:10:15 -03:00
ace02a3133 test(stock): cover propagated reservation expiration 2026-08-25 16:10:14 -03:00
d69a6210f6 refactor(stock): propagate reservation expiration 2026-08-25 16:09:52 -03:00
3c0b43fea3 docs(stock): document terminal reservation recovery 2026-08-25 15:58:47 -03:00
9b0008626b test(stock): cover terminal reservation lifecycle 2026-08-25 15:58:47 -03:00
61861e331a refactor(stock): make expired reservations terminal 2026-08-25 15:58:41 -03:00
2cc9d7dd97 docs(stock): describe unified expiration ownership 2026-08-25 15:28:30 -03:00
f982bcead1 test(stock): cover authoritative reservation expiration 2026-08-25 15:28:23 -03:00
7a85e1731d refactor(stock): make reservation expiration authoritative 2026-08-25 15:28:17 -03:00
6db99e775a docs(stock): document centralized reservation flow 2026-08-25 15:05:34 -03:00
e5f7ba3615 test(stock): cover aggregate reservation lifecycle 2026-08-25 15:05:29 -03:00
24bfef431b refactor(stock): centralize reservation aggregate 2026-08-25 15:05:23 -03:00
241 changed files with 1201 additions and 14051 deletions

View File

@@ -8,7 +8,6 @@ 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
@@ -45,7 +44,6 @@ COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30 COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30 EMAILS_LOG_DAYS=30
EMAIL_DELIVERY_LEASE_SECONDS=300
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

View File

@@ -1,49 +0,0 @@
<?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();
}
}

View File

@@ -1,38 +0,0 @@
<?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'),
],
];
}
}

View File

@@ -1,41 +0,0 @@
<?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),
],
];
}
}

View File

@@ -1,27 +0,0 @@
<?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,
]),
];
}
}

View File

@@ -1,99 +0,0 @@
<?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'])),
];
}
}

View File

@@ -1,63 +0,0 @@
# 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.

View File

@@ -1,10 +0,0 @@
<?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');
});

View File

@@ -5,7 +5,6 @@ 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;
@@ -27,7 +26,6 @@ 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'),

View File

@@ -5,7 +5,6 @@ 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;
@@ -26,7 +25,6 @@ class ValidateResetPasswordAttemptController extends Controller
$result = $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) { if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {

View File

@@ -17,8 +17,6 @@ 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';

View File

@@ -6,7 +6,6 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Authorization\Models\Role; use App\Domains\Authorization\Models\Role;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
@@ -14,17 +13,16 @@ 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', 'active_email', 'active_google_id'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable, SoftDeletes; use HasApiTokens, HasFactory, Notifiable;
protected $attributes = [ protected $attributes = [
'rol_codigo' => RoleCode::User->value, 'rol_codigo' => RoleCode::User->value,
@@ -47,12 +45,6 @@ class User extends Authenticatable
return $this->hasMany(LoginAttempt::class); return $this->hasMany(LoginAttempt::class);
} }
/** @return HasMany<ScanAttempt, $this> */
public function scanAttempts(): HasMany
{
return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
}
/** /**
* @return BelongsTo<Role, $this> * @return BelongsTo<Role, $this>
*/ */

View File

@@ -2,7 +2,6 @@
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;
@@ -14,26 +13,15 @@ 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' => [ 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', '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'],

View File

@@ -4,7 +4,6 @@ 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
{ {
@@ -13,13 +12,6 @@ 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 [
@@ -27,13 +19,11 @@ class UpdateProfileRequest extends FormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo) Rule::unique('users', 'email')->ignore($this->user()->id),
->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', Password::min(8)->mixedCase()->symbols()], 'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
]; ];
} }
} }

View File

@@ -24,11 +24,6 @@ class UserResource extends JsonResource
'telefono' => $this->telefono, 'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [
'id' => $category->id,
'nombre' => $category->nombre,
])->values()),
]; ];
} }
} }

View File

@@ -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('active_email', mb_strtolower(trim($email))) ->where('email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Admin->value) ->where('rol_codigo', RoleCode::Admin->value)
->first(); ->first();

View File

@@ -3,7 +3,6 @@
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;
@@ -125,12 +124,12 @@ class GoogleAuthService
]); ]);
} }
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first(); $user = User::query()->where('google_id', $googleId)->first();
if ($user) { if ($user) {
return $user; return $user;
} }
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first(); $user = User::query()->where('email', $email)->first();
if ($user) { if ($user) {
$user->forceFill(['google_id' => $googleId])->save(); $user->forceFill(['google_id' => $googleId])->save();

View File

@@ -85,7 +85,7 @@ class PasswordLoginService
null, null,
$ipAddress, $ipAddress,
$userAgent, $userAgent,
RoleCode::Scanner, null,
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('active_email', $normalizedEmail) ->where('email', $normalizedEmail)
->when( ->when(
$requiredRole !== null, $requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value), fn ($query) => $query->where('rol_codigo', $requiredRole->value),

View File

@@ -28,8 +28,7 @@ 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('active_email', mb_strtolower(trim($email))) ->where('email', $email)
->where('rol_codigo', RoleCode::User->value)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
@@ -66,7 +65,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('active_email', mb_strtolower(trim($email))) ->where('email', $email)
->where('rol_codigo', RoleCode::AdminApp->value) ->where('rol_codigo', RoleCode::AdminApp->value)
->whereNotNull('tenant_codigo') ->whereNotNull('tenant_codigo')
->lockForUpdate() ->lockForUpdate()
@@ -114,7 +113,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('active_email', mb_strtolower(trim($email))) ->where('email', $email)
->where('rol_codigo', RoleCode::Scanner->value) ->where('rol_codigo', RoleCode::Scanner->value)
->whereNotNull('tenant_codigo') ->whereNotNull('tenant_codigo')
->lockForUpdate() ->lockForUpdate()
@@ -153,15 +152,14 @@ class ResetPasswordAttemptService
); );
} }
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string public function validateCode(string $email, string $code): string
{ {
$emailFingerprint = $this->emailFingerprint($email); $emailFingerprint = $this->emailFingerprint($email);
try { try {
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string { return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
$user = User::query() $user = User::query()
->where('active_email', mb_strtolower(trim($email))) ->where('email', $email)
->where('rol_codigo', $role->value)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
@@ -209,15 +207,14 @@ class ResetPasswordAttemptService
} }
} }
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool public function resetPassword(string $email, string $code, string $password): bool
{ {
$emailFingerprint = $this->emailFingerprint($email); $emailFingerprint = $this->emailFingerprint($email);
try { try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool { return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
$user = User::query() $user = User::query()
->where('active_email', mb_strtolower(trim($email))) ->where('email', $email)
->where('rol_codigo', $role->value)
->lockForUpdate() ->lockForUpdate()
->first(); ->first();

View File

@@ -19,16 +19,6 @@ class ScannerContextService
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
if ($tenant->requiresScannerCategoryValidation()) {
$categories = $user->scanCategories()
->orderBy('nombre')
->get();
if ($categories->isNotEmpty()) {
$user->setRelation('scanCategories', $categories);
}
}
return $user; return $user;
} }
} }

View File

@@ -12,10 +12,8 @@ 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);

View File

@@ -12,10 +12,8 @@ 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);

View File

@@ -17,7 +17,6 @@ class AdminAppBootstrapResource extends JsonResource
return [ return [
'website_type_code' => $websiteType->codigo, 'website_type_code' => $websiteType->codigo,
'site_title' => $websiteType->site_title,
'primary_color' => $websiteType->primary_color, 'primary_color' => $websiteType->primary_color,
'secondary_color' => $websiteType->secondary_color, 'secondary_color' => $websiteType->secondary_color,
'danger_color' => $websiteType->danger_color, 'danger_color' => $websiteType->danger_color,

View File

@@ -343,9 +343,9 @@ class StockReservationService
}); });
} }
public function clearExpirationForReview(Purchase $purchase): void public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void
{ {
DB::transaction(function () use ($purchase): void { DB::transaction(function () use ($purchase, $expiresAt): void {
/** @var Purchase $purchase */ /** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) { if ($purchase->stock_reservation_id === null) {
@@ -363,7 +363,7 @@ class StockReservationService
throw new StockReservationExpiredException; throw new StockReservationExpiredException;
} }
$reservation->update(['expires_at' => null]); $reservation->update(['expires_at' => $expiresAt]);
}); });
} }

View File

@@ -5,7 +5,6 @@ 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;
@@ -23,7 +22,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' => 17, 'type' => 'VIP + LUNCH'], ['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, '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'],
]; ];
@@ -59,7 +58,7 @@ class InvitationPurchaseProvisioner
$allocation['type'], $allocation['type'],
); );
$purchaseItemId = $this->createPurchaseItem( $this->createPurchaseItem(
$purchaseId, $purchaseId,
$catalogItem, $catalogItem,
$variant, $variant,
@@ -71,7 +70,6 @@ class InvitationPurchaseProvisioner
); );
$this->createTicketAndCommitStock( $this->createTicketAndCommitStock(
$purchaseId, $purchaseId,
$purchaseItemId,
$userId, $userId,
(int) $catalogItem->id, (int) $catalogItem->id,
$variant, $variant,
@@ -104,11 +102,7 @@ class InvitationPurchaseProvisioner
private function userId(DateTimeInterface $now): int private function userId(DateTimeInterface $now): int
{ {
$user = DB::table('users') $user = DB::table('users')->where('email', self::USER_EMAIL)->first();
->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) {
@@ -332,13 +326,12 @@ class InvitationPurchaseProvisioner
int $seat, int $seat,
string $type, string $type,
DateTimeInterface $now, DateTimeInterface $now,
): int { ): void {
$existingId = DB::table('compra_items') if (DB::table('compra_items')
->where('compra_id', $purchaseId) ->where('compra_id', $purchaseId)
->where('source_variant_id', $variant->id) ->where('source_variant_id', $variant->id)
->value('id'); ->exists()) {
if ($existingId !== null) { return;
return (int) $existingId;
} }
$attributes = [ $attributes = [
@@ -348,7 +341,7 @@ class InvitationPurchaseProvisioner
['name' => 'Asiento', 'value' => (string) $seat], ['name' => 'Asiento', 'value' => (string) $seat],
]; ];
return DB::table('compra_items')->insertGetId([ DB::table('compra_items')->insert([
'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,
@@ -374,18 +367,13 @@ 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($purchaseReference) ->where('source_purchase_id', $purchaseId)
->where('source_variant_id', $variant->id) ->where('source_variant_id', $variant->id)
->exists()) { ->exists()) {
return; return;
@@ -432,7 +420,7 @@ class InvitationPurchaseProvisioner
'ticket' => (string) Str::uuid(), 'ticket' => (string) Str::uuid(),
'name' => null, 'name' => null,
'description' => null, 'description' => null,
...$purchaseReference, 'source_purchase_id' => $purchaseId,
'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,

View File

@@ -2,11 +2,7 @@
namespace App\Domains\Event\Controllers\AdminApp; namespace App\Domains\Event\Controllers\AdminApp;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Event\Requests\StoreEventDateRequest;
use App\Domains\Event\Requests\UpdateEventRequest; use App\Domains\Event\Requests\UpdateEventRequest;
use App\Domains\Event\Resources\EventDateResource;
use App\Domains\Event\Resources\EventResource; use App\Domains\Event\Resources\EventResource;
use App\Domains\Event\Services\EventService; use App\Domains\Event\Services\EventService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@@ -32,37 +28,4 @@ class EventController extends Controller
) )
); );
} }
public function storeDate(StoreEventDateRequest $request): EventDateResource
{
return EventDateResource::make(
$this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
)
);
}
public function rescheduleDate(
RescheduleEventDateRequest $request,
EventDate $eventDate,
): EventDateResource {
return EventDateResource::make(
$this->eventService->rescheduleDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
$request->validated(),
)
);
}
public function suspendDate(Request $request, EventDate $eventDate): EventDateResource
{
return EventDateResource::make(
$this->eventService->suspendDateForTenant(
$request->user()->tenant()->firstOrFail(),
$eventDate,
)
);
}
} }

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateStatus: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
case Scheduled = 'scheduled';
case InProgress = 'in_progress';
case Completed = 'completed';
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateRescheduled
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $sourceEventDateId,
public readonly int $destinationEventDateId,
public readonly string $previousDate,
public readonly string $newDate,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Event\Events;
use Illuminate\Foundation\Events\Dispatchable;
class EventDateSuspended
{
use Dispatchable;
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function __construct(
public readonly string $tenantCode,
public readonly int $eventDateId,
public readonly string $date,
public readonly array $purchaseTickets,
) {}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Event\Models; namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Enums\EventDateStatus;
use App\Domains\Event\Services\EventDateTextFormatter; use App\Domains\Event\Services\EventDateTextFormatter;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ValidityTimeType; use App\Domains\Ticket\Enums\ValidityTimeType;
@@ -22,8 +21,6 @@ use Illuminate\Support\Carbon;
'date', 'date',
'time_start', 'time_start',
'time_end', 'time_end',
'rescheduled_to_event_date_id',
'suspended_at',
])] ])]
class EventDate extends Model class EventDate extends Model
{ {
@@ -31,8 +28,6 @@ class EventDate extends Model
public $timestamps = false; public $timestamps = false;
protected $appends = ['status'];
protected static function booted(): void protected static function booted(): void
{ {
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime()); static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
@@ -57,8 +52,6 @@ class EventDate extends Model
return [ return [
'date' => 'date:Y-m-d', 'date' => 'date:Y-m-d',
'validity_time_id' => 'integer', 'validity_time_id' => 'integer',
'rescheduled_to_event_date_id' => 'integer',
'suspended_at' => 'datetime',
]; ];
} }
@@ -74,18 +67,6 @@ class EventDate extends Model
return $this->belongsTo(ValidityTime::class); return $this->belongsTo(ValidityTime::class);
} }
/** @return BelongsTo<EventDate, $this> */
public function rescheduledTo(): BelongsTo
{
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
}
/** @return HasMany<EventDate, $this> */
public function rescheduledFrom(): HasMany
{
return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
}
/** @return HasMany<Variant, $this> */ /** @return HasMany<Variant, $this> */
public function variants(): HasMany public function variants(): HasMany
{ {
@@ -113,27 +94,6 @@ class EventDate extends Model
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end); return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
} }
public function getStatusAttribute(): EventDateStatus
{
if ($this->rescheduled_to_event_date_id !== null) {
return EventDateStatus::Rescheduled;
}
if ($this->suspended_at !== null) {
return EventDateStatus::Suspended;
}
if (now()->lt($this->startsAt())) {
return EventDateStatus::Scheduled;
}
if (now()->lt($this->endsAt())) {
return EventDateStatus::InProgress;
}
return EventDateStatus::Completed;
}
private function syncTenantDateText(): void private function syncTenantDateText(): void
{ {
$tenant = $this->tenant()->first(); $tenant = $this->tenant()->first();
@@ -144,10 +104,7 @@ class EventDate extends Model
$tenant->update([ $tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format( 'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates() $tenant->eventDates()->pluck('date')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->pluck('date')
), ),
]); ]);
} }

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RescheduleEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
];
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domains\Event\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEventDateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
'start_time' => ['required', 'date_format:H:i'],
'end_time' => ['required', 'date_format:H:i'],
];
}
}

View File

@@ -19,6 +19,11 @@ class UpdateEventRequest extends FormRequest
return [ return [
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'location' => ['required', 'string', 'max:255'], 'location' => ['required', 'string', 'max:255'],
'dates' => ['required', 'array', 'min:1'],
'dates.*' => ['required', 'array:date,start_time,end_time'],
'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'],
'dates.*.start_time' => ['required', 'date_format:H:i'],
'dates.*.end_time' => ['required', 'date_format:H:i'],
'social_media' => ['sometimes', 'array'], 'social_media' => ['sometimes', 'array'],
'social_media.*' => ['required', 'array:code,url,orden'], 'social_media.*' => ['required', 'array:code,url,orden'],
'social_media.*.code' => [ 'social_media.*.code' => [
@@ -33,16 +38,6 @@ class UpdateEventRequest extends FormRequest
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'], 'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
'contact.instagram_url' => ['nullable', 'url', 'max:2048'], 'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
'contact.facebook_url' => ['nullable', 'url', 'max:2048'], 'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
'allow_ticket_refund' => ['sometimes', 'boolean'],
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
'ticket_partial_refund_percentage' => [
'sometimes',
'numeric',
'decimal:0,2',
'min:0',
'max:99.99',
],
]; ];
} }
@@ -59,41 +54,6 @@ class UpdateEventRequest extends FormRequest
'The social media field is required.' 'The social media field is required.'
); );
} }
if (! array_key_exists('allow_ticket_refund', $input)) {
return;
}
foreach ([
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
] as $field) {
if (! array_key_exists($field, $input)) {
$validator->errors()->add($field, 'El campo es obligatorio.');
}
}
$totalEnabled = $this->boolean('allow_ticket_total_refund');
$partialEnabled = $this->boolean('allow_ticket_partial_refund');
$refundEnabled = $this->boolean('allow_ticket_refund');
if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) {
$validator->errors()->add(
'allow_ticket_refund',
'Seleccioná al menos un tipo de reembolso.'
);
}
if ($refundEnabled
&& $partialEnabled
&& (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) {
$validator->errors()->add(
'ticket_partial_refund_percentage',
'Ingresá un porcentaje mayor que cero para el reembolso parcial.'
);
}
}, },
]; ];
} }

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Event\Resources;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin EventDate */
class EventDateResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'validity_time_id' => $this->validity_time_id,
'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')),
'date' => $this->date->format('Y-m-d'),
'start_time' => substr($this->time_start, 0, 5),
'end_time' => substr($this->time_end, 0, 5),
'status' => $this->status->value,
'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id,
'suspended_at' => $this->suspended_at?->toISOString(),
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Event\Resources; namespace App\Domains\Event\Resources;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -18,11 +19,14 @@ class EventResource extends JsonResource
'id' => $this->id, 'id' => $this->id,
'title' => $this->event_title, 'title' => $this->event_title,
'location' => $this->event_location, 'location' => $this->event_location,
'allow_ticket_refund' => $this->allow_ticket_refund, 'dates' => $this->eventDates->map(fn ($eventDate): array => [
'allow_ticket_total_refund' => $this->allow_ticket_total_refund, 'id' => $eventDate->id,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, 'validity_time_id' => $eventDate->validity_time_id,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, 'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'dates' => EventDateResource::collection($this->eventDates), 'date' => $eventDate->date->format('Y-m-d'),
'start_time' => substr($eventDate->time_start, 0, 5),
'end_time' => substr($eventDate->time_end, 0, 5),
])->values(),
'social_media' => $this->socialMedia->map(fn ($item): array => [ 'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code, 'code' => $item->code,
'url' => $item->pivot->url, 'url' => $item->pivot->url,

View File

@@ -1,57 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class AffectedEventDatePurchaseResolver
{
/**
* Finds active tickets belonging to paid purchases before an event-date mutation.
*
* @param Collection<int, int>|list<int> $eventDateIds
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
*/
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
{
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
if ($eventDateIds->isEmpty()) {
return [];
}
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
->where('status', Purchase::STATUS_PAID))
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
$query->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
->whereIn('event_dates.id', $eventDateIds));
})
->with([
...TicketValidityResolver::RELATIONS,
'sourcePurchaseItem.purchase',
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
return $tickets
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
->map(function (Collection $purchaseTickets): array {
return [
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
'ticket_ids' => $purchaseTickets->modelKeys(),
];
})
->values()
->all();
}
}

View File

@@ -1,45 +0,0 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver
{
public function resolve(EventDate $eventDate): ?EventDate
{
$date = $this->resolveLatest($eventDate);
return $date !== null && $date->suspended_at === null ? $date : null;
}
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
public function resolveLatest(EventDate $eventDate): ?EventDate
{
$current = $eventDate;
$visited = [];
while (true) {
$identity = $current->getKey() === null
? 'object:'.spl_object_id($current)
: 'key:'.$current->getKey();
if (isset($visited[$identity])) {
return null;
}
$visited[$identity] = true;
if ($current->rescheduled_to_event_date_id === null) {
return $current;
}
$current->loadMissing('rescheduledTo');
$current = $current->rescheduledTo;
if ($current === null) {
return null;
}
}
}
}

View File

@@ -2,13 +2,7 @@
namespace App\Domains\Event\Services; namespace App\Domains\Event\Services;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -20,11 +14,6 @@ class EventService
'facebook_url' => 'facebook', 'facebook_url' => 'facebook',
]; ];
public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
) {}
public function forTenant(Tenant $tenant): Tenant public function forTenant(Tenant $tenant): Tenant
{ {
return $tenant->load(['eventDates.validityTime', 'socialMedia']); return $tenant->load(['eventDates.validityTime', 'socialMedia']);
@@ -38,14 +27,9 @@ class EventService
$tenant->update([ $tenant->update([
'event_title' => $data['title'], 'event_title' => $data['title'],
'event_location' => $data['location'], 'event_location' => $data['location'],
...array_intersect_key($data, array_flip([
'allow_ticket_refund',
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
])),
]); ]);
$this->syncDates($tenant, $data['dates']);
if (array_key_exists('social_media', $data)) { if (array_key_exists('social_media', $data)) {
$this->syncSocialMedia($tenant, $data['social_media']); $this->syncSocialMedia($tenant, $data['social_media']);
} else { } else {
@@ -56,217 +40,41 @@ class EventService
}); });
} }
/** @param array{date: string, start_time: string, end_time: string} $data */ /** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
public function createDateForTenant(Tenant $tenant, array $data): EventDate private function syncDates(Tenant $tenant, array $dates): void
{ {
return DB::transaction(function () use ($tenant, $data): EventDate { $existingDates = $tenant->eventDates()->get()->values();
$attributes = $this->dateAttributes($data);
if ($tenant->eventDates()->where($attributes)->exists()) { foreach (array_values($dates) as $index => $date) {
throw ValidationException::withMessages([ $attributes = [
'date' => ['La fecha y el horario ya existen.'], 'date' => $date['date'],
]); 'time_start' => $date['start_time'],
} 'time_end' => $date['end_time'],
return $tenant->eventDates()->create($attributes)->load('validityTime');
});
}
/** @param array{date: string} $data */
public function rescheduleDateForTenant(Tenant $tenant, EventDate $eventDate, array $data): EventDate
{
return DB::transaction(function () use ($tenant, $eventDate, $data): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede reprogramar una fecha suspendida.'],
]);
}
if ($source->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['La fecha ya fue reprogramada.'],
]);
}
$destination = $tenant->eventDates()
->whereDate('date', $data['date'])
->lockForUpdate()
->first();
if ($destination === null) {
$destination = $tenant->eventDates()->create([
'date' => $data['date'],
'time_start' => $source->time_start,
'time_end' => $source->time_end,
]);
}
if ($destination->is($source) || $this->chainContains($destination, $source)) {
throw ValidationException::withMessages([
'date' => ['La reprogramación generaría una referencia circular.'],
]);
}
if ($this->effectiveEventDateResolver->resolve($destination) === null) {
throw ValidationException::withMessages([
'date' => ['La fecha de destino no es utilizable.'],
]);
}
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant,
$this->affectedDateIds($tenant, $source),
);
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
EventDateRescheduled::dispatch(
$tenant->codigo,
$source->getKey(),
$destination->getKey(),
$source->date->format('d/m/Y'),
$destination->date->format('d/m/Y'),
$purchaseTickets,
);
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
});
}
public function suspendDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return DB::transaction(function () use ($tenant, $eventDate): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([
'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'],
]);
}
if ($date->suspended_at !== null) {
return $date->load('validityTime');
}
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
$tenant,
$this->affectedDateIds($tenant, $date),
);
$date->update(['suspended_at' => now()]);
$this->disableTicketsWithoutUsableDates($tenant, $date);
EventDateSuspended::dispatch(
$tenant->codigo,
$date->getKey(),
$date->date->format('d/m/Y'),
$purchaseTickets,
);
return $date->fresh('validityTime');
});
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return $tenant->eventDates()
->whereKey($eventDate->getKey())
->lockForUpdate()
->firstOrFail();
}
private function chainContains(EventDate $start, EventDate $expected): bool
{
$current = $start;
$visited = [];
while ($current->rescheduled_to_event_date_id !== null) {
if ($current->is($expected)) {
return true;
}
if (isset($visited[$current->getKey()])) {
return true;
}
$visited[$current->getKey()] = true;
$current = $current->rescheduledTo()->lockForUpdate()->first();
if ($current === null) {
return false;
}
}
return $current->is($expected);
}
/** @return Collection<int, int> */
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
{
$affectedDateIds = collect([$eventDate->getKey()]);
$frontier = $affectedDateIds;
while ($frontier->isNotEmpty()) {
$predecessors = $tenant->eventDates()
->whereIn('rescheduled_to_event_date_id', $frontier)
->pluck('id')
->diff($affectedDateIds)
->values();
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
$frontier = $predecessors;
}
return $affectedDateIds;
}
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
{
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
$variants = Variant::withTrashed()
->where(function ($query) use ($affectedDateIds): void {
$query->whereIn('event_date_id', $affectedDateIds)
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->whereIn('event_dates.id', $affectedDateIds));
})
->with(['eventDates', 'eventDate'])
->get();
foreach ($variants as $variant) {
$hasUsableDate = $variant->selectedEventDates()->contains(
fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null
);
if ($hasUsableDate) {
continue;
}
Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('source_variant_id', $variant->getKey())
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->lockForUpdate()
->get()
->each(function (Ticket $ticket): void {
$ticket->markAsDisabled();
$ticket->save();
});
}
}
/**
* @param array{date: string, start_time: string, end_time: string} $data
* @return array{date: string, time_start: string, time_end: string}
*/
private function dateAttributes(array $data): array
{
return [
'date' => $data['date'],
'time_start' => $data['start_time'].':00',
'time_end' => $data['end_time'].':00',
]; ];
$existingDate = $existingDates->get($index);
if ($existingDate) {
$existingDate->update($attributes);
} else {
$tenant->eventDates()->create($attributes);
}
}
$datesToDelete = $existingDates->slice(count($dates));
if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate
->selectedByVariants()
->whereHas('sourceTickets')
->exists()
|| $eventDate->variants()->whereHas('sourceTickets')->exists())) {
throw ValidationException::withMessages([
'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'],
]);
}
$datesToDelete->each->delete();
$tenant->unsetRelation('eventDates');
} }
/** @param array<string, string|null> $contact */ /** @param array<string, string|null> $contact */

View File

@@ -8,7 +8,4 @@ Route::prefix('v1/adminapp/tenant')
->group(function (): void { ->group(function (): void {
Route::get('event', [EventController::class, 'show']); Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']); Route::put('event', [EventController::class, 'update']);
Route::post('event-dates', [EventController::class, 'storeDate']);
Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']);
Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']);
}); });

View File

@@ -1,20 +0,0 @@
<?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));
}
}

View File

@@ -1,22 +0,0 @@
<?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()
)
);
}
}

View File

@@ -1,21 +0,0 @@
<?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'],
];
}
}

View File

@@ -1,18 +0,0 @@
<?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'],
];
}
}

View File

@@ -6,18 +6,27 @@ use App\Domains\Purchase\Models\Purchase;
class SaleFormService class SaleFormService
{ {
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */ /** @return array{statuses: list<array{code: string, name: 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 $code, array $definition): array => [ fn (string $status): array => [
'value' => $code, 'code' => $status,
'label' => $definition['name'], 'name' => $names[$status],
'real_statuses' => $definition['statuses'],
], ],
array_keys(Purchase::adminStatuses()), Purchase::statuses(),
array_values(Purchase::adminStatuses()),
), ),
]; ];
} }

View File

@@ -1,148 +0,0 @@
<?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' => Ticket::statusOptions(),
],
];
}
/** @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' => [],
];
}
}

View File

@@ -1,396 +0,0 @@
<?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' => Ticket::statusOptions(),
'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;
}
}

View File

@@ -9,7 +9,6 @@ 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.
@@ -20,7 +19,6 @@ 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

View File

@@ -5,8 +5,6 @@ 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')
@@ -15,13 +13,6 @@ 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

View File

@@ -4,9 +4,8 @@ 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\ConfigureIntegrationRequest; use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
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;
@@ -36,7 +35,7 @@ class ClientIntegrationController extends Controller
} }
public function store( public function store(
ConfigureIntegrationRequest $request, StoreClientIntegrationRequest $request,
Client $client, Client $client,
string $integrationCode, string $integrationCode,
): JsonResponse { ): JsonResponse {
@@ -62,11 +61,4 @@ class ClientIntegrationController extends Controller
], 400); ], 400);
} }
} }
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);
return response()->noContent();
}
} }

View File

@@ -35,7 +35,6 @@ 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();

View File

@@ -1,43 +0,0 @@
<?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();
}
}

View File

@@ -3,15 +3,22 @@
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_instance_id', 'integration_data',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
]; ];
/** @return BelongsTo<Client, $this> */ /** @return BelongsTo<Client, $this> */
@@ -25,10 +32,4 @@ 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);
}
} }

View File

@@ -3,7 +3,6 @@
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
{ {
@@ -14,29 +13,16 @@ class Integration extends Model
'name', 'name',
'url', 'url',
'integration_data_schema', 'integration_data_schema',
'requires_configuration', 'requires_client_configuration',
]; ];
protected $casts = [ protected $casts = [
'integration_data_schema' => 'array', 'integration_data_schema' => 'array',
'requires_configuration' => 'boolean', 'requires_client_configuration' => 'boolean',
]; ];
/** @return HasMany<IntegrationInstance, $this> */ public function clientIntegrations()
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');
}
} }

View File

@@ -1,41 +0,0 @@
<?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);
}
}

View File

@@ -1,30 +0,0 @@
<?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);
}
}

View File

@@ -1,14 +0,0 @@
<?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;
}
}

View File

@@ -6,19 +6,20 @@ 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 ConfigureIntegrationRequest extends FormRequest class StoreClientIntegrationRequest extends FormRequest
{ {
protected ?Integration $integrationModel = null; protected ?Integration $integrationModel = null;
public function authorize(): bool public function authorize(): bool
{ {
return $this->user()?->can('manage', Integration::class) ?? false; return true;
} }
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::query() $this->integrationModel = Integration::query()
->where('integration_code', $this->route('integration_code')) ->where('integration_code', $integrationCode)
->first(); ->first();
if (! $this->integrationModel) { if (! $this->integrationModel) {
@@ -30,7 +31,7 @@ class ConfigureIntegrationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$rules = ['integration_data' => ['present', 'array']]; $rules = [];
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;

View File

@@ -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_configuration' => ['sometimes', 'boolean'], 'requires_client_configuration' => ['sometimes', 'boolean'],
]; ];
} }
} }

View File

@@ -3,7 +3,6 @@
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
{ {
@@ -20,8 +19,9 @@ 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_configuration' => ['sometimes', 'boolean'], 'requires_client_configuration' => ['sometimes', 'boolean'],
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])], // the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
]; ];
} }
} }

View File

@@ -1,21 +0,0 @@
<?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')),
];
}
}

View File

@@ -1,20 +0,0 @@
<?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,
];
}
}

View File

@@ -5,8 +5,6 @@ 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;
@@ -34,9 +32,9 @@ abstract class BaseIntegrationService
protected ?Integration $integration = null; protected ?Integration $integration = null;
/** /**
* The effective integration instance. * The client-owned integration configuration.
*/ */
protected ?IntegrationInstance $integrationInstance = null; protected ?ClientIntegration $clientIntegration = null;
/** /**
* Set the integration code. * Set the integration code.
@@ -87,7 +85,7 @@ abstract class BaseIntegrationService
} }
/** /**
* Load the integration definition and its effective instance configuration. * Load the integration definition and its client-owned configuration.
* *
* @throws Exception * @throws Exception
*/ */
@@ -97,7 +95,6 @@ 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.");
@@ -107,18 +104,11 @@ abstract class BaseIntegrationService
throw new Exception('Client context is not set.'); throw new Exception('Client context is not set.');
} }
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id) $this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode) ->where('integration_code', $this->integrationCode)
->first()?->integrationInstance; ->first();
if (! $this->integrationInstance && $this->tenant?->website_type_code) { if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
$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.");
} }
} }
@@ -141,15 +131,15 @@ abstract class BaseIntegrationService
} }
/** /**
* Get an integration setting from the effective instance configuration. * Get an integration setting from the client-owned configuration.
*/ */
protected function getIntegrationSetting(string $key, mixed $default = null): mixed protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{ {
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) { if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
return $default; return $default;
} }
return $this->integrationInstance->integration_data[$key] ?? $default; return $this->clientIntegration->integration_data[$key] ?? $default;
} }
/** /**

View File

@@ -13,7 +13,6 @@ 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();
} }
@@ -21,7 +20,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', 'integrationInstance'])->get(); return $client->integrations()->with('integration')->get();
} }
public function updateOrCreateIntegration( public function updateOrCreateIntegration(
@@ -30,7 +29,13 @@ 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 = app(IntegrationAssociationService::class)->configure($client, $integration, $data); $clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'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();

View File

@@ -1,98 +0,0 @@
<?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;
}
}

View File

@@ -1,40 +0,0 @@
<?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));
});
}
}

View File

@@ -29,7 +29,7 @@ class MailService extends BaseIntegrationService
private ?Mailer $mailer = null; private ?Mailer $mailer = null;
private bool $usesInstanceMailer = false; private bool $usesClientMailer = false;
public function __construct(?MailFactory $mailFactory = null) public function __construct(?MailFactory $mailFactory = null)
{ {
@@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
if ($this->integrationInstance) { if ($this->clientIntegration) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true; $this->usesClientMailer = true;
} else { } else {
$this->mailer = $this->mailFactory->mailer(); $this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false; $this->usesClientMailer = false;
} }
return $this; return $this;
@@ -56,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->integrationInstance) { if ($this->clientIntegration) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true; $this->usesClientMailer = true;
} else { } else {
$this->mailer = $this->mailFactory->mailer(); $this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false; $this->usesClientMailer = false;
} }
return $this; return $this;
@@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService
public function mailerName(): string public function mailerName(): string
{ {
return $this->usesInstanceMailer return $this->usesClientMailer
? 'integration-smtp' ? 'client-smtp'
: (string) config('mail.default'); : (string) config('mail.default');
} }
@@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService
private function resolveMailer(): Mailer private function resolveMailer(): Mailer
{ {
$data = $this->integrationInstance?->integration_data; $data = $this->clientIntegration?->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.');
@@ -205,7 +205,7 @@ class MailService extends BaseIntegrationService
} }
$mailer = $this->mailFactory->build([ $mailer = $this->mailFactory->build([
'name' => 'integration-smtp-'.$this->integrationInstance?->id, 'name' => 'client-smtp-'.$this->clientContext?->id,
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null, 'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'], 'host' => $data['MAIL_HOST'],

View File

@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/ */
public function getToken(): string public function getToken(): string
{ {
if (! $this->integrationInstance) { if (! $this->clientIntegration || ! $this->clientContext) {
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 = $this->integrationInstance->tokenCacheKey(); $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
$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 = $this->integrationInstance->tokenCacheKey(); $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
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->integrationInstance) { if (! $this->clientContext) {
return; return;
} }
$cacheKey = $this->integrationInstance->tokenCacheKey(); $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
Cache::forget($cacheKey); Cache::forget($cacheKey);
} }

View File

@@ -7,10 +7,7 @@ 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;
@@ -18,7 +15,6 @@ class TelepagosWebhookService
{ {
public function __construct( public function __construct(
private readonly CheckoutService $checkoutService, private readonly CheckoutService $checkoutService,
private readonly DniDistanceService $dniDistance,
) {} ) {}
/** /**
@@ -45,6 +41,7 @@ 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,
@@ -76,55 +73,24 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo'); $tenantCodes = $client->tenants()->pluck('codigo');
$eligiblePurchases = Purchase::query() $purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
->whereIn('tenant_codigo', $tenantCodes) ->where('transfer_payer_dni', $dni)
->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) {
$candidatePurchases = $this->findTransferCandidates( if ($purchases->count() > 1) {
$eligiblePurchases, TelepagosPayment::create($paymentData);
$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.', [
@@ -243,91 +209,4 @@ 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');
});
}
} }

View File

@@ -1,48 +1,33 @@
# Dominio Integration # Dominio Integration
## Modelo ## Propósito
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`. 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.
- `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.
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. ## Modelo y seguridad
## Resolución - `Integration`: definición global de una integració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.
`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. ## Servicios externos
`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`. - `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
- `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.
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. ## Endpoints
## Administración - CRUD global bajo `/integrations`.
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
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. ## Logging de Telepagos
| Método | Ruta | Operación | 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.
| --- | --- | --- |
| 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. |
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. ## Dependencias y reglas
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`. 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.
## 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.

View File

@@ -1,25 +1,22 @@
<?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::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void { Route::group(['prefix' => 'integrations'], function () {
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void { Route::get('/', [IntegrationController::class, 'index']);
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']); Route::post('/', [IntegrationController::class, 'store']);
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']); Route::get('/{integration}', [IntegrationController::class, 'show']);
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']); Route::put('/{integration}', [IntegrationController::class, 'update']);
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']); Route::delete('/{integration}', [IntegrationController::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']);

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Event\Events\EventDateRescheduled;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendEventDateRescheduledEmails 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(EventDateRescheduled $event): void
{
app(NotificationMailService::class)->sendEventDateRescheduled(
$event->tenantCode,
$event->sourceEventDateId,
$event->destinationEventDateId,
$event->previousDate,
$event->newDate,
$event->purchaseTickets,
);
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Event\Events\EventDateSuspended;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendEventDateSuspendedEmails 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(EventDateSuspended $event): void
{
app(NotificationMailService::class)->sendEventDateSuspended(
$event->tenantCode,
$event->eventDateId,
$event->date,
$event->purchaseTickets,
);
}
}

View File

@@ -1,44 +0,0 @@
<?php
namespace App\Domains\Notification\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'idempotency_key',
'email_type',
'tenant_code',
'status',
'attempts',
'context',
'recipient_fingerprint',
'claim_token',
'claimed_at',
'lease_expires_at',
'sent_at',
'failed_at',
'last_error',
])]
class EmailDelivery extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_PROCESSING = 'processing';
public const STATUS_SENT = 'sent';
public const STATUS_FAILED = 'failed';
protected function casts(): array
{
return [
'attempts' => 'integer',
'context' => 'array',
'claimed_at' => 'datetime',
'lease_expires_at' => 'datetime',
'sent_at' => 'datetime',
'failed_at' => 'datetime',
];
}
}

View File

@@ -1,112 +0,0 @@
<?php
namespace App\Domains\Notification\Services;
use App\Domains\Notification\Models\EmailDelivery;
use Closure;
use Illuminate\Database\Query\Expression;
use Illuminate\Support\Str;
use Throwable;
class IdempotentEmailDeliveryService
{
/**
* @param array<string, mixed> $context
* @param Closure(): void $send
*/
public function sendOnce(
string $key,
string $type,
?string $tenantCode,
array $context,
string $recipient,
Closure $send,
): bool {
$now = now();
EmailDelivery::query()->insertOrIgnore([
'idempotency_key' => $key,
'email_type' => $type,
'tenant_code' => $tenantCode,
'status' => EmailDelivery::STATUS_PENDING,
'attempts' => 0,
'context' => json_encode($context, JSON_THROW_ON_ERROR),
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
'created_at' => $now,
'updated_at' => $now,
]);
$claimToken = (string) Str::uuid();
$leaseExpiresAt = $now->copy()->addSeconds(
max(1, (int) config('mail.delivery_lease_seconds', 300)),
);
$claimed = EmailDelivery::query()
->where('idempotency_key', $key)
->where(function ($query) use ($now): void {
$query->whereIn('status', [
EmailDelivery::STATUS_PENDING,
EmailDelivery::STATUS_FAILED,
])->orWhere(function ($query) use ($now): void {
$query->where('status', EmailDelivery::STATUS_PROCESSING)
->where('lease_expires_at', '<=', $now);
});
})
->update([
'status' => EmailDelivery::STATUS_PROCESSING,
'attempts' => new Expression('attempts + 1'),
'context' => json_encode($context, JSON_THROW_ON_ERROR),
'recipient_fingerprint' => $this->recipientFingerprint($recipient),
'claim_token' => $claimToken,
'claimed_at' => $now,
'lease_expires_at' => $leaseExpiresAt,
'failed_at' => null,
'last_error' => null,
'updated_at' => $now,
]) === 1;
if (! $claimed) {
return false;
}
try {
$send();
EmailDelivery::query()
->where('idempotency_key', $key)
->where('claim_token', $claimToken)
->update([
'status' => EmailDelivery::STATUS_SENT,
'claim_token' => null,
'lease_expires_at' => null,
'sent_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $exception) {
EmailDelivery::query()
->where('idempotency_key', $key)
->where('claim_token', $claimToken)
->update([
'status' => EmailDelivery::STATUS_FAILED,
'claim_token' => null,
'lease_expires_at' => null,
'failed_at' => now(),
'last_error' => Str::limit($exception::class, 2000, ''),
'updated_at' => now(),
]);
throw $exception;
}
return true;
}
private function recipientFingerprint(string $recipient): string
{
return hash_hmac(
'sha256',
mb_strtolower(trim($recipient)),
(string) config('app.key'),
);
}
}

View File

@@ -11,7 +11,6 @@ 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\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticket\Services\TicketValidityResolver;
use Closure; use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -22,42 +21,31 @@ class NotificationMailService
public function __construct( public function __construct(
private readonly MailService $mailService, private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService, private readonly TicketPdfService $ticketPdfService,
private readonly IdempotentEmailDeliveryService $emailDeliveryService,
) {} ) {}
public function sendWelcome(int $userId, string $tenantCode): void public function sendWelcome(int $userId, string $tenantCode): void
{ {
$context = [ $this->sendLogged('welcome', [
'user_id' => $userId, 'user_id' => $userId,
'tenant_code' => $tenantCode, 'tenant_code' => $tenantCode,
]; ], function () use ($userId, $tenantCode): array {
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail(); $tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId); $user = User::query()->findOrFail($userId);
$this->sendIdempotently(
"welcome:{$tenantCode}:{$userId}",
'welcome',
$tenantCode,
$context,
$user->email,
function () use ($user, $tenant, $tenantCode): array {
$brand = $tenant->websiteType ?? $tenant; $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 {$brand->nombre}", "Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(), view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand, $brand,
); );
return [ return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type', 'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
]; ];
}, });
);
} }
public function sendPasswordResetCode( public function sendPasswordResetCode(
@@ -71,6 +59,7 @@ class NotificationMailService
'channel' => $channel, 'channel' => $channel,
]; ];
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
$tenant = Tenant::query() $tenant = Tenant::query()
->with('websiteType') ->with('websiteType')
->where('codigo', $tenantCode) ->where('codigo', $tenantCode)
@@ -86,16 +75,9 @@ class NotificationMailService
'user_id' => $attempt->user_id, 'user_id' => $attempt->user_id,
])); ]));
return; return null;
} }
$this->sendIdempotently(
"password-reset:{$attemptId}",
'password_reset',
$tenantCode,
$context,
$attempt->user->email,
function () use ($attempt, $tenant, $tenantCode, $channel): array {
$recoveryDomain = match ($channel) { $recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain, PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
@@ -117,25 +99,12 @@ class NotificationMailService
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery); : 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant; $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 $this->mailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$attempt->user->email, $attempt->user->email,
"{$subject} - {$brand->nombre}", "Código para recuperar tu contraseña - {$brand->nombre}",
view("mail.notifications.{$template}", [ view('mail.notifications.password-reset', [
'attempt' => $attempt, 'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl, 'recoveryUrl' => $recoveryUrl,
'brand' => $brand, 'brand' => $brand,
@@ -147,14 +116,14 @@ class NotificationMailService
'user_id' => $attempt->user_id, 'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null, 'recovery_domain_available' => $recoveryDomain !== null,
]; ];
}, });
);
} }
public function sendPurchaseConfirmed(int $purchaseId): void public function sendPurchaseConfirmed(int $purchaseId): void
{ {
$context = ['purchase_id' => $purchaseId]; $context = ['purchase_id' => $purchaseId];
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query() $purchase = Purchase::query()
->with(['tenant', 'user', 'items']) ->with(['tenant', 'user', 'items'])
->find($purchaseId); ->find($purchaseId);
@@ -165,25 +134,12 @@ class NotificationMailService
'missing_model' => Purchase::class, 'missing_model' => Purchase::class,
])); ]));
return; return null;
} }
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('purchase_confirmed', array_merge($context, ['reason' => 'missing_recipient']));
return;
}
$this->sendIdempotently(
"purchase-confirmed:{$purchaseId}",
'purchase_confirmed',
$purchase->tenant_codigo,
$context,
$recipient,
function () use ($purchase, $recipient): array {
/** @var Collection<int, Ticket> $tickets */ /** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets() $tickets = Ticket::query()
->where('source_purchase_id', $purchase->getKey())
->where('tenant_code', $purchase->tenant_codigo) ->where('tenant_code', $purchase->tenant_codigo)
->with(TicketPresentationResolver::RELATIONS) ->with(TicketPresentationResolver::RELATIONS)
->get(); ->get();
@@ -198,7 +154,7 @@ class NotificationMailService
$this->mailService $this->mailService
->forTenant($purchase->tenant_codigo) ->forTenant($purchase->tenant_codigo)
->send( ->send(
$recipient, $this->recipientFor($purchase),
"Compra confirmada - Compra #{$purchase->getKey()}", "Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(), view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments, attachments: $attachments,
@@ -212,215 +168,7 @@ class NotificationMailService
'ticket_count' => $tickets->count(), 'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(), 'ticket_ids' => $tickets->modelKeys(),
]; ];
}, });
);
}
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function sendEventDateRescheduled(
string $tenantCode,
int $sourceEventDateId,
int $destinationEventDateId,
string $previousDate,
string $newDate,
array $purchaseTickets,
): void {
foreach ($purchaseTickets as $purchaseTicketGroup) {
$purchaseId = $purchaseTicketGroup['purchase_id'];
$ticketIds = $purchaseTicketGroup['ticket_ids'];
$context = [
'tenant_code' => $tenantCode,
'event_date_id' => $sourceEventDateId,
'destination_event_date_id' => $destinationEventDateId,
'purchase_id' => $purchaseId,
'ticket_ids' => $ticketIds,
];
$purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId);
if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'purchase_not_paid_or_not_found',
]));
continue;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $tenantCode)
->whereKey($ticketIds)
->with([
...TicketPresentationResolver::RELATIONS,
...TicketValidityResolver::RELATIONS,
])
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->values();
if ($tickets->isEmpty()) {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'no_longer_active_tickets',
]));
continue;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('event_date_rescheduled', array_merge($context, [
'reason' => 'missing_recipient',
]));
continue;
}
$deliveryKey = "event-date-rescheduled:{$sourceEventDateId}:{$destinationEventDateId}:{$purchaseId}";
$this->sendIdempotently(
$deliveryKey,
'event_date_rescheduled',
$tenantCode,
$context,
$recipient,
function () use (
$tenantCode,
$purchase,
$recipient,
$previousDate,
$newDate,
$tickets,
): array {
$brand = $purchase->tenant->websiteType ?? $purchase->tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$recipient,
"Tu evento fue reprogramado - Compra #{$purchase->getKey()}",
view('mail.notifications.event-date-rescheduled', compact(
'purchase', 'previousDate', 'newDate', 'tickets'
))->render(),
$brand,
);
return ['ticket_count' => $tickets->count()];
},
);
}
}
/**
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
*/
public function sendEventDateSuspended(
string $tenantCode,
int $eventDateId,
string $date,
array $purchaseTickets,
): void {
foreach ($purchaseTickets as $purchaseTicketGroup) {
$purchaseId = $purchaseTicketGroup['purchase_id'];
$ticketIds = $purchaseTicketGroup['ticket_ids'];
$context = [
'tenant_code' => $tenantCode,
'event_date_id' => $eventDateId,
'purchase_id' => $purchaseId,
'ticket_ids' => $ticketIds,
];
$purchase = $this->eventDateNotificationPurchase($tenantCode, $purchaseId);
if ($purchase === null || $purchase->status !== Purchase::STATUS_PAID) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'purchase_not_paid_or_not_found',
]));
continue;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = $purchase->tickets()
->where('tenant_code', $tenantCode)
->whereKey($ticketIds)
->with([
...TicketPresentationResolver::RELATIONS,
...TicketValidityResolver::RELATIONS,
])
->get()
->filter(fn (Ticket $ticket): bool => in_array($ticket->status, [
Ticket::STATUS_ACTIVE,
Ticket::STATUS_DISABLED,
], true))
->values();
if ($tickets->isEmpty()) {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'no_longer_relevant_tickets',
]));
continue;
}
$recipient = $this->recipientFor($purchase);
if ($recipient === '') {
$this->logSkipped('event_date_suspended', array_merge($context, [
'reason' => 'missing_recipient',
]));
continue;
}
$deliveryKey = "event-date-suspended:{$eventDateId}:{$purchaseId}";
$disabledTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_DISABLED)
->values();
$activeTickets = $tickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_ACTIVE)
->values();
$this->sendIdempotently(
$deliveryKey,
'event_date_suspended',
$tenantCode,
$context,
$recipient,
function () use (
$tenantCode,
$purchase,
$recipient,
$date,
$disabledTickets,
$activeTickets,
): array {
$brand = $purchase->tenant->websiteType ?? $purchase->tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$recipient,
"Una fecha de tu evento fue suspendida - Compra #{$purchase->getKey()}",
view('mail.notifications.event-date-suspended', compact(
'purchase', 'date', 'disabledTickets', 'activeTickets'
))->render(),
$brand,
);
return [
'ticket_count' => $disabledTickets->count() + $activeTickets->count(),
'disabled_ticket_ids' => $disabledTickets->modelKeys(),
'active_ticket_ids' => $activeTickets->modelKeys(),
];
},
);
}
}
private function eventDateNotificationPurchase(string $tenantCode, int $purchaseId): ?Purchase
{
return Purchase::query()
->where('tenant_codigo', $tenantCode)
->with(['tenant.websiteType', 'user'])
->find($purchaseId);
} }
private function recipientFor(Purchase $purchase): string private function recipientFor(Purchase $purchase): string
@@ -428,34 +176,6 @@ class NotificationMailService
return (string) ($purchase->email ?: $purchase->user?->email); return (string) ($purchase->email ?: $purchase->user?->email);
} }
/**
* @param array<string, mixed> $context
* @param Closure(): array<string, mixed> $send
*/
private function sendIdempotently(
string $key,
string $emailType,
?string $tenantCode,
array $context,
string $recipient,
Closure $send,
): void {
$sent = $this->emailDeliveryService->sendOnce(
$key,
$emailType,
$tenantCode,
$context,
$recipient,
function () use ($emailType, $context, $send): void {
$this->sendLogged($emailType, $context, $send);
},
);
if (! $sent) {
$this->logSkipped($emailType, array_merge($context, ['reason' => 'already_claimed']));
}
}
/** /**
* @param array<string, mixed> $context * @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send * @param Closure(): (array<string, mixed>|null) $send

View File

@@ -12,19 +12,7 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
## Componentes ## Componentes
Los listeners 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`.
`IdempotentEmailDeliveryService` coordina los envíos automáticos mediante la tabla
`email_deliveries`. Cada correo utiliza una clave de negocio única:
- bienvenida: `welcome:{tenant_code}:{user_id}`;
- recuperación: `password-reset:{attempt_id}`;
- compra confirmada: `purchase-confirmed:{purchase_id}`;
- reprogramación: `event-date-rescheduled:{source_event_date_id}:{destination_event_date_id}:{purchase_id}`;
- suspensión: `event-date-suspended:{event_date_id}:{purchase_id}`.
Los correos de prueba y de validación de una integración SMTP no usan esta capa,
porque su reenvío explícito es parte de su comportamiento esperado.
## API y dependencias ## API y dependencias
@@ -37,12 +25,3 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
- 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. - 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. - 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.
- Una entrega queda en estado `processing` mientras un worker posee su claim. Si
el worker se interrumpe, el claim vence según `EMAIL_DELIVERY_LEASE_SECONDS` y
otro intento puede recuperarlo.
- Los fallos quedan registrados como `failed` y pueden ser retomados por los
reintentos de la cola. Los envíos exitosos permanecen como `sent` y las llamadas
posteriores con la misma clave no vuelven a enviar el correo.
- SMTP no ofrece una confirmación transaccional junto con la base de datos. Una
interrupción ocurrida después de entregar el correo y antes de registrar
`sent` puede producir un duplicado excepcional al recuperar el claim.

View File

@@ -93,6 +93,7 @@ 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);
@@ -102,6 +103,7 @@ class PurchaseController extends Controller
: null; : null;
$updated = DB::transaction(function () use ( $updated = DB::transaction(function () use (
$checkoutService,
$compra, $compra,
$method, $method,
$purchaseState, $purchaseState,
@@ -140,6 +142,12 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
} }
$purchase->update($purchaseUpdate); $purchase->update($purchaseUpdate);
$checkoutService->refreshReservationExpiration(
$purchase,
now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)),
),
);
return true; return true;
}); });

View File

@@ -14,7 +14,6 @@ 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;
@@ -52,14 +51,6 @@ 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
{ {
@@ -75,68 +66,6 @@ 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> */
@@ -186,15 +115,12 @@ class Purchase extends Model
return $this->hasMany(PurchaseItem::class, 'compra_id'); return $this->hasMany(PurchaseItem::class, 'compra_id');
} }
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */ /**
public function tickets(): HasManyThrough * @return HasMany<Ticket, $this>
*/
public function tickets(): HasMany
{ {
return $this->hasManyThrough( return $this->hasMany(Ticket::class, 'source_purchase_id');
Ticket::class,
PurchaseItem::class,
'compra_id',
'source_purchase_item_id',
);
} }
/** @return BelongsTo<StockReservation, $this> */ /** @return BelongsTo<StockReservation, $this> */
@@ -219,12 +145,6 @@ 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) {

View File

@@ -5,13 +5,10 @@ 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 App\Domains\Ticket\Models\TicketRefund;
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',
@@ -28,7 +25,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'discount_total', 'discount_total',
'tax_total', 'tax_total',
'total', 'total',
'refunded_amount',
])] ])]
class PurchaseItem extends Model class PurchaseItem extends Model
{ {
@@ -49,7 +45,6 @@ class PurchaseItem extends Model
'discount_total' => 'decimal:2', 'discount_total' => 'decimal:2',
'tax_total' => 'decimal:2', 'tax_total' => 'decimal:2',
'total' => 'decimal:2', 'total' => 'decimal:2',
'refunded_amount' => 'decimal:2',
]; ];
} }
@@ -61,18 +56,6 @@ 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 HasMany<TicketRefund, $this> */
public function ticketRefunds(): HasMany
{
return $this->hasMany(TicketRefund::class);
}
/** @return BelongsTo<Attachment, $this> */ /** @return BelongsTo<Attachment, $this> */
public function imageAttachment(): BelongsTo public function imageAttachment(): BelongsTo
{ {

View File

@@ -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,6 +30,7 @@ class TelepagosPayment extends Model
{ {
return [ return [
'compra_id' => 'integer', 'compra_id' => 'integer',
'matched_purchase_ids' => 'array',
'amount' => 'decimal:2', 'amount' => 'decimal:2',
]; ];
} }
@@ -41,10 +42,4 @@ 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');
}
} }

View File

@@ -1,50 +0,0 @@
<?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');
}
}

View File

@@ -25,7 +25,6 @@ class PurchaseItemResource extends JsonResource
'quantity' => (int) $this->cantidad, 'quantity' => (int) $this->cantidad,
'unit_price' => $this->formatMoney($this->precio_unitario), 'unit_price' => $this->formatMoney($this->precio_unitario),
'line_total' => $this->formatMoney($this->total), 'line_total' => $this->formatMoney($this->total),
'refunded_amount' => $this->formatMoney($this->refunded_amount),
'source_catalog_item_id' => $this->source_catalog_item_id, 'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id, 'source_variant_id' => $this->source_variant_id,
'item_details' => [ 'item_details' => [

View File

@@ -4,7 +4,6 @@ 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;
@@ -18,15 +17,12 @@ 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(
@@ -52,11 +48,7 @@ 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' => $expiresAt, 'expires_at' => $this->stockReservation?->expires_at,
'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,
@@ -66,7 +58,6 @@ 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),
]; ];
@@ -86,75 +77,4 @@ 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,
};
}
} }

View File

@@ -70,7 +70,7 @@ class CompleteCheckoutService
$purchase->update([ $purchase->update([
'status' => Purchase::STATUS_IN_REVIEW, 'status' => Purchase::STATUS_IN_REVIEW,
]); ]);
$this->reservations->clearExpirationForReview($purchase); $this->reservations->refreshForPurchase($purchase, null);
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
}); });

View File

@@ -8,15 +8,6 @@ class PurchaseResponseLoader
{ {
public function load(Purchase $purchase): Purchase public function load(Purchase $purchase): Purchase
{ {
$relations = ['tenant', 'items.imageAttachment', 'stockReservation']; return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $purchase->payment_method === 'transfer'
) {
$relations[] = 'telepagosPaymentCandidates.payment';
}
return $purchase->load($relations);
} }
} }

View File

@@ -43,11 +43,6 @@ class ReleaseCheckoutService
): Purchase { ): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus === Purchase::STATUS_CANCELLED) {
return $this->loadPurchase($purchase);
}
if ($purchase->status === Purchase::STATUS_EXPIRED if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus !== Purchase::STATUS_EXPIRED) { && $targetStatus !== Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException; throw new PurchaseExpiredException;
@@ -77,14 +72,6 @@ class ReleaseCheckoutService
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); $cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
// Leaving checkout is allowed at the exact instant the reservation
// expires. Finish the expiration while holding the purchase lock so
// the request is idempotent with the scheduled expiration job.
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $this->hasOverdueActiveReservation($purchase)) {
$targetStatus = Purchase::STATUS_EXPIRED;
}
if ($targetStatus === Purchase::STATUS_CANCELLED if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active' && $cart?->status === 'active'
&& in_array($purchase->status, [ && in_array($purchase->status, [

View File

@@ -2,12 +2,14 @@
namespace App\Domains\Purchase\Services; namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService; use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService; use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService; use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService; use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
/** /**
@@ -22,6 +24,7 @@ class CheckoutService
private readonly EditCheckoutService $editor, private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer, private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser, private readonly ReleaseCheckoutService $releaser,
private readonly StockReservationService $reservations,
) {} ) {}
/** @param array<string, mixed> $purchaseData */ /** @param array<string, mixed> $purchaseData */
@@ -75,4 +78,9 @@ class CheckoutService
{ {
return $this->releaser->expire($purchase); return $this->releaser->expire($purchase);
} }
public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void
{
$this->reservations->refreshForPurchase($purchase, $expiresAt);
}
} }

View File

@@ -1,82 +0,0 @@
<?php
namespace App\Domains\Purchase\Services;
/**
* Measures likely DNI typing errors using the optimal-string-alignment
* variant of the Damerau-Levenshtein distance.
*
* The returned value is the minimum number of single-character edits needed
* to transform one DNI into the other. Supported edits are insertion,
* deletion, substitution and transposition of two adjacent digits.
*/
class DniDistanceService
{
/**
* Calculate the edit distance between two normalized DNI strings.
*
* Each matrix cell [row][column] stores the minimum edits required to
* transform the first $row digits of $left into the first $column digits
* of $right. The bottom-right cell therefore contains the final distance.
*/
public function distance(string $left, string $right): int
{
$left = $this->normalize($left);
$right = $this->normalize($right);
$leftLength = strlen($left);
$rightLength = strlen($right);
$matrix = [];
// Transforming a prefix into an empty string requires deleting every digit.
for ($row = 0; $row <= $leftLength; $row++) {
$matrix[$row] = [$row];
}
// Transforming an empty string into a prefix requires inserting every digit.
for ($column = 0; $column <= $rightLength; $column++) {
$matrix[0][$column] = $column;
}
for ($row = 1; $row <= $leftLength; $row++) {
for ($column = 1; $column <= $rightLength; $column++) {
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
$deletionDistance = $matrix[$row - 1][$column] + 1;
$insertionDistance = $matrix[$row][$column - 1] + 1;
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
// Keep the cheapest way to align the two prefixes at this position.
$matrix[$row][$column] = min(
$deletionDistance,
$insertionDistance,
$substitutionDistance,
);
// Count two adjacent inverted digits as one edit instead of two substitutions.
if (
$row > 1
&& $column > 1
&& $left[$row - 1] === $right[$column - 2]
&& $left[$row - 2] === $right[$column - 1]
) {
$matrix[$row][$column] = min(
$matrix[$row][$column],
$matrix[$row - 2][$column - 2] + 1,
);
}
}
}
return $matrix[$leftLength][$rightLength];
}
/**
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
* the leading zero that is present when the DNI is extracted from a CUIT.
*/
public function normalize(string $dni): string
{
$digits = preg_replace('/\D+/', '', $dni) ?? '';
return str_pad($digits, 8, '0', STR_PAD_LEFT);
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService
{
public function totalForTenant(Tenant $tenant): string
{
$total = PurchaseItem::query()
->whereHas(
'purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
)
->sum('refunded_amount');
return number_format((float) $total, 2, '.', '');
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Sale\Controllers\AdminApp; namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest; use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest; use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest; use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource; use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
@@ -35,7 +34,6 @@ class SaleController extends Controller
$this->saleService->sales($tenant, $request->validated()) $this->saleService->sales($tenant, $request->validated())
)->additional([ )->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant), 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
'refunded_total' => $this->saleService->refundedTotal($tenant),
]); ]);
} }
@@ -69,13 +67,11 @@ class SaleController extends Controller
return new SaleResource($this->saleService->cancel($tenant, $sale)); return new SaleResource($this->saleService->cancel($tenant, $sale));
} }
public function modifications( public function modifications(Request $request): AnonymousResourceCollection
AdminAppSaleModificationIndexRequest $request, {
): AnonymousResourceCollection {
return SaleModificationResource::collection( return SaleModificationResource::collection(
$this->saleService->modifications( $this->saleService->modifications(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail()
$request->validated(),
) )
); );
} }
@@ -97,7 +93,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadModifications( return $this->salePdfService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()), $this->saleService->modificationsForExport($tenant),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -120,7 +116,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadModifications( return $this->saleExcelService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()), $this->saleService->modificationsForExport($tenant),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }

View File

@@ -20,7 +20,7 @@ class AdminAppSaleIndexRequest extends FormRequest
'q' => ['sometimes', 'nullable', 'string', 'max:255'], 'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'id' => ['sometimes', 'nullable', 'integer', 'min:1'], 'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'], 'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())], 'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'], 'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'], 'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
'page' => ['sometimes', 'integer', 'min:1'], 'page' => ['sometimes', 'integer', 'min:1'],

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domains\Sale\Requests;
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
$rules = parent::rules();
unset($rules['sort_by'], $rules['sort_direction']);
return $rules;
}
}

View File

@@ -3,14 +3,19 @@
namespace App\Domains\Sale\Requests; namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone; use App\Domains\Shared\Rules\ValidTimezone;
use Illuminate\Foundation\Http\FormRequest;
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest class AdminAppSaleModificationPdfRequest extends FormRequest
{ {
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */ /** @return array<string, list<string>> */
public function rules(): array public function rules(): array
{ {
return [ return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone], 'timezone' => ['required', 'string', new ValidTimezone],
]; ];
} }

View File

@@ -22,7 +22,6 @@ class SaleDetailResource extends JsonResource
'quantity' => (int) $item->cantidad, 'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($item->precio_unitario), 'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total), 'total' => $this->formatMoney($item->total),
'refunded_amount' => $this->formatMoney($item->refunded_amount),
])->values(), ])->values(),
'total' => $this->formatMoney($this->total), 'total' => $this->formatMoney($this->total),
]; ];

View File

@@ -23,12 +23,6 @@ class SaleModificationResource extends JsonResource
'attribute' => $this->attribute, 'attribute' => $this->attribute,
'old_value' => $this->old_value, 'old_value' => $this->old_value,
'new_value' => $this->new_value, 'new_value' => $this->new_value,
'admin_status' => is_string($this->new_value)
? Purchase::adminStatusFor($this->new_value)
: null,
'status_label' => is_string($this->new_value)
? Purchase::adminStatusNameFor($this->new_value)
: null,
'changed_at' => $this->changed_at->utc()->toIso8601String(), 'changed_at' => $this->changed_at->utc()->toIso8601String(),
'date' => $this->changed_at->format('Y-m-d'), 'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'), 'time' => $this->changed_at->format('H:i:s'),
@@ -37,8 +31,6 @@ class SaleModificationResource extends JsonResource
'id' => $sale->id, 'id' => $sale->id,
'customer_name' => $sale->nombre_apellido, 'customer_name' => $sale->nombre_apellido,
'status' => $sale->status, 'status' => $sale->status,
'admin_status' => Purchase::adminStatusFor($sale->status),
'status_label' => Purchase::adminStatusNameFor($sale->status),
] : null, ] : null,
'modified_by' => $user ? [ 'modified_by' => $user ? [
'id' => $user->id, 'id' => $user->id,

View File

@@ -20,8 +20,6 @@ class SaleResource extends JsonResource
'customer_name' => $this->nombre_apellido, 'customer_name' => $this->nombre_apellido,
'quantity' => (int) ($this->quantity ?? 0), 'quantity' => (int) ($this->quantity ?? 0),
'status' => $this->status, 'status' => $this->status,
'admin_status' => Purchase::adminStatusFor($this->status),
'status_label' => Purchase::adminStatusNameFor($this->status),
'total' => number_format((float) $this->total, 2, '.', ''), 'total' => number_format((float) $this->total, 2, '.', ''),
'tickets_count' => $ticketsCount, 'tickets_count' => $ticketsCount,
'has_generated_tickets' => $ticketsCount > 0, 'has_generated_tickets' => $ticketsCount > 0,

View File

@@ -17,7 +17,6 @@ class SaleTicketResource extends JsonResource
'id' => $this->id, 'id' => $this->id,
'expires_at' => $this->getEffectiveExpiresAt(), 'expires_at' => $this->getEffectiveExpiresAt(),
'status' => $this->status, 'status' => $this->status,
'status_label' => $this->status_label,
]; ];
} }
} }

View File

@@ -201,6 +201,11 @@ class AdminAppSaleExcelService
private function saleStatus(string $status): string private function saleStatus(string $status): string
{ {
return Purchase::adminStatusNameFor($status) ?? $status; return match ($status) {
Purchase::STATUS_PAID => 'Confirmado',
Purchase::STATUS_CREATED => 'Por completar datos',
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
default => 'Anulado',
};
} }
} }

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange; use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\PurchaseRefundSummaryService;
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\TicketPresentationResolver; use App\Domains\Ticket\Services\TicketPresentationResolver;
@@ -18,7 +17,6 @@ class AdminAppSaleService
{ {
public function __construct( public function __construct(
protected CheckoutService $checkoutService, protected CheckoutService $checkoutService,
protected PurchaseRefundSummaryService $refundSummaryService,
) {} ) {}
public function confirmedSalesTotal(Tenant $tenant): string public function confirmedSalesTotal(Tenant $tenant): string
@@ -31,11 +29,6 @@ class AdminAppSaleService
return number_format((float) $total, 2, '.', ''); return number_format((float) $total, 2, '.', '');
} }
public function refundedTotal(Tenant $tenant): string
{
return $this->refundSummaryService->totalForTenant($tenant);
}
/** /**
* @param array{ * @param array{
* q?: string|null, * q?: string|null,
@@ -67,7 +60,7 @@ class AdminAppSaleService
{ {
return $this->findForTenant($tenant, $saleId) return $this->findForTenant($tenant, $saleId)
->tickets() ->tickets()
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS])
->orderBy('id') ->orderBy('id')
->get(); ->get();
} }
@@ -99,24 +92,18 @@ class AdminAppSaleService
return $this->salesQuery($tenant, $filters)->get(); return $this->salesQuery($tenant, $filters)->get();
} }
/** /** @return LengthAwarePaginator<ValueChange> */
* @param array<string, mixed> $filters public function modifications(Tenant $tenant): LengthAwarePaginator
* @return LengthAwarePaginator<ValueChange>
*/
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
{ {
return $this->modificationsQuery($tenant, $filters) return $this->modificationsQuery($tenant)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
/** /** @return Collection<int, ValueChange> */
* @param array<string, mixed> $filters public function modificationsForExport(Tenant $tenant): Collection
* @return Collection<int, ValueChange>
*/
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
{ {
return $this->modificationsQuery($tenant, $filters)->get(); return $this->modificationsQuery($tenant)->get();
} }
/** @param array<string, mixed> $filters */ /** @param array<string, mixed> $filters */
@@ -139,6 +126,7 @@ class AdminAppSaleService
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -156,10 +144,12 @@ class AdminAppSaleService
) )
->when( ->when(
$filters['status'] ?? null, $filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query->whereIn( fn (Builder $query, string $status): Builder => $status === Purchase::STATUS_PENDING_PAYMENT
'status', ? $query->whereIn('status', [
Purchase::realStatusesForAdminStatus($status), Purchase::STATUS_PENDING_PAYMENT,
) Purchase::STATUS_IN_REVIEW,
])
: $query->where('status', $status)
) )
->select('compras.*') ->select('compras.*')
->selectRaw( ->selectRaw(
@@ -172,50 +162,12 @@ class AdminAppSaleService
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id')); ->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
} }
/** /** @return Builder<ValueChange> */
* @param array<string, mixed> $filters protected function modificationsQuery(Tenant $tenant): Builder
* @return Builder<ValueChange>
*/
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
{ {
return ValueChange::query() return ValueChange::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass()) ->where('trackable_type', (new Purchase)->getMorphClass())
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search);
$query->where(function (Builder $query) use ($term): void {
$query
->where('trackable_id', 'like', "%{$term}%")
->orWhereHasMorph(
'trackable',
[Purchase::class],
function (Builder $sales) use ($term): void {
$sales
->where('nombre_apellido', 'like', "%{$term}%")
->orWhere('created_at', 'like', "%{$term}%");
},
);
});
})
->when(
$filters['id'] ?? null,
fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id)
)
->when(
$filters['sale_date'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereHasMorph(
'trackable',
[Purchase::class],
fn (Builder $sales): Builder => $sales->whereDate('created_at', $date),
)
)
->when(
$filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query
->where('attribute', 'status')
->whereIn('new_value', Purchase::realStatusesForAdminStatus($status))
)
->with(['trackable', 'user']) ->with(['trackable', 'user'])
->orderByDesc('changed_at') ->orderByDesc('changed_at')
->orderByDesc('id'); ->orderByDesc('id');

Some files were not shown because too many files have changed in this diff Show More