Compare commits

..

61 Commits

Author SHA1 Message Date
4f7ede1072 feat(purchase,sale,ticket): expose refunded total summary in admin sales and tickets 2026-09-11 16:23:23 -03:00
4bb4f526e4 feat(event): support ticket refund configuration in event endpoints 2026-09-11 16:22:46 -03:00
c0c19c9c01 feat(tenant): add master toggle for ticket refunds 2026-09-11 16:22:27 -03:00
b4da6e3747 feat(event): omit rescheduled and suspended dates from tenant resource 2026-09-11 16:21:37 -03:00
1880fc8147 feat(event): suspend event dates instead of cancelling 2026-09-11 15:57:17 -03:00
96df431d60 feat(event): implement admin date creation, rescheduling, and cancellation endpoints 2026-09-11 12:28:51 -03:00
106cf017dc feat(ticket): resolve effective event dates and handle reschedule chains 2026-09-11 12:28:37 -03:00
d5fdae9a24 feat(event): add rescheduling and cancellation state to event dates 2026-09-11 12:28:29 -03:00
4abb6c67fd feat(ticket): add refund calculation functionality and corresponding tests 2026-09-11 10:43:03 -03:00
6384c0046d feat(ticket): validate ticket capability status and use transaction in cancel and refund 2026-09-11 09:25:32 -03:00
2ddb046c26 feat(ticket): add action capability helpers and expose them in admin resource 2026-09-11 09:25:19 -03:00
5d00dc439e feat(ticket): implement refund functionality and expose allow_refund flag 2026-09-10 17:05:57 -03:00
210c854fee feat(tenant): add allow_refund and allow_partial_refund methods 2026-09-10 17:04:34 -03:00
beb5d18b29 feat(ticket): add cancel ticket functionality and corresponding tests 2026-09-10 16:28:45 -03:00
de259f4286 feat(ticket): implement terminal status management and validation 2026-09-10 16:20:35 -03:00
f19bca64d0 feat(admin): expose ticket refund states 2026-09-10 16:10:17 -03:00
18f739b712 feat(ticket): add refundable terminal states 2026-09-10 16:10:08 -03:00
1564985259 feat(tenant): expose ticket refund configuration 2026-09-10 15:29:41 -03:00
471a941587 feat(tenant): persist ticket refund configuration 2026-09-10 15:29:35 -03:00
4c647968cb Merge branch 'fix/admin_title' into dev 2026-09-09 16:30:00 -03:00
55dcc2e37f feat(website): add site_title to AdminAppBootstrapResource and related tests 2026-09-09 16:29:37 -03:00
cac4fcf2b2 feat(scanner): update scan handling to return results for invalid QR data and other scan conditions 2026-09-09 12:49:27 -03:00
64e965aff7 feat(scanner): update scan attempt result labels for consistency 2026-09-09 08:47:10 -03:00
002f08a8fa feat(staff): expose scanner attempt history 2026-09-08 10:56:27 -03:00
531dbe52b9 feat(scanner): expose scan result labels 2026-09-08 10:24:28 -03:00
a885a2fc0e feat(scanner): expose assigned scan categories 2026-09-08 10:01:18 -03:00
0c49bae752 feat(scanner): add scan attempt detail endpoint 2026-09-08 08:51:19 -03:00
67bcb420e4 feat(scanner): return scan attempt result context 2026-09-08 08:51:10 -03:00
34058e6a81 feat(scanner): implement scan attempt endpoint and update routes and tests 2026-09-07 16:56:12 -03:00
8a65d358a4 feat(scanner): implement scan attempt tracking with new request and resource classes 2026-09-07 16:55:52 -03:00
193e10dc48 feat(scan): implement scan attempt tracking and validation logic 2026-09-07 16:39:14 -03:00
b607c1b673 feat(scanner): refactor ticket scanning endpoint to accept UUID in request body and add validation 2026-09-07 16:18:16 -03:00
e9521e65d0 Merge branch 'refactor/integrations' into dev 2026-09-07 11:12:16 -03:00
d0625f25e5 refactor(integration): enhance association logic to delete unused instances and improve transaction handling 2026-09-07 11:09:08 -03:00
1fdcfc1547 Merge branch 'fix/sonder_bugs' into dev 2026-09-07 09:34:13 -03:00
ca4ea0aa6c feat(tenant): add asset URL to tenant resource response 2026-09-07 09:20:46 -03:00
bb1f9c8e91 Update postman collection 2026-09-04 16:46:40 -03:00
99fe94fa6a refactor(integration): streamline integration management by removing IntegrationInstanceController and updating related requests and services 2026-09-04 16:00:19 -03:00
a4b5c2eb19 refactor(auth): remove role-based logic from scanner authentication and permissions 2026-09-04 15:33:01 -03:00
2feca2bed5 docs(auth): explain email identity and role-specific flows 2026-09-04 14:17:42 -03:00
a35ff69140 feat(users): enforce active email uniqueness per role 2026-09-04 14:17:37 -03:00
75152b53a4 fix(auth): isolate password reset flows by application role 2026-09-04 14:17:27 -03:00
99594b17e7 fix(auth): resolve login identities by active email and role 2026-09-04 14:17:19 -03:00
85edea0661 docs(integration): explicar instancias, endpoints y despliegue
Documenta la prioridad cliente a tipo de sitio, la edicion compartida, los permisos y los contratos de la API. Aclara la migracion del requisito de configuracion y la asociacion directa requerida por el webhook de Telepagos.
2026-09-04 12:48:15 -03:00
ab5ea7dd33 feat(integration): administrar instancias y asociaciones con permisos
Agrega endpoints para crear, editar y asociar instancias a clientes y tipos de sitio. Valida las credenciales contra el esquema del catalogo y devuelve metadatos sin secretos.

Restringe la administracion al rol admin autenticado y mantiene inmutables los codigos de integracion. Incluye pruebas de API, permisos, prioridad de resolucion, edicion compartida y renovacion de tokens.
2026-09-04 12:48:06 -03:00
c0057c237a refactor(integration): resolver credenciales mediante instancias
Resuelve primero la instancia del cliente y luego la del tipo de sitio cuando existe contexto de tenant. Adapta SMTP y Telepagos, con tokens identificados por instancia y version de credenciales.

Separa la edicion de instancias de sus asociaciones y conserva el guardado por cliente creando una instancia nueva. Impide eliminar instancias en uso y adapta las pruebas existentes, incluida la herencia SMTP.
2026-09-04 12:47:47 -03:00
e4146288f1 refactor(integration): generalizar el requisito de configuracion
Renombra requires_client_configuration a requires_configuration conservando los valores existentes y su rollback. Actualiza el modelo, la validacion, los seeders y los consumidores del campo para permitir configuraciones de cliente o tipo de sitio.
2026-09-04 12:47:29 -03:00
f11ba5a470 feat(integration): add IntegrationInstance and WebsiteTypeIntegration models with migrations; refactor ClientIntegration and Integration models 2026-09-04 12:17:30 -03:00
e4db36e650 feat(notification): update welcome email to include tenant URL and call-to-action button 2026-09-04 10:51:00 -03:00
ffa3f10b18 feat(scanner): implement category validation for AdminApp role and update tests 2026-09-04 10:40:46 -03:00
7acef66ee7 feat(notification): enhance password reset emails with custom templates for different scenarios 2026-09-04 10:31:16 -03:00
18f1217daa feat(admin): implement CRUD for administrators with role management and tenant restrictions 2026-09-04 10:28:33 -03:00
ac44e82454 fix(menu): rename 'Staff' to 'Usuarios' in menu seeder and migration 2026-09-04 09:54:56 -03:00
1e7a9b6876 Merge pull request 'homo' (#4) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/4
2026-09-04 12:01:35 +00:00
deea4fd4af Merge branch 'test/stress' into dev 2026-09-04 08:21:50 -03:00
c4ff695f2e refactor(ticket): simplify tenant validation and update command documentation 2026-09-03 14:30:38 -03:00
703158bd06 feat(ticket): implement LoadTestTicketDatasetService for generating load test datasets
- Added LoadTestTicketDatasetService to prepare disposable scanner tickets and generate a Postman importable dataset.
- Introduced console command `load-test🎟️prepare` for executing the dataset preparation.
- Updated README documentation with usage instructions for the load test command.
- Enhanced Postman collection generation script to include new parameters.
- Created tests for the load test command to ensure proper functionality and validation.
2026-09-03 14:15:23 -03:00
eda2343380 fix(user): update hidden attributes and handle soft-deleted users in registration 2026-09-03 10:31:43 -03:00
0baad641d5 fix(auth): scope email uniqueness to active users 2026-09-03 09:59:21 -03:00
ddb8c3743f fix(staff): preserve scanner history on deletion 2026-09-03 09:58:08 -03:00
900ef6cf98 Merge pull request 'homo' (#3) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/3
2026-08-31 11:36:04 +00:00
165 changed files with 7597 additions and 941 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Administrator\Controllers;
use App\Domains\Administrator\Requests\StoreAdministratorRequest;
use App\Domains\Administrator\Requests\UpdateAdministratorRequest;
use App\Domains\Administrator\Resources\AdministratorResource;
use App\Domains\Administrator\Services\AdministratorService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class AdminAppAdministratorController extends Controller
{
public function __construct(private readonly AdministratorService $administratorService) {}
public function index(Request $request): AnonymousResourceCollection
{
return AdministratorResource::collection($this->administratorService->list(
$request->user()->tenant()->firstOrFail(),
$request->string('search')->trim()->toString() ?: null,
));
}
public function store(StoreAdministratorRequest $request): AdministratorResource
{
return AdministratorResource::make($this->administratorService->create(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
));
}
public function update(UpdateAdministratorRequest $request, int $administrator): AdministratorResource
{
return AdministratorResource::make($this->administratorService->update(
$request->user()->tenant()->firstOrFail(),
$administrator,
$request->validated(),
));
}
public function destroy(Request $request, int $administrator): Response
{
$this->administratorService->delete($request->user()->tenant()->firstOrFail(), $administrator, $request->user());
return response()->noContent();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Administrator\Requests;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreAdministratorRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
}
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'dni' => ['required', 'string', 'max:50'],
'email' => [
'required',
'email',
'max:255',
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'),
],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Administrator\Requests;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAdministratorRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
}
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
/** @return array<string, mixed> */
public function rules(): array
{
$administratorId = (int) $this->route('administrator');
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'dni' => ['required', 'string', 'max:50'],
'email' => [
'required',
'email',
'max:255',
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)
->whereNull('deleted_at')
->ignore($administratorId),
],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Administrator\Resources;
use App\Domains\Auth\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class AdministratorResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nombre_apellido' => $this->nombre_apellido,
'dni' => $this->dni,
'email' => $this->email,
'rol_codigo' => $this->rol_codigo,
'role' => $this->whenLoaded('role', fn () => [
'codigo' => $this->role?->codigo,
'nombre' => $this->role?->nombre,
]),
];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Administrator\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AdministratorService
{
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
/** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null): Collection
{
return $this->query($tenant)->with('role')
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
$query->where('nombre_apellido', 'like', "%{$search}%")
->orWhere('dni', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
}))
->orderBy('nombre_apellido')->get();
}
/** @param array<string, mixed> $data */
public function create(Tenant $tenant, array $data): User
{
return DB::transaction(function () use ($tenant, $data): User {
$administrator = User::query()->create([
...$this->attributes($data),
'password' => Str::random(64),
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]);
$this->resetPasswordAttemptService->createForAdminAppEmail(
$administrator->email,
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED,
);
return $administrator->load('role');
});
}
/** @param array<string, mixed> $data */
public function update(Tenant $tenant, int $administratorId, array $data): User
{
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
$administrator->update($this->attributes($data));
return $administrator->load('role');
});
}
public function delete(Tenant $tenant, int $administratorId, User $actor): void
{
DB::transaction(function () use ($tenant, $administratorId, $actor): void {
// Serialize deletions for this tenant, including requests already authenticated
// when another administrator removes their account.
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
if ($administrator->is($actor)) {
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
}
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
if ($activeAdministrators->count() <= 1) {
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
}
abort_unless($activeAdministrators->contains('id', $actor->id), 403);
$administrator->tokens()->delete();
$administrator->delete();
});
}
private function query(Tenant $tenant): Builder
{
return User::query()->where('tenant_codigo', $tenant->codigo)
->where('rol_codigo', RoleCode::AdminApp->value);
}
/** @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function attributes(array $data): array
{
return [
...Arr::only($data, ['nombre_apellido', 'dni']),
'email' => mb_strtolower(trim((string) $data['email'])),
];
}
}

View File

@@ -0,0 +1,63 @@
# Administradores de AdminApp
CRUD de usuarios con rol `adminapp`, limitado al tenant del usuario autenticado.
Todos los administradores del tenant pueden gestionar esta sección.
## Endpoints
Base: `/api/v1/adminapp/tenant/administrators`.
Requieren `auth:sanctum` y `adminapp.tenant`.
- `GET /`: listado ordenado por nombre; acepta `search` por nombre, DNI o email.
- `POST /`: alta; responde `201` con `data`.
- `PUT /{administrator}`: actualización de los tres campos; responde `200` con `data`.
- `DELETE /{administrator}`: baja lógica; responde `204`.
Alta y actualización reciben:
```json
{
"nombre_apellido": "Ada Lovelace",
"dni": "12345678",
"email": "ada@example.test"
}
```
Nombre (hasta 255 caracteres), DNI (hasta 50) y email (hasta 255) son obligatorios.
El email se normaliza a minúsculas antes de validar y debe ser único entre
usuarios activos, sin importar su tenant o rol. Se permite reutilizar el email
de un usuario eliminado. Rol y tenant no son editables desde esta API.
Las respuestas incluyen `id`, `nombre_apellido`, `dni`, `email`, `rol_codigo`
y `role` (`codigo`, `nombre`). Nunca incluyen contraseña ni datos de escaneo.
## Alta y acceso
Se genera una contraseña aleatoria y un intento de establecimiento de contraseña
con motivo `administrator_created`, reutilizando `createForAdminAppEmail`.
El evento usa el canal `adminapp`; el listener existente envía el email después
del commit mediante la cola `emails`. Requiere la configuración de correo,
dominio AdminApp y worker existentes. No se envían contraseñas en texto plano.
## Eliminación y aislamiento
Las consultas de usuarios se limitan por tenant y rol `adminapp`. IDs ajenos,
usuarios eliminados y usuarios de otros roles devuelven `404`.
La validación de campos devuelve `422`; falta de autenticación, `401`, y rol
no autorizado, `403`.
No se permite eliminar al propio usuario ni dejar al tenant sin administradores
(`422`, error `administrator`). La eliminación bloquea la fila del tenant dentro
de una transacción para serializar bajas concurrentes. También verifica que el
actor siga activo, revoca tokens y aplica el borrado lógico existente en `users`.
No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el frontend.
## Verificación
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
contraseña, restricciones de rol y tenant, baja lógica, tokens y protecciones de
eliminación. El caso de petición autenticada antes de la baja del actor se simula;
no es una prueba con conexiones concurrentes reales.

View File

@@ -0,0 +1,10 @@
<?php
use App\Domains\Administrator\Controllers\AdminAppAdministratorController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::apiResource('administrators', AdminAppAdministratorController::class)->except('show');
});

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ResetPasswordRequest; use App\Domains\Auth\Requests\ResetPasswordRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService; use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
$data['email'], $data['email'],
$data['codigo'], $data['codigo'],
$data['password'], $data['password'],
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
)) { )) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'codigo' => __('api.auth.password_reset_invalid'), 'codigo' => __('api.auth.password_reset_invalid'),

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt; use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest; use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService; use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -25,6 +26,7 @@ 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,6 +17,8 @@ class ResetPasswordAttempt extends Model
public const REASON_STAFF_CREATED = 'staff_created'; public const REASON_STAFF_CREATED = 'staff_created';
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
public const STATUS_PENDING = 'pending'; public const STATUS_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated'; public const STATUS_VALIDATED = 'validated';

View File

@@ -6,6 +6,7 @@ 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;
@@ -13,16 +14,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])] #[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable; use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
protected $attributes = [ protected $attributes = [
'rol_codigo' => RoleCode::User->value, 'rol_codigo' => RoleCode::User->value,
@@ -45,6 +47,12 @@ 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,6 +2,7 @@
namespace App\Domains\Auth\Requests; namespace App\Domains\Auth\Requests;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
return true; return true;
} }
/** protected function prepareForValidation(): void
* @return array<string, mixed> {
*/ if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
/** @return array<string, mixed> */
public function rules(): array public function rules(): array
{ {
return [ return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')], 'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'nombre_apellido' => ['required', 'string', 'max:255'], 'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], 'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()], 'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'], 'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'], 'telefono' => ['nullable', 'string', 'max:255'],

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateProfileRequest extends FormRequest class UpdateProfileRequest extends FormRequest
{ {
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
return true; return true;
} }
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
public function rules(): array public function rules(): array
{ {
return [ return [
@@ -19,11 +27,13 @@ class UpdateProfileRequest extends FormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
Rule::unique('users', 'email')->ignore($this->user()->id), Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
->whereNull('deleted_at')
->ignore($this->user()->id),
], ],
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'], 'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'], 'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()], 'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
]; ];
} }
} }

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Auth\Services; namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\UserRegistered; use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer; use App\Domains\Tenant\Support\TenantDomainNormalizer;
@@ -124,12 +125,12 @@ class GoogleAuthService
]); ]);
} }
$user = User::query()->where('google_id', $googleId)->first(); $user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
if ($user) { if ($user) {
return $user; return $user;
} }
$user = User::query()->where('email', $email)->first(); $user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
if ($user) { if ($user) {
$user->forceFill(['google_id' => $googleId])->save(); $user->forceFill(['google_id' => $googleId])->save();

View File

@@ -85,7 +85,7 @@ class PasswordLoginService
null, null,
$ipAddress, $ipAddress,
$userAgent, $userAgent,
null, RoleCode::Scanner,
true, true,
PermissionCode::ScanTickets->value, PermissionCode::ScanTickets->value,
PasswordResetRequested::CHANNEL_SCANNER, PasswordResetRequested::CHANNEL_SCANNER,
@@ -120,7 +120,7 @@ class PasswordLoginService
$passwordResetChannel, $passwordResetChannel,
): array { ): array {
$user = User::query() $user = User::query()
->where('email', $normalizedEmail) ->where('active_email', $normalizedEmail)
->when( ->when(
$requiredRole !== null, $requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value), fn ($query) => $query->where('rol_codigo', $requiredRole->value),

View File

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

View File

@@ -19,6 +19,16 @@ 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,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class) Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class) Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->defaults('reset_role', 'adminapp')
->middleware('throttle:10,1'); ->middleware('throttle:10,1');
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'adminapp')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'adminapp.tenant']) Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class); ->get('me', AdminAppMeController::class);

View File

@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class) Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class) Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->defaults('reset_role', 'scanner')
->middleware('throttle:10,1'); ->middleware('throttle:10,1');
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'scanner')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'scanner.tenant']) Route::middleware(['auth:sanctum', 'scanner.tenant'])
->get('me', ScannerMeController::class); ->get('me', ScannerMeController::class);

View File

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

@@ -104,7 +104,11 @@ class InvitationPurchaseProvisioner
private function userId(DateTimeInterface $now): int private function userId(DateTimeInterface $now): int
{ {
$user = DB::table('users')->where('email', self::USER_EMAIL)->first(); $user = DB::table('users')
->where('active_email', self::USER_EMAIL)
->where('rol_codigo', 'user')
->whereNull('deleted_at')
->first();
if ($user !== null) { if ($user !== null) {
if ($user->tenant_codigo !== self::TENANT_CODE) { if ($user->tenant_codigo !== self::TENANT_CODE) {

View File

@@ -2,7 +2,11 @@
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;
@@ -28,4 +32,37 @@ 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

@@ -0,0 +1,12 @@
<?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

@@ -3,6 +3,7 @@
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;
@@ -21,6 +22,8 @@ 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
{ {
@@ -28,6 +31,8 @@ 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());
@@ -52,6 +57,8 @@ 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',
]; ];
} }
@@ -67,6 +74,18 @@ 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
{ {
@@ -94,6 +113,27 @@ 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();
@@ -104,7 +144,10 @@ class EventDate extends Model
$tenant->update([ $tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format( 'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()->pluck('date') $tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->pluck('date')
), ),
]); ]);
} }

View File

@@ -0,0 +1,21 @@
<?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

@@ -0,0 +1,23 @@
<?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,11 +19,6 @@ 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' => [
@@ -38,6 +33,16 @@ 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',
],
]; ];
} }
@@ -54,6 +59,41 @@ 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

@@ -0,0 +1,28 @@
<?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,7 +3,6 @@
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;
@@ -19,14 +18,11 @@ 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,
'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'allow_ticket_refund' => $this->allow_ticket_refund,
'id' => $eventDate->id, 'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'validity_time_id' => $eventDate->validity_time_id, 'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime), 'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'date' => $eventDate->date->format('Y-m-d'), 'dates' => EventDateResource::collection($this->eventDates),
'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

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver
{
public function resolve(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->suspended_at === null ? $current : null;
}
$current->loadMissing('rescheduledTo');
$current = $current->rescheduledTo;
if ($current === null) {
return null;
}
}
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Domains\Event\Services; namespace App\Domains\Event\Services;
use App\Domains\Catalog\Models\Variant;
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\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -14,6 +17,10 @@ class EventService
'facebook_url' => 'facebook', 'facebook_url' => 'facebook',
]; ];
public function __construct(
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
) {}
public function forTenant(Tenant $tenant): Tenant public function forTenant(Tenant $tenant): Tenant
{ {
return $tenant->load(['eventDates.validityTime', 'socialMedia']); return $tenant->load(['eventDates.validityTime', 'socialMedia']);
@@ -27,9 +34,14 @@ 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 {
@@ -40,41 +52,185 @@ class EventService
}); });
} }
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */ /** @param array{date: string, start_time: string, end_time: string} $data */
private function syncDates(Tenant $tenant, array $dates): void public function createDateForTenant(Tenant $tenant, array $data): EventDate
{ {
$existingDates = $tenant->eventDates()->get()->values(); return DB::transaction(function () use ($tenant, $data): EventDate {
$attributes = $this->dateAttributes($data);
foreach (array_values($dates) as $index => $date) { if ($tenant->eventDates()->where($attributes)->exists()) {
$attributes = [ throw ValidationException::withMessages([
'date' => $date['date'], 'date' => ['La fecha y el horario ya existen.'],
'time_start' => $date['start_time'], ]);
'time_end' => $date['end_time'], }
];
$existingDate = $existingDates->get($index); return $tenant->eventDates()->create($attributes)->load('validityTime');
});
}
if ($existingDate) { /** @param array{date: string} $data */
$existingDate->update($attributes); public function rescheduleDateForTenant(Tenant $tenant, EventDate $eventDate, array $data): EventDate
} else { {
$tenant->eventDates()->create($attributes); 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.'],
]);
}
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
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');
}
$date->update(['suspended_at' => now()]);
$this->disableTicketsWithoutUsableDates($tenant, $date);
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;
} }
} }
$datesToDelete = $existingDates->slice(count($dates)); return $current->is($expected);
}
if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
->selectedByVariants() {
->whereHas('sourceTickets') $affectedDateIds = collect([$suspendedDate->getKey()]);
->exists() $frontier = $affectedDateIds;
|| $eventDate->variants()->whereHas('sourceTickets')->exists())) {
throw ValidationException::withMessages([ while ($frontier->isNotEmpty()) {
'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'], $predecessors = $tenant->eventDates()
]); ->whereIn('rescheduled_to_event_date_id', $frontier)
->pluck('id')
->diff($affectedDateIds)
->values();
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
$frontier = $predecessors;
} }
$datesToDelete->each->delete(); $variants = Variant::withTrashed()
$tenant->unsetRelation('eventDates'); ->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',
];
} }
/** @param array<string, string|null> $contact */ /** @param array<string, string|null> $contact */

View File

@@ -8,4 +8,7 @@ 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

@@ -124,11 +124,7 @@ class TicketFilterFormService
'required' => false, 'required' => false,
'default' => null, 'default' => null,
'placeholder' => 'Estado', 'placeholder' => 'Estado',
'options' => [ 'options' => Ticket::statusOptions(),
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
],
], ],
]; ];
} }

View File

@@ -236,11 +236,7 @@ class TicketFormService
?: $left['label'] <=> $right['label']); ?: $left['label'] <=> $right['label']);
return [ return [
'statuses' => [ 'statuses' => Ticket::statusOptions(),
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
],
'categories' => array_values(array_map( 'categories' => array_values(array_map(
fn (array $category): array => [ fn (array $category): array => [
'value' => $category['value'], 'value' => $category['value'],

View File

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

View File

@@ -35,6 +35,7 @@ class IntegrationController extends Controller
public function destroy(Integration $integration) public function destroy(Integration $integration)
{ {
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
$integration->delete(); $integration->delete();
return response()->noContent(); return response()->noContent();

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Resources\IntegrationAssociationResource;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Domains\Tenant\Models\WebsiteType;
use App\Http\Controllers\Controller;
class WebsiteTypeIntegrationController extends Controller
{
public function __construct(private readonly IntegrationAssociationService $service) {}
public function index(WebsiteType $websiteType)
{
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
}
public function show(WebsiteType $websiteType, string $integrationCode)
{
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
}
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
{
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
return new IntegrationAssociationResource($this->service->configure(
$websiteType,
$integration,
$request->validated('integration_data'),
));
}
public function destroy(WebsiteType $websiteType, string $integrationCode)
{
$this->service->detach($websiteType, $integrationCode);
return response()->noContent();
}
}

View File

@@ -3,22 +3,15 @@
namespace App\Domains\Integration\Models; namespace App\Domains\Integration\Models;
use App\Domains\Client\Models\Client; use App\Domains\Client\Models\Client;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ClientIntegration extends Model class ClientIntegration extends Model
{ {
protected $hidden = ['integration_data'];
protected $fillable = [ protected $fillable = [
'client_id', 'client_id',
'integration_code', 'integration_code',
'integration_data', 'integration_instance_id',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
]; ];
/** @return BelongsTo<Client, $this> */ /** @return BelongsTo<Client, $this> */
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
{ {
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
} }
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Models; namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Integration extends Model class Integration extends Model
{ {
@@ -13,16 +14,29 @@ class Integration extends Model
'name', 'name',
'url', 'url',
'integration_data_schema', 'integration_data_schema',
'requires_client_configuration', 'requires_configuration',
]; ];
protected $casts = [ protected $casts = [
'integration_data_schema' => 'array', 'integration_data_schema' => 'array',
'requires_client_configuration' => 'boolean', 'requires_configuration' => 'boolean',
]; ];
public function clientIntegrations() /** @return HasMany<IntegrationInstance, $this> */
public function instances(): HasMany
{
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{ {
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code'); return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
} }
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
}
} }

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class IntegrationInstance extends Model
{
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
public function tokenCacheKey(): string
{
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
}
protected $fillable = ['integration_code', 'name', 'integration_data'];
protected $hidden = ['integration_data'];
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return HasMany<ClientIntegration, $this> */
public function clientIntegrations(): HasMany
{
return $this->hasMany(ClientIntegration::class);
}
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function websiteTypeIntegrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WebsiteTypeIntegration extends Model
{
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
/** @return BelongsTo<WebsiteType, $this> */
public function websiteType(): BelongsTo
{
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
/** @return BelongsTo<IntegrationInstance, $this> */
public function integrationInstance(): BelongsTo
{
return $this->belongsTo(IntegrationInstance::class);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Integration\Policies;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
class IntegrationPolicy
{
public function manage(User $user): bool
{
return $user->rol_codigo === RoleCode::Admin->value;
}
}

View File

@@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class StoreClientIntegrationRequest extends FormRequest class ConfigureIntegrationRequest extends FormRequest
{ {
protected ?Integration $integrationModel = null; protected ?Integration $integrationModel = null;
public function authorize(): bool public function authorize(): bool
{ {
return true; return $this->user()?->can('manage', Integration::class) ?? false;
} }
protected function prepareForValidation(): void protected function prepareForValidation(): void
{ {
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::query() $this->integrationModel = Integration::query()
->where('integration_code', $integrationCode) ->where('integration_code', $this->route('integration_code'))
->first(); ->first();
if (! $this->integrationModel) { if (! $this->integrationModel) {
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
$rules = []; $rules = ['integration_data' => ['present', 'array']];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) { foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule; $rules['integration_data.'.$field] = $rule;

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_client_configuration' => ['sometimes', 'boolean'], 'requires_configuration' => ['sometimes', 'boolean'],
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Integration\Requests; namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIntegrationRequest extends FormRequest class UpdateIntegrationRequest extends FormRequest
{ {
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
'name' => ['sometimes', 'required', 'string', 'max:255'], 'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'], 'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'], 'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'], 'requires_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is: 'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
]; ];
} }
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationAssociationResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'client_id' => $this->when(isset($this->client_id), $this->client_id),
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
'integration_code' => $this->integration_code,
'integration_instance_id' => $this->integration_instance_id,
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Integration\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class IntegrationInstanceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'integration_code' => $this->integration_code,
'name' => $this->name,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

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

View File

@@ -13,6 +13,7 @@ class ClientIntegrationService
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{ {
return $client->integrations() return $client->integrations()
->with(['integration', 'integrationInstance'])
->where('integration_code', $integrationCode) ->where('integration_code', $integrationCode)
->first(); ->first();
} }
@@ -20,7 +21,7 @@ class ClientIntegrationService
/** @return Collection<int, ClientIntegration> */ /** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection public function getAllForClient(Client $client): Collection
{ {
return $client->integrations()->with('integration')->get(); return $client->integrations()->with(['integration', 'integrationInstance'])->get();
} }
public function updateOrCreateIntegration( public function updateOrCreateIntegration(
@@ -29,13 +30,7 @@ class ClientIntegrationService
array $data, array $data,
): ClientIntegration { ): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration { return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = ClientIntegration::query()->updateOrCreate( $clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
);
$service = $this->resolveService($integration->integration_code); $service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup(); $service?->forClient($client)->onSetup();

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\IntegrationInstance;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
use App\Domains\Tenant\Models\WebsiteType;
use Illuminate\Support\Facades\DB;
class IntegrationAssociationService
{
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
$instance = IntegrationInstance::create([
'integration_code' => $integration->integration_code,
'name' => $integration->name.' / '.$this->ownerName($owner),
'integration_data' => $integrationData,
]);
return $this->associate($owner, $integration->integration_code, $instance);
});
}
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
{
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
$association = $owner->integrations()
->where('integration_code', $code)
->lockForUpdate()
->first();
$previousInstanceId = $association?->integration_instance_id;
$instanceIds = array_values(array_unique(array_filter([
$previousInstanceId,
$instance->id,
])));
sort($instanceIds);
$instances = IntegrationInstance::query()
->whereKey($instanceIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
$association = $owner->integrations()->updateOrCreate(
['integration_code' => $code],
['integration_instance_id' => $instance->id],
);
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
$this->deleteIfUnused($previousInstanceId);
}
return $association->load('integrationInstance');
});
}
public function detach(Client|WebsiteType $owner, string $code): void
{
DB::transaction(function () use ($owner, $code): void {
$association = $owner->integrations()
->where('integration_code', $code)
->lockForUpdate()
->first();
if (! $association) {
return;
}
$instanceId = $association->integration_instance_id;
$association->delete();
$this->deleteIfUnused($instanceId);
});
}
private function deleteIfUnused(int $instanceId): void
{
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
if ($instance
&& ! $instance->clientIntegrations()->exists()
&& ! $instance->websiteTypeIntegrations()->exists()) {
$instance->delete();
}
}
private function ownerName(Client|WebsiteType $owner): string
{
return $owner instanceof Client ? $owner->name : $owner->nombre;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\IntegrationInstance;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class IntegrationInstanceService
{
public function create(array $data): IntegrationInstance
{
return IntegrationInstance::create($data);
}
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
{
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
$cacheKey = $instance->tokenCacheKey();
$instance->update($data);
if (array_key_exists('integration_data', $data)) {
DB::afterCommit(fn () => Cache::forget($cacheKey));
}
return $instance;
});
}
public function delete(IntegrationInstance $instance): void
{
DB::transaction(function () use ($instance): void {
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
$cacheKey = $instance->tokenCacheKey();
$instance->delete();
DB::afterCommit(fn () => Cache::forget($cacheKey));
});
}
}

View File

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

View File

@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/ */
public function getToken(): string public function getToken(): string
{ {
if (! $this->clientIntegration || ! $this->clientContext) { if (! $this->integrationInstance) {
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.'); throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
} }
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; $cacheKey = $this->integrationInstance->tokenCacheKey();
$token = Cache::get($cacheKey); $token = Cache::get($cacheKey);
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
// Calculate TTL and subtract a buffer of 60 seconds // Calculate TTL and subtract a buffer of 60 seconds
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60); $ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; $cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::put($cacheKey, $token, $ttlSeconds); Cache::put($cacheKey, $token, $ttlSeconds);
return $token; return $token;
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
*/ */
public function clearToken(): void public function clearToken(): void
{ {
if (! $this->clientContext) { if (! $this->integrationInstance) {
return; return;
} }
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}"; $cacheKey = $this->integrationInstance->tokenCacheKey();
Cache::forget($cacheKey); Cache::forget($cacheKey);
} }

View File

@@ -1,33 +1,48 @@
# Dominio Integration # Dominio Integration
## Propósito ## Modelo
Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos. - `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
## Modelo y seguridad Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
- `Integration`: definición global de una integración. ## Resolución
- `ClientIntegration`: configuración y credenciales de una integración para un cliente.
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
## Servicios externos `BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente. `forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
- `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
## Endpoints Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
- CRUD global bajo `/integrations`. ## Administración
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
## Logging de Telepagos Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor. | Método | Ruta | Operación |
| --- | --- | --- |
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
## Dependencias y reglas Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra. Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
## Webhooks y contexto operativo
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
## Despliegue
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
## Logging
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.

View File

@@ -1,22 +1,25 @@
<?php <?php
use App\Domains\Integration\Controllers\ClientIntegrationController; use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\TelepagosWebhookController; use App\Domains\Integration\Controllers\TelepagosWebhookController;
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
use App\Domains\Integration\Models\Integration;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'integrations'], function () { Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
Route::get('/', [IntegrationController::class, 'index']); Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
Route::post('/', [IntegrationController::class, 'store']); Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
Route::get('/{integration}', [IntegrationController::class, 'show']); Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
Route::put('/{integration}', [IntegrationController::class, 'update']); Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
Route::delete('/{integration}', [IntegrationController::class, 'destroy']); Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
}); });
Route::group(['prefix' => 'clients/{client}/integrations'], function () { Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [ClientIntegrationController::class, 'index']); Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']); Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']); Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
});
}); });
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']); Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);

View File

@@ -32,13 +32,14 @@ class NotificationMailService
$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);
$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'))->render(), view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
$brand, $brand,
); );
@@ -99,12 +100,25 @@ 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,
"Código para recuperar tu contraseña - {$brand->nombre}", "{$subject} - {$brand->nombre}",
view('mail.notifications.password-reset', [ view("mail.notifications.{$template}", [
'attempt' => $attempt, 'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl, 'recoveryUrl' => $recoveryUrl,
'brand' => $brand, 'brand' => $brand,

View File

@@ -27,6 +27,7 @@ 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
{ {
@@ -47,6 +48,7 @@ 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',
]; ];
} }

View File

@@ -25,6 +25,7 @@ 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

@@ -0,0 +1,22 @@
<?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

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

View File

@@ -22,6 +22,7 @@ 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

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

@@ -5,6 +5,7 @@ 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;
@@ -17,6 +18,7 @@ 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
@@ -29,6 +31,11 @@ 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,

View File

@@ -6,6 +6,9 @@ use App\Domains\Staff\Requests\StoreStaffRequest;
use App\Domains\Staff\Requests\UpdateStaffRequest; use App\Domains\Staff\Requests\UpdateStaffRequest;
use App\Domains\Staff\Resources\StaffResource; use App\Domains\Staff\Resources\StaffResource;
use App\Domains\Staff\Services\StaffService; use App\Domains\Staff\Services\StaffService;
use App\Domains\Ticket\Requests\ScanAttemptIndexRequest;
use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource;
use App\Domains\Ticket\Services\ScannerTicketService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -13,7 +16,10 @@ use Symfony\Component\HttpFoundation\Response;
class AdminAppStaffController extends Controller class AdminAppStaffController extends Controller
{ {
public function __construct(private readonly StaffService $staffService) {} public function __construct(
private readonly StaffService $staffService,
private readonly ScannerTicketService $scannerTicketService,
) {}
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
@@ -46,4 +52,18 @@ class AdminAppStaffController extends Controller
return response()->noContent(); return response()->noContent();
} }
public function scanAttempts(
ScanAttemptIndexRequest $request,
int $staff,
): AnonymousResourceCollection {
$scanner = $this->staffService->find(
$request->user()->tenant()->firstOrFail(),
$staff,
);
return ScanAttemptResource::collection(
$this->scannerTicketService->attemptsByStaff($scanner, $request->validated())
);
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Staff\Requests; namespace App\Domains\Staff\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;
@@ -12,6 +13,13 @@ class StoreStaffRequest 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')))]);
}
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function rules(): array public function rules(): array
{ {
@@ -23,7 +31,12 @@ class StoreStaffRequest extends FormRequest
return [ return [
'nombre_apellido' => ['required', 'string', 'max:255'], 'nombre_apellido' => ['required', 'string', 'max:255'],
'dni' => ['required', 'string', 'max:50'], 'dni' => ['required', 'string', 'max:50'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'email' => [
'required',
'email',
'max:255',
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)->whereNull('deleted_at'),
],
'category_ids' => $categoryRules, 'category_ids' => $categoryRules,
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
]; ];

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Staff\Requests; namespace App\Domains\Staff\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;
@@ -12,6 +13,13 @@ class UpdateStaffRequest 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')))]);
}
}
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function rules(): array public function rules(): array
{ {
@@ -28,7 +36,9 @@ class UpdateStaffRequest extends FormRequest
'required', 'required',
'email', 'email',
'max:255', 'max:255',
Rule::unique('users', 'email')->ignore($staffId), Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)
->whereNull('deleted_at')
->ignore($staffId),
], ],
'category_ids' => $categoryRules, 'category_ids' => $categoryRules,
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')], 'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],

View File

@@ -94,7 +94,12 @@ class StaffService
public function delete(Tenant $tenant, int $staffId): void public function delete(Tenant $tenant, int $staffId): void
{ {
$this->find($tenant, $staffId)->delete(); $staff = $this->find($tenant, $staffId);
DB::transaction(function () use ($staff): void {
$staff->tokens()->delete();
$staff->delete();
});
} }
public function find(Tenant $tenant, int $staffId): User public function find(Tenant $tenant, int $staffId): User

View File

@@ -6,5 +6,6 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']);
Route::apiResource('staff', AdminAppStaffController::class)->except('show'); Route::apiResource('staff', AdminAppStaffController::class)->except('show');
}); });

View File

@@ -12,6 +12,7 @@ use App\Domains\Event\Models\EventDate;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu; use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Enums\CartEditingPolicy; use App\Domains\Tenant\Enums\CartEditingPolicy;
use App\Domains\Ticket\Models\ScanAttempt;
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;
@@ -51,6 +52,10 @@ use Illuminate\Support\Facades\Schema;
'checkout_editing_policy', 'checkout_editing_policy',
'display_cart_item_images', 'display_cart_item_images',
'scanner_category_validation_enabled', 'scanner_category_validation_enabled',
'allow_ticket_refund',
'allow_ticket_total_refund',
'allow_ticket_partial_refund',
'ticket_partial_refund_percentage',
'event_title', 'event_title',
'event_location', 'event_location',
'event_date_text', 'event_date_text',
@@ -71,6 +76,10 @@ class Tenant extends Model
'checkout_editing_policy' => CartEditingPolicy::Disabled->value, 'checkout_editing_policy' => CartEditingPolicy::Disabled->value,
'display_cart_item_images' => true, 'display_cart_item_images' => true,
'scanner_category_validation_enabled' => true, 'scanner_category_validation_enabled' => true,
'allow_ticket_refund' => false,
'allow_ticket_total_refund' => false,
'allow_ticket_partial_refund' => false,
'ticket_partial_refund_percentage' => 0,
]; ];
public function getRouteKeyName(): string public function getRouteKeyName(): string
@@ -105,6 +114,35 @@ class Tenant extends Model
return $this->scanner_category_validation_enabled; return $this->scanner_category_validation_enabled;
} }
public function allow_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund());
}
public function allow_partial_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& (bool) $this->allow_ticket_partial_refund
&& $this->ticket_partial_refund_percentage !== null
&& (float) $this->ticket_partial_refund_percentage > 0;
}
public function allowRefund(): bool
{
return $this->allow_refund();
}
public function allowPartialRefund(): bool
{
return $this->allow_partial_refund();
}
public function getAllowRefundAttribute(): bool
{
return $this->allow_refund();
}
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
* *
@@ -123,6 +161,10 @@ class Tenant extends Model
'checkout_editing_policy' => CartEditingPolicy::class, 'checkout_editing_policy' => CartEditingPolicy::class,
'display_cart_item_images' => 'boolean', 'display_cart_item_images' => 'boolean',
'scanner_category_validation_enabled' => 'boolean', 'scanner_category_validation_enabled' => 'boolean',
'allow_ticket_refund' => 'boolean',
'allow_ticket_total_refund' => 'boolean',
'allow_ticket_partial_refund' => 'boolean',
'ticket_partial_refund_percentage' => 'decimal:2',
]; ];
} }
@@ -195,6 +237,12 @@ class Tenant extends Model
return $this->hasMany(Category::class, 'tenant_code', 'codigo'); return $this->hasMany(Category::class, 'tenant_code', 'codigo');
} }
/** @return HasMany<ScanAttempt, $this> */
public function scanAttempts(): HasMany
{
return $this->hasMany(ScanAttempt::class, 'tenant_code', 'codigo');
}
/** /**
* @return BelongsToMany<SocialMedia, $this> * @return BelongsToMany<SocialMedia, $this>
*/ */

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Tenant\Models; namespace App\Domains\Tenant\Models;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Integration\Models\WebsiteTypeIntegration;
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;
@@ -36,6 +37,12 @@ class WebsiteType extends Model
protected $table = 'website_type'; protected $table = 'website_type';
/** @return HasMany<WebsiteTypeIntegration, $this> */
public function integrations(): HasMany
{
return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo');
}
/** /**
* @return BelongsTo<Attachment, $this> * @return BelongsTo<Attachment, $this>
*/ */

View File

@@ -117,6 +117,16 @@ class StoreTenantRequest extends FormRequest
], ],
'display_cart_item_images' => ['sometimes', 'boolean'], 'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'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',
],
'website_type_code' => [ 'website_type_code' => [
'required_with:extras', 'required_with:extras',
'sometimes', 'sometimes',

View File

@@ -138,6 +138,16 @@ class UpdateTenantRequest extends FormRequest
], ],
'display_cart_item_images' => ['sometimes', 'boolean'], 'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'], 'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'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',
],
]; ];
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Resources;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop; use App\Domains\Attachable\Models\AttachmentCrop;
use App\Domains\Catalog\Models\Category; use App\Domains\Catalog\Models\Category;
use App\Domains\Event\Models\EventDate;
use App\Domains\Menu\Models\Menu; use App\Domains\Menu\Models\Menu;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -32,6 +33,7 @@ class TenantResource extends JsonResource
'site_title' => $this->site_title 'site_title' => $this->site_title
?? $this->websiteType?->site_title ?? $this->websiteType?->site_title
?? 'ShopitFront', ?? 'ShopitFront',
'asset_url' => config('filesystems.disks.s3.url'),
'address' => $this->address, 'address' => $this->address,
'phone' => $this->phone, 'phone' => $this->phone,
'favicon' => ($this->favicon ?? $this->websiteType?->favicon) 'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
@@ -49,12 +51,16 @@ class TenantResource extends JsonResource
: [ : [
'title' => $this->event_title, 'title' => $this->event_title,
'location' => $this->event_location, 'location' => $this->event_location,
'dates' => $this->eventDates->map(fn ($eventDate): array => [ 'dates' => $this->eventDates
'id' => $eventDate->id, ->filter(fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
'date' => $eventDate->date->format('Y-m-d'), && $eventDate->suspended_at === null
'time_start' => $eventDate->time_start, )
'time_end' => $eventDate->time_end, ->map(fn (EventDate $eventDate): array => [
])->values(), 'id' => $eventDate->id,
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
])->values(),
]), ]),
'extras' => $this->whenLoaded( 'extras' => $this->whenLoaded(
'websiteExtras', 'websiteExtras',
@@ -81,6 +87,10 @@ class TenantResource extends JsonResource
'checkout_editing_policy' => CartEditingPolicyResource::make($this->checkout_editing_policy), 'checkout_editing_policy' => CartEditingPolicyResource::make($this->checkout_editing_policy),
'display_cart_item_images' => $this->display_cart_item_images, 'display_cart_item_images' => $this->display_cart_item_images,
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled, 'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
'allow_ticket_refund' => $this->allow_ticket_refund,
'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
'social_media' => $this->whenLoaded( 'social_media' => $this->whenLoaded(
'socialMedia', 'socialMedia',
fn () => $this->socialMedia fn () => $this->socialMedia

View File

@@ -4,11 +4,15 @@ namespace App\Domains\Ticket\Controllers\AdminApp;
use App\Domains\Ticket\Requests\AdminAppTicketExportRequest; use App\Domains\Ticket\Requests\AdminAppTicketExportRequest;
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest; use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
use App\Domains\Ticket\Requests\AdminAppTicketRefundRequest;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection; use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketRefundCalculationResource;
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketResource;
use App\Domains\Ticket\Services\AdminAppTicketExcelService; use App\Domains\Ticket\Services\AdminAppTicketExcelService;
use App\Domains\Ticket\Services\AdminAppTicketPdfService; use App\Domains\Ticket\Services\AdminAppTicketPdfService;
use App\Domains\Ticket\Services\AdminAppTicketService; use App\Domains\Ticket\Services\AdminAppTicketService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -29,6 +33,31 @@ class TicketController extends Controller
); );
} }
public function cancel(Request $request, int $ticket): AdminAppTicketResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
}
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketRefundCalculationResource(
$this->ticketService->calculateRefund($tenant, $ticket)
);
}
public function refund(AdminAppTicketRefundRequest $request, int $ticket): AdminAppTicketResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new AdminAppTicketResource(
$this->ticketService->refund($tenant, $ticket, $request->validated('refund_type'))
);
}
public function downloadPdf(AdminAppTicketExportRequest $request): Response public function downloadPdf(AdminAppTicketExportRequest $request): Response
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Ticket\Controllers\Scanner;
use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Requests\ScanAttemptIndexRequest;
use App\Domains\Ticket\Resources\Scanner\ScanAttemptResource;
use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource;
use App\Domains\Ticket\Services\ScannerTicketService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class ScanAttemptController extends Controller
{
public function __construct(private readonly ScannerTicketService $ticketService) {}
public function __invoke(ScanAttemptIndexRequest $request): AnonymousResourceCollection
{
/** @var User $scanner */
$scanner = $request->user();
return ScanAttemptResource::collection(
$this->ticketService->attemptsBy($scanner, $request->validated())
);
}
public function show(Request $request, int $scanAttempt): ScannerScanResultResource
{
/** @var User $scanner */
$scanner = $request->user();
return ScannerScanResultResource::make(
$this->ticketService->scanAttemptDetail($scanner, $scanAttempt)
);
}
}

View File

@@ -3,28 +3,18 @@
namespace App\Domains\Ticket\Controllers\Scanner; namespace App\Domains\Ticket\Controllers\Scanner;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Requests\ScannerTicketIndexRequest; use App\Domains\Ticket\Resources\Scanner\ScannerScanResultResource;
use App\Domains\Ticket\Resources\Scanner\ScannedTicketResource;
use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\ScannerTicketService; use App\Domains\Ticket\Services\ScannerTicketService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Symfony\Component\HttpFoundation\Response;
class TicketController extends Controller class TicketController extends Controller
{ {
public function __construct(private readonly ScannerTicketService $ticketService) {} public function __construct(private readonly ScannerTicketService $ticketService) {}
public function index(ScannerTicketIndexRequest $request): AnonymousResourceCollection
{
/** @var User $scanner */
$scanner = $request->user();
return ScannedTicketResource::collection(
$this->ticketService->scannedBy($scanner, $request->validated())
);
}
public function show(Request $request, string $ticketUuid): TicketResource public function show(Request $request, string $ticketUuid): TicketResource
{ {
/** @var User $scanner */ /** @var User $scanner */
@@ -35,13 +25,13 @@ class TicketController extends Controller
); );
} }
public function scan(Request $request, string $ticketUuid): TicketResource public function scan(Request $request): JsonResponse
{ {
/** @var User $scanner */ /** @var User $scanner */
$scanner = $request->user(); $scanner = $request->user();
return TicketResource::make( return ScannerScanResultResource::make(
$this->ticketService->scan($scanner, $ticketUuid) $this->ticketService->scan($scanner, $request->input('data'))
); )->response()->setStatusCode(Response::HTTP_OK);
} }
} }

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Ticket\Enums;
enum ScanAttemptResult: string
{
case Processing = 'processing';
case Accepted = 'accepted';
case InvalidQr = 'invalid_qr';
case TicketNotFound = 'ticket_not_found';
case CategoryForbidden = 'category_forbidden';
case AlreadyScanned = 'already_scanned';
case Expired = 'expired';
case NotValid = 'not_valid';
case UnexpectedError = 'unexpected_error';
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Ticket\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'tenant_code',
'scanner_user_id',
'ticket_id',
'data',
'result',
'resolved_at',
])]
class ScanAttempt extends Model
{
public const UPDATED_AT = null;
/** @return BelongsTo<User, $this> */
public function scanner(): BelongsTo
{
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
}
/** @return BelongsTo<Ticket, $this> */
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
protected function casts(): array
{
return [
'scanner_user_id' => 'integer',
'ticket_id' => 'integer',
'result' => ScanAttemptResult::class,
'created_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Models;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Services\ResolvedTicketValidity; use App\Domains\Ticket\Services\ResolvedTicketValidity;
@@ -16,7 +17,9 @@ 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;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Validation\ValidationException;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
@@ -25,12 +28,15 @@ use Illuminate\Support\Collection;
'source_catalog_item_id', 'source_catalog_item_id',
'source_variant_id', 'source_variant_id',
'used_at', 'used_at',
'disabled_at',
'cancelled_at',
'refunded_at',
'scanner_user_id', 'scanner_user_id',
'user_id', 'user_id',
])] ])]
class Ticket extends Model class Ticket extends Model
{ {
use HasFactory; use HasFactory, LogsValueChanges;
private ?ResolvedTicketValidity $resolvedValidity = null; private ?ResolvedTicketValidity $resolvedValidity = null;
@@ -40,8 +46,22 @@ class Ticket extends Model
public const STATUS_USED = 'used'; public const STATUS_USED = 'used';
public const STATUS_DISABLED = 'disabled';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REFUNDED = 'refunded';
public $timestamps = false; public $timestamps = false;
/** @var list<string> */
protected array $loggedAttributes = [
'used_at',
'disabled_at',
'cancelled_at',
'refunded_at',
];
protected $appends = [ protected $appends = [
'name', 'name',
'description', 'description',
@@ -58,17 +78,123 @@ class Ticket extends Model
'source_variant_id' => 'integer', 'source_variant_id' => 'integer',
'source_purchase_item_id' => 'integer', 'source_purchase_item_id' => 'integer',
'used_at' => 'datetime', 'used_at' => 'datetime',
'disabled_at' => 'datetime',
'cancelled_at' => 'datetime',
'refunded_at' => 'datetime',
'scanner_user_id' => 'integer', 'scanner_user_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
]; ];
} }
/** @return list<string> */
public static function statuses(): array
{
return array_keys(self::statusLabels());
}
/** @return array<string, string> */
public static function statusLabels(): array
{
return [
self::STATUS_ACTIVE => 'Activo',
self::STATUS_USED => 'Usado',
self::STATUS_EXPIRED => 'Vencido',
self::STATUS_DISABLED => 'Inhabilitado',
self::STATUS_CANCELLED => 'Cancelado',
self::STATUS_REFUNDED => 'Reembolsado',
];
}
/** @return list<array{value: string, label: string}> */
public static function statusOptions(): array
{
return collect(self::statusLabels())
->map(fn (string $label, string $status): array => [
'value' => $status,
'label' => $label,
])
->values()
->all();
}
public static function statusLabel(string $status): string
{
return self::statusLabels()[$status] ?? $status;
}
protected static function booted(): void
{
static::saving(function (self $ticket): void {
$ticket->ensureTerminalStatusTransitionIsAllowed();
});
}
/** @return BelongsTo<Tenant, $this> */ /** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo public function tenant(): BelongsTo
{ {
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
} }
public function allow_refund(): bool
{
return $this->tenant?->allow_refund() ?? false;
}
public function allowRefund(): bool
{
return $this->allow_refund();
}
public function getAllowRefundAttribute(): bool
{
return $this->allow_refund();
}
public function is_active(): bool
{
return $this->status === self::STATUS_ACTIVE;
}
public function isActive(): bool
{
return $this->is_active();
}
public function getIsActiveAttribute(): bool
{
return $this->is_active();
}
public function can_cancel(): bool
{
return $this->is_active();
}
public function canCancel(): bool
{
return $this->can_cancel();
}
public function getCanCancelAttribute(): bool
{
return $this->can_cancel();
}
public function can_refund(): bool
{
return $this->is_active() && $this->allow_refund();
}
public function canRefund(): bool
{
return $this->can_refund();
}
public function getCanRefundAttribute(): bool
{
return $this->can_refund();
}
/** @return BelongsTo<User, $this> */ /** @return BelongsTo<User, $this> */
public function user(): BelongsTo public function user(): BelongsTo
{ {
@@ -78,7 +204,13 @@ class Ticket extends Model
/** @return BelongsTo<User, $this> */ /** @return BelongsTo<User, $this> */
public function scannerUser(): BelongsTo public function scannerUser(): BelongsTo
{ {
return $this->belongsTo(User::class, 'scanner_user_id'); return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
}
/** @return HasMany<ScanAttempt, $this> */
public function scanAttempts(): HasMany
{
return $this->hasMany(ScanAttempt::class);
} }
/** @return BelongsTo<PurchaseItem, $this> */ /** @return BelongsTo<PurchaseItem, $this> */
@@ -101,7 +233,7 @@ class Ticket extends Model
public function isValid(): bool public function isValid(): bool
{ {
if ($this->used_at !== null) { if ($this->hasTerminalStatus() || $this->used_at !== null) {
return false; return false;
} }
@@ -115,7 +247,9 @@ class Ticket extends Model
public function getIsExpiredAttribute(): bool public function getIsExpiredAttribute(): bool
{ {
return $this->used_at === null && $this->resolvedValidity()->isExpired(); return ! $this->hasTerminalStatus()
&& $this->used_at === null
&& $this->resolvedValidity()->isExpired();
} }
public function getIsUsedAttribute(): bool public function getIsUsedAttribute(): bool
@@ -125,6 +259,18 @@ class Ticket extends Model
public function getStatusAttribute(): string public function getStatusAttribute(): string
{ {
if ($this->refunded_at !== null) {
return self::STATUS_REFUNDED;
}
if ($this->cancelled_at !== null) {
return self::STATUS_CANCELLED;
}
if ($this->disabled_at !== null) {
return self::STATUS_DISABLED;
}
if ($this->is_used) { if ($this->is_used) {
return self::STATUS_USED; return self::STATUS_USED;
} }
@@ -136,6 +282,102 @@ class Ticket extends Model
return self::STATUS_ACTIVE; return self::STATUS_ACTIVE;
} }
public function getStatusLabelAttribute(): string
{
return self::statusLabel($this->status);
}
public function markAsDisabled(): void
{
$this->markAsTerminalStatus(self::STATUS_DISABLED);
}
public function markAsCancelled(): void
{
$this->markAsTerminalStatus(self::STATUS_CANCELLED);
}
public function markAsRefunded(): void
{
$this->markAsTerminalStatus(self::STATUS_REFUNDED);
}
protected function valueChangeTenantCode(): string
{
return $this->tenant_code;
}
private function hasTerminalStatus(): bool
{
return $this->terminalStatus() !== null;
}
private function markAsTerminalStatus(string $status): void
{
$currentStatus = $this->terminalStatus();
if ($currentStatus === $status) {
return;
}
if ($currentStatus !== null) {
$this->throwTerminalStatusTransitionException();
}
$this->ensureTerminalStatusTransitionIsAllowed($status);
$this->{self::terminalStatusTimestampColumn($status)} = now();
}
private function ensureTerminalStatusTransitionIsAllowed(?string $targetStatus = null): void
{
$currentStatus = $this->terminalStatusFromAttributes($this->getRawOriginal());
$nextStatus = $targetStatus ?? $this->terminalStatus();
if ($currentStatus === null || $nextStatus === null || $currentStatus === $nextStatus) {
return;
}
$this->throwTerminalStatusTransitionException();
}
private function throwTerminalStatusTransitionException(): never
{
throw ValidationException::withMessages([
'status' => 'No se puede cambiar un ticket con estado terminal a otro estado terminal.',
]);
}
private function terminalStatus(): ?string
{
return $this->terminalStatusFromAttributes($this->getAttributes());
}
/** @param array<string, mixed> $attributes */
private function terminalStatusFromAttributes(array $attributes): ?string
{
foreach ([
self::STATUS_REFUNDED,
self::STATUS_CANCELLED,
self::STATUS_DISABLED,
] as $status) {
if (($attributes[self::terminalStatusTimestampColumn($status)] ?? null) !== null) {
return $status;
}
}
return null;
}
private static function terminalStatusTimestampColumn(string $status): string
{
return match ($status) {
self::STATUS_DISABLED => 'disabled_at',
self::STATUS_CANCELLED => 'cancelled_at',
self::STATUS_REFUNDED => 'refunded_at',
};
}
public function getNameAttribute(): string public function getNameAttribute(): string
{ {
return app(TicketPresentationResolver::class)->name($this); return app(TicketPresentationResolver::class)->name($this);

View File

@@ -32,11 +32,7 @@ class AdminAppTicketIndexRequest extends FormRequest
'status' => [ 'status' => [
'sometimes', 'sometimes',
'nullable', 'nullable',
Rule::in([ Rule::in(Ticket::statuses()),
Ticket::STATUS_ACTIVE,
Ticket::STATUS_USED,
Ticket::STATUS_EXPIRED,
]),
], ],
'page' => ['sometimes', 'integer', 'min:1'], 'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppTicketRefundRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string|object>> */
public function rules(): array
{
return [
'refund_type' => ['required', 'string', Rule::in(['partial', 'total'])],
];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class ScannerTicketIndexRequest extends FormRequest class ScanAttemptIndexRequest extends FormRequest
{ {
public function authorize(): bool public function authorize(): bool
{ {

View File

@@ -15,20 +15,24 @@ class AdminAppTicketCollection extends ResourceCollection
private readonly int $totalTickets; private readonly int $totalTickets;
private readonly string $refundedTotal;
public function __construct(AdminAppTicketResult $result) public function __construct(AdminAppTicketResult $result)
{ {
parent::__construct($result->tickets); parent::__construct($result->tickets);
$this->scannedTickets = $result->scannedTickets; $this->scannedTickets = $result->scannedTickets;
$this->totalTickets = $result->totalTickets; $this->totalTickets = $result->totalTickets;
$this->refundedTotal = $result->refundedTotal;
} }
/** @return array{scanned_tickets: int, total_tickets: int} */ /** @return array{scanned_tickets: int, total_tickets: int, refunded_total: string} */
public function with(Request $request): array public function with(Request $request): array
{ {
return [ return [
'scanned_tickets' => $this->scannedTickets, 'scanned_tickets' => $this->scannedTickets,
'total_tickets' => $this->totalTickets, 'total_tickets' => $this->totalTickets,
'refunded_total' => $this->refundedTotal,
]; ];
} }
} }

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Ticket\Resources\AdminApp;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property-read array{
* total: string|null,
* partial: string|null,
* } $resource
*/
class AdminAppTicketRefundCalculationResource extends JsonResource
{
/**
* @return array{total: string|null, partial: string|null}
*/
public function toArray(Request $request): array
{
return [
'total' => $this->resource['total'],
'partial' => $this->resource['partial'],
];
}
}

View File

@@ -19,6 +19,10 @@ class AdminAppTicketResource extends TicketResource
return [ return [
...parent::toArray($request), ...parent::toArray($request),
...$details, ...$details,
'allow_refund' => $this->resource->allow_refund(),
'is_active' => $this->resource->is_active(),
'can_cancel' => $this->resource->can_cancel(),
'can_refund' => $this->resource->can_refund(),
'values' => $rowService->values($this->resource, $details), 'values' => $rowService->values($this->resource, $details),
]; ];
} }

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Ticket\Resources\Scanner;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticket\Models\ScanAttempt;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ScanAttempt */
class ScanAttemptResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'data' => $this->data,
'ticket_id' => $this->ticket_id,
'ticket' => $this->ticket?->ticket,
'category' => $this->ticket?->sourceCatalogItem?->category?->nombre,
'attempted_at' => $this->created_at,
'resolved_at' => $this->resolved_at,
'result' => $this->result->value,
'result_label' => match ($this->result) {
ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido',
default => 'Error',
},
'result_detail_label' => match ($this->result) {
ScanAttemptResult::Processing => 'Error',
ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::InvalidQr => 'QR no pertenece al evento',
ScanAttemptResult::TicketNotFound => 'Error',
ScanAttemptResult::CategoryForbidden => 'Error',
ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido',
ScanAttemptResult::NotValid => 'No válido',
ScanAttemptResult::UnexpectedError => 'Error',
},
'can_view_ticket' => $this->ticket !== null
&& $this->result !== ScanAttemptResult::CategoryForbidden,
];
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Ticket\Resources\Scanner;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Ticket */
class ScannedTicketResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'product' => $this->name,
'id' => $this->id,
'ticket' => $this->ticket,
'used_at' => $this->used_at,
'expires_at' => $this->getEffectiveExpiresAt(),
'status' => $this->status,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Ticket\Resources\Scanner;
use App\Domains\Ticket\Models\ScanAttempt;
use App\Domains\Ticket\Resources\TicketResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ScanAttempt */
class ScannerScanResultResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$ticket = $this->ticket;
$client = $ticket?->user;
return [
'scan_attempt' => ScanAttemptResource::make($this->resource),
'ticket' => $ticket === null ? null : TicketResource::make($ticket),
'client' => $client === null ? null : [
'id' => $client->id,
'nombre_apellido' => $client->nombre_apellido,
],
];
}
}

View File

@@ -16,6 +16,8 @@ class TicketResource extends JsonResource
'id' => $this->id, 'id' => $this->id,
'tenant_code' => $this->tenant_code, 'tenant_code' => $this->tenant_code,
'ticket' => $this->ticket, 'ticket' => $this->ticket,
'status' => $this->status,
'status_label' => $this->status_label,
'name' => $this->name, 'name' => $this->name,
'description' => $this->description, 'description' => $this->description,
'client' => $this->user?->nombre_apellido, 'client' => $this->user?->nombre_apellido,

View File

@@ -12,5 +12,6 @@ final readonly class AdminAppTicketResult
public LengthAwarePaginator $tickets, public LengthAwarePaginator $tickets,
public int $scannedTickets, public int $scannedTickets,
public int $totalTickets, public int $totalTickets,
public string $refundedTotal,
) {} ) {}
} }

View File

@@ -31,10 +31,12 @@ class AdminAppTicketRowService
?? $ticket->sourceCatalogItem?->nombre ?? $ticket->sourceCatalogItem?->nombre
?? $ticket->name, ?? $ticket->name,
'amount' => $purchaseItem?->precio_unitario, 'amount' => $purchaseItem?->precio_unitario,
'refunded_amount' => $purchaseItem?->refunded_amount,
'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido, 'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,
'status' => $ticket->status, 'status' => $ticket->status,
'scanned_by' => $ticket->scannerUser?->nombre_apellido, 'scanned_by' => $ticket->scannerUser?->nombre_apellido,
'variant_properties' => $this->variantProperties($ticket), 'variant_properties' => $this->variantProperties($ticket),
'allow_refund' => $ticket->allow_refund(),
]; ];
} }
@@ -95,11 +97,7 @@ class AdminAppTicketRowService
return match ($type) { return match ($type) {
'order_number' => '#'.$value, 'order_number' => '#'.$value,
'currency' => '$'.number_format((float) $value, 2, ',', '.'), 'currency' => '$'.number_format((float) $value, 2, ',', '.'),
'status' => match ((string) $value) { 'status' => Ticket::statusLabel((string) $value),
Ticket::STATUS_USED => 'Usado',
Ticket::STATUS_EXPIRED => 'Vencido',
default => 'Activo',
},
default => (string) $value, default => (string) $value,
}; };
} }

View File

@@ -4,17 +4,21 @@ namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Models\PurchaseItem;
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 Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class AdminAppTicketService class AdminAppTicketService
{ {
private const RELATIONS = [ private const RELATIONS = [
...TicketValidityResolver::RELATIONS, ...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS,
'tenant',
'user', 'user',
'scannerUser', 'scannerUser',
'sourceCatalogItem.category', 'sourceCatalogItem.category',
@@ -24,6 +28,7 @@ class AdminAppTicketService
public function __construct( public function __construct(
private readonly AdminAppTicketColumnService $columnService, private readonly AdminAppTicketColumnService $columnService,
private readonly AdminAppTicketRowService $rowService, private readonly AdminAppTicketRowService $rowService,
private readonly PurchaseRefundSummaryService $refundSummaryService,
) {} ) {}
/** /**
@@ -42,20 +47,30 @@ class AdminAppTicketService
->get(); ->get();
$matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters); $matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters);
$tickets = $this->paginate($matchingTickets, $filters); $tickets = $this->paginate($matchingTickets, $filters);
$scannedTickets = $matchingTickets->whereNotNull('used_at')->count(); $scannedTickets = $matchingTickets
->filter(fn (Ticket $ticket): bool => $ticket->status === Ticket::STATUS_USED)
->count();
$activeTickets = $matchingTickets
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->count();
$totalTickets = $activeTickets + $scannedTickets;
} else { } else {
$tickets = (clone $query) $tickets = (clone $query)
->with(self::RELATIONS) ->with(self::RELATIONS)
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id')) ->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
$scannedTickets = $countQuery->whereNotNull('used_at')->count();
$counts = $this->calculateTicketCounts($countQuery);
$scannedTickets = $counts['scanned'];
$totalTickets = $counts['total'];
} }
return new AdminAppTicketResult( return new AdminAppTicketResult(
tickets: $tickets, tickets: $tickets,
scannedTickets: $scannedTickets, scannedTickets: $scannedTickets,
totalTickets: $tickets->total(), totalTickets: $totalTickets,
refundedTotal: $this->refundSummaryService->totalForTenant($tenant),
); );
} }
@@ -75,6 +90,154 @@ class AdminAppTicketService
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters); return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
} }
public function cancel(Tenant $tenant, int $ticketId): Ticket
{
return DB::transaction(function () use ($tenant, $ticketId): Ticket {
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->findOrFail($ticketId);
if (! $ticket->can_cancel()) {
throw ValidationException::withMessages([
'status' => 'El ticket debe estar activo para poder cancelarlo.',
]);
}
$ticket->markAsCancelled();
$ticket->save();
return $ticket->refresh()->load(self::RELATIONS);
});
}
/**
* @return array{
* total: string|null,
* partial: string|null,
* }
*/
public function calculateRefund(Tenant $tenant, int $ticketId): array
{
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->findOrFail($ticketId);
if (! $ticket->can_refund()) {
throw ValidationException::withMessages([
'refund' => 'El reembolso no está disponible para este ticket.',
]);
}
$purchaseItem = PurchaseItem::query()
->find($ticket->source_purchase_item_id);
if ($purchaseItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.',
]);
}
$unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = (float) ($purchaseItem->refunded_amount ?? 0);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null;
if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) {
$total = number_format($unitPrice, 2, '.', '');
}
$partial = null;
if ($tenant->allow_refund() && $tenant->allow_partial_refund()) {
$partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial');
if ($partialAmount <= $remainingItemAmount) {
$partial = number_format($partialAmount, 2, '.', '');
}
}
return [
'total' => $total,
'partial' => $partial,
];
}
public function refund(Tenant $tenant, int $ticketId, string $refundType): Ticket
{
$this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType): Ticket {
$ticket = Ticket::query()
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->findOrFail($ticketId);
if (! $ticket->can_refund()) {
if ($ticket->status !== Ticket::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'status' => 'El ticket debe estar activo para poder reembolsarlo.',
]);
}
throw ValidationException::withMessages([
'refund' => 'El reembolso no está disponible para este ticket.',
]);
}
$purchaseItem = PurchaseItem::query()
->lockForUpdate()
->find($ticket->source_purchase_item_id);
if ($purchaseItem === null) {
throw ValidationException::withMessages([
'ticket' => 'El ticket no tiene un ítem de compra asociado para reembolsar.',
]);
}
$refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType);
$refundedAmount = round((float) $purchaseItem->refunded_amount + $refundAmount, 2);
if ($refundedAmount > (float) $purchaseItem->total) {
throw ValidationException::withMessages([
'refund_type' => 'El importe reembolsado no puede superar el total del ítem de compra.',
]);
}
$ticket->markAsRefunded();
$ticket->save();
$purchaseItem->update([
'refunded_amount' => number_format($refundedAmount, 2, '.', ''),
]);
return $ticket->refresh()->load(self::RELATIONS);
});
}
private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void
{
$isAllowed = match ($refundType) {
'partial' => $tenant->allow_refund() && $tenant->allow_partial_refund(),
'total' => $tenant->allow_refund() && (bool) $tenant->allow_ticket_total_refund,
};
if (! $isAllowed) {
throw ValidationException::withMessages([
'refund_type' => 'El tipo de reembolso solicitado no está habilitado para este tenant.',
]);
}
}
private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float
{
$ticketAmount = (float) $purchaseItem->precio_unitario;
return match ($refundType) {
'partial' => round($ticketAmount * (float) $tenant->ticket_partial_refund_percentage / 100, 2),
'total' => $ticketAmount,
};
}
/** /**
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters * @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
* @return Builder<Ticket> * @return Builder<Ticket>
@@ -255,13 +418,41 @@ class AdminAppTicketService
} }
if ($status === Ticket::STATUS_USED) { if ($status === Ticket::STATUS_USED) {
$query->whereNotNull('used_at'); $query
->whereNotNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at');
return;
}
$timestampColumn = match ($status) {
Ticket::STATUS_DISABLED => 'disabled_at',
Ticket::STATUS_CANCELLED => 'cancelled_at',
Ticket::STATUS_REFUNDED => 'refunded_at',
default => null,
};
if ($timestampColumn !== null) {
$query->whereNotNull($timestampColumn);
if ($status === Ticket::STATUS_DISABLED) {
$query->whereNull('cancelled_at')->whereNull('refunded_at');
}
if ($status === Ticket::STATUS_CANCELLED) {
$query->whereNull('refunded_at');
}
return; return;
} }
$matchingIds = (clone $query) $matchingIds = (clone $query)
->whereNull('used_at') ->whereNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->with(TicketValidityResolver::RELATIONS) ->with(TicketValidityResolver::RELATIONS)
->get() ->get()
->filter(fn (Ticket $ticket): bool => $ticket->status === $status) ->filter(fn (Ticket $ticket): bool => $ticket->status === $status)
@@ -270,6 +461,35 @@ class AdminAppTicketService
$query->whereIn('tickets.id', $matchingIds); $query->whereIn('tickets.id', $matchingIds);
} }
/**
* @param Builder<Ticket> $countQuery
* @return array{scanned: int, total: int}
*/
private function calculateTicketCounts(Builder $countQuery): array
{
$scannedTickets = (clone $countQuery)
->whereNotNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->count();
$activeTickets = (clone $countQuery)
->whereNull('used_at')
->whereNull('disabled_at')
->whereNull('cancelled_at')
->whereNull('refunded_at')
->with(TicketValidityResolver::RELATIONS)
->get()
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
->count();
return [
'scanned' => $scannedTickets,
'total' => $activeTickets + $scannedTickets,
];
}
private function normalizedCategory(string $category): string private function normalizedCategory(string $category): string
{ {
return mb_strtolower(trim($category)); return mb_strtolower(trim($category));
@@ -293,6 +513,7 @@ class AdminAppTicketService
'id' => 'tickets.id', 'id' => 'tickets.id',
'amount' => $this->purchaseItemColumnQuery('precio_unitario'), 'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
'scanned_by' => User::query() 'scanned_by' => User::query()
->withTrashed()
->select('nombre_apellido') ->select('nombre_apellido')
->whereColumn('users.id', 'tickets.scanner_user_id'), ->whereColumn('users.id', 'tickets.scanner_user_id'),
'product' => $tenant->codigo === 'fiesta_futbol_infantil' 'product' => $tenant->codigo === 'fiesta_futbol_infantil'

View File

@@ -0,0 +1,229 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use InvalidArgumentException;
class LoadTestTicketDatasetService
{
public function __construct(
private readonly TicketGeneratorService $ticketGenerator,
private readonly TicketValidityResolver $validityResolver,
) {}
/**
* @return array{
* run_id: string,
* tenant_code: string,
* catalog_item_id: int,
* variant_id: int|null,
* tickets: int,
* scanners: int,
* owners: int,
* rows: array<int, array{scanner_token: string, ticket_uuid: string, expected_status: int}>
* }
*/
public function prepare(
string $tenantCode,
int $ticketCount,
int $scannerCount,
int $ownerCount,
?int $catalogItemId = null,
?int $variantId = null,
?string $runId = null,
): array {
$this->validateInput($tenantCode, $ticketCount, $scannerCount, $ownerCount);
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$catalogItem = $this->resolveCatalogItem($tenant, $catalogItemId);
$variant = $this->resolveVariant($catalogItem, $variantId);
$runId ??= now()->format('Ymd-His').'-'.Str::lower(Str::random(6));
if (preg_match('/\A[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\z/', $runId) !== 1) {
throw new InvalidArgumentException('run_id contiene caracteres inválidos o es demasiado largo.');
}
if ($variant !== null && ! $this->validityResolver->resolveVariant($variant)->isValid()) {
throw new InvalidArgumentException(
'La variante seleccionada no tiene una vigencia activa y resoluble.'
);
}
$scanners = $this->scanners($tenant, $catalogItem, $scannerCount);
$owners = $this->owners($tenant, $ownerCount);
$tokens = $scanners->map(function (User $scanner): string {
$scanner->tokens()->where('name', 'load-test-scanner')->delete();
return $scanner->createToken(
'load-test-scanner',
['scanner'],
now()->addMinutes((int) config('sanctum.expiration', 720)),
)->plainTextToken;
})->values();
$rows = [];
$remaining = $ticketCount;
$ownerIndex = 0;
$scannerIndex = 0;
$batchSize = min(500, max(1, (int) ceil($ticketCount / $ownerCount)));
while ($remaining > 0) {
$quantity = min($batchSize, $remaining);
$owner = $owners[$ownerIndex % $owners->count()];
$tickets = $this->ticketGenerator->generate(
$catalogItem,
$owner,
$quantity,
$variant?->getKey(),
);
foreach ($tickets as $ticket) {
if (! $ticket->is_valid) {
throw new InvalidArgumentException(
'La configuración seleccionada genera tickets que no están vigentes.'
);
}
$rows[] = [
'scanner_token' => $tokens[$scannerIndex % $tokens->count()],
'ticket_uuid' => $ticket->ticket,
'expected_status' => 200,
];
$scannerIndex++;
}
$remaining -= $quantity;
$ownerIndex++;
}
return [
'run_id' => $runId,
'tenant_code' => $tenant->codigo,
'catalog_item_id' => $catalogItem->getKey(),
'variant_id' => $variant?->getKey(),
'tickets' => count($rows),
'scanners' => $scanners->count(),
'owners' => $owners->count(),
'rows' => $rows,
];
}
private function validateInput(
string $tenantCode,
int $ticketCount,
int $scannerCount,
int $ownerCount,
): void {
foreach ([
'tickets' => [$ticketCount, 100_000],
'scanners' => [$scannerCount, 10_000],
'owners' => [$ownerCount, 100_000],
] as $name => [$value, $maximum]) {
if ($value < 1 || $value > $maximum) {
throw new InvalidArgumentException("{$name} debe estar entre 1 y {$maximum}.");
}
}
if ($scannerCount > $ticketCount || $ownerCount > $ticketCount) {
throw new InvalidArgumentException(
'La cantidad de scanners y propietarios no puede superar la cantidad de tickets.'
);
}
}
private function resolveCatalogItem(Tenant $tenant, ?int $catalogItemId): CatalogItem
{
$query = $tenant->catalogItems()
->where('has_tickets', true)
->where('type', CatalogItemType::Standard->value);
if ($catalogItemId !== null) {
$query->whereKey($catalogItemId);
}
$catalogItem = $query->first();
if ($catalogItem === null) {
throw new InvalidArgumentException(
'No se encontró un producto estándar con tickets habilitados para el tenant.'
);
}
if ($tenant->requiresScannerCategoryValidation() && $catalogItem->category_id === null) {
throw new InvalidArgumentException(
'El producto debe tener una categoría para autorizar a los scanners.'
);
}
return $catalogItem;
}
private function resolveVariant(CatalogItem $catalogItem, ?int $variantId): ?Variant
{
if ($variantId === null) {
return null;
}
$variant = $catalogItem->variants()->whereKey($variantId)->first();
if ($variant === null) {
throw new InvalidArgumentException('La variante no pertenece al producto seleccionado.');
}
return $variant;
}
/** @return Collection<int, User> */
private function scanners(Tenant $tenant, CatalogItem $catalogItem, int $count): Collection
{
return Collection::times($count, function (int $number) use ($tenant, $catalogItem): User {
$scanner = User::query()->updateOrCreate(
['email' => $this->email($tenant, 'scanner', $number)],
[
'nombre_apellido' => "Load test scanner {$number}",
'password' => Str::password(32),
'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo,
],
);
if ($tenant->requiresScannerCategoryValidation()) {
$scanner->scanCategories()->syncWithoutDetaching([$catalogItem->category_id]);
}
return $scanner;
});
}
/** @return Collection<int, User> */
private function owners(Tenant $tenant, int $count): Collection
{
return Collection::times($count, function (int $number) use ($tenant): User {
$owner = User::query()->updateOrCreate(
['email' => $this->email($tenant, 'owner', $number)],
[
'nombre_apellido' => "Load test owner {$number}",
'password' => Str::password(32),
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => $tenant->codigo,
],
);
return $owner;
});
}
private function email(Tenant $tenant, string $kind, int $number): string
{
$tenantSlug = Str::lower(preg_replace('/[^a-z0-9]+/i', '-', $tenant->codigo));
return "loadtest+{$tenantSlug}.{$kind}.{$number}@shopit.test";
}
}

View File

@@ -3,46 +3,116 @@
namespace App\Domains\Ticket\Services; namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticket\Models\ScanAttempt;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Support\Str;
use Throwable;
class ScannerTicketService class ScannerTicketService
{ {
/** /**
* @param array{q?: string|null, page?: int, per_page?: int} $filters * @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<Ticket> * @return LengthAwarePaginator<ScanAttempt>
*/ */
public function scannedBy(User $scanner, array $filters = []): LengthAwarePaginator public function attemptsBy(User $scanner, array $filters = []): LengthAwarePaginator
{ {
$search = trim((string) ($filters['q'] ?? '')); $search = trim((string) ($filters['q'] ?? ''));
return $this->baseQuery() return ScanAttempt::query()
->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$usedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
$query->where(function (Builder $searchQuery) use ($search, $usedAtDate): void { $query->where(function (Builder $searchQuery) use ($search, $attemptedAtDate): void {
$searchQuery->where('ticket', 'like', "%{$search}%"); $searchQuery->where('data', 'like', "%{$search}%");
if (ctype_digit($search)) { if (ctype_digit($search)) {
$searchQuery->orWhere('id', (int) $search); $searchQuery->orWhere('id', (int) $search);
} }
if ($usedAtDate !== null) { if ($attemptedAtDate !== null) {
$searchQuery->orWhereDate('used_at', $usedAtDate); $searchQuery->orWhereDate('created_at', $attemptedAtDate);
} }
}); });
}) })
->orderByDesc('used_at') ->orderByDesc('created_at')
->orderByDesc('id') ->orderByDesc('id')
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
/**
* @param array{q?: string|null, page?: int, per_page?: int} $filters
* @return LengthAwarePaginator<ScanAttempt>
*/
public function attemptsByStaff(User $scanner, array $filters = []): LengthAwarePaginator
{
$search = trim((string) ($filters['q'] ?? ''));
return ScanAttempt::query()
->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey())
->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search);
$attemptedAtDayMonth = $this->parseSearchDayMonth($search);
$query->where(function (Builder $searchQuery) use (
$search,
$attemptedAtDate,
$attemptedAtDayMonth,
): void {
$searchQuery
->whereHas(
'ticket.sourceCatalogItem.category',
fn (Builder $categoryQuery): Builder => $categoryQuery
->where('nombre', 'like', "%{$search}%")
)
->orWhere('created_at', 'like', "%{$search}%");
if (ctype_digit($search)) {
$searchQuery->orWhere('ticket_id', (int) $search);
}
if ($attemptedAtDate !== null) {
$searchQuery->orWhereDate('created_at', $attemptedAtDate);
}
if ($attemptedAtDayMonth !== null) {
$searchQuery->orWhere(function (Builder $dateQuery) use ($attemptedAtDayMonth): void {
$dateQuery
->whereDay('created_at', $attemptedAtDayMonth['day'])
->whereMonth('created_at', $attemptedAtDayMonth['month']);
});
}
});
})
->orderByDesc('created_at')
->orderByDesc('id')
->paginateFromRequest()
->withQueryString();
}
public function scanAttemptDetail(User $scanner, int $scanAttemptId): ScanAttempt
{
$scanAttempt = ScanAttempt::query()
->with('ticket')
->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey())
->findOrFail($scanAttemptId);
$scanAttempt->ticket?->loadMissing($this->relations());
return $scanAttempt;
}
private function parseSearchDate(string $search): ?string private function parseSearchDate(string $search): ?string
{ {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
@@ -67,13 +137,26 @@ class ScannerTicketService
return null; return null;
} }
/** @return array{day: int, month: int}|null */
private function parseSearchDayMonth(string $search): ?array
{
if (preg_match('/^(\d{1,2})\/(\d{1,2})$/', $search, $matches) !== 1) {
return null;
}
$day = (int) $matches[1];
$month = (int) $matches[2];
return checkdate($month, $day, 2000) ? compact('day', 'month') : null;
}
public function detail(User $scanner, string $ticketUuid): Ticket public function detail(User $scanner, string $ticketUuid): Ticket
{ {
$query = $this->baseQuery() $query = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid); ->where('ticket', $ticketUuid);
if ($scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) { if ($this->requiresCategoryValidation($scanner)) {
$categoryIds = $this->scannerCategoryIds($scanner); $categoryIds = $this->scannerCategoryIds($scanner);
$query->where(function (Builder $query) use ($scanner, $categoryIds): void { $query->where(function (Builder $query) use ($scanner, $categoryIds): void {
@@ -90,42 +173,117 @@ class ScannerTicketService
return $query->firstOrFail(); return $query->firstOrFail();
} }
public function scan(User $scanner, string $ticketUuid): Ticket public function scan(User $scanner, mixed $scannedData): ScanAttempt
{ {
return DB::transaction(function () use ($scanner, $ticketUuid): Ticket { $scanAttempt = ScanAttempt::query()->create([
$ticket = $this->baseQuery() 'tenant_code' => $scanner->tenant_codigo,
->where('tenant_code', $scanner->tenant_codigo) 'scanner_user_id' => $scanner->getKey(),
->where('ticket', $ticketUuid) 'data' => $this->serializeScannedData($scannedData),
->lockForUpdate() 'result' => ScanAttemptResult::Processing,
->firstOrFail(); ]);
if (! $this->scannerCanScan($scanner, $ticket)) { if (! is_string($scannedData) || ! Str::isUuid($scannedData)) {
throw ValidationException::withMessages([ $this->resolveScanAttempt($scanAttempt, ScanAttemptResult::InvalidQr);
'ticket' => __('api.ticket.scanner_category_forbidden'),
]);
}
if ($ticket->is_used) { return $scanAttempt->refresh();
throw ValidationException::withMessages([ }
'ticket' => __('api.ticket.already_scanned'),
]);
}
if (! $ticket->is_valid) { $ticketId = null;
throw ValidationException::withMessages([
'ticket' => $ticket->is_expired
? __('api.ticket.expired_for_scan')
: __('api.ticket.not_valid_for_scan'),
]);
}
$ticket->forceFill([ try {
'used_at' => now(), return DB::transaction(function () use (
'scanner_user_id' => $scanner->getKey(), $scanner,
])->save(); $scannedData,
$scanAttempt,
&$ticketId,
): ScanAttempt {
$ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $scannedData)
->lockForUpdate()
->firstOrFail();
$ticketId = (int) $ticket->getKey();
return $ticket->refresh()->load($this->relations()); if (! $this->scannerCanScan($scanner, $ticket)) {
}); $this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::CategoryForbidden,
$ticketId,
);
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
}
if ($ticket->is_used) {
$this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::AlreadyScanned,
$ticketId,
);
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
}
if (! $ticket->is_valid) {
$result = $ticket->is_expired
? ScanAttemptResult::Expired
: ScanAttemptResult::NotValid;
$this->resolveScanAttempt($scanAttempt, $result, $ticketId);
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
}
$ticket->forceFill([
'used_at' => now(),
'scanner_user_id' => $scanner->getKey(),
])->save();
$this->resolveScanAttempt(
$scanAttempt,
ScanAttemptResult::Accepted,
$ticketId,
);
$ticket = $ticket->refresh()->load($this->relations());
return $scanAttempt->refresh()->setRelation('ticket', $ticket);
});
} catch (ModelNotFoundException) {
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::TicketNotFound);
return $scanAttempt->refresh();
} catch (Throwable $exception) {
report($exception);
$this->resolveScanAttempt($scanAttempt, ScanAttemptResult::UnexpectedError, $ticketId);
return $scanAttempt->refresh();
}
}
private function resolveScanAttempt(
ScanAttempt $scanAttempt,
ScanAttemptResult $result,
?int $ticketId = null,
): void {
$scanAttempt->forceFill([
'ticket_id' => $ticketId,
'result' => $result,
'resolved_at' => now(),
])->save();
}
private function serializeScannedData(mixed $scannedData): ?string
{
if ($scannedData === null || is_string($scannedData)) {
return $scannedData;
}
$encoded = json_encode(
$scannedData,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE,
);
return $encoded === false ? get_debug_type($scannedData) : $encoded;
} }
/** @return Builder<Ticket> */ /** @return Builder<Ticket> */
@@ -158,7 +316,7 @@ class ScannerTicketService
private function scannerCanScan(User $scanner, Ticket $ticket): bool private function scannerCanScan(User $scanner, Ticket $ticket): bool
{ {
if (! $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) { if (! $this->requiresCategoryValidation($scanner)) {
return true; return true;
} }
@@ -169,4 +327,9 @@ class ScannerTicketService
->where('categorias.id', $categoryId) ->where('categorias.id', $categoryId)
->exists(); ->exists();
} }
private function requiresCategoryValidation(User $scanner): bool
{
return $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation();
}
} }

View File

@@ -4,6 +4,8 @@ namespace App\Domains\Ticket\Services;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition; use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Event\Models\EventDate;
use App\Domains\Event\Services\EffectiveEventDateResolver;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\ValidityTime; use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -17,6 +19,14 @@ use Illuminate\Support\Collection;
*/ */
class TicketValidityResolver class TicketValidityResolver
{ {
private readonly EffectiveEventDateResolver $effectiveEventDateResolver;
public function __construct(?EffectiveEventDateResolver $effectiveEventDateResolver = null)
{
$this->effectiveEventDateResolver = $effectiveEventDateResolver
?? new EffectiveEventDateResolver;
}
/** Relaciones necesarias para resolver tickets sin consultas N+1. */ /** Relaciones necesarias para resolver tickets sin consultas N+1. */
public const RELATIONS = [ public const RELATIONS = [
'sourceVariant.eventDates.validityTime', 'sourceVariant.eventDates.validityTime',
@@ -56,7 +66,18 @@ class TicketValidityResolver
]); ]);
$dimensions = collect(); $dimensions = collect();
$eventDates = $variant->selectedEventDates(); $selectedEventDates = $variant->selectedEventDates();
$eventDates = $selectedEventDates
->map(fn (EventDate $eventDate): ?EventDate => $this->effectiveEventDateResolver->resolve($eventDate))
->filter()
->unique(fn (EventDate $eventDate): int => $eventDate->getKey() ?? spl_object_id($eventDate))
->values();
if ($selectedEventDates->isNotEmpty() && $eventDates->isEmpty()) {
return ResolvedTicketValidity::unresolvable();
}
$eventDates->each->loadMissing('validityTime');
if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) { if ($eventDates->contains(fn ($eventDate): bool => $eventDate->validityTime === null)) {
return ResolvedTicketValidity::unresolvable(); return ResolvedTicketValidity::unresolvable();

View File

@@ -24,6 +24,33 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia. 3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen. 4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
## Datos descartables para pruebas de carga
En ambientes `local`, `testing`, `staging`, `homo` u `homologation`, el comando siguiente crea tickets
válidos, identidades scanner con tokens Sanctum y un dataset JSON importable por Postman:
```bash
php artisan load-test:tickets:prepare loadtest-evento \
--tickets=40000 \
--scanners=100 \
--owners=1000 \
--catalog-item=123 \
--run=evento-001
```
El tenant debe existir y se recomienda que sea exclusivo para carga. Si no se indica `--catalog-item`,
se usa el primer producto estándar del tenant con tickets habilitados. `--variant`
es opcional; al indicarlo, su configuración de vigencia debe estar activa y ser resoluble. Sin variante,
los tickets tienen vigencia irrestricta.
El archivo se escribe por defecto en `storage/app/private/load-tests/` y contiene tokens secretos, por
lo que no debe versionarse. Para limpiar el tenant después de la ejecución:
```bash
php artisan tenants:reset-transactions loadtest-evento --dry-run
php artisan tenants:reset-transactions loadtest-evento
```
## Endpoints ## Endpoints
Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`: Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:

View File

@@ -9,6 +9,18 @@ Route::prefix('v1/adminapp/tenant')
Route::get('tickets', [TicketController::class, 'index']) Route::get('tickets', [TicketController::class, 'index'])
->middleware('tenant.menu:adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.index'); ->name('adminapp.tickets.index');
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.cancel');
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.calculate-refund');
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
->whereNumber('ticket')
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.refund');
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf']) Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
->middleware('tenant.menu:adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.tickets.pdf'); ->name('adminapp.tickets.pdf');

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