2 Commits

Author SHA1 Message Date
ff9d7728e8 Implement localization for API responses and error messages
- Added localization support for API responses in English and Spanish.
- Introduced a middleware to set the API locale based on the Accept-Language header.
- Updated various exception messages and validation responses to use localized strings.
- Created new language files for English and Spanish translations.
- Refactored existing code to replace hardcoded messages with localized strings.
- Added tests to verify localization functionality and response correctness.
2026-07-28 10:19:39 -03:00
754aeebe3a Squashed commit of the following:
commit c993da3397b65488d919d1b929cbd4638754601b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:54:10 2026 -0300

    feat(tickets): update ticket metadata layout for improved readability and clarity

commit 140fa4676d84bed788894e1d7994b26dccac7a34
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:43:21 2026 -0300

    feat(tickets): enhance PDF ticket download with dynamic filename and improved styling

commit 8875c047b198776e61e8353c99cf4c7b00bc8fc9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Jul 28 09:03:17 2026 -0300

    feat(tickets): add PDF download functionality for tickets

    - Implemented a new endpoint to download tickets as a PDF.
    - Created DownloadTicketsPdfRequest for validating ticket IDs.
    - Added TicketPdfService to handle PDF generation and QR code embedding.
    - Developed a Blade view for rendering ticket details in PDF format.
    - Updated API routes to include the new PDF download route.
    - Added tests to ensure authenticated users can download their tickets and that users cannot download tickets belonging to others.
    - Updated composer.json and composer.lock to include necessary dependencies for PDF generation and QR code creation.
2026-07-28 09:54:49 -03:00
41 changed files with 1736 additions and 110 deletions

View File

@@ -10,8 +10,8 @@ PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440 PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
FRONTEND_URLS=http://localhost:4200 FRONTEND_URLS=http://localhost:4200
APP_LOCALE=en APP_LOCALE=es
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file

View File

@@ -24,7 +24,8 @@ class CreateResetPasswordAttemptController extends Controller
); );
return response()->json([ return response()->json([
'message' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.', 'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING, 'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202); ], 202);
} }

View File

@@ -35,7 +35,8 @@ class GoogleTokenExchangeController extends Controller
); );
$response = response()->json([ $response = response()->json([
'message' => 'Sesion iniciada correctamente.', 'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $authentication['token'], 'token' => $authentication['token'],
'token_type' => 'Bearer', 'token_type' => 'Bearer',
'user' => UserResource::make($authentication['user']), 'user' => UserResource::make($authentication['user']),

View File

@@ -28,7 +28,7 @@ class LoginController extends Controller
if (! $user || ! Hash::check($credentials['password'], $user->password)) { if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => 'Email o Contraseña incorrectos.', 'email' => __('api.auth.invalid_credentials'),
]); ]);
} }
@@ -50,7 +50,8 @@ class LoginController extends Controller
); );
$response = response()->json([ $response = response()->json([
'message' => 'Sesion iniciada correctamente.', 'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token, 'token' => $token,
'token_type' => 'Bearer', 'token_type' => 'Bearer',
'user' => UserResource::make($user), 'user' => UserResource::make($user),

View File

@@ -23,7 +23,8 @@ class LogoutController extends Controller
} }
return response()->json([ return response()->json([
'message' => 'Sesion cerrada correctamente.', 'code' => 'auth.logout_success',
'message' => __('api.auth.logout_success'),
]); ]);
} }
} }

View File

@@ -12,15 +12,17 @@ class RegisterController extends Controller
{ {
public function __construct( public function __construct(
protected RegisterUserService $registerUserService, protected RegisterUserService $registerUserService,
) { ) {}
}
public function __invoke(RegisterUserRequest $request): JsonResponse public function __invoke(RegisterUserRequest $request): JsonResponse
{ {
$user = $this->registerUserService->register($request->validated()); $user = $this->registerUserService->register($request->validated());
return UserResource::make($user) return UserResource::make($user)
->additional(['message' => 'Usuario registrado correctamente.']) ->additional([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->response() ->response()
->setStatusCode(201); ->setStatusCode(201);
} }

View File

@@ -28,12 +28,13 @@ class ResetPasswordController extends Controller
$data['password'], $data['password'],
)) { )) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'codigo' => 'La solicitud de recuperación es inválida o ya fue utilizada.', 'codigo' => __('api.auth.password_reset_invalid'),
]); ]);
} }
return response()->json([ return response()->json([
'message' => 'Contraseña modificada correctamente.', 'code' => 'auth.password_updated',
'message' => __('api.auth.password_updated'),
'status' => ResetPasswordAttempt::STATUS_USED, 'status' => ResetPasswordAttempt::STATUS_USED,
]); ]);
} }

View File

@@ -27,12 +27,13 @@ class ValidateResetPasswordAttemptController extends Controller
$data['codigo'], $data['codigo'],
)) { )) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'codigo' => 'El código ingresado es inválido.', 'codigo' => __('api.auth.reset_code_invalid'),
]); ]);
} }
return response()->json([ return response()->json([
'message' => 'Código validado correctamente.', 'code' => 'auth.reset_code_valid',
'message' => __('api.auth.reset_code_valid'),
'status' => ResetPasswordAttempt::STATUS_VALIDATED, 'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]); ]);
} }

View File

@@ -25,7 +25,7 @@ class GoogleAuthService
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) { if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'tenant' => 'El tenant o la URL de retorno no son validos.', 'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]); ]);
} }
@@ -50,13 +50,13 @@ class GoogleAuthService
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null; $context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) { if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
abort(400, 'La solicitud de autenticacion expiro. Intenta nuevamente.'); abort(400, __('api.auth.request_expired'));
} }
$tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first(); $tenant = Tenant::query()->where('codigo', $context['tenant_codigo'])->first();
if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) { if (! $tenant || ! $this->isTenantReturnUrl($context['return_url'], $tenant)) {
abort(400, 'La URL de retorno no es valida.'); abort(400, __('api.auth.invalid_return_url'));
} }
/** @var SocialiteUser $googleUser */ /** @var SocialiteUser $googleUser */
@@ -88,13 +88,13 @@ class GoogleAuthService
if (! $authentication) { if (! $authentication) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'oauth_code' => 'El codigo de autenticacion expiro o ya fue utilizado.', 'oauth_code' => __('api.auth.oauth_code_expired'),
]); ]);
} }
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) { if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'tenant_codigo' => 'El tenant no coincide con la solicitud de autenticacion.', 'tenant_codigo' => __('api.auth.tenant_mismatch'),
]); ]);
} }
@@ -112,7 +112,7 @@ class GoogleAuthService
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'google' => 'Google no devolvio una identidad valida.', 'google' => __('api.auth.google_email_required'),
]); ]);
} }
@@ -120,7 +120,7 @@ class GoogleAuthService
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false; $emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) { if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'google' => 'La cuenta de Google debe tener el email verificado.', 'google' => __('api.auth.google_email_unverified'),
]); ]);
} }

View File

@@ -36,7 +36,10 @@ class CartController extends Controller
); );
$response = CartResource::make($result['cart']) $response = CartResource::make($result['cart'])
->additional(['message' => 'Producto agregado al carrito.']) ->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response(); ->response();
if ($result['guest_token'] !== null) { if ($result['guest_token'] !== null) {
@@ -58,13 +61,19 @@ class CartController extends Controller
$cartItem->getKey(), $cartItem->getKey(),
(int) $request->validated('cantidad'), (int) $request->validated('cantidad'),
) )
)->additional(['message' => 'Cantidad de producto actualizada.']); )->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
]);
} }
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{ {
return CartResource::make( return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey()) $this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional(['message' => 'Producto eliminado del carrito.']); )->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
} }
} }

View File

@@ -79,7 +79,7 @@ class Cart extends Model
{ {
if ($quantity <= 0) { if ($quantity <= 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.', 'cantidad' => __('api.cart.positive_quantity'),
]); ]);
} }
@@ -91,7 +91,7 @@ class Cart extends Model
if ($availableQuantity !== null && $availableQuantity < $quantity) { if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para el producto solicitado. Maximo disponible: {$availableQuantity}.", 'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]); ]);
} }
@@ -123,7 +123,7 @@ class Cart extends Model
{ {
if ($quantity <= 0) { if ($quantity <= 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.', 'cantidad' => __('api.cart.positive_quantity'),
]); ]);
} }
@@ -146,7 +146,7 @@ class Cart extends Model
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) { if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad; $maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.", 'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]); ]);
} }
@@ -209,13 +209,13 @@ class Cart extends Model
if ($catalogItem->isBundle()) { if ($catalogItem->isBundle()) {
if ($variantId !== null) { if ($variantId !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'variant_id' => 'Un bundle no admite una variante.', 'variant_id' => __('api.cart.bundle_variant_forbidden'),
]); ]);
} }
if (! $catalogItem->bundleComponents()->exists()) { if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'catalog_item_id' => 'El bundle no tiene componentes.', 'catalog_item_id' => __('api.cart.empty_bundle'),
]); ]);
} }
@@ -225,7 +225,7 @@ class Cart extends Model
if ($variantId === null) { if ($variantId === null) {
if ($catalogItem->inventory_id === null) { if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'variant_id' => 'Debe seleccionar una variante para este ítem.', 'variant_id' => __('api.cart.variant_required'),
]); ]);
} }

View File

@@ -45,7 +45,7 @@ class CatalogService
} else { } else {
if (array_key_exists('components', $data)) { if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'components' => ['Un item standard no puede tener componentes.'], 'components' => [__('api.catalog.standard_with_components')],
]); ]);
} }
@@ -265,7 +265,7 @@ class CatalogService
if (isset($seen[$key])) { if (isset($seen[$key])) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}" => ['El componente esta duplicado.'], "components.{$index}" => [__('api.catalog.duplicate_component')],
]); ]);
} }
$seen[$key] = true; $seen[$key] = true;
@@ -278,7 +278,7 @@ class CatalogService
if ($componentItem === null) { if ($componentItem === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [ "components.{$index}.catalog_item_id" => [
'El item no pertenece al tenant del bundle.', __('api.catalog.component_wrong_tenant'),
], ],
]); ]);
} }
@@ -286,7 +286,7 @@ class CatalogService
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) { if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [ "components.{$index}.catalog_item_id" => [
'El componente debe ser un item standard distinto del bundle.', __('api.catalog.invalid_component'),
], ],
]); ]);
} }
@@ -295,7 +295,7 @@ class CatalogService
if ($hasVariants && $variantId === null) { if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.variant_id" => [ "components.{$index}.variant_id" => [
'Debe seleccionar una variante para este componente.', __('api.catalog.component_variant_required'),
], ],
]); ]);
} }
@@ -303,7 +303,7 @@ class CatalogService
if (! $hasVariants && $variantId !== null) { if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.variant_id" => [ "components.{$index}.variant_id" => [
'El componente con inventario directo no admite una variante.', __('api.catalog.component_variant_forbidden'),
], ],
]); ]);
} }
@@ -311,7 +311,7 @@ class CatalogService
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) { if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"components.{$index}.variant_id" => [ "components.{$index}.variant_id" => [
'La variante no pertenece al componente indicado.', __('api.catalog.component_variant_invalid'),
], ],
]); ]);
} }
@@ -333,7 +333,7 @@ class CatalogService
{ {
if ($components === []) { if ($components === []) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'components' => ['Un bundle debe tener al menos un componente.'], 'components' => [__('api.catalog.bundle_component_required')],
]); ]);
} }
@@ -348,7 +348,7 @@ class CatalogService
] as $field) { ] as $field) {
if (array_key_exists($field, $data)) { if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
$field => ["{$field} no se admite para un bundle."], $field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
]); ]);
} }
} }
@@ -376,7 +376,7 @@ class CatalogService
if ($attachment === null) { if ($attachment === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
$validationKey => ['El attachment indicado no existe.'], $validationKey => [__('api.catalog.attachment_not_found')],
]); ]);
} }
@@ -408,7 +408,7 @@ class CatalogService
if ($attribute === null) { if ($attribute === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'attribute_codes' => [ 'attribute_codes' => [
"El atributo {$attributeCode} no existe para el tenant del ítem.", __('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
], ],
]); ]);
} }
@@ -442,7 +442,7 @@ class CatalogService
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"variants.{$index}.inventory" => [ "variants.{$index}.inventory" => [
'inventory_id, reserved_stock y sold_units son administrados internamente.', __('api.catalog.managed_inventory_fields'),
], ],
]); ]);
} }
@@ -463,7 +463,7 @@ class CatalogService
if ($itemAttribute === null) { if ($itemAttribute === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [ "variants.{$index}.values.{$attributeCode}" => [
'El atributo no pertenece al ítem de catálogo.', __('api.catalog.attribute_not_on_item'),
], ],
]); ]);
} }
@@ -489,7 +489,7 @@ class CatalogService
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [ "variants.{$index}.maximum_use_date" => [
'La fecha maxima efectiva debe ser posterior o igual a la fecha minima efectiva.', __('api.catalog.invalid_effective_date_range'),
], ],
]); ]);
} }
@@ -508,7 +508,7 @@ class CatalogService
if ($hasVariants && $variants === []) { if ($hasVariants && $variants === []) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'variants' => [ 'variants' => [
'Un ítem con attribute_codes debe tener variantes.', __('api.catalog.variants_required'),
], ],
]); ]);
} }
@@ -516,7 +516,7 @@ class CatalogService
if (! $hasVariants && $variants !== []) { if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'variants' => [ 'variants' => [
'Un ítem sin attribute_codes no puede tener variantes.', __('api.catalog.variants_forbidden'),
], ],
]); ]);
} }
@@ -524,7 +524,7 @@ class CatalogService
if ($hasVariants && $hasDirectStock) { if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'real_stock' => [ 'real_stock' => [
'Un ítem con variantes no puede tener inventario directo.', __('api.catalog.direct_inventory_forbidden'),
], ],
]); ]);
} }
@@ -536,7 +536,7 @@ class CatalogService
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'inventory' => [ 'inventory' => [
'inventory_id, reserved_stock y sold_units son administrados internamente.', __('api.catalog.managed_inventory_fields'),
], ],
]); ]);
} }

View File

@@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService; use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class TelepagosWebhookController extends Controller class TelepagosWebhookController extends Controller
{ {
/** /**
* Handle the incoming Telepagos webhook. * Handle the incoming Telepagos webhook.
*
* @param TelepagosWebhookRequest $request
* @param string $tenantCodigo
* @param TelepagosWebhookService $service
* @return JsonResponse
*/ */
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
{ {
try { try {
$cashinId = $request->validated('id'); $cashinId = $request->validated('id');
$service->handleWebhook($tenantCodigo, $cashinId); $service->handleWebhook($tenantCodigo, $cashinId);
return response()->json(['status' => 'success']); return response()->json(['status' => 'success']);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 500); return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
} }
} }
} }

View File

@@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller
public function show(string $tenantCode, string $integrationCode) public function show(string $tenantCode, string $integrationCode)
{ {
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode); $integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (!$integration) { if (! $integration) {
return response()->json(['message' => 'Integration not configured for this tenant'], 404); return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
} }
return response()->json($integration); return response()->json($integration);
@@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode) public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{ {
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail(); $integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
try { try {
$this->tenantIntegrationService->updateOrCreateIntegration( $this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode, $tenantCode,
@@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller
); );
return response()->json([ return response()->json([
'message' => 'integration configured correctly', 'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'message' => 'Error validando la configuración: ' . $e->getMessage() 'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
], 400); ], 400);
} }
} }

View File

@@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest
$integrationCode = $this->route('integration_code'); $integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first(); $this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
if (!$this->integrationModel) { if (! $this->integrationModel) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'integration_code' => 'Integration not found.' 'integration_code' => __('api.integration.not_configured'),
]); ]);
} }
} }
@@ -34,7 +34,7 @@ class StoreTenantIntegrationRequest extends FormRequest
// Dynamic validation rules based on the integration data schema // Dynamic validation rules based on the integration data schema
if ($this->integrationModel && $this->integrationModel->integration_data_schema) { if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
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

@@ -24,7 +24,8 @@ class MailTestService
); );
return [ return [
'message' => 'Correo de prueba enviado correctamente.', 'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient, 'recipient' => $recipient,
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(), 'mailer' => $mailService->mailerName(),

View File

@@ -48,7 +48,7 @@ class Menu extends Model
&& empty($menu->static_content_schema) && empty($menu->static_content_schema)
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'static_content_schema' => 'El schema es obligatorio para los menús estáticos.', 'static_content_schema' => __('api.menu.schema_required'),
]); ]);
} }
}); });

View File

@@ -23,7 +23,7 @@ class StoreTenantMenuRequest extends FormRequest
if (! $this->menuModel) { if (! $this->menuModel) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'menu_code' => 'El menú indicado no existe.', 'menu_code' => __('api.menu.not_found'),
]); ]);
} }
} }

View File

@@ -135,7 +135,7 @@ class PurchaseController extends Controller
if ($updated === 0) { if ($updated === 0) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer available for payment.', 'purchase' => __('api.purchase.not_available_for_payment'),
]); ]);
} }
@@ -159,8 +159,14 @@ class PurchaseController extends Controller
], ],
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Unable to retrieve TelePagos account information.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([ return response()->json([
'message' => 'Error getting account info: '.$e->getMessage(), 'code' => 'integration.account_info_failed',
'message' => __('api.integration.account_info_failed'),
], 500); ], 500);
} }
} }
@@ -182,8 +188,14 @@ class PurchaseController extends Controller
'qr_code' => $qrResponse['qr_code'] ?? '', 'qr_code' => $qrResponse['qr_code'] ?? '',
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Unable to generate TelePagos QR code.', [
'purchase_id' => $compra->id,
'exception' => $e,
]);
return response()->json([ return response()->json([
'message' => 'Error generating QR: '.$e->getMessage(), 'code' => 'integration.qr_generation_failed',
'message' => __('api.integration.qr_generation_failed'),
], 500); ], 500);
} }
@@ -197,7 +209,8 @@ class PurchaseController extends Controller
} }
return response()->json([ return response()->json([
'message' => 'Invalid payment method.', 'code' => 'integration.invalid_payment_method',
'message' => __('api.integration.invalid_payment_method'),
], 400); ], 400);
} }
@@ -222,7 +235,7 @@ class PurchaseController extends Controller
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{ {
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) { if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
throw new NotFoundHttpException('Purchase not found for tenant.'); throw new NotFoundHttpException(__('api.errors.not_found'));
} }
return $purchase; return $purchase;

View File

@@ -41,7 +41,7 @@ class CheckoutService
if ($cartId === null) { if ($cartId === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'A cart or direct item is required.', 'cart_id' => __('api.purchase.source_required'),
]); ]);
} }
@@ -64,7 +64,7 @@ class CheckoutService
if ($purchase->payment_method === null) { if ($purchase->payment_method === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'payment_method' => 'The purchase payment method must be selected before finalizing.', 'payment_method' => __('api.purchase.payment_method_required'),
]); ]);
} }
@@ -102,7 +102,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast()) || ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.', 'purchase' => __('api.purchase.not_editable'),
]); ]);
} }
@@ -128,7 +128,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast()) || ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.', 'purchase' => __('api.purchase.not_editable'),
]); ]);
} }
@@ -144,7 +144,7 @@ class CheckoutService
if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) { if ($purchaseItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'item' => 'The purchase item is no longer editable.', 'item' => __('api.purchase.item_not_editable'),
]); ]);
} }
@@ -162,7 +162,7 @@ class CheckoutService
} }
} catch (\InvalidArgumentException $exception) { } catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'quantity' => 'No hay suficiente stock disponible.', 'quantity' => __('api.purchase.insufficient_stock'),
]); ]);
} }
@@ -197,7 +197,7 @@ class CheckoutService
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast()) || ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) { ) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => 'The purchase is no longer editable.', 'purchase' => __('api.purchase.not_editable'),
]); ]);
} }
@@ -233,7 +233,7 @@ class CheckoutService
Purchase::STATUS_EXPIRED, Purchase::STATUS_EXPIRED,
], true)) { ], true)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => 'A cancelled, rejected or expired purchase cannot be confirmed.', 'purchase' => __('api.purchase.cannot_confirm'),
]); ]);
} }
@@ -249,7 +249,7 @@ class CheckoutService
$this->catalogInventoryService->commit($selection, (int) $item->cantidad); $this->catalogInventoryService->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) { } catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'items' => 'The purchase has an inconsistent stock reservation.', 'items' => __('api.purchase.inconsistent_reservation'),
]); ]);
} }
@@ -306,7 +306,7 @@ class CheckoutService
} }
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => 'A paid purchase cannot be cancelled.', 'purchase' => __('api.purchase.paid_cannot_cancel'),
]); ]);
} }
@@ -339,7 +339,7 @@ class CheckoutService
$this->catalogInventoryService->release($selection, (int) $item->cantidad); $this->catalogInventoryService->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException $exception) { } catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'items' => 'The purchase has an inconsistent stock reservation.', 'items' => __('api.purchase.inconsistent_reservation'),
]); ]);
} }
} }
@@ -375,7 +375,7 @@ class CheckoutService
if ($availableQuantity !== null && $availableQuantity < $quantity) { if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'direct_item.cantidad' => "Stock insuficiente. Maximo disponible: {$availableQuantity}.", 'direct_item.cantidad' => __('api.purchase.direct_item_max_stock', ['max' => $availableQuantity]),
]); ]);
} }
@@ -383,7 +383,7 @@ class CheckoutService
$this->catalogInventoryService->reserve($selection, $quantity); $this->catalogInventoryService->reserve($selection, $quantity);
} catch (\InvalidArgumentException $exception) { } catch (\InvalidArgumentException $exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'direct_item.cantidad' => 'No hay suficiente stock disponible.', 'direct_item.cantidad' => __('api.purchase.insufficient_stock'),
]); ]);
} }
@@ -416,7 +416,7 @@ class CheckoutService
if ($cartItems->isEmpty()) { if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'The selected cart does not contain items.', 'cart_id' => __('api.purchase.empty_cart'),
]); ]);
} }
@@ -596,13 +596,13 @@ class CheckoutService
foreach ($cartItems as $item) { foreach ($cartItems as $item) {
if ($item->selectedItem() === null) { if ($item->selectedItem() === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items could not be loaded.', 'cart_id' => __('api.purchase.catalog_item_missing'),
]); ]);
} }
if ($item->catalogItem?->tenant_code !== $tenant->codigo) { if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'One or more catalog items do not belong to the tenant.', 'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]); ]);
} }
} }
@@ -621,7 +621,7 @@ class CheckoutService
if ($cart->status !== 'active') { if ($cart->status !== 'active') {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'cart_id' => 'The selected cart is no longer active.', 'cart_id' => __('api.purchase.inactive_cart'),
]); ]);
} }
@@ -682,13 +682,13 @@ class CheckoutService
if ($catalogItem->isBundle()) { if ($catalogItem->isBundle()) {
if ($variantId !== null) { if ($variantId !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'direct_item.variant_id' => 'Un bundle no admite una variante.', 'direct_item.variant_id' => __('api.cart.bundle_variant_forbidden'),
]); ]);
} }
if (! $catalogItem->bundleComponents()->exists()) { if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'direct_item.catalog_item_id' => 'El bundle no tiene componentes.', 'direct_item.catalog_item_id' => __('api.cart.empty_bundle'),
]); ]);
} }
@@ -698,7 +698,7 @@ class CheckoutService
if ($variantId === null) { if ($variantId === null) {
if ($catalogItem->inventory_id === null) { if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'direct_item.variant_id' => 'Debe seleccionar una variante para este item.', 'direct_item.variant_id' => __('api.cart.variant_required'),
]); ]);
} }

View File

@@ -225,7 +225,7 @@ class TenantService
if ($attachment === null) { if ($attachment === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
"main_carousel_images.{$order}" => [ "main_carousel_images.{$order}" => [
'La imagen indicada no existe o no es un attachment de tipo imagen.', __('api.tenant.invalid_carousel_image'),
], ],
]); ]);
} }

View File

@@ -3,14 +3,20 @@
namespace App\Domains\Ticket\Controllers; namespace App\Domains\Ticket\Controllers;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest;
use App\Domains\Ticket\Resources\TicketResource; use App\Domains\Ticket\Resources\TicketResource;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
class TicketController extends Controller class TicketController extends Controller
{ {
public function __construct(private readonly TicketPdfService $ticketPdfService) {}
public function index(Request $request, Tenant $tenant): JsonResponse public function index(Request $request, Tenant $tenant): JsonResponse
{ {
$tickets = Ticket::query() $tickets = Ticket::query()
@@ -21,4 +27,21 @@ class TicketController extends Controller
return TicketResource::collection($tickets)->response(); return TicketResource::collection($tickets)->response();
} }
public function downloadPdf(DownloadTicketsPdfRequest $request, Tenant $tenant): Response
{
$ticketIds = $request->validated('ticket_ids');
$tickets = Ticket::query()
->where('tenant_code', $tenant->codigo)
->where('user_id', $request->user()->getKey())
->whereIn('id', $ticketIds)
->orderByDesc('id')
->get();
if ($tickets->count() !== count($ticketIds)) {
throw new TicketNotAvailableException(__('api.ticket.not_available'));
}
return $this->ticketPdfService->download($tenant, $tickets);
}
} }

View File

@@ -11,22 +11,22 @@ class TicketGenerationException extends RuntimeException
{ {
public static function invalidQuantity(): self public static function invalidQuantity(): self
{ {
return new self('La cantidad de tickets a generar debe ser mayor a cero.'); return new self(__('api.ticket.positive_quantity'));
} }
public static function emptyBundle(CatalogItem $bundle): self public static function emptyBundle(CatalogItem $bundle): self
{ {
return new self("El bundle {$bundle->id} no tiene componentes."); return new self(__('api.ticket.empty_bundle', ['bundle' => $bundle->id]));
} }
public static function ticketsDisabled(CatalogItem $catalogItem): self public static function ticketsDisabled(CatalogItem $catalogItem): self
{ {
return new self("El producto {$catalogItem->id} no tiene tickets habilitados."); return new self(__('api.ticket.disabled', ['product' => $catalogItem->id]));
} }
public static function maximumUseDateReached(CatalogItem $catalogItem): self public static function maximumUseDateReached(CatalogItem $catalogItem): self
{ {
return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso."); return new self(__('api.ticket.expired', ['product' => $catalogItem->id]));
} }
public static function variantNotFound( public static function variantNotFound(
@@ -34,17 +34,20 @@ class TicketGenerationException extends RuntimeException
int $variantId, int $variantId,
): self { ): self {
return new self( return new self(
"La variante {$variantId} no pertenece al producto {$catalogItem->id}.", __('api.ticket.invalid_variant', [
'variant' => $variantId,
'product' => $catalogItem->id,
]),
); );
} }
public static function purchaseWithoutUser(Purchase $purchase): self public static function purchaseWithoutUser(Purchase $purchase): self
{ {
return new self("La compra {$purchase->id} no tiene un usuario asociado."); return new self(__('api.ticket.purchase_without_user', ['purchase' => $purchase->id]));
} }
public static function catalogItemNotFound(PurchaseItem $purchaseItem): self public static function catalogItemNotFound(PurchaseItem $purchaseItem): self
{ {
return new self("No se encontró el producto de la línea de compra {$purchaseItem->id}."); return new self(__('api.ticket.product_not_found', ['purchase_item' => $purchaseItem->id]));
} }
} }

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Domains\Ticket\Exceptions;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class TicketNotAvailableException extends NotFoundHttpException {}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Ticket\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DownloadTicketsPdfRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'ticket_ids' => ['required', 'array', 'min:1', 'max:25'],
'ticket_ids.*' => ['required', 'integer', 'distinct'],
];
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Barryvdh\DomPDF\Facade\Pdf;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Throwable;
class TicketPdfService
{
/**
* @param Collection<int, Ticket> $tickets
*/
public function download(Tenant $tenant, Collection $tickets): Response
{
$tenant->loadMissing('headerLogo');
$primaryColor = $this->color($tenant->primary_color, '#009933');
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
$pdf = Pdf::loadView('pdf.tickets', [
'tenant' => $tenant,
'tickets' => $tickets,
'logoDataUri' => $this->logoDataUri($tenant),
'primaryColor' => $primaryColor,
'headerBackgroundColor' => $headerBackgroundColor,
'headerTextColor' => $this->contrastingTextColor($headerBackgroundColor),
'qrCodes' => $tickets->mapWithKeys(
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
),
])->setPaper('a4');
$ticketIds = $tickets->pluck('id')->implode('_');
return $pdf->download("tickets_{$ticketIds}.pdf");
}
private function qrCodeDataUri(string $value): string
{
$qrCode = new QrCode(
data: $value,
errorCorrectionLevel: ErrorCorrectionLevel::Medium,
size: 700,
margin: 10,
);
return (new PngWriter)->write($qrCode)->getDataUri();
}
private function logoDataUri(Tenant $tenant): ?string
{
$logo = $tenant->headerLogo;
if ($logo === null) {
return null;
}
try {
$contents = Storage::disk('s3')->get($logo->path);
} catch (Throwable) {
return null;
}
return 'data:'.($logo->mime_type ?: 'image/png').';base64,'.base64_encode($contents);
}
private function color(?string $color, string $fallback): string
{
return is_string($color) && preg_match('/^#[0-9A-Fa-f]{6}$/', $color)
? $color
: $fallback;
}
private function contrastingTextColor(string $backgroundColor): string
{
$red = hexdec(substr($backgroundColor, 1, 2));
$green = hexdec(substr($backgroundColor, 3, 2));
$blue = hexdec(substr($backgroundColor, 5, 2));
$luminance = ($red * 299 + $green * 587 + $blue * 114) / 1000;
return $luminance > 160 ? '#17211b' : '#ffffff';
}
}

View File

@@ -7,4 +7,5 @@ Route::prefix('tenants/{tenant:codigo}')
->middleware('auth:sanctum') ->middleware('auth:sanctum')
->group(function (): void { ->group(function (): void {
Route::get('tickets', [TicketController::class, 'index']); Route::get('tickets', [TicketController::class, 'index']);
Route::post('tickets/pdf', [TicketController::class, 'downloadPdf']);
}); });

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetApiLocale
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->is('api/*')) {
return $next($request);
}
$supportedLocales = config('app.supported_locales', ['es', 'en']);
$acceptLanguage = $request->header('Accept-Language');
$locale = is_string($acceptLanguage) && $acceptLanguage !== ''
? $request->getPreferredLanguage($supportedLocales)
: null;
$locale ??= config('app.locale', 'es');
App::setLocale($locale);
$response = $next($request);
$response->headers->set('Content-Language', $locale);
$response->setVary('Accept-Language', false);
return $response;
}
}

View File

@@ -1,9 +1,16 @@
<?php <?php
use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Http\Middleware\SetApiLocale;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -16,9 +23,70 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->encryptCookies(except: [ $middleware->encryptCookies(except: [
'guest_token', 'guest_token',
]); ]);
$middleware->prepend(SetApiLocale::class);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen( $exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'), fn (Request $request) => $request->is('api/*'),
); );
$exceptions->render(function (AuthenticationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'auth.unauthenticated',
'message' => __('api.auth.unauthenticated'),
], 401);
});
$exceptions->render(function (AuthorizationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.forbidden',
'message' => __('api.errors.forbidden'),
], 403);
});
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.not_found',
'message' => __('api.errors.not_found'),
], 404);
});
$exceptions->render(function (TicketNotAvailableException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'ticket.not_available',
'message' => __('api.ticket.not_available'),
], 404);
});
$exceptions->render(function (NotFoundHttpException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.not_found',
'message' => __('api.errors.not_found'),
], 404);
});
$exceptions->render(function (MethodNotAllowedHttpException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'errors.method_not_allowed',
'message' => __('api.errors.method_not_allowed'),
], 405, $exception->getHeaders());
});
})->create(); })->create();

View File

@@ -7,6 +7,8 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.3", "php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"endroid/qr-code": "^6.1",
"laravel/framework": "^13.8", "laravel/framework": "^13.8",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/socialite": "^5.29", "laravel/socialite": "^5.29",

701
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "930f49a939d8b56a337d2fca9c48ad86", "content-hash": "aa7e98d017dd610946c62579b20c501f",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@@ -157,6 +157,138 @@
}, },
"time": "2026-07-23T18:06:54+00:00" "time": "2026-07-23T18:06:54+00:00"
}, },
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
"dasprid/enum": "^1.0.3",
"ext-iconv": "*",
"php": "^8.1"
},
"require-dev": {
"phly/keep-a-changelog": "^2.12",
"phpunit/phpunit": "^10.5.11 || ^11.0.4",
"spatie/phpunit-snapshot-assertions": "^5.1.5",
"spatie/pixelmatch-php": "^1.2.0",
"squizlabs/php_codesniffer": "^3.9"
},
"suggest": {
"ext-imagick": "to generate QR code images"
},
"type": "library",
"autoload": {
"psr-4": {
"BaconQrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "BaconQrCode is a QR code generator for PHP.",
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
"source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
"time": "2026-04-05T21:06:35+00:00"
},
{
"name": "barryvdh/laravel-dompdf",
"version": "v3.1.2",
"source": {
"type": "git",
"url": "https://github.com/barryvdh/laravel-dompdf.git",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/barryvdh/laravel-dompdf/zipball/ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"reference": "ee3b72b19ccdf57d0243116ecb2b90261344dedc",
"shasum": ""
},
"require": {
"dompdf/dompdf": "^3.0",
"illuminate/support": "^9|^10|^11|^12|^13.0",
"php": "^8.1"
},
"require-dev": {
"larastan/larastan": "^2.7|^3.0",
"orchestra/testbench": "^7|^8|^9.16|^10|^11.0",
"phpro/grumphp": "^2.5",
"squizlabs/php_codesniffer": "^3.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PDF": "Barryvdh\\DomPDF\\Facade\\Pdf",
"Pdf": "Barryvdh\\DomPDF\\Facade\\Pdf"
},
"providers": [
"Barryvdh\\DomPDF\\ServiceProvider"
]
},
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Barryvdh\\DomPDF\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Barry vd. Heuvel",
"email": "barryvdh@gmail.com"
}
],
"description": "A DOMPDF Wrapper for Laravel",
"keywords": [
"dompdf",
"laravel",
"pdf"
],
"support": {
"issues": "https://github.com/barryvdh/laravel-dompdf/issues",
"source": "https://github.com/barryvdh/laravel-dompdf/tree/v3.1.2"
},
"funding": [
{
"url": "https://fruitcake.nl",
"type": "custom"
},
{
"url": "https://github.com/barryvdh",
"type": "github"
}
],
"time": "2026-02-21T08:51:10+00:00"
},
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.18.0", "version": "0.18.0",
@@ -285,6 +417,56 @@
], ],
"time": "2024-02-09T16:56:22+00:00" "time": "2024-02-09T16:56:22+00:00"
}, },
{
"name": "dasprid/enum",
"version": "1.0.7",
"source": {
"type": "git",
"url": "https://github.com/DASPRiD/Enum.git",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
"shasum": ""
},
"require": {
"php": ">=7.1 <9.0"
},
"require-dev": {
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "*"
},
"type": "library",
"autoload": {
"psr-4": {
"DASPRiD\\Enum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Ben Scholzen 'DASPRiD'",
"email": "mail@dasprids.de",
"homepage": "https://dasprids.de/",
"role": "Developer"
}
],
"description": "PHP 7.1 enum implementation",
"keywords": [
"enum",
"map"
],
"support": {
"issues": "https://github.com/DASPRiD/Enum/issues",
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
},
"time": "2025-09-16T12:23:56+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@@ -527,6 +709,161 @@
], ],
"time": "2024-02-05T11:56:58+00:00" "time": "2024-02-05T11:56:58+00:00"
}, },
{
"name": "dompdf/dompdf",
"version": "v3.1.6",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005",
"reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005",
"shasum": ""
},
"require": {
"dompdf/php-font-lib": "^1.0.0",
"dompdf/php-svg-lib": "^1.0.0",
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-gd": "*",
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11",
"squizlabs/php_codesniffer": "^3.5",
"symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v3.1.6"
},
"time": "2026-07-20T12:29:38+00:00"
},
{
"name": "dompdf/php-font-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "The FontLib Community",
"homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/dompdf/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/1.0.2"
},
"time": "2026-01-20T14:10:26+00:00"
},
{
"name": "dompdf/php-svg-lib",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4 || ^9.0"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "The SvgLib Community",
"homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/dompdf/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2"
},
"time": "2026-01-02T16:01:13+00:00"
},
{ {
"name": "dragonmantank/cron-expression", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.6.0",
@@ -658,6 +995,78 @@
], ],
"time": "2025-03-06T22:45:56+00:00" "time": "2025-03-06T22:45:56+00:00"
}, },
{
"name": "endroid/qr-code",
"version": "6.1.3",
"source": {
"type": "git",
"url": "https://github.com/endroid/qr-code.git",
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/endroid/qr-code/zipball/5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
"reference": "5fa534856ed95649d67c0eab0cabc03ab1d8e0e2",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"php": "^8.4"
},
"require-dev": {
"endroid/quality": "dev-main",
"ext-gd": "*",
"khanamiryan/qrcode-detector-decoder": "^2.0.3",
"setasign/fpdf": "^1.8.2"
},
"suggest": {
"ext-gd": "Enables you to write PNG images",
"khanamiryan/qrcode-detector-decoder": "Enables you to use the image validator",
"roave/security-advisories": "Makes sure package versions with known security issues are not installed",
"setasign/fpdf": "Enables you to use the PDF writer"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "6.x-dev"
}
},
"autoload": {
"psr-4": {
"Endroid\\QrCode\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeroen van den Enden",
"email": "info@endroid.nl"
}
],
"description": "Endroid QR Code",
"homepage": "https://github.com/endroid/qr-code",
"keywords": [
"code",
"endroid",
"php",
"qr",
"qrcode"
],
"support": {
"issues": "https://github.com/endroid/qr-code/issues",
"source": "https://github.com/endroid/qr-code/tree/6.1.3"
},
"funding": [
{
"url": "https://github.com/endroid",
"type": "github"
}
],
"time": "2026-02-05T07:01:58+00:00"
},
{ {
"name": "firebase/php-jwt", "name": "firebase/php-jwt",
"version": "v7.1.0", "version": "v7.1.0",
@@ -2515,6 +2924,73 @@
], ],
"time": "2026-03-08T20:05:35+00:00" "time": "2026-03-08T20:05:35+00:00"
}, },
{
"name": "masterminds/html5",
"version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fd5018f6815fff903946d0564977b44ce8010e29",
"reference": "fd5018f6815fff903946d0564977b44ce8010e29",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9 || ^10"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.1"
},
"time": "2026-06-23T18:43:15+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -4084,6 +4560,86 @@
}, },
"time": "2026-06-18T03:57:49+00:00" "time": "2026-06-18T03:57:49+00:00"
}, },
{
"name": "sabberworm/php-css-parser",
"version": "v9.4.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "1.4.0",
"phpstan/extension-installer": "1.4.3",
"phpstan/phpstan": "1.12.33 || 2.2.2",
"phpstan/phpstan-phpunit": "1.4.2 || 2.0.16",
"phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11",
"phpunit/phpunit": "8.5.52",
"rawr/phpunit-data-provider": "3.3.1",
"rector/rector": "1.2.10 || 2.4.6",
"rector/type-perfect": "1.0.0 || 2.1.3",
"squizlabs/php_codesniffer": "4.0.1",
"thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.5.x-dev"
}
},
"autoload": {
"files": [
"src/Rule/Rule.php",
"src/RuleSet/RuleContainer.php"
],
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0"
},
"time": "2026-06-18T15:10:53+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v8.1.0", "version": "v8.1.0",
@@ -6634,6 +7190,149 @@
], ],
"time": "2026-06-09T10:54:51+00:00" "time": "2026-06-09T10:54:51+00:00"
}, },
{
"name": "thecodingmachine/safe",
"version": "v3.4.0",
"source": {
"type": "git",
"url": "https://github.com/thecodingmachine/safe.git",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19",
"reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10",
"squizlabs/php_codesniffer": "^3.2"
},
"type": "library",
"autoload": {
"files": [
"lib/special_cases.php",
"generated/apache.php",
"generated/apcu.php",
"generated/array.php",
"generated/bzip2.php",
"generated/calendar.php",
"generated/classobj.php",
"generated/com.php",
"generated/cubrid.php",
"generated/curl.php",
"generated/datetime.php",
"generated/dir.php",
"generated/eio.php",
"generated/errorfunc.php",
"generated/exec.php",
"generated/fileinfo.php",
"generated/filesystem.php",
"generated/filter.php",
"generated/fpm.php",
"generated/ftp.php",
"generated/funchand.php",
"generated/gettext.php",
"generated/gmp.php",
"generated/gnupg.php",
"generated/hash.php",
"generated/ibase.php",
"generated/ibmDb2.php",
"generated/iconv.php",
"generated/image.php",
"generated/imap.php",
"generated/info.php",
"generated/inotify.php",
"generated/json.php",
"generated/ldap.php",
"generated/libxml.php",
"generated/lzf.php",
"generated/mailparse.php",
"generated/mbstring.php",
"generated/misc.php",
"generated/mysql.php",
"generated/mysqli.php",
"generated/network.php",
"generated/oci8.php",
"generated/opcache.php",
"generated/openssl.php",
"generated/outcontrol.php",
"generated/pcntl.php",
"generated/pcre.php",
"generated/pgsql.php",
"generated/posix.php",
"generated/ps.php",
"generated/pspell.php",
"generated/readline.php",
"generated/rnp.php",
"generated/rpminfo.php",
"generated/rrd.php",
"generated/sem.php",
"generated/session.php",
"generated/shmop.php",
"generated/sockets.php",
"generated/sodium.php",
"generated/solr.php",
"generated/spl.php",
"generated/sqlsrv.php",
"generated/ssdeep.php",
"generated/ssh2.php",
"generated/stream.php",
"generated/strings.php",
"generated/swoole.php",
"generated/uodbc.php",
"generated/uopz.php",
"generated/url.php",
"generated/var.php",
"generated/xdiff.php",
"generated/xml.php",
"generated/xmlrpc.php",
"generated/yaml.php",
"generated/yaz.php",
"generated/zip.php",
"generated/zlib.php"
],
"classmap": [
"lib/DateTime.php",
"lib/DateTimeImmutable.php",
"lib/Exceptions/",
"generated/Exceptions/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHP core functions that throw exceptions instead of returning FALSE on error",
"support": {
"issues": "https://github.com/thecodingmachine/safe/issues",
"source": "https://github.com/thecodingmachine/safe/tree/v3.4.0"
},
"funding": [
{
"url": "https://github.com/OskarStark",
"type": "github"
},
{
"url": "https://github.com/shish",
"type": "github"
},
{
"url": "https://github.com/silasjoisten",
"type": "github"
},
{
"url": "https://github.com/staabm",
"type": "github"
}
],
"time": "2026-02-04T18:08:13+00:00"
},
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.4.0", "version": "v2.4.0",

View File

@@ -78,9 +78,11 @@ return [
| |
*/ */
'locale' => env('APP_LOCALE', 'en'), 'locale' => env('APP_LOCALE', 'es'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'es'),
'supported_locales' => ['es', 'en'],
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),

104
lang/en/api.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
return [
'auth' => [
'unauthenticated' => 'Unauthenticated.',
'invalid_credentials' => 'Email or password is incorrect.',
'login_success' => 'Signed in successfully.',
'logout_success' => 'Signed out successfully.',
'register_success' => 'User registered successfully.',
'password_reset_requested' => 'If the email is registered, you will receive a code to reset your password.',
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.',
'reset_code_invalid' => 'The code you entered is invalid.',
'reset_code_valid' => 'Code validated successfully.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.',
'invalid_return_url' => 'The return URL is invalid.',
'oauth_code_expired' => 'The authentication code expired or has already been used.',
'tenant_mismatch' => 'The tenant does not match the authentication request.',
'google_email_required' => 'Google did not return an email for the account.',
'google_email_unverified' => 'The Google account must have a verified email.',
],
'cart' => [
'item_added' => 'Product added to cart.',
'quantity_updated' => 'Product quantity updated.',
'item_removed' => 'Product removed from cart.',
'positive_quantity' => 'The quantity must be greater than zero.',
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
'max_quantity' => 'You can add a maximum of :max.',
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.',
'variant_required' => 'You must select a variant for this item.',
],
'purchase' => [
'source_required' => 'A cart or direct item is required.',
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
'not_editable' => 'The purchase is no longer editable.',
'item_not_editable' => 'The purchase item is no longer editable.',
'insufficient_stock' => 'There is not enough stock available.',
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
'direct_item_max_stock' => 'There is not enough stock. Maximum available: :max.',
'empty_cart' => 'The selected cart does not contain items.',
'catalog_item_missing' => 'One or more catalog items could not be loaded.',
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
'inactive_cart' => 'The selected cart is no longer active.',
'not_available_for_payment' => 'The purchase is no longer available for payment.',
],
'ticket' => [
'not_available' => 'One or more tickets are not available.',
'positive_quantity' => 'The number of tickets to generate must be greater than zero.',
'empty_bundle' => 'Bundle :bundle has no components.',
'disabled' => 'Product :product does not have tickets enabled.',
'expired' => 'Product :product has reached its maximum usage date.',
'invalid_variant' => 'Variant :variant does not belong to product :product.',
'purchase_without_user' => 'Purchase :purchase does not have an associated user.',
'product_not_found' => 'The product for purchase item :purchase_item was not found.',
],
'integration' => [
'not_configured' => 'The integration is not configured for this tenant.',
'configured' => 'Integration configured successfully.',
'validation_failed' => 'Configuration validation failed: :error',
'invalid_payment_method' => 'Invalid payment method.',
'account_info_failed' => 'Could not retrieve account information.',
'qr_generation_failed' => 'Could not generate the QR code.',
'webhook_failed' => 'The webhook could not be processed.',
],
'mail' => [
'test_sent' => 'Test email sent successfully.',
],
'catalog' => [
'standard_with_components' => 'A standard item cannot have components.',
'duplicate_component' => 'The component is duplicated.',
'component_wrong_tenant' => 'The item does not belong to the bundle tenant.',
'invalid_component' => 'The component must be a standard item other than the bundle.',
'component_variant_required' => 'You must select a variant for this component.',
'component_variant_forbidden' => 'A component with direct inventory cannot have a variant.',
'component_variant_invalid' => 'The variant does not belong to the selected component.',
'bundle_component_required' => 'A bundle must have at least one component.',
'bundle_field_forbidden' => ':field is not allowed for a bundle.',
'attachment_not_found' => 'The specified attachment does not exist.',
'attribute_not_found' => 'Attribute :attribute does not exist for the item tenant.',
'managed_inventory_fields' => 'inventory_id, reserved_stock, and sold_units are managed internally.',
'attribute_not_on_item' => 'The attribute does not belong to the catalog item.',
'invalid_effective_date_range' => 'The effective maximum date must be after or equal to the effective minimum date.',
'variants_required' => 'An item with attribute_codes must have variants.',
'variants_forbidden' => 'An item without attribute_codes cannot have variants.',
'direct_inventory_forbidden' => 'An item with variants cannot have direct inventory.',
],
'menu' => [
'schema_required' => 'The schema is required for static menus.',
'not_found' => 'The specified menu does not exist.',
],
'tenant' => [
'invalid_carousel_image' => 'The specified image does not exist or is not an image attachment.',
],
'errors' => [
'forbidden' => 'You do not have permission to perform this action.',
'not_found' => 'The requested resource was not found.',
'method_not_allowed' => 'The HTTP method is not allowed for this route.',
'request_failed' => 'The request could not be processed.',
],
];

57
lang/en/validation.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
return [
'after_or_equal' => 'The :attribute must be a date after or equal to :date.',
'array' => 'The :attribute must be an array.',
'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.',
'date' => 'The :attribute must be a valid date.',
'distinct' => 'The :attribute field has a duplicate value.',
'email' => 'The :attribute must be a valid email address.',
'exists' => 'The selected :attribute is invalid.',
'file' => 'The :attribute must be a file.',
'integer' => 'The :attribute must be an integer.',
'max' => [
'array' => 'The :attribute must not have more than :max items.',
'file' => 'The :attribute must not be greater than :max kilobytes.',
'numeric' => 'The :attribute must not be greater than :max.',
'string' => 'The :attribute must not be greater than :max characters.',
],
'min' => [
'array' => 'The :attribute must have at least :min items.',
'file' => 'The :attribute must be at least :min kilobytes.',
'numeric' => 'The :attribute must be at least :min.',
'string' => 'The :attribute must be at least :min characters.',
],
'numeric' => 'The :attribute must be a number.',
'password' => [
'letters' => 'The :attribute must contain at least one letter.',
'mixed' => 'The :attribute must contain at least one uppercase and one lowercase letter.',
'numbers' => 'The :attribute must contain at least one number.',
'symbols' => 'The :attribute must contain at least one symbol.',
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different one.',
],
'prohibited' => 'The :attribute field is prohibited.',
'regex' => 'The :attribute format is invalid.',
'required' => 'The :attribute field is required.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'string' => 'The :attribute must be a string.',
'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.',
'attributes' => [
'cantidad' => 'quantity',
'catalog_item_id' => 'product',
'codigo' => 'code',
'dni' => 'ID number',
'email' => 'email',
'nombre_apellido' => 'full name',
'oauth_code' => 'authentication code',
'password' => 'password',
'password_confirmation' => 'password confirmation',
'telefono' => 'phone number',
'tenant_codigo' => 'tenant',
'variant_id' => 'variant',
],
];

104
lang/es/api.php Normal file
View File

@@ -0,0 +1,104 @@
<?php
return [
'auth' => [
'unauthenticated' => 'No autenticado.',
'invalid_credentials' => 'Email o contraseña incorrectos.',
'login_success' => 'Sesión iniciada correctamente.',
'logout_success' => 'Sesión cerrada correctamente.',
'register_success' => 'Usuario registrado correctamente.',
'password_reset_requested' => 'Si el email está registrado, recibirás un código para recuperar tu contraseña.',
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.',
'reset_code_invalid' => 'El código ingresado es inválido.',
'reset_code_valid' => 'Código validado correctamente.',
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
'invalid_return_url' => 'La URL de retorno no es válida.',
'oauth_code_expired' => 'El código de autenticación expiró o ya fue utilizado.',
'tenant_mismatch' => 'El tenant no coincide con la solicitud de autenticación.',
'google_email_required' => 'Google no devolvió un email para la cuenta.',
'google_email_unverified' => 'La cuenta de Google debe tener el email verificado.',
],
'cart' => [
'item_added' => 'Producto agregado al carrito.',
'quantity_updated' => 'Cantidad de producto actualizada.',
'item_removed' => 'Producto eliminado del carrito.',
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
'max_quantity' => 'El máximo que se puede agregar es :max.',
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.',
'variant_required' => 'Debe seleccionar una variante para este ítem.',
],
'purchase' => [
'source_required' => 'Se requiere un carrito o un producto directo.',
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
'not_editable' => 'La compra ya no se puede modificar.',
'item_not_editable' => 'El producto de la compra ya no se puede modificar.',
'insufficient_stock' => 'No hay suficiente stock disponible.',
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
'direct_item_max_stock' => 'Stock insuficiente. Máximo disponible: :max.',
'empty_cart' => 'El carrito seleccionado no contiene productos.',
'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.',
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
'inactive_cart' => 'El carrito seleccionado ya no está activo.',
'not_available_for_payment' => 'La compra ya no está disponible para el pago.',
],
'ticket' => [
'not_available' => 'Uno o más tickets no están disponibles.',
'positive_quantity' => 'La cantidad de tickets a generar debe ser mayor a cero.',
'empty_bundle' => 'El bundle :bundle no tiene componentes.',
'disabled' => 'El producto :product no tiene tickets habilitados.',
'expired' => 'El producto :product alcanzó su fecha máxima de uso.',
'invalid_variant' => 'La variante :variant no pertenece al producto :product.',
'purchase_without_user' => 'La compra :purchase no tiene un usuario asociado.',
'product_not_found' => 'No se encontró el producto de la línea de compra :purchase_item.',
],
'integration' => [
'not_configured' => 'La integración no está configurada para este tenant.',
'configured' => 'Integración configurada correctamente.',
'validation_failed' => 'Error validando la configuración: :error',
'invalid_payment_method' => 'Método de pago inválido.',
'account_info_failed' => 'No se pudo obtener la información de la cuenta.',
'qr_generation_failed' => 'No se pudo generar el código QR.',
'webhook_failed' => 'No se pudo procesar el webhook.',
],
'mail' => [
'test_sent' => 'Correo de prueba enviado correctamente.',
],
'catalog' => [
'standard_with_components' => 'Un ítem standard no puede tener componentes.',
'duplicate_component' => 'El componente está duplicado.',
'component_wrong_tenant' => 'El ítem no pertenece al tenant del bundle.',
'invalid_component' => 'El componente debe ser un ítem standard distinto del bundle.',
'component_variant_required' => 'Debe seleccionar una variante para este componente.',
'component_variant_forbidden' => 'El componente con inventario directo no admite una variante.',
'component_variant_invalid' => 'La variante no pertenece al componente indicado.',
'bundle_component_required' => 'Un bundle debe tener al menos un componente.',
'bundle_field_forbidden' => ':field no se admite para un bundle.',
'attachment_not_found' => 'El attachment indicado no existe.',
'attribute_not_found' => 'El atributo :attribute no existe para el tenant del ítem.',
'managed_inventory_fields' => 'inventory_id, reserved_stock y sold_units son administrados internamente.',
'attribute_not_on_item' => 'El atributo no pertenece al ítem de catálogo.',
'invalid_effective_date_range' => 'La fecha máxima efectiva debe ser posterior o igual a la fecha mínima efectiva.',
'variants_required' => 'Un ítem con attribute_codes debe tener variantes.',
'variants_forbidden' => 'Un ítem sin attribute_codes no puede tener variantes.',
'direct_inventory_forbidden' => 'Un ítem con variantes no puede tener inventario directo.',
],
'menu' => [
'schema_required' => 'El schema es obligatorio para los menús estáticos.',
'not_found' => 'El menú indicado no existe.',
],
'tenant' => [
'invalid_carousel_image' => 'La imagen indicada no existe o no es un attachment de tipo imagen.',
],
'errors' => [
'forbidden' => 'No tienes permiso para realizar esta acción.',
'not_found' => 'El recurso solicitado no fue encontrado.',
'method_not_allowed' => 'El método HTTP no está permitido para esta ruta.',
'request_failed' => 'No se pudo procesar la solicitud.',
],
];

57
lang/es/validation.php Normal file
View File

@@ -0,0 +1,57 @@
<?php
return [
'after_or_equal' => ':Attribute debe ser una fecha posterior o igual a :date.',
'array' => ':Attribute debe ser un arreglo.',
'boolean' => 'El campo :attribute debe ser verdadero o falso.',
'confirmed' => 'La confirmación de :attribute no coincide.',
'date' => ':Attribute no es una fecha válida.',
'distinct' => 'El campo :attribute tiene un valor duplicado.',
'email' => ':Attribute debe ser una dirección de email válida.',
'exists' => 'El valor seleccionado para :attribute no es válido.',
'file' => ':Attribute debe ser un archivo.',
'integer' => ':Attribute debe ser un número entero.',
'max' => [
'array' => ':Attribute no debe tener más de :max elementos.',
'file' => ':Attribute no debe superar los :max kilobytes.',
'numeric' => ':Attribute no debe ser mayor que :max.',
'string' => ':Attribute no debe tener más de :max caracteres.',
],
'min' => [
'array' => ':Attribute debe tener al menos :min elementos.',
'file' => ':Attribute debe pesar al menos :min kilobytes.',
'numeric' => ':Attribute debe ser al menos :min.',
'string' => ':Attribute debe tener al menos :min caracteres.',
],
'numeric' => ':Attribute debe ser un número.',
'password' => [
'letters' => ':Attribute debe contener al menos una letra.',
'mixed' => ':Attribute debe contener al menos una letra mayúscula y una minúscula.',
'numbers' => ':Attribute debe contener al menos un número.',
'symbols' => ':Attribute debe contener al menos un símbolo.',
'uncompromised' => ':Attribute aparece en una filtración de datos. Elige una contraseña diferente.',
],
'prohibited' => 'El campo :attribute está prohibido.',
'regex' => 'El formato de :attribute no es válido.',
'required' => 'El campo :attribute es obligatorio.',
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'string' => ':Attribute debe ser texto.',
'unique' => 'El valor de :attribute ya está en uso.',
'url' => ':Attribute debe ser una URL válida.',
'uuid' => ':Attribute debe ser un UUID válido.',
'attributes' => [
'cantidad' => 'cantidad',
'catalog_item_id' => 'producto',
'codigo' => 'código',
'dni' => 'DNI',
'email' => 'email',
'nombre_apellido' => 'nombre y apellido',
'oauth_code' => 'código de autenticación',
'password' => 'contraseña',
'password_confirmation' => 'confirmación de contraseña',
'telefono' => 'teléfono',
'tenant_codigo' => 'tenant',
'variant_id' => 'variante',
],
];

View File

@@ -0,0 +1,213 @@
<!doctype html>
<html lang="es">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<style>
@page { margin: 0; }
* { box-sizing: border-box; }
body {
background: #f4f7f5;
color: #17211b;
font-family: DejaVu Sans, sans-serif;
font-size: 11px;
margin: 0;
}
.ticket {
background: #f4f7f5;
min-height: 1120px;
padding: 0 54px;
position: relative;
}
.ticket + .ticket { page-break-before: always; }
.accent { background: {{ $primaryColor }}; height: 8px; margin: 0 -54px; }
.header {
background: {{ $headerBackgroundColor }};
color: {{ $headerTextColor }};
height: 132px;
margin: 0 -54px;
padding: 32px 54px 0;
}
.header-table, .meta-table, .footer-table { border-collapse: collapse; width: 100%; }
.brand-cell { vertical-align: middle; }
.type-cell { text-align: right; vertical-align: middle; width: 190px; }
.logo {
height: 64px;
max-width: 180px;
object-fit: contain;
vertical-align: middle;
}
.tenant-name {
display: inline-block;
font-size: 22px;
font-weight: bold;
line-height: 1.2;
max-width: 340px;
vertical-align: middle;
}
.ticket-type {
background: #fff;
border-radius: 16px;
color: {{ $primaryColor }};
display: inline-block;
font-size: 9px;
font-weight: bold;
letter-spacing: 1.25px;
padding: 8px 13px;
}
.content { padding-top: 36px; text-align: center; }
.eyebrow {
color: {{ $primaryColor }};
font-size: 9px;
font-weight: bold;
letter-spacing: 1.4px;
margin: 0 0 10px;
text-transform: uppercase;
}
.ticket-name {
font-size: 27px;
font-weight: bold;
line-height: 1.2;
margin: 0 auto 23px;
max-width: 570px;
}
.meta-card {
background: #fff;
border: 1px solid #dfe7e2;
border-radius: 10px;
margin: 0 auto 24px;
padding: 15px 18px;
text-align: left;
width: 500px;
}
.meta-cell {
border-right: 1px solid #e5ebe7;
padding: 0 14px;
vertical-align: top;
width: 33.33%;
}
.meta-cell:first-child { padding-left: 0; }
.meta-cell:last-child { border-right: 0; padding-right: 0; }
.meta-label {
color: #758078;
font-size: 8px;
font-weight: bold;
letter-spacing: 0.9px;
margin-bottom: 5px;
text-transform: uppercase;
}
.meta-value { color: #26322a; font-size: 12px; font-weight: bold; }
.qr-card {
background: #fff;
border: 1px solid #dfe7e2;
border-radius: 16px;
display: inline-block;
padding: 18px 22px 17px;
}
.qr-frame {
border: 3px solid {{ $primaryColor }};
border-radius: 14px;
padding: 11px;
}
.qr { display: block; height: 276px; width: 276px; }
.scan-label {
color: #26322a;
font-size: 12px;
font-weight: bold;
margin: 14px 0 3px;
}
.scan-help { color: #77827b; font-size: 9px; margin: 0; }
.notice {
background: #eaf1ed;
border-left: 4px solid {{ $primaryColor }};
border-radius: 6px;
color: #445049;
line-height: 1.45;
margin: 22px auto 0;
padding: 11px 14px;
text-align: left;
width: 500px;
}
.notice strong { color: #26322a; }
.footer {
border-top: 1px solid #dfe7e2;
bottom: 32px;
color: #7b857e;
font-size: 8px;
left: 54px;
padding-top: 11px;
position: absolute;
right: 54px;
}
.footer-right { text-align: right; }
</style>
</head>
<body>
@foreach ($tickets as $ticket)
<section class="ticket">
<div class="accent"></div>
<header class="header">
<table class="header-table" role="presentation">
<tr>
<td class="brand-cell">
@if ($logoDataUri)
<img class="logo" src="{{ $logoDataUri }}" alt="Logo de {{ $tenant->nombre }}">
@else
<span class="tenant-name">{{ $tenant->nombre }}</span>
@endif
</td>
<td class="type-cell">
<span class="ticket-type">TICKET DIGITAL</span>
</td>
</tr>
</table>
</header>
<main class="content">
<p class="eyebrow">Tu acceso</p>
<h1 class="ticket-name">{{ $ticket->name }}</h1>
<div class="meta-card">
<table class="meta-table" role="presentation">
<tr>
<td class="meta-cell">
<div class="meta-label">Número de ticket</div>
<div class="meta-value">#{{ $ticket->id }}</div>
</td>
<td class="meta-cell">
<div class="meta-label">Desde</div>
<div class="meta-value">{{ $ticket->starts_at?->format('d/m/Y') ?? 'Sin fecha inicial' }}</div>
</td>
<td class="meta-cell">
<div class="meta-label">Hasta</div>
<div class="meta-value">{{ $ticket->expires_at?->format('d/m/Y') ?? 'Sin vencimiento' }}</div>
</td>
</tr>
</table>
</div>
<div class="qr-card">
<div class="qr-frame">
<img class="qr" src="{{ $qrCodes[$ticket->id] }}" alt="Código QR del ticket {{ $ticket->id }}">
</div>
<p class="scan-label">Escaneá este código al ingresar</p>
<p class="scan-help">Aumentá el brillo de la pantalla para una lectura más rápida.</p>
</div>
<div class="notice">
<strong>Importante:</strong> este ticket es personal. No compartas el código QR con otras personas.
</div>
</main>
<footer class="footer">
<table class="footer-table" role="presentation">
<tr>
<td>{{ $tenant->nombre }}</td>
<td class="footer-right">Conservá este ticket hasta finalizar tu ingreso.</td>
</tr>
</table>
</footer>
</section>
@endforeach
</body>
</html>

View File

@@ -35,7 +35,9 @@ class LoginControllerTest extends TestCase
$response $response
->assertOk() ->assertOk()
->assertJsonPath('message', 'Sesion iniciada correctamente.') ->assertHeader('Content-Language', 'es')
->assertJsonPath('code', 'auth.login_success')
->assertJsonPath('message', 'Sesión iniciada correctamente.')
->assertJsonPath('token_type', 'Bearer') ->assertJsonPath('token_type', 'Bearer')
->assertJsonPath('user.id', $user->id) ->assertJsonPath('user.id', $user->id)
->assertJsonPath('user.nombre_apellido', 'Grace Hopper') ->assertJsonPath('user.nombre_apellido', 'Grace Hopper')
@@ -85,7 +87,8 @@ class LoginControllerTest extends TestCase
$this->withHeader('Authorization', 'Bearer '.$token) $this->withHeader('Authorization', 'Bearer '.$token)
->postJson('/api/logout') ->postJson('/api/logout')
->assertOk() ->assertOk()
->assertJsonPath('message', 'Sesion cerrada correctamente.'); ->assertJsonPath('code', 'auth.logout_success')
->assertJsonPath('message', 'Sesión cerrada correctamente.');
$this->assertDatabaseCount('personal_access_tokens', 0); $this->assertDatabaseCount('personal_access_tokens', 0);
} }
@@ -118,6 +121,34 @@ class LoginControllerTest extends TestCase
]); ]);
} }
public function test_it_localizes_success_and_validation_responses_from_accept_language(): void
{
$tenant = $this->createTenant('acme');
User::query()->create([
'nombre_apellido' => 'Grace Hopper',
'email' => 'grace@example.com',
'password' => Hash::make('secret123'),
]);
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
->postJson('/api/login', [
'email' => 'grace@example.com',
'password' => 'secret123',
'tenant_codigo' => $tenant->codigo,
])
->assertOk()
->assertHeader('Content-Language', 'en')
->assertJsonPath('code', 'auth.login_success')
->assertJsonPath('message', 'Signed in successfully.');
$this->withHeader('Accept-Language', 'en')
->postJson('/api/login', [])
->assertUnprocessable()
->assertHeader('Content-Language', 'en')
->assertJsonPath('errors.email.0', 'The email field is required.')
->assertJsonPath('errors.password.0', 'The password field is required.');
}
public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void public function test_it_replaces_the_active_user_cart_with_the_guest_cart_on_login(): void
{ {
$tenant = $this->createTenant('acme'); $tenant = $this->createTenant('acme');

View File

@@ -31,7 +31,7 @@ class TenantIntegrationControllerTest extends TestCase
Mockery::on(fn (Integration $argument) => $argument->is($integration)), Mockery::on(fn (Integration $argument) => $argument->is($integration)),
['api_key' => 'secret'] ['api_key' => 'secret']
) )
->andReturn(new TenantIntegration()); ->andReturn(new TenantIntegration);
}); });
$this->postJson('/api/test-tenant/integrations/test_integration', [ $this->postJson('/api/test-tenant/integrations/test_integration', [
@@ -40,7 +40,8 @@ class TenantIntegrationControllerTest extends TestCase
], ],
])->assertOk() ])->assertOk()
->assertExactJson([ ->assertExactJson([
'message' => 'integration configured correctly', 'code' => 'integration.configured',
'message' => 'Integración configurada correctamente.',
]); ]);
} }
} }

View File

@@ -0,0 +1,40 @@
<?php
namespace Tests\Feature\Localization;
use Tests\TestCase;
class ApiLocalizationTest extends TestCase
{
public function test_it_negotiates_spanish_from_accept_language(): void
{
$this->withHeader('Accept-Language', 'es-AR,es;q=0.9')
->postJson('/api/login')
->assertUnprocessable()
->assertHeader('Content-Language', 'es')
->assertJsonPath('errors.email.0', 'El campo email es obligatorio.')
->assertJsonPath('errors.password.0', 'El campo contraseña es obligatorio.');
}
public function test_it_negotiates_english_from_accept_language(): void
{
$this->withHeader('Accept-Language', 'en-US,en;q=0.9')
->postJson('/api/login')
->assertUnprocessable()
->assertHeader('Content-Language', 'en')
->assertJsonPath('errors.email.0', 'The email field is required.')
->assertJsonPath('errors.password.0', 'The password field is required.');
}
public function test_it_localizes_framework_error_responses(): void
{
$this->withHeader('Accept-Language', 'en')
->getJson('/api/route-that-does-not-exist')
->assertNotFound()
->assertHeader('Content-Language', 'en')
->assertJson([
'code' => 'errors.not_found',
'message' => 'The requested resource was not found.',
]);
}
}

View File

@@ -61,6 +61,43 @@ class TicketControllerTest extends TestCase
->assertUnauthorized(); ->assertUnauthorized();
} }
public function test_an_authenticated_user_can_download_a_pdf_for_their_tickets(): void
{
$tenant = $this->createTenant('current');
$user = User::factory()->create();
$firstTicket = $this->createTicket($tenant, $user, 'First ticket');
$secondTicket = $this->createTicket($tenant, $user, 'Second ticket');
$response = $this->actingAs($user, 'sanctum')
->post("/api/tenants/{$tenant->codigo}/tickets/pdf", [
'ticket_ids' => [$firstTicket->id, $secondTicket->id],
]);
$response
->assertOk()
->assertHeader('content-type', 'application/pdf')
->assertHeader(
'content-disposition',
"attachment; filename=tickets_{$secondTicket->id}_{$firstTicket->id}.pdf"
);
$this->assertStringStartsWith('%PDF', $response->getContent());
}
public function test_a_user_cannot_download_someone_elses_ticket(): void
{
$tenant = $this->createTenant('current');
$user = User::factory()->create();
$otherUser = User::factory()->create();
$ownTicket = $this->createTicket($tenant, $user, 'Own ticket');
$otherTicket = $this->createTicket($tenant, $otherUser, 'Other ticket');
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/{$tenant->codigo}/tickets/pdf", [
'ticket_ids' => [$ownTicket->id, $otherTicket->id],
])
->assertNotFound();
}
private function createTicket(Tenant $tenant, User $user, string $name): Ticket private function createTicket(Tenant $tenant, User $user, string $name): Ticket
{ {
return Ticket::query()->create([ return Ticket::query()->create([