diff --git a/.env.example b/.env.example index 8bc28d1..17693c1 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,8 @@ PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30 PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440 FRONTEND_URLS=http://localhost:4200 -APP_LOCALE=en -APP_FALLBACK_LOCALE=en +APP_LOCALE=es +APP_FALLBACK_LOCALE=es APP_FAKER_LOCALE=en_US APP_MAINTENANCE_DRIVER=file diff --git a/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php b/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php index 919cf3c..cc52307 100644 --- a/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php +++ b/app/Domains/Auth/Controllers/CreateResetPasswordAttemptController.php @@ -24,7 +24,8 @@ class CreateResetPasswordAttemptController extends Controller ); 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, ], 202); } diff --git a/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php b/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php index 63c4250..1aaa181 100644 --- a/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php +++ b/app/Domains/Auth/Controllers/GoogleTokenExchangeController.php @@ -35,7 +35,8 @@ class GoogleTokenExchangeController extends Controller ); $response = response()->json([ - 'message' => 'Sesion iniciada correctamente.', + 'code' => 'auth.login_success', + 'message' => __('api.auth.login_success'), 'token' => $authentication['token'], 'token_type' => 'Bearer', 'user' => UserResource::make($authentication['user']), diff --git a/app/Domains/Auth/Controllers/LoginController.php b/app/Domains/Auth/Controllers/LoginController.php index 00ad8f7..bfdab97 100644 --- a/app/Domains/Auth/Controllers/LoginController.php +++ b/app/Domains/Auth/Controllers/LoginController.php @@ -28,7 +28,7 @@ class LoginController extends Controller if (! $user || ! Hash::check($credentials['password'], $user->password)) { 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([ - 'message' => 'Sesion iniciada correctamente.', + 'code' => 'auth.login_success', + 'message' => __('api.auth.login_success'), 'token' => $token, 'token_type' => 'Bearer', 'user' => UserResource::make($user), diff --git a/app/Domains/Auth/Controllers/LogoutController.php b/app/Domains/Auth/Controllers/LogoutController.php index 3b4499f..c6b1f78 100644 --- a/app/Domains/Auth/Controllers/LogoutController.php +++ b/app/Domains/Auth/Controllers/LogoutController.php @@ -23,7 +23,8 @@ class LogoutController extends Controller } return response()->json([ - 'message' => 'Sesion cerrada correctamente.', + 'code' => 'auth.logout_success', + 'message' => __('api.auth.logout_success'), ]); } } diff --git a/app/Domains/Auth/Controllers/RegisterController.php b/app/Domains/Auth/Controllers/RegisterController.php index 98ec675..95dbe29 100644 --- a/app/Domains/Auth/Controllers/RegisterController.php +++ b/app/Domains/Auth/Controllers/RegisterController.php @@ -12,15 +12,17 @@ class RegisterController extends Controller { public function __construct( protected RegisterUserService $registerUserService, - ) { - } + ) {} public function __invoke(RegisterUserRequest $request): JsonResponse { $user = $this->registerUserService->register($request->validated()); return UserResource::make($user) - ->additional(['message' => 'Usuario registrado correctamente.']) + ->additional([ + 'code' => 'auth.register_success', + 'message' => __('api.auth.register_success'), + ]) ->response() ->setStatusCode(201); } diff --git a/app/Domains/Auth/Controllers/ResetPasswordController.php b/app/Domains/Auth/Controllers/ResetPasswordController.php index a355a1d..5411a97 100644 --- a/app/Domains/Auth/Controllers/ResetPasswordController.php +++ b/app/Domains/Auth/Controllers/ResetPasswordController.php @@ -28,12 +28,13 @@ class ResetPasswordController extends Controller $data['password'], )) { 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([ - 'message' => 'Contraseña modificada correctamente.', + 'code' => 'auth.password_updated', + 'message' => __('api.auth.password_updated'), 'status' => ResetPasswordAttempt::STATUS_USED, ]); } diff --git a/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php b/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php index 7905602..07fc705 100644 --- a/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php +++ b/app/Domains/Auth/Controllers/ValidateResetPasswordAttemptController.php @@ -27,12 +27,13 @@ class ValidateResetPasswordAttemptController extends Controller $data['codigo'], )) { throw ValidationException::withMessages([ - 'codigo' => 'El código ingresado es inválido.', + 'codigo' => __('api.auth.reset_code_invalid'), ]); } return response()->json([ - 'message' => 'Código validado correctamente.', + 'code' => 'auth.reset_code_valid', + 'message' => __('api.auth.reset_code_valid'), 'status' => ResetPasswordAttempt::STATUS_VALIDATED, ]); } diff --git a/app/Domains/Auth/Services/GoogleAuthService.php b/app/Domains/Auth/Services/GoogleAuthService.php index d90d774..364c09f 100644 --- a/app/Domains/Auth/Services/GoogleAuthService.php +++ b/app/Domains/Auth/Services/GoogleAuthService.php @@ -25,7 +25,7 @@ class GoogleAuthService if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) { 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; 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(); 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 */ @@ -88,13 +88,13 @@ class GoogleAuthService if (! $authentication) { 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) { 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)) { 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; if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) { throw ValidationException::withMessages([ - 'google' => 'La cuenta de Google debe tener el email verificado.', + 'google' => __('api.auth.google_email_unverified'), ]); } diff --git a/app/Domains/Cart/Controllers/CartController.php b/app/Domains/Cart/Controllers/CartController.php index bde6581..88524ba 100644 --- a/app/Domains/Cart/Controllers/CartController.php +++ b/app/Domains/Cart/Controllers/CartController.php @@ -36,7 +36,10 @@ class CartController extends Controller ); $response = CartResource::make($result['cart']) - ->additional(['message' => 'Producto agregado al carrito.']) + ->additional([ + 'code' => 'cart.item_added', + 'message' => __('api.cart.item_added'), + ]) ->response(); if ($result['guest_token'] !== null) { @@ -58,13 +61,19 @@ class CartController extends Controller $cartItem->getKey(), (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 { return CartResource::make( $this->cartService->removeItem($tenant, $request, $cartItem->getKey()) - )->additional(['message' => 'Producto eliminado del carrito.']); + )->additional([ + 'code' => 'cart.item_removed', + 'message' => __('api.cart.item_removed'), + ]); } } diff --git a/app/Domains/Cart/Models/Cart.php b/app/Domains/Cart/Models/Cart.php index 20ff68d..f4f1aca 100644 --- a/app/Domains/Cart/Models/Cart.php +++ b/app/Domains/Cart/Models/Cart.php @@ -79,7 +79,7 @@ class Cart extends Model { if ($quantity <= 0) { 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) { 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) { 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) { $maxAvailable = $availableQuantity + $item->cantidad; 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 ($variantId !== null) { throw ValidationException::withMessages([ - 'variant_id' => 'Un bundle no admite una variante.', + 'variant_id' => __('api.cart.bundle_variant_forbidden'), ]); } if (! $catalogItem->bundleComponents()->exists()) { 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 ($catalogItem->inventory_id === null) { throw ValidationException::withMessages([ - 'variant_id' => 'Debe seleccionar una variante para este ítem.', + 'variant_id' => __('api.cart.variant_required'), ]); } diff --git a/app/Domains/Catalog/Services/CatalogService.php b/app/Domains/Catalog/Services/CatalogService.php index e7a592a..0736f03 100644 --- a/app/Domains/Catalog/Services/CatalogService.php +++ b/app/Domains/Catalog/Services/CatalogService.php @@ -45,7 +45,7 @@ class CatalogService } else { if (array_key_exists('components', $data)) { 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])) { throw ValidationException::withMessages([ - "components.{$index}" => ['El componente esta duplicado.'], + "components.{$index}" => [__('api.catalog.duplicate_component')], ]); } $seen[$key] = true; @@ -278,7 +278,7 @@ class CatalogService if ($componentItem === null) { throw ValidationException::withMessages([ "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) { throw ValidationException::withMessages([ "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) { throw ValidationException::withMessages([ "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) { throw ValidationException::withMessages([ "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()) { throw ValidationException::withMessages([ "components.{$index}.variant_id" => [ - 'La variante no pertenece al componente indicado.', + __('api.catalog.component_variant_invalid'), ], ]); } @@ -333,7 +333,7 @@ class CatalogService { if ($components === []) { 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) { if (array_key_exists($field, $data)) { 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) { throw ValidationException::withMessages([ - $validationKey => ['El attachment indicado no existe.'], + $validationKey => [__('api.catalog.attachment_not_found')], ]); } @@ -408,7 +408,7 @@ class CatalogService if ($attribute === null) { throw ValidationException::withMessages([ '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([ "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) { throw ValidationException::withMessages([ "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([ "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 === []) { throw ValidationException::withMessages([ 'variants' => [ - 'Un ítem con attribute_codes debe tener variantes.', + __('api.catalog.variants_required'), ], ]); } @@ -516,7 +516,7 @@ class CatalogService if (! $hasVariants && $variants !== []) { throw ValidationException::withMessages([ 'variants' => [ - 'Un ítem sin attribute_codes no puede tener variantes.', + __('api.catalog.variants_forbidden'), ], ]); } @@ -524,7 +524,7 @@ class CatalogService if ($hasVariants && $hasDirectStock) { throw ValidationException::withMessages([ '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([ 'inventory' => [ - 'inventory_id, reserved_stock y sold_units son administrados internamente.', + __('api.catalog.managed_inventory_fields'), ], ]); } diff --git a/app/Domains/Integration/Controllers/TelepagosWebhookController.php b/app/Domains/Integration/Controllers/TelepagosWebhookController.php index cdb6347..47c3a5b 100644 --- a/app/Domains/Integration/Controllers/TelepagosWebhookController.php +++ b/app/Domains/Integration/Controllers/TelepagosWebhookController.php @@ -6,28 +6,26 @@ use App\Domains\Integration\Requests\TelepagosWebhookRequest; use App\Domains\Integration\Services\TelepagosWebhookService; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; class TelepagosWebhookController extends Controller { /** * 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 { try { $cashinId = $request->validated('id'); - + $service->handleWebhook($tenantCodigo, $cashinId); - + return response()->json(['status' => 'success']); } 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); } } } diff --git a/app/Domains/Integration/Controllers/TenantIntegrationController.php b/app/Domains/Integration/Controllers/TenantIntegrationController.php index 60b324c..b89f1b8 100644 --- a/app/Domains/Integration/Controllers/TenantIntegrationController.php +++ b/app/Domains/Integration/Controllers/TenantIntegrationController.php @@ -24,9 +24,12 @@ class TenantIntegrationController extends Controller public function show(string $tenantCode, string $integrationCode) { $integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode); - - if (!$integration) { - return response()->json(['message' => 'Integration not configured for this tenant'], 404); + + if (! $integration) { + return response()->json([ + 'code' => 'integration.not_configured', + 'message' => __('api.integration.not_configured'), + ], 404); } return response()->json($integration); @@ -35,7 +38,7 @@ class TenantIntegrationController extends Controller public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode) { $integration = Integration::where('integration_code', $integrationCode)->firstOrFail(); - + try { $this->tenantIntegrationService->updateOrCreateIntegration( $tenantCode, @@ -44,11 +47,13 @@ class TenantIntegrationController extends Controller ); return response()->json([ - 'message' => 'integration configured correctly', + 'code' => 'integration.configured', + 'message' => __('api.integration.configured'), ]); } catch (\Exception $e) { return response()->json([ - 'message' => 'Error validando la configuración: ' . $e->getMessage() + 'code' => 'integration.validation_failed', + 'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]), ], 400); } } diff --git a/app/Domains/Integration/Requests/StoreTenantIntegrationRequest.php b/app/Domains/Integration/Requests/StoreTenantIntegrationRequest.php index d6d4d54..ddddd02 100644 --- a/app/Domains/Integration/Requests/StoreTenantIntegrationRequest.php +++ b/app/Domains/Integration/Requests/StoreTenantIntegrationRequest.php @@ -20,9 +20,9 @@ class StoreTenantIntegrationRequest extends FormRequest $integrationCode = $this->route('integration_code'); $this->integrationModel = Integration::where('integration_code', $integrationCode)->first(); - if (!$this->integrationModel) { + if (! $this->integrationModel) { 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 if ($this->integrationModel && $this->integrationModel->integration_data_schema) { foreach ($this->integrationModel->integration_data_schema as $field => $rule) { - $rules['integration_data.' . $field] = $rule; + $rules['integration_data.'.$field] = $rule; } } diff --git a/app/Domains/MailTest/Services/MailTestService.php b/app/Domains/MailTest/Services/MailTestService.php index 958e2ce..92c269b 100644 --- a/app/Domains/MailTest/Services/MailTestService.php +++ b/app/Domains/MailTest/Services/MailTestService.php @@ -24,7 +24,8 @@ class MailTestService ); return [ - 'message' => 'Correo de prueba enviado correctamente.', + 'code' => 'mail.test_sent', + 'message' => __('api.mail.test_sent'), 'recipient' => $recipient, 'tenant_code' => $tenant->codigo, 'mailer' => $mailService->mailerName(), diff --git a/app/Domains/Menu/Models/Menu.php b/app/Domains/Menu/Models/Menu.php index de3eaee..26a1a77 100644 --- a/app/Domains/Menu/Models/Menu.php +++ b/app/Domains/Menu/Models/Menu.php @@ -48,7 +48,7 @@ class Menu extends Model && empty($menu->static_content_schema) ) { throw ValidationException::withMessages([ - 'static_content_schema' => 'El schema es obligatorio para los menús estáticos.', + 'static_content_schema' => __('api.menu.schema_required'), ]); } }); diff --git a/app/Domains/Menu/Requests/StoreTenantMenuRequest.php b/app/Domains/Menu/Requests/StoreTenantMenuRequest.php index abc2e1f..0ee9b99 100644 --- a/app/Domains/Menu/Requests/StoreTenantMenuRequest.php +++ b/app/Domains/Menu/Requests/StoreTenantMenuRequest.php @@ -23,7 +23,7 @@ class StoreTenantMenuRequest extends FormRequest if (! $this->menuModel) { throw ValidationException::withMessages([ - 'menu_code' => 'El menú indicado no existe.', + 'menu_code' => __('api.menu.not_found'), ]); } } diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index a02f034..a92e6af 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -135,7 +135,7 @@ class PurchaseController extends Controller if ($updated === 0) { 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) { + Log::error('Unable to retrieve TelePagos account information.', [ + 'purchase_id' => $compra->id, + 'exception' => $e, + ]); + return response()->json([ - 'message' => 'Error getting account info: '.$e->getMessage(), + 'code' => 'integration.account_info_failed', + 'message' => __('api.integration.account_info_failed'), ], 500); } } @@ -182,8 +188,14 @@ class PurchaseController extends Controller 'qr_code' => $qrResponse['qr_code'] ?? '', ]); } catch (\Exception $e) { + Log::error('Unable to generate TelePagos QR code.', [ + 'purchase_id' => $compra->id, + 'exception' => $e, + ]); + return response()->json([ - 'message' => 'Error generating QR: '.$e->getMessage(), + 'code' => 'integration.qr_generation_failed', + 'message' => __('api.integration.qr_generation_failed'), ], 500); } @@ -197,7 +209,8 @@ class PurchaseController extends Controller } return response()->json([ - 'message' => 'Invalid payment method.', + 'code' => 'integration.invalid_payment_method', + 'message' => __('api.integration.invalid_payment_method'), ], 400); } @@ -222,7 +235,7 @@ class PurchaseController extends Controller protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase { 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; diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index e2707f3..7a150e9 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -41,7 +41,7 @@ class CheckoutService if ($cartId === null) { 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) { 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()) ) { 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()) ) { 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) { 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) { 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()) ) { throw ValidationException::withMessages([ - 'purchase' => 'The purchase is no longer editable.', + 'purchase' => __('api.purchase.not_editable'), ]); } @@ -233,7 +233,7 @@ class CheckoutService Purchase::STATUS_EXPIRED, ], true)) { 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); } catch (\InvalidArgumentException $exception) { 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([ - '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); } catch (\InvalidArgumentException $exception) { 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) { 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); } catch (\InvalidArgumentException $exception) { 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()) { 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) { if ($item->selectedItem() === null) { 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) { 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') { 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 ($variantId !== null) { 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()) { 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 ($catalogItem->inventory_id === null) { throw ValidationException::withMessages([ - 'direct_item.variant_id' => 'Debe seleccionar una variante para este item.', + 'direct_item.variant_id' => __('api.cart.variant_required'), ]); } diff --git a/app/Domains/Tenant/Services/TenantService.php b/app/Domains/Tenant/Services/TenantService.php index 9cfc5b4..1ce024d 100644 --- a/app/Domains/Tenant/Services/TenantService.php +++ b/app/Domains/Tenant/Services/TenantService.php @@ -225,7 +225,7 @@ class TenantService if ($attachment === null) { throw ValidationException::withMessages([ "main_carousel_images.{$order}" => [ - 'La imagen indicada no existe o no es un attachment de tipo imagen.', + __('api.tenant.invalid_carousel_image'), ], ]); } diff --git a/app/Domains/Ticket/Controllers/TicketController.php b/app/Domains/Ticket/Controllers/TicketController.php index 9c118a8..7562b47 100644 --- a/app/Domains/Ticket/Controllers/TicketController.php +++ b/app/Domains/Ticket/Controllers/TicketController.php @@ -3,6 +3,7 @@ namespace App\Domains\Ticket\Controllers; use App\Domains\Tenant\Models\Tenant; +use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Requests\DownloadTicketsPdfRequest; use App\Domains\Ticket\Resources\TicketResource; @@ -37,11 +38,9 @@ class TicketController extends Controller ->orderByDesc('id') ->get(); - abort_if( - $tickets->count() !== count($ticketIds), - 404, - 'Uno o más tickets no están disponibles.' - ); + if ($tickets->count() !== count($ticketIds)) { + throw new TicketNotAvailableException(__('api.ticket.not_available')); + } return $this->ticketPdfService->download($tenant, $tickets); } diff --git a/app/Domains/Ticket/Exceptions/TicketGenerationException.php b/app/Domains/Ticket/Exceptions/TicketGenerationException.php index 94c44a0..e9fa303 100644 --- a/app/Domains/Ticket/Exceptions/TicketGenerationException.php +++ b/app/Domains/Ticket/Exceptions/TicketGenerationException.php @@ -11,22 +11,22 @@ class TicketGenerationException extends RuntimeException { 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 { - 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 { - 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 { - 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( @@ -34,17 +34,20 @@ class TicketGenerationException extends RuntimeException int $variantId, ): 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 { - 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 { - 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])); } } diff --git a/app/Domains/Ticket/Exceptions/TicketNotAvailableException.php b/app/Domains/Ticket/Exceptions/TicketNotAvailableException.php new file mode 100644 index 0000000..7fac93e --- /dev/null +++ b/app/Domains/Ticket/Exceptions/TicketNotAvailableException.php @@ -0,0 +1,7 @@ +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; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 8a4fa0e..e5122d6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,9 +1,16 @@ withRouting( @@ -16,9 +23,70 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->encryptCookies(except: [ 'guest_token', ]); + $middleware->prepend(SetApiLocale::class); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( 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(); diff --git a/config/app.php b/config/app.php index 423eed5..722f90d 100644 --- a/config/app.php +++ b/config/app.php @@ -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'), diff --git a/lang/en/api.php b/lang/en/api.php new file mode 100644 index 0000000..4f491f3 --- /dev/null +++ b/lang/en/api.php @@ -0,0 +1,104 @@ + [ + '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.', + ], +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 0000000..8476649 --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,57 @@ + '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', + ], +]; diff --git a/lang/es/api.php b/lang/es/api.php new file mode 100644 index 0000000..19a3c67 --- /dev/null +++ b/lang/es/api.php @@ -0,0 +1,104 @@ + [ + '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.', + ], +]; diff --git a/lang/es/validation.php b/lang/es/validation.php new file mode 100644 index 0000000..7aa7787 --- /dev/null +++ b/lang/es/validation.php @@ -0,0 +1,57 @@ + ':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', + ], +]; diff --git a/tests/Feature/Auth/LoginControllerTest.php b/tests/Feature/Auth/LoginControllerTest.php index 38de2c6..37fd92f 100644 --- a/tests/Feature/Auth/LoginControllerTest.php +++ b/tests/Feature/Auth/LoginControllerTest.php @@ -35,7 +35,9 @@ class LoginControllerTest extends TestCase $response ->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('user.id', $user->id) ->assertJsonPath('user.nombre_apellido', 'Grace Hopper') @@ -85,7 +87,8 @@ class LoginControllerTest extends TestCase $this->withHeader('Authorization', 'Bearer '.$token) ->postJson('/api/logout') ->assertOk() - ->assertJsonPath('message', 'Sesion cerrada correctamente.'); + ->assertJsonPath('code', 'auth.logout_success') + ->assertJsonPath('message', 'Sesión cerrada correctamente.'); $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 { $tenant = $this->createTenant('acme'); diff --git a/tests/Feature/Integration/TenantIntegrationControllerTest.php b/tests/Feature/Integration/TenantIntegrationControllerTest.php index d56be2c..744f3f8 100644 --- a/tests/Feature/Integration/TenantIntegrationControllerTest.php +++ b/tests/Feature/Integration/TenantIntegrationControllerTest.php @@ -31,7 +31,7 @@ class TenantIntegrationControllerTest extends TestCase Mockery::on(fn (Integration $argument) => $argument->is($integration)), ['api_key' => 'secret'] ) - ->andReturn(new TenantIntegration()); + ->andReturn(new TenantIntegration); }); $this->postJson('/api/test-tenant/integrations/test_integration', [ @@ -40,7 +40,8 @@ class TenantIntegrationControllerTest extends TestCase ], ])->assertOk() ->assertExactJson([ - 'message' => 'integration configured correctly', + 'code' => 'integration.configured', + 'message' => 'Integración configurada correctamente.', ]); } } diff --git a/tests/Feature/Localization/ApiLocalizationTest.php b/tests/Feature/Localization/ApiLocalizationTest.php new file mode 100644 index 0000000..ffde69c --- /dev/null +++ b/tests/Feature/Localization/ApiLocalizationTest.php @@ -0,0 +1,40 @@ +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.', + ]); + } +}