186 Commits

Author SHA1 Message Date
d02b0c5551 feat(auth): add reason field to reset password attempts and enhance email notifications for account lock scenarios 2026-08-05 10:27:29 -03:00
ee911171be fix(variant): simplify getName method and add test for variant without catalog attributes 2026-08-05 09:18:12 -03:00
7c9ebc6d4d feat(auth): implement login security features including account locking and login attempt tracking 2026-07-29 10:47:26 -03:00
45553dc514 feat(purchase): add review submission for pending purchases and update related logic 2026-07-29 09:11:00 -03:00
1c870d9cfa feat(tickets): add source_purchase_id to tickets and update related logic 2026-07-28 16:10:37 -03:00
8ab94ec61f fix(validation): improve uniqueness message for attribute validation 2026-07-28 15:51:43 -03:00
4eacff86bc feat(purchase): update customer data for pending payment purchases 2026-07-28 15:06:00 -03:00
5c457db1c9 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-07-28 12:20:20 -03:00
8dc80411d7 feat(categories): add category retrieval and pagination for tenant-specific products 2026-07-28 12:20:17 -03:00
e16d91f3f9 feat(tenant): add categories relationship and structure to tenant resource and controller 2026-07-28 10:57:00 -03:00
89f6dab0c4 feat(seeders): associate categories with tenant codes in seeders and tests 2026-07-28 10:42:55 -03:00
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
5107d858cc fix(ticket): temporarily disable maximum use date validation in ticket generation 2026-07-27 17:01:08 -03:00
aa4f3303fc feat(variants): add minimum and maximum use dates to variants and update related logic 2026-07-27 14:59:07 -03:00
069867522a fix(env): update frontend URL variable and adjust CORS settings for improved origin handling 2026-07-27 14:09:43 -03:00
8a19b0136d fix(routes): update Google auth callback route to match expected path 2026-07-27 13:39:56 -03:00
8b26a2b63e feat(purchase): add checkout expiration settings and reservation status to purchase items
- Introduced a new configuration file for purchase settings, including checkout and payment expiration times.
- Added a migration to include a `reservation_status` column in the `compra_items` table, defaulting to 'active' and updating existing records based on purchase status.
- Created a migration to add an `expires_at` timestamp to the `compras` table, updating it for existing records based on their status.
- Scoped unique indexes in the `carritos` table to only active carts, adding virtual columns for active user and guest tokens.
- Implemented a console command to expire overdue purchases and release stock reservations, scheduled to run every minute.
- Added tests for Google token exchange and login functionality, ensuring proper cart handling and user authentication.
- Updated purchase-related tests to reflect changes in item handling and customer data validation.
2026-07-27 12:49:27 -03:00
c37b8894e4 Forgot password flow 2026-07-27 09:43:00 -03:00
7b565e7fa7 feat(routes): remove welcome route to streamline authentication flow 2026-07-24 15:17:18 -03:00
cb76d1c559 Implement code changes to enhance functionality and improve performance 2026-07-24 14:28:18 -03:00
135a3bea3e feat(search): implement catalog item search functionality with request validation and resource formatting 2026-07-24 14:17:34 -03:00
9e6923e732 feat(env): update environment configuration for Google OAuth and database connection 2026-07-24 11:18:40 -03:00
18c23a112a feat(seeder): update featured groups configuration with product and group layouts 2026-07-24 10:42:27 -03:00
c1be9ecf7f feat(menu): ensure label column is added only if it doesn't exist and simplify menu update logic 2026-07-24 10:13:18 -03:00
979ed428a2 feat(menu): add label field to menu model and update related functionality 2026-07-24 10:08:04 -03:00
7889d747d9 feat(menu): add account menu hierarchy and related tests in MenuSeeder and MenuSeederTest 2026-07-24 10:07:47 -03:00
f6ce85cbde feat(menu): update validation rules and structure for contact content in MenuSeeder and related tests 2026-07-24 09:41:13 -03:00
460eec3c78 feat(menu): implement menu hierarchy and static content for help section in MenuSeeder and related tests 2026-07-23 17:07:55 -03:00
2b1ef5f8a9 feat(menu): implement TenantMenuController, StoreTenantMenuRequest, and TenantMenuService for managing tenant-specific menus 2026-07-23 16:23:19 -03:00
5ada399adf feat(menu): enhance validation for content_type and static_content_schema in Menu model 2026-07-23 15:57:14 -03:00
aed818da6e feat(menu): enhance Menu and TenantMenu models with parent-child relationships and static content support 2026-07-23 15:54:16 -03:00
018f3f6c13 feat(seeder): add SocialMediaSeeder and integrate social media data into TenantSeeder 2026-07-23 15:19:25 -03:00
217f86f624 feat(social-media): add 'orden' field to social media pivot table and update related functionality 2026-07-23 15:19:17 -03:00
25891cefbc feat(tenant): add social media functionality with validation, synchronization, and tests 2026-07-23 15:03:57 -03:00
c66d2019d6 feat(social-media): add SocialMedia model, migration, and tests for tenant associations 2026-07-23 14:54:42 -03:00
12d496544d feat(tenant): add main carousel images to tenant seeder and implement corresponding tests 2026-07-23 14:53:00 -03:00
c31be9c927 feat(tenant): update validation rules for logos and main carousel images, enhance tests for carousel image upload and update 2026-07-23 14:20:26 -03:00
67ede1a2b4 feat(tenant): add main carousel images functionality with migration, model, and tests 2026-07-23 14:19:00 -03:00
e778d862ba feat(catalog): enhance featured groups with paginated and carousel layouts, update seeder and tests 2026-07-23 13:24:39 -03:00
1438ff66c7 feat(catalog): add GroupLayout enum and integrate into FeaturedGroup model, migrations, and seeders 2026-07-23 13:22:22 -03:00
873855c85a feat(auth): implement Google OAuth integration with user authentication and token exchange 2026-07-22 13:43:14 -03:00
855ac13990 feat(auth): configure Google OAuth credentials in .env.example and update socialite version in composer.json 2026-07-22 12:00:38 -03:00
87153b7839 feat(dependencies): add laravel/socialite and firebase/php-jwt to composer dependencies 2026-07-22 11:54:10 -03:00
528772fc96 feat(notification): implement email notifications for user registration and purchase events 2026-07-22 09:54:19 -03:00
7e1ffbf417 feat(integration): add requires_tenant_configuration field and update related logic and tests 2026-07-22 09:23:58 -03:00
03d8361e5f feat(mail): introduce MailService for tenant-specific SMTP handling and update related tests 2026-07-21 17:00:07 -03:00
3fe48fbfa5 feat(mail): implement MailService for tenant-specific SMTP configuration and update MailTestService to utilize it 2026-07-21 16:43:09 -03:00
22da4966e9 feat(mail-test): enhance email functionality with tenant branding and routing 2026-07-21 16:26:46 -03:00
1a05c380a0 feat(mail-test): implement email testing functionality
- Created MailTestController to handle email sending requests.
- Added SendTestMailRequest for validating email input.
- Developed MailTestService to manage email sending logic.
- Introduced TestMail Mailable for formatting test emails.
- Defined API route for sending test emails.
- Created EmailIntegrationSeeder to seed email integration settings.
- Updated DatabaseSeeder to include EmailIntegrationSeeder.
- Added MailTestControllerTest to ensure email sending functionality works as expected.
2026-07-21 16:01:18 -03:00
e1a67fd9f3 feat(ticket): add TicketController, TicketResource, and related routes with tests 2026-07-21 14:56:17 -03:00
38f74739da feat(ticket): add source IDs to tickets and update related services and tests 2026-07-21 12:27:26 -03:00
c0978033c1 feat(ticket): implement ticket generation on purchase payment and add related tests 2026-07-21 11:23:17 -03:00
a928a2e848 feat(ticket): implement ticket generation service, model, and exception handling with tests 2026-07-21 11:20:52 -03:00
c3f54c79cb feat(auth): implement token expiration for personal access tokens and update tests 2026-07-21 10:36:40 -03:00
0f36c3402b feat: update AWS S3 configuration to include visibility settings and retain visibility option 2026-07-21 10:29:34 -03:00
29a2ca19a3 refactor(catalog): Complete catalog refactor to simplify its data model and its querying.
source commits: refactor/catalog
2026-07-21 09:23:19 -03:00
d3182fbd13 feat: add migration to expand tenant_codigo in bundles and update related seeders and tests 2026-07-17 16:07:33 -03:00
b8eb71896c feat: refactor featured variants to group items, including new controller and resource implementations 2026-07-17 14:42:53 -03:00
cb0fb2899c feat: implement polymorphic relationships for group items to support bundles and product variants 2026-07-17 14:42:44 -03:00
615eacea91 feat: rename featured_variants to group_items and update related references 2026-07-17 14:31:05 -03:00
84c9fa4c9d feat: update purchase status to pending payment in payment intent and related test 2026-07-16 12:13:48 -03:00
b3d06431e5 feat: rename payer_dni to transfer_payer_dni across purchase handling and update related tests 2026-07-16 11:45:34 -03:00
379c2bdbeb Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-07-16 10:00:12 -03:00
6fc6ed1fac feat: refactor cart and purchase handling to support polymorphic buyable types and improve code readability 2026-07-16 09:55:38 -03:00
c05206cc51 feat: refactor cart item handling to use mapped buyable types and improve method signatures 2026-07-16 09:19:38 -03:00
480ee70393 feat: refactor cart and bundle handling to support Buyable interface for improved flexibility 2026-07-16 09:19:03 -03:00
ceb1e2b04a feat: implement Stockable interface and methods for Bundle and ProductVariant models 2026-07-16 09:11:37 -03:00
1d56d27350 feat: implement Bundle and BundleItem models, migrations, and polymorphic relationships for cart and purchase items 2026-07-16 09:11:00 -03:00
18c80b075b feat: Update product seeder to include inventory policy and improve code readability 2026-07-16 09:07:05 -03:00
10157d7c63 feat: Implement inventory policy for product variants
- Introduced InventoryPolicy enum to manage tracked and unlimited inventory types.
- Updated ProductVariant model to include inventory_policy and cantidad_vendida attributes.
- Modified addItem and updateItem methods in Cart to check available quantity based on inventory policy.
- Added migration to include inventory_policy and cantidad_vendida in productos_variantes table.
- Enhanced tests to validate inventory behavior for both tracked and unlimited policies.
- Updated various request and resource classes to handle new inventory fields.
2026-07-16 08:36:59 -03:00
716d8e447c feat: add Fecha attribute seeder and integrate FiestaFutbolInfantilProductSeeder 2026-07-15 14:21:19 -03:00
cababe1a43 feat: add ticket fields to Product and ProductVariant models, requests, and resources 2026-07-14 15:19:53 -03:00
0e9f7faaf1 feat: update CatalogController to return JsonResponse instead of AnonymousResourceCollection 2026-07-14 15:04:01 -03:00
13bdb436ed feat: implement FeaturedGroup and FeaturedVariant controllers, models, requests, resources, and routes for managing featured groups and variants 2026-07-14 14:58:28 -03:00
c2efec8906 feat: implement FeaturedVariant and CatalogController for managing featured variants and catalog display 2026-07-14 14:55:48 -03:00
299a462283 feat: implement FeaturedGroup and FeaturedProduct controllers, requests, resources, and routes for managing featured products 2026-07-14 14:21:04 -03:00
38f36a3a23 feat: add hero background image functionality to Tenant model and related requests, resources, and seeder 2026-07-14 13:58:49 -03:00
b4b888adef feat: add FeaturedGroup and FeaturedProduct models with migrations for database structure 2026-07-14 13:30:07 -03:00
3afef63fc7 feat: update MenuSeeder to dynamically fetch menu codes and adjust tenant associations 2026-07-14 12:10:39 -03:00
e325c49a89 feat: enhance tenant resource to include menues and update seeder for new menu routes 2026-07-14 11:44:57 -03:00
9c7d025258 feat: implement Menu management with CRUD operations and database migrations 2026-07-14 11:31:30 -03:00
ba58bf79f8 feat: add 'fiesta_futbol_infantil' tenant with associated images and configurations 2026-07-14 11:25:16 -03:00
2910d3f929 feat: add hero_config and event_config to Tenant model with validation and migration 2026-07-14 11:08:59 -03:00
ca7a2ae55c feat: add payer_dni field and validation for transfer payments, update related logic and tests 2026-07-12 19:48:56 +00:00
ca4fea6bcd feat: enhance purchase detail handling to support cart items and improve resource structure 2026-07-08 15:50:40 -03:00
73bf285bad feat: implement profile update functionality with validation and password handling 2026-07-08 12:10:23 -03:00
b273b2866f feat: add status filter to purchase index query for enhanced data retrieval 2026-07-08 11:32:56 -03:00
05a92b7ec3 feat: update purchase query in TelepagosWebhookService to handle multiple statuses and normalize DNI format 2026-07-08 10:05:49 -03:00
f23029e785 feat: enhance integration setup process with error handling and validation 2026-07-08 09:00:42 -03:00
f18fbc8147 feat: rename finalize methods to complete for consistency in purchase processing 2026-07-07 15:36:07 -03:00
c276ccd311 feat: refactor purchase status handling and add finalize endpoint for purchases 2026-07-07 15:33:29 -03:00
786b4eb6a9 feat: add MergeGuestCartMiddleware to tenant route for enhanced cart handling 2026-07-07 12:55:07 -03:00
eca3b01083 feat: update Telepagos webhook and payment intent logic for improved data handling 2026-07-07 11:11:21 -03:00
7b2a741884 feat: add total field to purchases and update related logic for payment processing 2026-07-07 10:16:28 -03:00
362e888e46 feat: update .gitignore to include .env.testing and enhance phpunit.xml with additional environment variables 2026-07-07 09:25:39 -03:00
92458e217e feat: enhance Telepagos webhook handling for transferencia and QR operations with improved purchase matching logic 2026-07-07 08:57:27 -03:00
4edda0bfd8 feat: add default entity value for payment account information in paymentIntent method 2026-07-06 16:53:58 -03:00
2c2cf45f78 feat: remove BankAccount domain and related functionality, including controller, model, requests, routes, and tests 2026-07-06 16:53:45 -03:00
1fcb3129ea feat: enhance Telepagos integration with token refresh handling and refactor CheckoutService to use updateOrCreate for purchases 2026-07-06 16:24:00 -03:00
3703ae9bcf feat: add getTotalAmount method to Cart and refactor Purchase model to utilize it 2026-07-06 15:26:41 -03:00
c19e00987e feat: implement MergeGuestCartMiddleware to merge guest cart with user cart upon authentication 2026-07-06 14:57:19 -03:00
7be2221bb8 feat: update MeController to return user data as UserResource 2026-07-06 14:30:15 -03:00
dc32eb9bee feat: enhance checkout process with cart integration and stock confirmation 2026-07-06 14:06:25 -03:00
6bda3bf013 feat: add payment intent handling in PurchaseController with validation and QR generation 2026-07-06 11:47:04 -03:00
3731cd67fe feat: implement Telepagos webhook handling with request validation and service integration 2026-07-06 09:57:45 -03:00
403caf77f5 feat: add getCashinDetails method to TelepagosIntegrationService and corresponding tests 2026-07-06 09:01:45 -03:00
04b2bb0b2a feat: add QR code generation and response handling to TelepagosIntegrationService with logging for errors 2026-07-06 08:58:14 -03:00
1af5b1d7ed feat: add client method to BaseIntegrationService for pre-configured HTTP client and implement test for client functionality 2026-07-06 08:50:52 -03:00
387e136707 feat: update environment configuration for production and enhance TelepagosIntegrationService with token caching and error handling 2026-07-06 08:48:40 -03:00
9a1e36337d feat: implement BaseIntegrationService and TelepagosIntegrationService with integration handling and header generation 2026-07-06 08:40:32 -03:00
0c28302f84 refactor: remove unused 'payment_status' field from StorePurchaseRequest rules 2026-07-06 08:35:52 -03:00
0cb1c37566 refactor: remove PaymentProviderInterface and TelepagosProvider, simplify CheckoutService and related tests 2026-07-03 16:55:11 -03:00
057e6757dc feat: add customer details fields to Purchase model and request, implement checkout process with cart clearing 2026-07-03 16:48:31 -03:00
6d066beea3 feat: add optional 'dni' and 'telefono' fields to User model and registration process 2026-07-03 16:24:56 -03:00
1d94ff8885 feat: implement checkout service and payment processing integration 2026-07-03 15:37:26 -03:00
ee3be8d550 feat: configure integration secret and update seeder for Telepagos integration 2026-07-03 15:35:31 -03:00
21b8fa5f35 feat: add 'url' field to Integration model and update related requests and seeder 2026-07-03 14:45:05 -03:00
3887a4fcba feat: implement integration management with CRUD operations and routes for tenant integration 2026-07-03 14:44:18 -03:00
0d4c6a4492 feat: implement BankAccount management with CRUD operations and routes for tenant selection 2026-07-03 13:58:21 -03:00
c1b7b12fe1 feat: add endpoint to retrieve selected bank account for a tenant and update routes with authentication 2026-07-03 13:54:31 -03:00
b1e40b3430 feat: implement BankAccount management with CRUD operations and selection for tenants 2026-07-03 13:43:32 -03:00
b94efea4e0 feat: update maximum quantity error message in Cart model and test 2026-07-03 08:44:49 -03:00
1ae3e9ce92 feat: update login error message for incorrect credentials 2026-07-03 08:37:04 -03:00
0efa1650a0 feat: add MeController and endpoint for retrieving current user information 2026-07-02 16:07:48 -03:00
9a5a8e85b9 feat: implement authentication functionality with login and logout endpoints 2026-07-02 14:55:47 -03:00
a95cfc780b feat: implement user registration functionality with validation and response handling 2026-07-02 12:23:50 -03:00
18eedd3209 feat: add success messages for cart item operations in CartController 2026-07-02 12:06:00 -03:00
00837850e6 feat: enhance stock validation messages in addItem and updateItem methods 2026-07-02 10:37:08 -03:00
e5bc2cbe38 feat: add CORS configuration file for handling cross-origin requests 2026-07-02 09:49:29 -03:00
efa499d993 feat: rename stock fields to cantidad_maxima in ProductResource and ProductVariantResource 2026-07-02 09:37:43 -03:00
6a0b08c9d7 feat: implement multi-tenant cart system with stock reservation and inventory tracking 2026-07-01 16:21:53 -03:00
230ea57ce0 feat: add TenantSeeder to initialize sonder tenant with header logo asset 2026-07-01 16:05:55 -03:00
4943e68606 feat: implement multi-tenant cart functionality and add image validation helper 2026-07-01 15:38:12 -03:00
3285015841 feat: differentiate between header and footer colors 2026-07-01 15:37:46 -03:00
52b422c1ca feat: update product tenant relations, implement attachments, refactor product attributes, and configure SQLite for testing 2026-07-01 15:05:21 -03:00
a79f58d520 feat: implement cart service and resource to manage user and guest shopping carts 2026-07-01 14:56:12 -03:00
a7dba4dc10 feat: implement CartService to manage cart lifecycle and guest identity resolution 2026-07-01 12:38:46 -03:00
4aedf7ca86 feat: add tenant success color 2026-07-01 11:55:08 -03:00
a150a9f694 feat: implement product and variant CRUD management service with attachment handling 2026-07-01 11:44:56 -03:00
6d0d600b4b feat: add product catalog image seeder and support assets 2026-07-01 11:01:48 -03:00
67c2a7350e feat: create ProductService to handle product, variant, and attribute lifecycle management 2026-07-01 10:22:02 -03:00
a0a0cc7255 feat: add ProductVariant model and service layer for CRUD operations with attachment handling 2026-07-01 10:09:04 -03:00
d6a17cb449 feat: add is_default field to product variants and implement default variant management logic in ProductService 2026-07-01 10:02:26 -03:00
83f42f3cf9 test: add feature tests for product creation, variant management, and attribute synchronization 2026-07-01 09:58:49 -03:00
bbfe632926 test: add feature tests for product and variant management with attribute associations 2026-07-01 09:51:24 -03:00
e78e64ed2e feat: implement product seeding logic from image catalog and add supporting service methods and requests 2026-07-01 09:48:43 -03:00
35384b48ab feat: add filtering for product detail attribute options based on available variant values 2026-07-01 08:55:27 -03:00
46685a7141 feat: enhance product detail retrieval to support variant selection and fallback image handling 2026-07-01 01:05:31 +00:00
1aa831f921 feat: refactor product detail retrieval to simplify variant handling and enhance response structure 2026-07-01 01:05:21 +00:00
429fa36b05 feat: update product and variant resources to improve variant selection handling and response structure 2026-06-30 16:53:23 -03:00
cb15905c46 feat: refactor product attributes handling to use product_attribute table and update related models and requests 2026-06-30 16:35:56 -03:00
191a40fcb9 feat: enhance product detail retrieval to support variant selection and update resource fields 2026-06-30 16:21:15 -03:00
1d0f9e323d feat: update product resource and seeder to support variant groups and remove unused fields 2026-06-30 16:11:18 -03:00
fdeba7540b feat: refactor product variant handling to use product pricing and remove unnecessary fields 2026-06-30 15:58:19 -03:00
8b10996319 feat: exclude attributes from product list response in ProductController 2026-06-30 11:18:53 -03:00
72261bf422 feat: implement product management module with CRUD operations, service layer, and validation logic 2026-06-30 09:50:40 -03:00
f664fbe2dc feat: add Postman collection for ShopIt API endpoints 2026-06-29 17:01:27 -03:00
46e7c27ae7 feat: implement product controller, service, and feature tests with updated API collection. 2026-06-29 17:01:21 -03:00
14f3906ef0 feat: update ProductController and ProductResource to include attribute options in product loading 2026-06-29 15:02:02 -03:00
edcb73f748 feat: refactor ProductVariantController and requests to use scoped product handling and update route parameters 2026-06-29 14:51:36 -03:00
a7372f9d08 feat: update product and variant resources to include attribute options and metadata resolution 2026-06-29 14:42:14 -03:00
b05a574610 feat: optimize category and product resource queries with eager loading and response formatting improvements 2026-06-29 14:35:20 -03:00
59ed592787 feat: enhance Category and Brand resources with tenant handling and serialization improvements 2026-06-29 14:18:29 -03:00
24235c5ca9 feat: add brand and category relationships to products, update requests and resources, and seed product catalog from images 2026-06-29 13:47:05 -03:00
49771ebb13 feat: implement product and attachment management service with supporting migrations and API controllers 2026-06-29 12:13:21 -03:00
cd420e02d8 feat: create ProductService to handle product, variant, and attribute management operations 2026-06-29 12:04:14 -03:00
0206ee2985 feat: implement product variant CRUD operations and tenant configuration seeding 2026-06-29 11:59:07 -03:00
9b7d728117 feat: implement product and attribute management services with corresponding controllers and feature tests 2026-06-29 11:37:22 -03:00
b5b57a753c feat: implement product and variant management controllers with request validation and feature tests 2026-06-29 11:10:04 -03:00
18504594eb feat: implement product creation workflow with variants, attribute definitions, and validation logic 2026-06-29 11:02:04 -03:00
9d3ed17c00 feat: implement BrandSeeder and CategorySeeder and register them in DatabaseSeeder 2026-06-29 10:02:12 -03:00
74e443ece1 feat: add attachment relationship to ProductVariant model and database schema 2026-06-29 09:56:15 -03:00
6efda4727f feat: implement product attribute options with database migration, resource, and validation requests 2026-06-29 09:48:56 -03:00
b0508d79b3 feat: implement Attachment domain for file storage and integrate into Tenant service for logo management 2026-06-29 09:43:46 -03:00
67bc9e6cb4 feat: add DatabaseSeeder and TenantSeeder with sample tenant configuration and assets 2026-06-29 09:32:09 -03:00
4125d8353d feat: add multi-tenant controllers for catalog and tenant management and implement pagination macro in AppServiceProvider 2026-06-29 09:02:39 -03:00
31b65eb676 feat: implement full CRUD support and database schema for multi-tenant architecture 2026-06-26 16:57:59 -03:00
8708cea122 feat: implement tenant update request validation and add feature tests for domain bootstrapping 2026-06-26 16:23:14 -03:00
54aa696145 feat: implement tenant management system with attachment support and bootstrap controller 2026-06-26 13:17:48 -03:00
1f9d876bc3 feat: implement tenant management system including database schema, CRUD services, and bootstrap functionality 2026-06-26 12:16:50 -03:00
0dae985230 feat: implement tenant management system including CRUD operations and domain bootstrapping functionality 2026-06-26 11:55:05 -03:00
4c237fb729 feat: implement product attributes and tenant properties management with CRUD controllers, requests, and domain models 2026-06-26 11:40:07 -03:00
edb4c128d6 Merge branch 'feature/compras' into dev 2026-06-26 11:16:11 -03:00
6c45f45815 feat(purchase): implement PurchaseController with CRUD operations and request validation 2026-06-25 10:58:55 -03:00
70f51ac863 feat(purchase): add Purchase and PurchaseItem models with migrations for compras and compra_items tables 2026-06-25 10:34:33 -03:00
376 changed files with 26060 additions and 2590 deletions

View File

@@ -1,11 +1,17 @@
APP_NAME=Laravel
APP_ENV=local
APP_ENV=production
APP_KEY=
APP_DEBUG=true
APP_DEBUG=false
APP_URL=http://localhost
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
@@ -13,25 +19,38 @@ APP_MAINTENANCE_DRIVER=file
# PHP_CLI_SERVER_WORKERS=4
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
BCRYPT_ROUNDS=12
AUTH_MAX_LOGIN_ATTEMPTS=5
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
INTEGRATION_SECRET=change-me
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
SANCTUM_EXPIRATION=720
SESSION_SECURE_COOKIE=false
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -47,7 +66,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_MAILER=smtp
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
@@ -56,13 +75,11 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ENDPOINT=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=
AWS_HTTP_VERIFY=
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_HTTP_VERIFY=true
VITE_APP_NAME="${APP_NAME}"

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
*.log
.DS_Store
.env
.env.testing
.env.backup
.env.production
.phpactor.json

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,367 @@
{
"info": {
"_postman_id": "76df1733-a3d8-4f2b-9ffb-7c70e3032549",
"name": "ShopIt - Tenant CRUD",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "List Tenants",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
}
},
"response": []
},
{
"name": "Bootstrap Tenant",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/bootstrap/acme.com",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"bootstrap",
"acme.com"
]
}
},
"response": []
},
{
"name": "Create Tenant (JSON)",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#111111\",\n \"secondary_color\": \"#222222\",\n \"danger_color\": \"#333333\",\n \"header_bg_color\": \"#444444\",\n \"footer_bg_color\": \"#444444\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
}
},
"response": []
},
{
"name": "Create Tenant (Form Data)",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "codigo",
"value": "acme",
"type": "text"
},
{
"key": "nombre",
"value": "Acme Corporation",
"type": "text"
},
{
"key": "dominio",
"value": "acme.com",
"type": "text"
},
{
"key": "primary_color",
"value": "#111111",
"type": "text"
},
{
"key": "secondary_color",
"value": "#222222",
"type": "text"
},
{
"key": "danger_color",
"value": "#333333",
"type": "text"
},
{
"key": "header_bg_color",
"value": "#444444",
"type": "text"
},
{
"key": "footer_bg_color",
"value": "#444444",
"type": "text"
},
{
"key": "header_logo",
"type": "file",
"src": []
},
{
"key": "footer_logo",
"type": "file",
"src": []
}
]
},
"url": {
"raw": "{{base_url}}/api/tenants",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants"
]
}
},
"response": []
},
{
"name": "Show Tenant",
"request": {
"method": "GET",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
},
{
"name": "Update Tenant (JSON)",
"request": {
"method": "PUT",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"codigo\": \"acme\",\n \"nombre\": \"Acme Corporation Updated\",\n \"dominio\": \"acme.com\",\n \"primary_color\": \"#555555\",\n \"secondary_color\": \"#666666\",\n \"danger_color\": \"#777777\",\n \"header_bg_color\": \"#888888\",\n \"footer_bg_color\": \"#888888\"\n}"
},
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
},
{
"name": "Update Tenant (Form Data)",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "formdata",
"formdata": [
{
"key": "_method",
"value": "PUT",
"type": "text"
},
{
"key": "codigo",
"value": "acme",
"type": "text"
},
{
"key": "nombre",
"value": "Acme Corporation Updated",
"type": "text"
},
{
"key": "dominio",
"value": "acme.com",
"type": "text"
},
{
"key": "primary_color",
"value": "#555555",
"type": "text"
},
{
"key": "secondary_color",
"value": "#666666",
"type": "text"
},
{
"key": "danger_color",
"value": "#777777",
"type": "text"
},
{
"key": "header_bg_color",
"value": "#888888",
"type": "text"
},
{
"key": "footer_bg_color",
"value": "#888888",
"type": "text"
},
{
"key": "header_logo",
"type": "file",
"src": []
},
{
"key": "footer_logo",
"type": "file",
"src": []
}
]
},
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
},
{
"name": "Delete Tenant",
"request": {
"method": "DELETE",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"url": {
"raw": "{{base_url}}/api/tenants/acme",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"acme"
]
}
},
"response": []
}
],
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
""
]
}
}
],
"variable": [
{
"key": "base_url",
"value": "http://localhost:8000",
"type": "string"
}
]
}

View File

@@ -1,44 +0,0 @@
<?php
namespace App\Domains\Attachable\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'attachable_type',
'attachable_id',
'attachment_id',
])]
class AttachableAttachment extends Model
{
protected $table = 'attachable_attachments';
public $timestamps = false;
protected function casts(): array
{
return [
'attachable_id' => 'integer',
'attachment_id' => 'integer',
];
}
/**
* @return MorphTo<Model, $this>
*/
public function attachable(): MorphTo
{
return $this->morphTo();
}
/**
* @return BelongsTo<Attachment, $this>
*/
public function attachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'attachment_id');
}
}

View File

@@ -6,7 +6,6 @@ use App\Domains\Attachable\Enums\AttachmentType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable([
@@ -42,10 +41,13 @@ class Attachment extends Model
}
/**
* @return HasMany<AttachableAttachment, $this>
* Get the pre-signed temporary S3 URL for this attachment.
*/
public function attachables(): HasMany
public function getTemporaryUrl(int $expiresInMinutes = 10): string
{
return $this->hasMany(AttachableAttachment::class, 'attachment_id');
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
$this->path,
now()->addMinutes($expiresInMinutes)
);
}
}

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Attachable\Models\Concerns;
use App\Domains\Attachable\Models\Attachment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
trait HasAttachments
{
/**
* @return MorphToMany<Attachment, Model, $this>
*/
public function attachments(): MorphToMany
{
return $this->morphToMany(
Attachment::class,
'attachable',
'attachable_attachments',
'attachable_id',
'attachment_id',
);
}
}

View File

@@ -66,6 +66,42 @@ class AttachmentService
$attachment->delete();
}
public function copy(Attachment $source, string $path): Attachment
{
$normalizedPath = $this->normalizeDirectory($path);
if ($normalizedPath === '') {
throw new AttachmentStorageException('The attachment path cannot be empty.');
}
$key = (string) Str::uuid();
$storedPath = $normalizedPath.'/'.$this->buildStoredFilename($key, (string) $source->extension);
$copied = Storage::disk('s3')->copy($source->path, $storedPath);
if (! $copied) {
throw new AttachmentStorageException('No se pudo copiar el archivo en el disco s3.');
}
try {
/** @var Attachment $attachment */
$attachment = Attachment::query()->create([
'key' => $key,
'path' => $storedPath,
'filename' => $source->filename,
'type' => $source->type,
'mime_type' => $source->mime_type,
'extension' => $source->extension,
'size' => $source->size,
]);
return $attachment;
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
}
protected function normalizeDirectory(string $path): string
{
return trim($path, '/');

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\CreateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class CreateResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
public function __invoke(CreateResetPasswordAttemptRequest $request): JsonResponse
{
$data = $request->validated();
$this->resetPasswordAttemptService->createForEmail(
$data['email'],
$data['tenant_codigo'],
);
return response()->json([
'code' => 'auth.password_reset_requested',
'message' => __('api.auth.password_reset_requested'),
'status' => ResetPasswordAttempt::STATUS_PENDING,
], 202);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Services\GoogleAuthService;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class GoogleAuthController extends Controller
{
public function __construct(private readonly GoogleAuthService $googleAuthService) {}
public function redirect(Request $request): RedirectResponse
{
return $this->googleAuthService->redirect($request);
}
public function callback(Request $request): RedirectResponse
{
return $this->googleAuthService->callback($request);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\GoogleTokenExchangeRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\GoogleAuthService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
class GoogleTokenExchangeController extends Controller
{
public function __construct(
private readonly GoogleAuthService $googleAuthService,
private readonly GuestCartMergeService $guestCartMergeService,
) {}
public function __invoke(GoogleTokenExchangeRequest $request): JsonResponse
{
$authentication = $this->googleAuthService->exchange(
$request->validated('oauth_code'),
$request->validated('tenant_codigo'),
);
$guestTokenCookie = $request->cookie('guest_token');
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
? $guestTokenCookie
: null;
$this->guestCartMergeService->merge(
$authentication['tenant_codigo'],
$authentication['user'],
$guestToken,
);
$response = response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $authentication['token'],
'token_type' => 'Bearer',
'user' => UserResource::make($authentication['user']),
]);
if ($guestToken !== null) {
$response->withCookie(Cookie::forget('guest_token'));
}
return $response;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\LoginUserRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\PasswordLoginService;
use App\Domains\Cart\Services\GuestCartMergeService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cookie;
class LoginController extends Controller
{
public function __construct(
private readonly GuestCartMergeService $guestCartMergeService,
private readonly PasswordLoginService $passwordLoginService,
) {}
public function __invoke(LoginUserRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = $this->passwordLoginService->authenticate(
$credentials['email'],
$credentials['password'],
$credentials['tenant_codigo'],
$request->ip(),
$request->userAgent(),
);
$expirationMinutes = (int) config('sanctum.expiration');
$token = $user->createToken(
'api-token',
['*'],
now()->addMinutes($expirationMinutes),
)->plainTextToken;
$guestTokenCookie = $request->cookie('guest_token');
$guestToken = is_string($guestTokenCookie) && $guestTokenCookie !== ''
? $guestTokenCookie
: null;
$this->guestCartMergeService->merge(
$credentials['tenant_codigo'],
$user,
$guestToken,
);
$response = response()->json([
'code' => 'auth.login_success',
'message' => __('api.auth.login_success'),
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
if ($guestToken !== null) {
$response->withCookie(Cookie::forget('guest_token'));
}
return $response;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LogoutController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$user->currentAccessToken()?->delete();
$user->tokens()->delete();
Auth::guard('web')->logout();
if ($request->hasSession()) {
$request->session()->invalidate();
$request->session()->regenerateToken();
}
return response()->json([
'code' => 'auth.logout_success',
'message' => __('api.auth.logout_success'),
]);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json(UserResource::make($request->user())->resolve());
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\RegisterUserRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\RegisterUserService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
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([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->response()
->setStatusCode(201);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ResetPasswordRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ResetPasswordController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(ResetPasswordRequest $request): JsonResponse
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->resetPassword(
$data['email'],
$data['codigo'],
$data['password'],
)) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.password_reset_invalid'),
]);
}
return response()->json([
'code' => 'auth.password_updated',
'message' => __('api.auth.password_updated'),
'status' => ResetPasswordAttempt::STATUS_USED,
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\UpdateProfileRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\ProfileService;
use Illuminate\Http\JsonResponse;
class UpdateProfileController
{
public function __invoke(UpdateProfileRequest $request, ProfileService $service): JsonResponse
{
$user = $service->update($request->user(), $request->validated());
return response()->json(UserResource::make($user)->resolve());
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
class ValidateResetPasswordAttemptController extends Controller
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws ValidationException
*/
public function __invoke(ValidateResetPasswordAttemptRequest $request): JsonResponse
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode(
$data['email'],
$data['codigo'],
)) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'),
]);
}
return response()->json([
'code' => 'auth.reset_code_valid',
'message' => __('api.auth.reset_code_valid'),
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Auth\Exceptions;
use Carbon\CarbonImmutable;
use RuntimeException;
class AccountLockedException extends RuntimeException
{
public function __construct(
public readonly CarbonImmutable $lockedUntil,
) {
parent::__construct('The account is temporarily locked.');
}
public function retryAfterSeconds(): int
{
return max(1, (int) now()->diffInSeconds($this->lockedUntil, false));
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'user_id',
'email_fingerprint',
'tenant_codigo',
'outcome',
'ip_address',
'user_agent',
])]
class LoginAttempt extends Model
{
public const OUTCOME_SUCCESS = 'success';
public const OUTCOME_INVALID_CREDENTIALS = 'invalid_credentials';
public const OUTCOME_ACCOUNT_LOCKED = 'account_locked';
public const UPDATED_AT = null;
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
protected function casts(): array
{
return [
'user_id' => 'integer',
'created_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated';
public const STATUS_USED = 'used';
public const STATUS_EXPIRED = 'expired';
public $timestamps = false;
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Auth\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
/** @return HasMany<ResetPasswordAttempt, $this> */
public function resetPasswordAttempts(): HasMany
{
return $this->hasMany(ResetPasswordAttempt::class);
}
/** @return HasMany<LoginAttempt, $this> */
public function loginAttempts(): HasMany
{
return $this->hasMany(LoginAttempt::class);
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'failed_login_attempts' => 'integer',
'last_failed_login_at' => 'datetime',
'locked_until' => 'datetime',
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class CreateResetPasswordAttemptRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'tenant_codigo' => ['required', 'string', Rule::exists('tenants', 'codigo')],
'email' => ['required', 'string', 'email', 'max:255'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GoogleTokenExchangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, array<int, string>> */
public function rules(): array
{
return [
'oauth_code' => ['required', 'uuid'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class RegisterUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Password;
class ResetPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
'password' => [
'required',
'string',
'confirmed',
Password::min(8)->mixedCase()->symbols(),
],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProfileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($this->user()->id),
],
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class ValidateResetPasswordAttemptRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$email = $this->input('email');
if (is_string($email)) {
$this->merge([
'email' => Str::lower(trim($email)),
]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email', 'max:255'],
'codigo' => ['required', 'string', 'regex:/^\d{4}$/'],
];
}
}

View File

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

View File

@@ -0,0 +1,173 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
class GoogleAuthService
{
public function redirect(Request $request): RedirectResponse
{
$tenantCode = $request->string('tenant')->toString();
$returnUrl = $request->string('return_url')->toString();
$tenant = Tenant::query()->where('codigo', $tenantCode)->first();
if (! $tenant || ! $this->isTenantReturnUrl($returnUrl, $tenant)) {
throw ValidationException::withMessages([
'tenant' => __('api.auth.invalid_tenant_or_return_url'),
]);
}
$state = (string) Str::uuid();
Cache::put("google-oauth-context:{$state}", [
'tenant_codigo' => $tenant->codigo,
'return_url' => rtrim($returnUrl, '/'),
], now()->addMinutes(10));
return Socialite::driver('google')
->scopes(['openid', 'profile', 'email'])
->stateless()
->with(['state' => $state])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
$state = $request->string('state')->toString();
/** @var array{tenant_codigo?: string, return_url?: string}|null $context */
$context = Str::isUuid($state) ? Cache::pull("google-oauth-context:{$state}") : null;
if (! is_array($context) || ! isset($context['tenant_codigo'], $context['return_url'])) {
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, __('api.auth.invalid_return_url'));
}
/** @var SocialiteUser $googleUser */
$googleUser = Socialite::driver('google')->stateless()->user();
$user = $this->resolveUser($googleUser, $tenant);
$token = $user->createToken(
'google-oauth',
['*'],
now()->addMinutes((int) config('sanctum.expiration')),
)->plainTextToken;
$exchangeCode = (string) Str::uuid();
Cache::put("google-oauth-exchange:{$exchangeCode}", [
'user_id' => $user->id,
'token' => $token,
'tenant_codigo' => $tenant->codigo,
], now()->addMinutes(5));
return redirect()->to($context['return_url'].'/login?'.http_build_query([
'oauth_code' => $exchangeCode,
]));
}
/** @return array{user: User, token: string, tenant_codigo: string} */
public function exchange(string $exchangeCode, string $tenantCodigo): array
{
/** @var array{user_id: int, token: string, tenant_codigo?: string}|null $authentication */
$authentication = Cache::pull("google-oauth-exchange:{$exchangeCode}");
if (! $authentication) {
throw ValidationException::withMessages([
'oauth_code' => __('api.auth.oauth_code_expired'),
]);
}
if (($authentication['tenant_codigo'] ?? null) !== $tenantCodigo) {
throw ValidationException::withMessages([
'tenant_codigo' => __('api.auth.tenant_mismatch'),
]);
}
return [
'user' => User::query()->findOrFail($authentication['user_id']),
'token' => $authentication['token'],
'tenant_codigo' => $tenantCodigo,
];
}
private function resolveUser(SocialiteUser $googleUser, Tenant $tenant): User
{
$googleId = $googleUser->getId();
$email = $googleUser->getEmail();
if (! is_string($googleId) || $googleId === '' || ! is_string($email) || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw ValidationException::withMessages([
'google' => __('api.auth.google_email_required'),
]);
}
$rawUser = $googleUser instanceof \Laravel\Socialite\Two\User ? $googleUser->getRaw() : [];
$emailVerified = $rawUser['email_verified'] ?? $rawUser['verified_email'] ?? false;
if (! in_array($emailVerified, [true, 'true', 1, '1'], true)) {
throw ValidationException::withMessages([
'google' => __('api.auth.google_email_unverified'),
]);
}
$user = User::query()->where('google_id', $googleId)->first();
if ($user) {
return $user;
}
$user = User::query()->where('email', $email)->first();
if ($user) {
$user->forceFill(['google_id' => $googleId])->save();
return $user;
}
$name = $googleUser->getName();
$user = User::query()->create([
'nombre_apellido' => is_string($name) && $name !== '' ? $name : $email,
'email' => $email,
'email_verified_at' => now(),
'google_id' => $googleId,
'password' => Str::password(64),
]);
UserRegistered::dispatch($user, $tenant->codigo);
return $user;
}
private function isTenantReturnUrl(string $returnUrl, Tenant $tenant): bool
{
$parts = parse_url($returnUrl);
if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|| ($parts['path'] ?? '') !== '') {
return false;
}
$scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
return false;
}
return $scheme === 'https' || ($scheme === 'http' && in_array($host, ['localhost', '127.0.0.1'], true));
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Auth\Models\LoginAttempt;
use App\Domains\Auth\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PasswordLoginService
{
public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
) {}
/**
* @throws AccountLockedException
* @throws ValidationException
*/
public function authenticate(
string $email,
string $password,
string $tenantCode,
?string $ipAddress,
?string $userAgent,
): User {
$normalizedEmail = mb_strtolower(trim($email));
$now = CarbonImmutable::now();
/** @var array{outcome: string, user: User|null, locked_until: CarbonImmutable|null} $result */
$result = DB::transaction(function () use (
$normalizedEmail,
$password,
$tenantCode,
$ipAddress,
$userAgent,
$now,
): array {
$user = User::query()
->where('email', $normalizedEmail)
->lockForUpdate()
->first();
if ($user?->locked_until?->isFuture()) {
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
$ipAddress,
$userAgent,
);
return [
'outcome' => LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
'user' => $user,
'locked_until' => CarbonImmutable::instance($user->locked_until),
];
}
if ($user !== null && $user->locked_until !== null) {
$user->forceFill([
'failed_login_attempts' => 0,
'last_failed_login_at' => null,
'locked_until' => null,
])->save();
}
if ($user === null || ! Hash::check($password, $user->password)) {
if ($user !== null) {
$this->registerFailure($user, $now, $tenantCode);
}
$outcome = $user?->locked_until?->isFuture()
? LoginAttempt::OUTCOME_ACCOUNT_LOCKED
: LoginAttempt::OUTCOME_INVALID_CREDENTIALS;
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
$outcome,
$ipAddress,
$userAgent,
);
return [
'outcome' => $outcome,
'user' => $user,
'locked_until' => $user?->locked_until === null
? null
: CarbonImmutable::instance($user->locked_until),
];
}
$user->forceFill([
'failed_login_attempts' => 0,
'last_failed_login_at' => null,
'locked_until' => null,
])->save();
$this->recordAttempt(
$user,
$normalizedEmail,
$tenantCode,
LoginAttempt::OUTCOME_SUCCESS,
$ipAddress,
$userAgent,
);
return [
'outcome' => LoginAttempt::OUTCOME_SUCCESS,
'user' => $user,
'locked_until' => null,
];
});
if ($result['outcome'] === LoginAttempt::OUTCOME_ACCOUNT_LOCKED) {
throw new AccountLockedException($result['locked_until']);
}
if ($result['outcome'] === LoginAttempt::OUTCOME_INVALID_CREDENTIALS) {
throw ValidationException::withMessages([
'email' => __('api.auth.invalid_credentials'),
]);
}
return $result['user'];
}
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
{
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
$lockMinutes = max(1, (int) config('login-security.lock_minutes'));
$withinAttemptWindow = $user->last_failed_login_at !== null
&& $user->last_failed_login_at->gte($now->subMinutes($windowMinutes));
$attempts = $withinAttemptWindow
? $user->failed_login_attempts + 1
: 1;
$previousAttempts = $user->failed_login_attempts;
$user->forceFill([
'failed_login_attempts' => $attempts,
'last_failed_login_at' => $now,
'locked_until' => $attempts >= $maxAttempts
? $now->addMinutes($lockMinutes)
: null,
])->save();
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
try {
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
} catch (\Throwable $e) {
Log::error('Failed to trigger reset password on account lock', [
'user_id' => $user->id,
'exception' => $e
]);
}
}
}
private function recordAttempt(
?User $user,
string $normalizedEmail,
string $tenantCode,
string $outcome,
?string $ipAddress,
?string $userAgent,
): void {
LoginAttempt::query()->create([
'user_id' => $user?->getKey(),
'email_fingerprint' => hash_hmac(
'sha256',
$normalizedEmail,
(string) config('app.key'),
),
'tenant_codigo' => $tenantCode,
'outcome' => $outcome,
'ip_address' => $ipAddress,
'user_agent' => $userAgent === null
? null
: mb_substr($userAgent, 0, 1024),
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use Illuminate\Support\Facades\Hash;
class ProfileService
{
/**
* Update the given user's profile information.
*
* @param User $user
* @param array $data
* @return User
*/
public function update(User $user, array $data): User
{
// Handle password hashing if a new password is provided
if (!empty($data['password'])) {
$data['password'] = Hash::make($data['password']);
} else {
// Remove password from array if empty so we don't overwrite it with null
unset($data['password']);
}
// Standardize phone number (strip all but numbers and leading '+')
if (!empty($data['telefono'])) {
$data['telefono'] = preg_replace('/[^\+0-9]/', '', $data['telefono']);
}
// DNI is already validated as numbers only, but we can do a quick strip just in case
if (!empty($data['dni'])) {
$data['dni'] = preg_replace('/[^0-9]/', '', $data['dni']);
}
$user->update($data);
return $user;
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\UserRegistered;
class RegisterUserService
{
/**
* @param array{tenant_codigo?: string|null, nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
*/
public function register(array $data): User
{
$user = User::query()->create([
'nombre_apellido' => $data['nombre_apellido'],
'email' => $data['email'],
'password' => $data['password'],
'dni' => $data['dni'] ?? null,
'telefono' => $data['telefono'] ?? null,
]);
if (! empty($data['tenant_codigo'])) {
UserRegistered::dispatch($user, $data['tenant_codigo']);
}
return $user;
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Models\User;
use App\Domains\Notification\Events\PasswordResetRequested;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
class ResetPasswordAttemptService
{
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
{
$emailFingerprint = $this->emailFingerprint($email);
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
if ($user === null) {
Log::warning('Password reset attempt was not created because the user was not found.', [
'email_fingerprint' => $emailFingerprint,
]);
return null;
}
$user->resetPasswordAttempts()
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
]);
return $attempt->getKey();
});
} catch (Throwable $exception) {
Log::error('Failed to create password reset attempt.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
if ($attemptId !== null) {
try {
PasswordResetRequested::dispatch($attemptId, $tenantCode);
} catch (Throwable $exception) {
Log::error('Failed to dispatch password reset email.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
}
public function validateCode(string $email, string $code): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
$attempt = $user?->resetPasswordAttempts()
->where('codigo', $code)
->where('status', ResetPasswordAttempt::STATUS_PENDING)
->latest('id')
->lockForUpdate()
->first();
if ($attempt === null) {
Log::warning('Password reset code validation failed: no matching pending attempt.', [
'email_fingerprint' => $emailFingerprint,
]);
return false;
}
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
public function resetPassword(string $email, string $code, string $password): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
->first();
$attempt = $user?->resetPasswordAttempts()
->where('codigo', $code)
->where('status', ResetPasswordAttempt::STATUS_VALIDATED)
->latest('id')
->lockForUpdate()
->first();
if ($user === null || $attempt === null) {
Log::warning('Password reset failed: no matching validated attempt.', [
'email_fingerprint' => $emailFingerprint,
]);
return false;
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
$user->locked_until = null;
$user->save();
$user->tokens()->delete();
$user->resetPasswordAttempts()
->whereKeyNot($attempt->getKey())
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_USED,
]);
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to reset user password.', [
'email_fingerprint' => $emailFingerprint,
'exception' => $exception,
]);
throw $exception;
}
}
private function generateCode(): string
{
return str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT);
}
private function emailFingerprint(string $email): string
{
return substr(hash('sha256', strtolower(trim($email))), 0, 12);
}
}

View File

@@ -0,0 +1,25 @@
<?php
use App\Domains\Auth\Controllers\CreateResetPasswordAttemptController;
use App\Domains\Auth\Controllers\GoogleTokenExchangeController;
use App\Domains\Auth\Controllers\LoginController;
use App\Domains\Auth\Controllers\LogoutController;
use App\Domains\Auth\Controllers\MeController;
use App\Domains\Auth\Controllers\RegisterController;
use App\Domains\Auth\Controllers\ResetPasswordController;
use App\Domains\Auth\Controllers\UpdateProfileController;
use App\Domains\Auth\Controllers\ValidateResetPasswordAttemptController;
use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class)->middleware('throttle:login');
Route::post('/password/reset-attempts', CreateResetPasswordAttemptController::class)
->middleware('throttle:5,1');
Route::post('/password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
->middleware('throttle:10,1');
Route::post('/password/reset', ResetPasswordController::class)
->middleware('throttle:5,1');
Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);

View File

@@ -2,11 +2,11 @@
namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -16,8 +16,7 @@ class CartController extends Controller
{
public function __construct(
protected CartService $cartService,
) {
}
) {}
public function show(Request $request, Tenant $tenant): CartResource
{
@@ -29,11 +28,19 @@ class CartController extends Controller
$result = $this->cartService->addItem(
$tenant,
$request,
(int) $request->validated('product_variant_id'),
(int) $request->validated('catalog_item_id'),
$request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null,
(int) $request->validated('cantidad'),
);
$response = CartResource::make($result['cart'])->response();
$response = CartResource::make($result['cart'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response();
if ($result['guest_token'] !== null) {
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
@@ -45,22 +52,28 @@ class CartController extends Controller
public function updateItemQuantity(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
ProductVariant $productVariant,
CartItem $cartItem,
): CartResource {
return CartResource::make(
$this->cartService->updateItemQuantity(
$tenant,
$request,
$productVariant->getKey(),
$cartItem->getKey(),
(int) $request->validated('cantidad'),
)
);
)->additional([
'code' => 'cart.quantity_updated',
'message' => __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, ProductVariant $productVariant): CartResource
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $productVariant->getKey())
);
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
}
}

View File

@@ -2,14 +2,18 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -23,6 +27,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Cart extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'carritos';
@@ -57,32 +62,50 @@ class Cart extends Model
return $this->hasMany(CartItem::class, 'cart_id');
}
public function addItem(int $productVariantId, int $quantity): CartItem
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
? $this->getRelation('items')
: $this->items()->with(['catalogItem', 'variant'])->get();
return (float) $items->reduce(
fn (float $carry, CartItem $item): float => $carry
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0,
);
}
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
$variant = $this->resolveScopedVariant($productVariantId, true);
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
if ($variant->stock < $quantity) {
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'cantidad' => 'Stock insuficiente para la variante solicitada.',
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
/** @var CartItem|null $item */
$item = $this->items()
->where('producto_variante_id', $variant->getKey())
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $variantId)
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'producto_variante_id' => $variant->getKey(),
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
} else {
@@ -90,33 +113,40 @@ class Cart extends Model
$item->save();
}
$variant->decrement('stock', $quantity);
$inventoryService->reserve($selectedItem, $quantity);
return $item->fresh();
});
}
public function updateItem(int $productVariantId, int $quantity): CartItem
public function updateItem(int $cartItemId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use ($productVariantId, $quantity): CartItem {
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
/** @var CartItem $item */
$item = $this->items()
->where('producto_variante_id', $productVariantId)
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$variant = $this->resolveScopedVariant($productVariantId, true);
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
$inventoryService = app(CatalogInventoryService::class);
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
if ($delta > 0 && $variant->stock < $delta) {
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => 'Stock insuficiente para actualizar la cantidad solicitada.',
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]);
}
@@ -124,49 +154,116 @@ class Cart extends Model
$item->save();
if ($delta > 0) {
$variant->decrement('stock', $delta);
$inventoryService->reserve($selectedItem, $delta);
}
if ($delta < 0) {
$variant->increment('stock', abs($delta));
$inventoryService->release($selectedItem, abs($delta));
}
return $item->fresh();
});
}
public function removeItem(int $productVariantId): void
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($productVariantId): void {
DB::transaction(function () use ($cartItemId): void {
/** @var CartItem $item */
$item = $this->items()
->where('producto_variante_id', $productVariantId)
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$variant = $this->resolveScopedVariant($productVariantId, true);
$variant->increment('stock', $item->cantidad);
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(CatalogInventoryService::class)->release(
$selectedItem,
$item->cantidad,
);
$item->delete();
});
}
protected function resolveScopedVariant(int $productVariantId, bool $lockForUpdate = false): ProductVariant
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,
bool $lockForUpdate = false,
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
}
$catalogItem = $catalogItemQuery->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_required'),
]);
}
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
$catalogItem->setRelation('inventory', $inventory);
return $catalogItem;
}
$variantQuery = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id);
if ($lockForUpdate) {
$variantQuery->lockForUpdate();
}
$variant = $variantQuery->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);
return $variant;
}
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
{
$query = ProductVariant::query()
->whereKey($productVariantId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
$query = Inventory::query()->whereKey($inventoryId);
if ($lockForUpdate) {
$query->lockForUpdate();
}
/** @var ProductVariant|null $variant */
$variant = $query->first();
if ($variant === null) {
throw new NotFoundHttpException('Product variant not found for tenant.');
}
return $variant;
return $query->firstOrFail();
}
}

View File

@@ -2,7 +2,8 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,7 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'cart_id',
'producto_variante_id',
'catalog_item_id',
'variant_id',
'cantidad',
])]
class CartItem extends Model
@@ -23,7 +25,8 @@ class CartItem extends Model
{
return [
'cart_id' => 'integer',
'producto_variante_id' => 'integer',
'catalog_item_id' => 'integer',
'variant_id' => 'integer',
'cantidad' => 'integer',
];
}
@@ -36,11 +39,20 @@ class CartItem extends Model
return $this->belongsTo(Cart::class, 'cart_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
return $this->belongsTo(Variant::class);
}
public function selectedItem(): CatalogItem|Variant|null
{
return $this->variant ?? $this->catalogItem;
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddCartItemRequest extends FormRequest
{
@@ -16,8 +17,24 @@ class AddCartItemRequest extends FormRequest
*/
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
return [
'product_variant_id' => ['required', 'integer'],
'catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id')->where(
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
),
],
'cantidad' => ['required', 'integer', 'min:1'],
];
}

View File

@@ -18,6 +18,8 @@ class UpdateCartItemQuantityRequest extends FormRequest
{
return [
'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['prohibited'],
];
}
}

View File

@@ -2,12 +2,12 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Catalog\Resources\ProductVariantDefinitionResource;
use App\Domains\Cart\Models\CartItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Cart\Models\CartItem
* @mixin CartItem
*/
class CartItemResource extends JsonResource
{
@@ -16,24 +16,26 @@ class CartItemResource extends JsonResource
*/
public function toArray(Request $request): array
{
$variant = $this->variant;
$product = $variant?->product;
$selectedItem = $this->selectedItem();
$imageUrl = null;
if ($selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
}
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($variant?->precio),
'subtotal' => $this->formatMoney(($variant?->precio ?? 0) * $this->cantidad),
'product' => $product === null ? null : [
'id' => $product->id,
'nombre' => $product->nombre,
'slug' => $product->slug,
],
'variant' => $variant === null ? null : [
'id' => $variant->id,
'nombre' => $variant->nombre,
'slug' => $variant->slug,
'definitions' => ProductVariantDefinitionResource::collection($variant->definitions),
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'product' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'imagen' => $imageUrl,
],
];
}

View File

@@ -2,11 +2,12 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Cart\Models\Cart
* @mixin Cart
*/
class CartResource extends JsonResource
{
@@ -20,7 +21,8 @@ class CartResource extends JsonResource
: collect();
$subtotal = $items->reduce(
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->precio ?? 0) * $item->cantidad),
fn (float $carry, $item): float => $carry
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0,
);

View File

@@ -2,10 +2,11 @@
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Tenant\Models\Tenant;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -14,13 +15,13 @@ class CartService
{
public function show(Tenant $tenant, Request $request): Cart
{
$identity = $this->resolveIdentity($request);
$resolvedIdentity = $this->resolveIdentity($request);
if ($identity === null) {
if ($resolvedIdentity === null) {
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $identity);
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
@@ -32,12 +33,17 @@ class CartService
/**
* @return array{cart: Cart, guest_token: ?string}
*/
public function addItem(Tenant $tenant, Request $request, int $productVariantId, int $quantity): array
{
public function addItem(
Tenant $tenant,
Request $request,
int $catalogItemId,
?int $variantId,
int $quantity,
): array {
$resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($productVariantId, $quantity);
$cart->addItem($catalogItemId, $variantId, $quantity);
return [
'cart' => $this->loadCart($cart),
@@ -45,20 +51,20 @@ class CartService
];
}
public function updateItemQuantity(Tenant $tenant, Request $request, int $productVariantId, int $quantity): Cart
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($productVariantId, $quantity);
$cart->updateItem($cartItemId, $quantity);
return $this->loadCart($cart);
}
public function removeItem(Tenant $tenant, Request $request, int $productVariantId): Cart
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($productVariantId);
$cart->removeItem($cartItemId);
return $this->loadCart($cart);
}
@@ -93,8 +99,11 @@ class CartService
protected function loadCart(Cart $cart): Cart
{
return $cart->fresh()->load([
'items.variant.product',
'items.variant.definitions.attribute',
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute',
]);
}
@@ -103,7 +112,7 @@ class CartService
*/
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{
$user = $request->user();
$user = $request->user() ?? Auth::guard('sanctum')->user();
if ($user instanceof User) {
return [
@@ -163,6 +172,7 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', 'active')
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
@@ -190,7 +200,10 @@ class CartService
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
$attributes = ['tenant_codigo' => $tenant->codigo];
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
];
if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id'];
@@ -198,7 +211,6 @@ class CartService
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
return Cart::query()->firstOrCreate($attributes);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use Illuminate\Support\Facades\DB;
class GuestCartMergeService
{
public function merge(string $tenantCodigo, User $user, ?string $guestToken): void
{
if ($guestToken === null || $guestToken === '') {
return;
}
DB::transaction(function () use ($tenantCodigo, $user, $guestToken): void {
$guestCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('guest_token', $guestToken)
->where('status', 'active')
->lockForUpdate()
->first();
if ($guestCart === null || ! $guestCart->items()->exists()) {
return;
}
$userCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('user_id', $user->getKey())
->where('status', 'active')
->lockForUpdate()
->first();
if ($userCart !== null) {
$userCart->update([
'status' => 'converted',
]);
$userCart->delete();
}
$guestCart->update([
'user_id' => $user->getKey(),
'guest_token' => null,
]);
});
}
}

View File

@@ -3,9 +3,10 @@
use App\Domains\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']);
Route::patch('cart/items/{productVariant}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{productVariant}', [CartController::class, 'removeItem']);
});
Route::prefix('tenants/{tenant:codigo}')
->group(function (): void {
Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']);
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
});

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Requests\StoreBrandRequest;
use App\Domains\Catalog\Requests\UpdateBrandRequest;
use App\Domains\Catalog\Resources\BrandResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BrandController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return BrandResource::collection(
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->get()
)->response();
}
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
{
$brand = Brand::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return BrandResource::make($brand)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
return BrandResource::make($marca);
}
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->update($request->validated());
return BrandResource::make($marca);
}
public function destroy(Tenant $tenant, Brand $marca): Response
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->delete();
return response()->noContent();
}
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
{
if ($brand->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Brand not found for tenant.');
}
return $brand;
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class CatalogController extends Controller
{
private const ITEMS_PER_PAGE = 12;
public function index(Tenant $tenant): JsonResponse
{
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->orderBy('group_order')
->get();
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$this->featuredItemsResponse($featuredGroup, 1),
))->resolve()
));
}
public function search(
SearchCatalogItemsRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): AnonymousResourceCollection {
return CatalogSearchItemResource::collection(
$catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
)
);
}
public function category(
CategoryPageRequest $request,
Tenant $tenant,
Category $category,
CatalogService $catalogService,
): AnonymousResourceCollection {
abort_unless($category->tenant_code === $tenant->codigo, 404);
return CatalogSearchItemResource::collection(
$catalogService->categoryItems(
$tenant,
$category,
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
)
)->additional([
'category' => [
'id' => $category->id,
'nombre' => $category->nombre,
'categoria_id' => $category->categoria_id,
],
'layout' => $tenant->search_product_layout->value,
'group_layout' => $tenant->search_group_layout->value,
]);
}
public function featuredGroupItems(
FeaturedGroupPageRequest $request,
Tenant $tenant,
FeaturedGroup $featuredGroup,
): JsonResponse {
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
$page = (int) $request->validated('page', 1);
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
}
public function show(
CatalogItemDetailRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
CatalogService $catalogService,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$variantId = $request->validated('variant_id');
return CatalogItemDetailResource::make(
$catalogService->getDetail(
$catalogItem,
$variantId === null ? null : (int) $variantId,
)
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
}
/** @return array<array-key, mixed> */
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$featuredItems = $this->featuredItemsQuery($featuredGroup)->get();
$featuredItems->each(
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
);
return CatalogFeaturedItemResource::collection($featuredItems)->resolve();
}
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
$paginator->getCollection()->each(
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
);
return CatalogFeaturedItemResource::collection($paginator)
->response()
->getData(true);
}
private function paginateFeaturedItems(
FeaturedGroup $featuredGroup,
int $page,
): LengthAwarePaginator {
$paginator = $this->featuredItemsQuery($featuredGroup)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,
);
return $paginator->withPath(route('catalog.featured-groups.items.index', [
'tenant' => $featuredGroup->tenant_code,
'featuredGroup' => $featuredGroup->id,
]));
}
/** @return HasMany<FeaturedItem, FeaturedGroup> */
private function featuredItemsQuery(FeaturedGroup $featuredGroup): HasMany
{
return $featuredGroup->featuredItems()->with([
'catalogItem.inventory',
'catalogItem.attachments',
'catalogItem.variants.inventory',
'catalogItem.variants.attachments',
'catalogItem.variants.definitions.itemAttribute.attribute',
'catalogItem.bundleComponents.catalogItem',
'catalogItem.bundleComponents.variant.catalogItem',
]);
}
}

View File

@@ -1,71 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Requests\StoreCategoryRequest;
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
use App\Domains\Catalog\Resources\CategoryResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CategoryController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return CategoryResource::collection(
Category::query()
->where('tenant_code', $tenant->codigo)
->with(['parent', 'subCategories', 'tenant'])
->orderByDesc('id')
->get()
)->response();
}
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
{
$category = Category::query()->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
->response()
->setStatusCode(201);
}
public function show(Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$categoria->update($request->validated());
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function destroy(Tenant $tenant, Category $categoria): Response
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$categoria->delete();
return response()->noContent();
}
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
{
if ($category->tenant_code !== $tenant->codigo) {
throw new NotFoundHttpException('Category not found for tenant.');
}
return $category;
}
}

View File

@@ -1,103 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Requests\StoreProductAttributeRequest;
use App\Domains\Catalog\Requests\UpdateProductAttributeRequest;
use App\Domains\Catalog\Resources\ProductAttributeResource;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductAttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = ProductAttribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return ProductAttributeResource::collection($query->get())->response();
}
public function store(StoreProductAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = DB::transaction(function () use ($request, $tenant): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
/** @var ProductAttribute $attribute */
$attribute = ProductAttribute::query()->create([
...$validated,
'tenant_codigo' => $tenant->codigo,
]);
$attribute->options()->createMany($options);
return $attribute->load('options');
});
return ProductAttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
return ProductAttributeResource::make($productAttribute->load('options'));
}
public function update(UpdateProductAttributeRequest $request, Tenant $tenant, ProductAttribute $productAttribute): ProductAttributeResource
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
$productAttribute = DB::transaction(function () use ($request, $productAttribute): ProductAttribute {
$validated = $request->validated();
$options = $validated['options'] ?? [];
unset($validated['options']);
$type = ProductAttributeType::from((string) $validated['type']);
if (! $type->supportsOptions()) {
$validated['metadata_schema'] = null;
$options = [];
}
$productAttribute->update($validated);
$productAttribute->options()->delete();
$productAttribute->options()->createMany($options);
return $productAttribute->load('options');
});
return ProductAttributeResource::make($productAttribute);
}
public function destroy(Tenant $tenant, ProductAttribute $productAttribute): Response
{
$productAttribute = $this->resolveScopedAttribute($tenant, $productAttribute);
$productAttribute->delete();
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, ProductAttribute $attribute): ProductAttribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Requests\StoreProductRequest;
use App\Domains\Catalog\Requests\UpdateProductRequest;
use App\Domains\Catalog\Resources\ProductResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return ProductResource::collection(
Product::query()->where('tenant_codigo', $tenant->codigo)->latest()->get()
)->response();
}
public function store(StoreProductRequest $request, Tenant $tenant): JsonResponse
{
$product = Product::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return ProductResource::make($product)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Product $producto): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
return ProductResource::make($producto);
}
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto->update($request->validated());
return ProductResource::make($producto);
}
public function destroy(Tenant $tenant, Product $producto): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto->delete();
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found for tenant.');
}
return $product;
}
}

View File

@@ -1,109 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
use App\Domains\Catalog\Resources\ProductVariantResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductVariantController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = ProductVariant::query()
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $tenant->codigo))
->with(['product', 'definitions.attribute'])
->latest();
return ProductVariantResource::collection($query->get())->response();
}
public function store(StoreProductVariantRequest $request, Tenant $tenant): JsonResponse
{
$validated = $request->validated();
$variant = DB::transaction(function () use ($request, $tenant): ProductVariant {
$definitions = $validated['definitions'] ?? [];
unset($validated['definitions']);
$product = $this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
/** @var ProductVariant $variant */
$variant = $product->variants()->create($validated);
$variant->definitions()->createMany($definitions);
return $variant->load(['product', 'definitions.attribute']);
});
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
return ProductVariantResource::make($productVariant->load(['product', 'definitions.attribute']));
}
public function update(UpdateProductVariantRequest $request, Tenant $tenant, ProductVariant $productVariant): ProductVariantResource
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
$productVariant = DB::transaction(function () use ($request, $tenant, $productVariant): ProductVariant {
$validated = $request->validated();
$definitions = $validated['definitions'] ?? [];
unset($validated['definitions']);
$this->resolveTenantProduct($tenant, (int) $validated['producto_id']);
$productVariant->update($validated);
$productVariant->definitions()->delete();
$productVariant->definitions()->createMany($definitions);
return $productVariant->load(['product', 'definitions.attribute']);
});
return ProductVariantResource::make($productVariant);
}
public function destroy(Tenant $tenant, ProductVariant $productVariant): Response
{
$productVariant = $this->resolveScopedVariant($tenant, $productVariant);
$productVariant->delete();
return response()->noContent();
}
protected function resolveScopedVariant(Tenant $tenant, ProductVariant $variant): ProductVariant
{
$variant->loadMissing('product');
if ($variant->product === null || $variant->product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product variant not found for tenant.');
}
return $variant;
}
protected function resolveTenantProduct(Tenant $tenant, int $productId): Product
{
$product = Product::query()
->whereKey($productId)
->where('tenant_codigo', $tenant->codigo)
->first();
if ($product === null) {
throw new NotFoundHttpException('Product not found for tenant.');
}
return $product;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogItemType: string
{
case Standard = 'standard';
case Bundle = 'bundle';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Catalog\Enums;
enum GroupLayout: string
{
case Paginated = 'paginated';
case Simple = 'simple';
case SimpleVertical = 'simple_vertical';
case Carousel = 'carousel';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,12 +1,11 @@
<?php
namespace App\Domains\Tenant\Support;
namespace App\Domains\Catalog\Enums;
enum TenantPropDataType: string
enum InventoryPolicy: string
{
case String = 'string';
case Number = 'number';
case Boolean = 'boolean';
case Tracked = 'tracked';
case Unlimited = 'unlimited';
/**
* @return list<string>

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Catalog\Enums;
enum ProductLayout: string
{
case Row = 'row';
case ColumnWithImage = 'column_with_image';
case ColumnWithCart = 'column_with_cart';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Support\ProductAttributeType;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -18,11 +18,11 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'metadata_schema',
'type',
])]
class ProductAttribute extends Model
class Attribute extends Model
{
use HasFactory;
protected $table = 'productos_attributes';
protected $table = 'attribute';
/**
* @return array<string, string>
@@ -32,7 +32,7 @@ class ProductAttribute extends Model
return [
'is_required' => 'boolean',
'metadata_schema' => 'array',
'type' => ProductAttributeType::class,
'type' => FieldType::class,
];
}
@@ -45,18 +45,10 @@ class ProductAttribute extends Model
}
/**
* @return HasMany<ProductAttributeOption, $this>
* @return HasMany<AttributeOption, $this>
*/
public function options(): HasMany
{
return $this->hasMany(ProductAttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function variantDefinitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'attribute_id');
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
}

View File

@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'value',
'label',
'sort_order',
'metadata',
])]
class ProductAttributeOption extends Model
class AttributeOption extends Model
{
use HasFactory;
@@ -31,10 +32,10 @@ class ProductAttributeOption extends Model
}
/**
* @return BelongsTo<ProductAttribute, $this>
* @return BelongsTo<Attribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
return $this->belongsTo(Attribute::class, 'attribute_id');
}
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
@@ -26,4 +27,10 @@ class Brand extends Model
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_catalog_item_id',
'component_catalog_item_id',
'component_variant_id',
'quantity',
])]
class BundleComponent extends Model
{
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'bundle_catalog_item_id' => 'integer',
'component_catalog_item_id' => 'integer',
'component_variant_id' => 'integer',
'quantity' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function bundle(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id');
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'component_variant_id');
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'category_id',
'brand_id',
'inventory_id',
'type',
'slug',
'nombre',
'descripcion',
'precio',
'inventory_policy',
'has_tickets',
'maximum_use_date',
'minimum_use_date',
])]
class CatalogItem extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'catalog_items';
protected $attributes = [
'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'has_tickets' => false,
];
protected function casts(): array
{
return [
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'has_tickets' => 'boolean',
'maximum_use_date' => 'datetime',
'minimum_use_date' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/** @return BelongsTo<Brand, $this> */
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return HasMany<BundleComponent, $this> */
public function bundleComponents(): HasMany
{
return $this->hasMany(BundleComponent::class, 'bundle_catalog_item_id');
}
/** @return HasMany<BundleComponent, $this> */
public function bundleComponentUsages(): HasMany
{
return $this->hasMany(BundleComponent::class, 'component_catalog_item_id');
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{
return $this->belongsToMany(Attribute::class, 'item_attributes')
->withTimestamps();
}
/** @return HasMany<ItemAttribute, $this> */
public function itemAttributes(): HasMany
{
return $this->hasMany(ItemAttribute::class);
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{
return $this->hasMany(FeaturedItem::class);
}
/** @return BelongsToMany<Attachment, $this> */
public function attachments(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'catalog_items_attachments',
'catalog_item_id',
'attachment_id'
)
->withPivot('orden')
->wherePivotNull('variant_id')
->orderByPivot('orden');
}
public function availableStock(): ?int
{
return app(CatalogInventoryService::class)->availableQuantity($this);
}
public function isAvailable(): bool
{
if ($this->type === CatalogItemType::Bundle) {
$availableStock = $this->availableStock();
return $availableStock === null || $availableStock > 0;
}
if ($this->inventory_policy === InventoryPolicy::Unlimited) {
return true;
}
return ($this->availableStock() ?? 0) > 0;
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date;
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date;
}
public function isBundle(): bool
{
return $this->type === CatalogItemType::Bundle;
}
}

View File

@@ -20,6 +20,9 @@ class Category extends Model
protected $table = 'categorias';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
@@ -55,4 +58,10 @@ class Category extends Model
{
return $this->hasMany(self::class, 'categoria_id');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'product_layout',
'group_layout',
'group_name',
'group_order',
])]
class FeaturedGroup extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'featured_groups';
protected function casts(): array
{
return [
'product_layout' => ProductLayout::class,
'group_layout' => GroupLayout::class,
'group_order' => 'integer',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{
return $this->hasMany(FeaturedItem::class)->orderBy('order');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'featured_group_id',
'catalog_item_id',
'order',
])]
class FeaturedItem extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'featured_items';
protected function casts(): array
{
return [
'featured_group_id' => 'integer',
'catalog_item_id' => 'integer',
'order' => 'integer',
];
}
/** @return BelongsTo<FeaturedGroup, $this> */
public function featuredGroup(): BelongsTo
{
return $this->belongsTo(FeaturedGroup::class);
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'sold_units',
'reserved_stock',
'real_stock',
])]
class Inventory extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'inventories';
protected $attributes = [
'sold_units' => 0,
'reserved_stock' => 0,
'real_stock' => 0,
];
protected function casts(): array
{
return [
'sold_units' => 'integer',
'reserved_stock' => 'integer',
'real_stock' => 'integer',
];
}
/** @return HasOne<CatalogItem, $this> */
public function catalogItem(): HasOne
{
return $this->hasOne(CatalogItem::class);
}
/** @return HasOne<Variant, $this> */
public function variant(): HasOne
{
return $this->hasOne(Variant::class);
}
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);
}
public function reserve(int $amount, bool $tracksInventory): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
}
if ($tracksInventory && $this->availableStock() < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
}
$this->reserved_stock += $amount;
$this->save();
}
public function release(int $amount): void
{
if ($amount < 0 || $this->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
}
$this->reserved_stock -= $amount;
$this->save();
}
public function buy(int $amount, bool $tracksInventory): void
{
if ($amount < 0 || $this->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
}
if ($tracksInventory && $this->real_stock < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
if ($tracksInventory) {
$this->real_stock -= $amount;
}
$this->reserved_stock -= $amount;
$this->sold_units += $amount;
$this->save();
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'attribute_id',
])]
class ItemAttribute extends Model
{
use HasFactory;
protected $table = 'item_attributes';
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Attribute, $this> */
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class);
}
/** @return HasMany<VariantDefinition, $this> */
public function variantDefinitions(): HasMany
{
return $this->hasMany(VariantDefinition::class);
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_codigo',
'categoria_id',
'slug',
'nombre',
'descripcion',
'precio',
])]
class Product extends Model
{
use HasFactory;
protected $table = 'productos';
protected function casts(): array
{
return [
'categoria_id' => 'integer',
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<ProductVariant, $this>
*/
public function variants(): HasMany
{
return $this->hasMany(ProductVariant::class, 'producto_id');
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'producto_id',
'slug',
'nombre',
'stock',
'descripcion',
'precio',
])]
class ProductVariant extends Model
{
use HasFactory;
protected $table = 'productos_variantes';
protected function casts(): array
{
return [
'producto_id' => 'integer',
'stock' => 'integer',
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'producto_id');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function definitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
}
}

View File

@@ -1,36 +0,0 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'producto_variante_id',
'attribute_id',
'value',
])]
class ProductVariantDefinition extends Model
{
use HasFactory;
protected $table = 'productos_variantes_definiciones';
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
/**
* @return BelongsTo<ProductAttribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'attribute_id');
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'catalog_item_id',
'inventory_id',
'minimum_use_date',
'maximum_use_date',
])]
class Variant extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'variantes';
protected function casts(): array
{
return [
'catalog_item_id' => 'integer',
'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany
{
return $this->hasMany(VariantDefinition::class);
}
/** @return BelongsToMany<Attachment, $this> */
public function attachments(): BelongsToMany
{
$relation = $this->belongsToMany(
Attachment::class,
'catalog_items_attachments',
'variant_id',
'attachment_id'
)
->withPivot('orden')
->orderByPivot('orden');
if ($this->catalog_item_id !== null) {
$relation->withPivotValue('catalog_item_id', $this->catalog_item_id);
}
return $relation;
}
public function getPrice(): float
{
return $this->catalogItem->getPrice();
}
public function getName(): string
{
return $this->catalogItem->nombre;
}
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'variant_id',
'item_attribute_id',
'value',
])]
class VariantDefinition extends Model
{
use HasFactory;
protected $table = 'variant_values';
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class);
}
/** @return BelongsTo<ItemAttribute, $this> */
public function itemAttribute(): BelongsTo
{
return $this->belongsTo(ItemAttribute::class);
}
}

View File

@@ -1 +0,0 @@

View File

@@ -4,21 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
class CatalogItemDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'variant_id' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -4,21 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBrandRequest extends FormRequest
class CategoryPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -4,21 +4,18 @@ namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCategoryRequest extends FormRequest
class FeaturedGroupPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
'nombre' => ['required', 'string', 'max:255'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SearchCatalogItemsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['required', 'string', 'min:2', 'max:100'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('q') && is_string($this->input('q'))) {
$this->merge(['q' => trim($this->string('q')->toString())]);
}
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCatalogItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
$type = $this->input('type', CatalogItemType::Standard->value);
$isBundle = $type === CatalogItemType::Bundle->value;
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'category_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('categorias', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'brand_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('catalog_items', 'slug')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],
'sold_units' => ['prohibited'],
'attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
'variants.*.maximum_use_date' => [
'sometimes',
'nullable',
'date',
'after_or_equal:variants.*.minimum_use_date',
],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [
Rule::requiredIf($isBundle),
Rule::prohibitedIf(! $isBundle),
'array',
'min:1',
],
'components.*.catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'components.*.variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id'),
],
'components.*.quantity' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -1,67 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Support\ProductAttributeType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreProductAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'options' => [
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
], true)),
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
ProductAttributeType::Select->value,
ProductAttributeType::Multiselect->value,
], true)),
'array',
],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'categoria_id' => ['required', 'integer'],
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
];
}
}

View File

@@ -1,52 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'producto_id' => [
'required',
'integer',
Rule::exists('productos', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'nullable',
'string',
'max:255',
Rule::unique('productos_variantes', 'slug')->where(
fn ($query) => $query->where('producto_id', $this->input('producto_id'))
),
],
'nombre' => ['nullable', 'string', 'max:255'],
'stock' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'definitions' => ['sometimes', 'array'],
'definitions.*.attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('productos_attributes', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'definitions.*.value' => ['nullable', 'string'],
];
}
}

View File

@@ -1,34 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Category;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Category|null $category */
$category = $this->route('categoria');
return [
'categoria_id' => [
'nullable',
'integer',
'exists:categorias,id',
Rule::notIn([$category?->id]),
],
'nombre' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -1,61 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Catalog\Support\ProductAttributeType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateProductAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var ProductAttribute|null $attribute */
$attribute = $this->route('productAttribute');
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('productos_attributes', 'codigo')
->ignore($attribute?->id)
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(ProductAttributeType::class)],
'options' => ['sometimes', 'array'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [ProductAttributeType::Select->value, ProductAttributeType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -1,37 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Product|null $product */
$product = $this->route('producto');
return [
'categoria_id' => ['required', 'integer'],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('productos', 'slug')->ignore($product?->id),
],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
];
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var ProductVariant|null $productVariant */
$productVariant = $this->route('productVariant');
return [
'producto_id' => [
'required',
'integer',
Rule::exists('productos', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'nullable',
'string',
'max:255',
Rule::unique('productos_variantes', 'slug')
->ignore($productVariant?->id)
->where(fn ($query) => $query->where('producto_id', $this->input('producto_id'))),
],
'nombre' => ['nullable', 'string', 'max:255'],
'stock' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'definitions' => ['sometimes', 'array'],
'definitions.*.attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('productos_attributes', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'definitions.*.value' => ['nullable', 'string'],
];
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\Brand
*/
class BrandResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
class CatalogFeaturedGroupResource extends JsonResource
{
/** @param array<array-key, mixed> $items */
public function __construct($resource, private readonly array $items)
{
parent::__construct($resource);
}
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->group_name,
'layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,
'group_order' => $this->group_order,
'items' => $this->items,
];
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedItem */
class CatalogFeaturedItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$catalogItem = $this->catalogItem;
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
return $this->columnWithImageData($catalogItem);
}
return [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'stock_tecnico' => $catalogItem->availableStock(),
'variants' => $catalogItem->variants
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
])
->values(),
];
}
/** @return array<string, mixed> */
private function columnWithImageData(CatalogItem $catalogItem): array
{
$attachment = $catalogItem->attachments->first()
?? $catalogItem->variants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
return [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'image' => $attachment?->getTemporaryUrl(1440),
];
}
}

View File

@@ -0,0 +1,142 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** @mixin CatalogItem */
class CatalogItemDetailResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var Variant|null $selectedVariant */
$selectedVariant = $this->resource->getRelation('selectedVariant');
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'category' => $this->category?->nombre,
'brand' => $this->brand?->nombre,
'inventory_policy' => $this->inventory_policy?->value,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'attributes' => $this->itemAttributes
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values(),
'stock_tecnico' => $this->when(
$selectedVariant === null,
fn () => $this->availableStock(),
),
'images' => $this->when(
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
'selected_variant' => $this->when(
$selectedVariant !== null,
fn (): array => [
...$this->variantData($selectedVariant),
'images' => $this->imageUrls($selectedVariant->attachments),
],
),
'components' => $this->when(
$this->isBundle(),
fn () => $this->bundleComponents
->map(function ($component): array {
$selectedItem = $component->variant ?? $component->catalogItem;
return [
'catalog_item_id' => $component->component_catalog_item_id,
'variant_id' => $component->component_variant_id,
'quantity' => $component->quantity,
'nombre' => $component->catalogItem->nombre,
'item_nombre' => $selectedItem->getName(),
];
})
->values(),
),
];
}
/** @return array<string, mixed> */
private function attributeData(ItemAttribute $itemAttribute): array
{
$attribute = $itemAttribute->attribute;
$availableValues = $this->variants
->flatMap->definitions
->where('item_attribute_id', $itemAttribute->id)
->pluck('value')
->filter()
->unique();
return [
'id' => $attribute->id,
'codigo' => $attribute->codigo,
'nombre' => $attribute->nombre,
'is_required' => $attribute->is_required,
'metadata_schema' => $attribute->metadata_schema,
'type' => $attribute->type->value,
'options' => $attribute->options
->whereIn('value', $availableValues)
->map(fn ($option): array => [
'id' => $option->id,
'value' => $option->value,
'label' => $option->label,
'sort_order' => $option->sort_order,
'metadata' => $option->metadata,
])
->values(),
];
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
return [
'id' => $variant->id,
'stock_tecnico' => $this->variantStock($variant),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
];
}
/** @return Collection<int, string> */
private function imageUrls(Collection $attachments): Collection
{
return $attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values();
}
private function variantStock(Variant $variant): ?int
{
return $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock();
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()),
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'real_stock' => $variant->inventory?->real_stock,
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key) => $key !== null),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),
])
->values()),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogSearchItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$attachment = $this->attachments->first()
?? $this->variants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
return [
'id' => $this->id,
'type' => $this->type->value,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'stock_tecnico' => $this->availableStock(),
'variants' => $this->variants
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key): bool => $key !== null),
])
->values(),
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Tenant\Resources\TenantResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\Category
*/
class CategoryResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_code' => $this->tenant_code,
'is_global' => $this->resource->isGlobal(),
'tenant' => $this->resource->isGlobal()
? null
: TenantResource::make($this->whenLoaded('tenant')),
'categoria_id' => $this->categoria_id,
'nombre' => $this->nombre,
'parent' => self::make($this->whenLoaded('parent')),
'sub_categories' => self::collection($this->whenLoaded('subCategories')),
];
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttributeOption
*/
class ProductAttributeOptionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'label' => $this->label,
'sort_order' => $this->sort_order,
'metadata' => $this->metadata,
];
}
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductAttribute
*/
class ProductAttributeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'is_required' => $this->is_required,
'metadata_schema' => $this->metadata_schema,
'type' => $this->type?->value ?? $this->type,
'options' => ProductAttributeOptionResource::collection($this->whenLoaded('options')),
];
}
}

View File

@@ -1,30 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\Product
*/
class ProductResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'categoria_id' => $this->categoria_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductVariantDefinition
*/
class ProductVariantDefinitionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'producto_variante_id' => $this->producto_variante_id,
'attribute_id' => $this->attribute_id,
'value' => $this->value,
'attribute' => ProductAttributeResource::make($this->whenLoaded('attribute')),
];
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductVariant
*/
class ProductVariantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'producto_id' => $this->producto_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'stock' => $this->stock,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'product' => ProductResource::make($this->whenLoaded('product')),
'definitions' => ProductVariantDefinitionResource::collection($this->whenLoaded('definitions')),
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View File

@@ -0,0 +1,218 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Support\Facades\DB;
class CatalogInventoryService
{
public function availableQuantity(CatalogItem|Variant $selection): ?int
{
if ($selection instanceof CatalogItem
&& $selection->type === CatalogItemType::Standard
&& $selection->relationLoaded('inventory')
&& $selection->inventory !== null) {
return $selection->inventory_policy === InventoryPolicy::Unlimited
? null
: $selection->inventory->availableStock();
}
if ($selection instanceof CatalogItem
&& $selection->type === CatalogItemType::Standard
&& $selection->inventory_id === null) {
if ($selection->inventory_policy === InventoryPolicy::Unlimited) {
return null;
}
$selection->loadMissing('variants.inventory');
return $selection->variants->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
}
$requirements = $this->inventoryRequirements($selection);
$trackedRequirements = array_filter(
$requirements,
fn (array $requirement): bool => $requirement['tracks_inventory'],
);
if ($trackedRequirements === []) {
return null;
}
$inventories = Inventory::query()
->whereKey(array_keys($trackedRequirements))
->get()
->keyBy('id');
$available = [];
foreach ($trackedRequirements as $inventoryId => $requirement) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
$available[] = intdiv(
$inventory->availableStock(),
$requirement['quantity'],
);
}
return min($available);
}
public function reserve(CatalogItem|Variant $selection, int $quantity): void
{
$this->mutate($selection, $quantity, 'reserve');
}
public function release(CatalogItem|Variant $selection, int $quantity): void
{
$this->mutate($selection, $quantity, 'release');
}
public function commit(CatalogItem|Variant $selection, int $quantity): void
{
$this->mutate($selection, $quantity, 'commit');
}
private function mutate(
CatalogItem|Variant $selection,
int $quantity,
string $operation,
): void {
if ($quantity <= 0) {
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
}
DB::transaction(function () use ($selection, $quantity, $operation): void {
$requirements = $this->inventoryRequirements($selection);
ksort($requirements);
$inventories = Inventory::query()
->whereKey(array_keys($requirements))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($requirements as $inventoryId => $requirement) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
$requiredQuantity = $requirement['quantity'] * $quantity;
if ($operation === 'reserve'
&& $requirement['tracks_inventory']
&& $inventory->availableStock() < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
}
if (in_array($operation, ['release', 'commit'], true)
&& $inventory->reserved_stock < $requiredQuantity) {
throw new \InvalidArgumentException('La cantidad reservada no alcanza para la operacion.');
}
if ($operation === 'commit'
&& $requirement['tracks_inventory']
&& $inventory->real_stock < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
}
foreach ($requirements as $inventoryId => $requirement) {
/** @var Inventory $inventory */
$inventory = $inventories->get($inventoryId);
$requiredQuantity = $requirement['quantity'] * $quantity;
match ($operation) {
'reserve' => $inventory->reserve($requiredQuantity, $requirement['tracks_inventory']),
'release' => $inventory->release($requiredQuantity),
'commit' => $inventory->buy($requiredQuantity, $requirement['tracks_inventory']),
};
}
});
}
/**
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
private function inventoryRequirements(CatalogItem|Variant $selection): array
{
if ($selection instanceof Variant) {
$selection->loadMissing('catalogItem');
return $this->singleRequirement(
$selection->inventory_id,
$selection->catalogItem->inventory_policy,
);
}
if ($selection->type !== CatalogItemType::Bundle) {
return $this->singleRequirement(
$selection->inventory_id,
$selection->inventory_policy,
);
}
$selection->loadMissing([
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
$requirements = [];
foreach ($selection->bundleComponents as $component) {
$this->addComponentRequirement($requirements, $component);
}
if ($requirements === []) {
throw new \InvalidArgumentException('El bundle no tiene componentes.');
}
return $requirements;
}
/** @return array<int, array{quantity: int, tracks_inventory: bool}> */
private function singleRequirement(
?int $inventoryId,
?InventoryPolicy $policy,
): array {
if ($inventoryId === null || $policy === null) {
throw new \InvalidArgumentException('El item requiere una variante con inventario.');
}
return [
$inventoryId => [
'quantity' => 1,
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
],
];
}
/**
* @param array<int, array{quantity: int, tracks_inventory: bool}> $requirements
*/
private function addComponentRequirement(array &$requirements, BundleComponent $component): void
{
$selectedItem = $component->variant ?? $component->catalogItem;
$inventoryId = $selectedItem->inventory_id;
$policy = $component->catalogItem->inventory_policy;
if ($inventoryId === null || $policy === null) {
throw new \InvalidArgumentException('Un componente del bundle no tiene inventario.');
}
if (isset($requirements[$inventoryId])) {
$requirements[$inventoryId]['quantity'] += $component->quantity;
return;
}
$requirements[$inventoryId] = [
'quantity' => $component->quantity,
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
];
}
}

View File

@@ -0,0 +1,568 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogService
{
public function __construct(protected AttachmentService $attachmentService) {}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): CatalogItem
{
return DB::transaction(function () use ($data): CatalogItem {
$type = CatalogItemType::from(
$data['type'] ?? CatalogItemType::Standard->value,
);
$variants = $data['variants'] ?? [];
$images = $data['images'] ?? [];
$attributeCodes = $data['attribute_codes'] ?? [];
$components = $data['components'] ?? [];
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasVariants = $attributeCodes !== [];
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
} else {
if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([
'components' => [__('api.catalog.standard_with_components')],
]);
}
$this->validateInventoryStrategy(
$data,
$variants,
$hasVariants,
$hasDirectStock,
);
}
unset(
$data['variants'],
$data['images'],
$data['attribute_codes'],
$data['components'],
$data['real_stock'],
$data['reserved_stock'],
$data['sold_units'],
$data['inventory_id'],
);
$data['type'] = $type;
if ($type === CatalogItemType::Bundle) {
$data['inventory_id'] = null;
$data['inventory_policy'] = null;
$data['has_tickets'] = false;
$data['minimum_use_date'] = null;
$data['maximum_use_date'] = null;
} elseif ($hasVariants) {
$data['inventory_id'] = null;
} else {
$data['inventory_id'] = $this->createInventory($realStock)->id;
}
$catalogItem = CatalogItem::query()->create($data);
$itemAttributes = $type === CatalogItemType::Standard
? $this->createItemAttributes($catalogItem, $attributeCodes)
: [];
if ($type === CatalogItemType::Bundle) {
$this->createBundleComponents($catalogItem, $components);
}
$createdVariants = [];
foreach ($variants as $index => $variantData) {
$createdVariants[] = [
'variant' => $this->createVariant(
$catalogItem,
$variantData,
$itemAttributes,
$index,
),
'images' => $variantData['images'] ?? [],
'index' => $index,
];
}
$this->attachImages($catalogItem, $images, 'images');
foreach ($createdVariants as $createdVariant) {
$this->attachImages(
$createdVariant['variant'],
$createdVariant['images'],
"variants.{$createdVariant['index']}.images",
);
}
return $catalogItem->load([
'attachments',
'inventory',
'category',
'brand',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
});
}
public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem
{
$catalogItem->load([
'attachments',
'inventory',
'category',
'brand',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem.inventory',
'bundleComponents.variant.inventory',
'bundleComponents.variant.definitions.itemAttribute.attribute',
]);
$selectedVariant = $variantId === null
? $catalogItem->variants->first()
: $catalogItem->variants->firstWhere('id', $variantId);
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$catalogItem->setRelation('selectedVariant', $selectedVariant);
return $catalogItem;
}
/** @return LengthAwarePaginator<CatalogItem> */
public function search(
Tenant $tenant,
string $term,
int $perPage,
int $page,
): LengthAwarePaginator {
$normalizedTerm = mb_strtolower($term);
$containsPattern = "%{$normalizedTerm}%";
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern])
->orWhereHas(
'brand',
fn (Builder $brandQuery) => $brandQuery
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
)
->orWhereHas(
'category',
fn (Builder $categoryQuery) => $categoryQuery
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
);
})
->with([
'attachments',
'inventory',
'variants.inventory',
'variants.attachments',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
->orderByRaw(
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
[$normalizedTerm, $startsWithPattern],
)
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
return $paginator->withPath(route('catalog-items.index', [
'tenant' => $tenant->codigo,
]));
}
/** @return LengthAwarePaginator<CatalogItem> */
public function categoryItems(
Tenant $tenant,
Category $category,
int $perPage,
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->with([
'attachments',
'inventory',
'variants.inventory',
'variants.attachments',
'variants.definitions.itemAttribute.attribute',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
}
public function delete(CatalogItem $catalogItem): void
{
DB::transaction(function () use ($catalogItem): void {
$catalogItem->load([
'attachments',
'variants.attachments',
]);
$attachments = $catalogItem->attachments
->merge($catalogItem->variants->flatMap->attachments)
->unique('id');
$inventoryIds = collect([$catalogItem->inventory_id])
->merge($catalogItem->variants->pluck('inventory_id'))
->filter()
->unique();
$catalogItem->attachments()->detach();
foreach ($catalogItem->variants as $variant) {
$variant->attachments()->detach();
}
$catalogItem->delete();
Inventory::query()->whereKey($inventoryIds)->delete();
foreach ($attachments as $attachment) {
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
$this->attachmentService->delete($attachment);
}
}
});
}
private function createInventory(int $realStock): Inventory
{
return Inventory::query()->create([
'real_stock' => $realStock,
]);
}
/**
* @param array<int, array<string, mixed>> $components
*/
private function createBundleComponents(CatalogItem $bundle, array $components): void
{
$seen = [];
foreach (array_values($components) as $index => $componentData) {
$catalogItemId = (int) $componentData['catalog_item_id'];
$variantId = isset($componentData['variant_id'])
? (int) $componentData['variant_id']
: null;
$key = $catalogItemId.':'.($variantId ?? 'direct');
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"components.{$index}" => [__('api.catalog.duplicate_component')],
]);
}
$seen[$key] = true;
$componentItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $bundle->tenant_code)
->first();
if ($componentItem === null) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
__('api.catalog.component_wrong_tenant'),
],
]);
}
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
__('api.catalog.invalid_component'),
],
]);
}
$hasVariants = $componentItem->variants()->exists();
if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_required'),
],
]);
}
if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_forbidden'),
],
]);
}
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'),
],
]);
}
$bundle->bundleComponents()->create([
'component_catalog_item_id' => $componentItem->id,
'component_variant_id' => $variantId,
'quantity' => (int) $componentData['quantity'],
]);
}
}
/**
* @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $components
*/
private function validateBundleData(array $data, array $components): void
{
if ($components === []) {
throw ValidationException::withMessages([
'components' => [__('api.catalog.bundle_component_required')],
]);
}
foreach ([
'real_stock',
'inventory_policy',
'attribute_codes',
'variants',
'has_tickets',
'minimum_use_date',
'maximum_use_date',
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
]);
}
}
}
/**
* @param array<int, mixed> $images
*/
private function attachImages(
CatalogItem|Variant $owner,
array $images,
string $validationKey,
): void {
foreach (array_values($images) as $order => $image) {
$attachment = $this->resolveAttachment($image, "{$validationKey}.{$order}");
$owner->attachments()->attach($attachment->id, ['orden' => $order]);
}
}
private function resolveAttachment(mixed $image, string $validationKey): Attachment
{
if (is_string($image) && Str::isUuid($image)) {
$attachment = Attachment::query()->where('key', $image)->first();
if ($attachment === null) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.attachment_not_found')],
]);
}
return $attachment;
}
return $this->attachmentService->store($image, 'catalog-items');
}
/**
* @param array<int, string> $attributeCodes
* @return array<string, ItemAttribute>
*/
private function createItemAttributes(
CatalogItem $catalogItem,
array $attributeCodes,
): array {
$itemAttributes = [];
$attributeCodes = array_values(array_unique($attributeCodes));
$attributes = Attribute::query()
->where('tenant_codigo', $catalogItem->tenant_code)
->whereIn('codigo', $attributeCodes)
->get()
->keyBy('codigo');
foreach ($attributeCodes as $attributeCode) {
$attribute = $attributes->get($attributeCode);
if ($attribute === null) {
throw ValidationException::withMessages([
'attribute_codes' => [
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
],
]);
}
$itemAttribute = $catalogItem->itemAttributes()->create([
'attribute_id' => $attribute->id,
]);
$itemAttributes[$attributeCode] = $itemAttribute;
}
return $itemAttributes;
}
/**
* @param array<string, mixed> $data
* @param array<string, ItemAttribute> $itemAttributes
*/
private function createVariant(
CatalogItem $catalogItem,
array $data,
array $itemAttributes,
int $index,
): Variant {
unset($data['images']);
if (
array_key_exists('inventory_id', $data)
|| array_key_exists('reserved_stock', $data)
|| array_key_exists('sold_units', $data)
) {
throw ValidationException::withMessages([
"variants.{$index}.inventory" => [
__('api.catalog.managed_inventory_fields'),
],
]);
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
]);
$variant->setRelation('catalogItem', $catalogItem);
$this->validateVariantUseDates($variant, $index);
foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
if ($itemAttribute === null) {
throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [
__('api.catalog.attribute_not_on_item'),
],
]);
}
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $value,
]);
}
return $variant;
}
private function validateVariantUseDates(Variant $variant, int $index): void
{
$minimumUseDate = $variant->getMinimumUseDate();
$maximumUseDate = $variant->getMaximumUseDate();
if (
$minimumUseDate !== null
&& $maximumUseDate !== null
&& $maximumUseDate->lessThan($minimumUseDate)
) {
throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [
__('api.catalog.invalid_effective_date_range'),
],
]);
}
}
/**
* @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $variants
*/
private function validateInventoryStrategy(
array $data,
array $variants,
bool $hasVariants,
bool $hasDirectStock,
): void {
if ($hasVariants && $variants === []) {
throw ValidationException::withMessages([
'variants' => [
__('api.catalog.variants_required'),
],
]);
}
if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([
'variants' => [
__('api.catalog.variants_forbidden'),
],
]);
}
if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([
'real_stock' => [
__('api.catalog.direct_inventory_forbidden'),
],
]);
}
if (
array_key_exists('inventory_id', $data)
|| array_key_exists('reserved_stock', $data)
|| array_key_exists('sold_units', $data)
) {
throw ValidationException::withMessages([
'inventory' => [
__('api.catalog.managed_inventory_fields'),
],
]);
}
}
}

View File

@@ -1,8 +0,0 @@
<?php
namespace App\Domains\Catalog\Services;
class ProductService
{
// Reserved for product domain business logic.
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domains\Catalog\Support;
enum ProductAttributeType: string
{
case Text = 'text';
case Numeric = 'numeric';
case Select = 'select';
case Multiselect = 'multiselect';
public function supportsOptions(): bool
{
return in_array($this, [self::Select, self::Multiselect], true);
}
}

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