Compare commits

...

274 Commits

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

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

Separa la edicion de instancias de sus asociaciones y conserva el guardado por cliente creando una instancia nueva. Impide eliminar instancias en uso y adapta las pruebas existentes, incluida la herencia SMTP.
2026-09-04 12:47:47 -03:00
e4146288f1 refactor(integration): generalizar el requisito de configuracion
Renombra requires_client_configuration a requires_configuration conservando los valores existentes y su rollback. Actualiza el modelo, la validacion, los seeders y los consumidores del campo para permitir configuraciones de cliente o tipo de sitio.
2026-09-04 12:47:29 -03:00
f11ba5a470 feat(integration): add IntegrationInstance and WebsiteTypeIntegration models with migrations; refactor ClientIntegration and Integration models 2026-09-04 12:17:30 -03:00
e4db36e650 feat(notification): update welcome email to include tenant URL and call-to-action button 2026-09-04 10:51:00 -03:00
ffa3f10b18 feat(scanner): implement category validation for AdminApp role and update tests 2026-09-04 10:40:46 -03:00
7acef66ee7 feat(notification): enhance password reset emails with custom templates for different scenarios 2026-09-04 10:31:16 -03:00
18f1217daa feat(admin): implement CRUD for administrators with role management and tenant restrictions 2026-09-04 10:28:33 -03:00
ac44e82454 fix(menu): rename 'Staff' to 'Usuarios' in menu seeder and migration 2026-09-04 09:54:56 -03:00
1e7a9b6876 Merge pull request 'homo' (#4) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/4
2026-09-04 12:01:35 +00:00
deea4fd4af Merge branch 'test/stress' into dev 2026-09-04 08:21:50 -03:00
c4ff695f2e refactor(ticket): simplify tenant validation and update command documentation 2026-09-03 14:30:38 -03:00
703158bd06 feat(ticket): implement LoadTestTicketDatasetService for generating load test datasets
- Added LoadTestTicketDatasetService to prepare disposable scanner tickets and generate a Postman importable dataset.
- Introduced console command `load-test🎟️prepare` for executing the dataset preparation.
- Updated README documentation with usage instructions for the load test command.
- Enhanced Postman collection generation script to include new parameters.
- Created tests for the load test command to ensure proper functionality and validation.
2026-09-03 14:15:23 -03:00
eda2343380 fix(user): update hidden attributes and handle soft-deleted users in registration 2026-09-03 10:31:43 -03:00
0baad641d5 fix(auth): scope email uniqueness to active users 2026-09-03 09:59:21 -03:00
ddb8c3743f fix(staff): preserve scanner history on deletion 2026-09-03 09:58:08 -03:00
c2233389d0 feat(sale): implement modifications filtering and validation for admin sales 2026-09-03 08:39:45 -03:00
334ffe92e7 Merge branch 'fix/cancelledpurchase' into feature/tickets_adminapp 2026-09-02 15:53:12 -03:00
a19ace3115 fix(purchase): handle cancellation of expired purchases and update test cases 2026-09-02 15:52:18 -03:00
acc0a68736 refactor(sales): reuse admin labels in exports 2026-09-02 12:42:19 -03:00
dcf105c316 feat(sales): filter and serialize admin statuses 2026-09-02 12:42:11 -03:00
f74bf41931 feat(forms): expose grouped admin sale statuses 2026-09-02 12:42:00 -03:00
34217be630 feat(purchase): centralize admin sale statuses 2026-09-02 12:41:49 -03:00
ee906d2d2e Squashed commit of the following:
commit 6ae625d4fdc11989950ef0678db62fdb52be7c8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Sep 2 09:46:18 2026 -0300

    refactor(tickets): enhance search functionality for tickets by client names and formatted amounts

commit 098e525d32be9cb6487bc01a3d120aa0f2386a15
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Sep 2 09:09:30 2026 -0300

    fix(tickets): protect referenced purchase items

commit 210db17f2f6a6a4a509297b773a2b5d3e3149a3d
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Sep 2 08:59:24 2026 -0300

    test(tickets): cover purchase item references

commit 6a004713c4bad622dfa5e26394458925db1cb5a7
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Sep 2 08:59:02 2026 -0300

    refactor(tickets): reference purchase items directly
2026-09-02 09:47:04 -03:00
836aa1afd7 feat(tickets): refine FFI variant filters 2026-09-01 14:29:26 -03:00
6896646c54 feat(tickets): extend FFI ticket column presentation 2026-09-01 14:29:18 -03:00
5e541f7cd0 fix(tickets): improve pdf table readability 2026-09-01 12:06:50 -03:00
80b8707771 fix(tickets): show numeric id and remove date column 2026-09-01 11:53:47 -03:00
58b15f3026 Merge branch 'feature/tickets_adminapp' of https://gitea.quo.ar/tbianchini/shopit-back into feature/tickets_adminapp 2026-08-31 13:54:37 -03:00
2e9355c85c fix(tickets): disable inconsistent FFI product and type sorting 2026-08-31 13:54:30 -03:00
fa1e11a69f feat(tickets): add tenant-aware server-side sorting 2026-08-31 13:54:30 -03:00
6a00d8d09c refactor(tickets): reuse dynamic columns in exports 2026-08-31 13:54:30 -03:00
fbfcb5cf63 feat(tickets): expose normalized values for dynamic columns 2026-08-31 13:54:30 -03:00
adce4ab59e feat(tickets): define tenant-specific table columns 2026-08-31 13:54:30 -03:00
5250d1a818 feat(tickets): add PDF and Excel export endpoints 2026-08-31 13:54:30 -03:00
457eba68a9 refactor(tickets): prepare filtered query for exports 2026-08-31 13:54:30 -03:00
52b0b339d6 feat(tickets): add query_param to ticket filter fields in TicketFilterFormService and update related tests 2026-08-31 13:54:30 -03:00
9175df1d7f feat(tickets): enhance AdminAppTicketIndexRequest and AdminAppTicketService with additional filters and update tests for ticket filtering functionality 2026-08-31 13:54:30 -03:00
38341b2bda feat(tickets): update TicketFilterFormService to use getForFilters method and enhance TicketFormService with historical data handling 2026-08-31 13:54:30 -03:00
34a0a14404 feat(tickets): add TicketFilterFormController, TicketFilterFormService, and TicketFilterFormResource with routes and tests 2026-08-31 13:54:29 -03:00
af516ce9e3 feat(tickets): add TicketFormController, TicketFormService, and TicketFormResource with related routes and tests 2026-08-31 13:53:18 -03:00
dca35e7b0c feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests 2026-08-31 13:53:18 -03:00
df853de843 feat(tickets): implement AdminAppTicketResource and update ticket collection to use it 2026-08-31 13:53:18 -03:00
4ba464bf85 feat(tickets): enhance ticket search functionality and add ticket counts to response 2026-08-31 13:53:18 -03:00
208e929262 feat(tickets): implement admin app ticket management with listing and search functionality 2026-08-31 13:53:17 -03:00
ffe47d903e feat(menu): add tickets menu and update related seeder and tests 2026-08-31 13:53:17 -03:00
24983439b1 fix(tickets): disable inconsistent FFI product and type sorting 2026-08-31 13:46:32 -03:00
d179a0f74e feat(tickets): add tenant-aware server-side sorting 2026-08-31 13:45:57 -03:00
636e39e98e refactor(tickets): reuse dynamic columns in exports 2026-08-31 12:31:32 -03:00
bda94d02af feat(tickets): expose normalized values for dynamic columns 2026-08-31 12:31:24 -03:00
bfb16ef60e feat(tickets): define tenant-specific table columns 2026-08-31 12:31:13 -03:00
1b6303237a feat(tickets): add PDF and Excel export endpoints 2026-08-31 12:03:25 -03:00
9da92c880a refactor(tickets): prepare filtered query for exports 2026-08-31 12:03:11 -03:00
63012838a4 feat(tickets): add query_param to ticket filter fields in TicketFilterFormService and update related tests 2026-08-31 11:14:41 -03:00
27544a23be feat(tickets): enhance AdminAppTicketIndexRequest and AdminAppTicketService with additional filters and update tests for ticket filtering functionality 2026-08-31 11:06:30 -03:00
a71c80248c feat(tickets): update TicketFilterFormService to use getForFilters method and enhance TicketFormService with historical data handling 2026-08-31 10:57:25 -03:00
1ca8fb20af feat(tickets): add TicketFilterFormController, TicketFilterFormService, and TicketFilterFormResource with routes and tests 2026-08-31 10:57:05 -03:00
ba167559a6 Merge branch 'feature/tickets_adminapp' of https://gitea.quo.ar/tbianchini/shopit-back into feature/tickets_adminapp 2026-08-31 09:00:41 -03:00
495515b1f3 feat(tickets): add TicketFormController, TicketFormService, and TicketFormResource with related routes and tests 2026-08-31 09:00:38 -03:00
781e282f16 feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests 2026-08-31 09:00:10 -03:00
7fb4c9674a feat(tickets): implement AdminAppTicketResource and update ticket collection to use it 2026-08-31 09:00:10 -03:00
0d85c3df19 feat(tickets): enhance ticket search functionality and add ticket counts to response 2026-08-31 09:00:10 -03:00
a93b260043 feat(tickets): implement admin app ticket management with listing and search functionality 2026-08-31 09:00:10 -03:00
c209e716e3 feat(menu): add tickets menu and update related seeder and tests 2026-08-31 09:00:10 -03:00
900ef6cf98 Merge pull request 'homo' (#3) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/3
2026-08-31 11:36:04 +00:00
71c914a389 Merge branch 'main' into homo 2026-08-31 11:35:52 +00:00
705b1247f5 Merge pull request 'homo_experimental' (#2) from homo_experimental into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/2
2026-08-31 11:25:26 +00:00
b1f42775d6 feat(tickets): add TicketFormController, TicketFormService, and TicketFormResource with related routes and tests 2026-08-28 17:00:47 -03:00
0f6f39cce7 feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests 2026-08-28 16:05:56 -03:00
1ec7ab3e35 feat(tickets): implement AdminAppTicketResource and update ticket collection to use it 2026-08-28 15:17:35 -03:00
ba0e2dd00c feat(tickets): enhance ticket search functionality and add ticket counts to response 2026-08-28 15:14:11 -03:00
c792e7d306 feat(tickets): implement admin app ticket management with listing and search functionality 2026-08-28 15:10:23 -03:00
6ee3f22e41 feat(menu): add tickets menu and update related seeder and tests 2026-08-28 14:44:55 -03:00
d4ec34db1a fix: update seating allocation for VIP section and adjust related test assertions 2026-08-28 13:41:37 -03:00
351178d9da Merge branch 'feature/candidate_payments' into homo_experimental 2026-08-27 16:52:08 -03:00
b617285148 feat(payments): rename match reason from 'exact_amount_different_dni' to 'exact_amount_near_dni' 2026-08-27 16:40:32 -03:00
9bca41bfa5 feat(payments): filter candidates by DNI distance 2026-08-27 16:04:12 -03:00
5601285369 feat(payments): expose primary transfer candidate 2026-08-27 15:38:24 -03:00
6fb60c9a73 feat(payments): track transfer match candidates 2026-08-27 15:21:28 -03:00
c3f1c320a4 test: isolate PHPUnit database 2026-08-27 15:21:16 -03:00
899bf12457 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-08-27 10:11:24 -03:00
a460743ae0 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-27 10:11:14 -03:00
a4d7b1b789 Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
2026-08-27 10:11:14 -03:00
10655b8c07 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-08-27 10:10:05 -03:00
adc7b21ab8 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-27 10:10:03 -03:00
ba1355448a Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
2026-08-27 10:10:02 -03:00
1c76e56f2d Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-08-27 10:07:58 -03:00
daa74845b7 feat(purchase): add expiration time calculations to PurchaseResource and implement related tests 2026-08-27 10:06:42 -03:00
05944cec9c feat(env): remove unused purchase expiration variables from .env.example 2026-08-27 10:06:42 -03:00
c820741e5f feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-27 10:06:42 -03:00
adc4cc9595 feat(auth): use environment variable for password reset expiration 2026-08-27 10:06:41 -03:00
c67dfcc1a1 test(auth): cover password reset expiration 2026-08-27 10:06:41 -03:00
f61b7e4dee feat(auth): expire stale password reset codes 2026-08-27 10:06:41 -03:00
e53fa949cf feat(auth): persist password reset expiration 2026-08-27 10:06:41 -03:00
c51e24311f Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
2026-08-27 10:06:41 -03:00
a650de79c1 feat(purchase): add expiration time calculations to PurchaseResource and implement related tests 2026-08-26 17:00:17 -03:00
8f4fc39858 feat(env): remove unused purchase expiration variables from .env.example 2026-08-26 16:43:02 -03:00
09554b9f80 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-26 16:36:26 -03:00
0d2198a3db Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 15:57:15 -03:00
52732da960 feat(auth): use environment variable for password reset expiration 2026-08-26 15:56:08 -03:00
1f7bb35f63 Merge pull request 'homo' (#1) from homo into main
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/1
2026-08-26 18:10:08 +00:00
22c6631652 Merge branch 'fix/website_types_favicons' into dev 2026-08-26 14:40:46 -03:00
bbe3cf82f5 feat(bootstrap): expose website type favicon 2026-08-26 14:37:25 -03:00
ff61a3d3b8 feat(tenant): share website type favicon attachment 2026-08-26 14:37:17 -03:00
6c5d49bf45 Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 11:51:00 -03:00
2596b5df18 test(auth): cover password reset expiration 2026-08-26 11:47:12 -03:00
733064c0dc feat(auth): expire stale password reset codes 2026-08-26 11:47:12 -03:00
99808c1053 feat(auth): persist password reset expiration 2026-08-26 11:47:12 -03:00
e2ad78b1fb Merge branch 'fixes/TL-1886' into dev 2026-08-26 11:40:21 -03:00
8ed8a11b7b fix: define featured group codes in seeders 2026-08-26 11:30:00 -03:00
dc33e5dc09 fix: consolidate purchase confirmation emails 2026-08-26 11:29:53 -03:00
414df97e9d Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

    Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations

commit ebcbbb1774
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 3dd378ed06
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 4f705175bd
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit 82570fe941
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit d2a2b323e0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit ec67623b7b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit f6f138e180
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit d612b7a118
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit 49f42d4507
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 1881cc1d4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 00ec37d8a0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit d9f374c718
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit bbfdf8f342
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit 8a0f29bdae
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 198ed400a9
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit 23d83166e6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit cb090402d0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit c120b6f7c0
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 1106fa28b4
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 896fb81bcf
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 8545fbc645
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit dd6dcb708a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 624b246ff5
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 007b95b904
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit 0b3c8c93be
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit ede718e448
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate

commit e63a03601a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:33:39 2026 -0300

    fix(cart): reconcile expired cart mutations

commit 389373ad8c
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:15 2026 -0300

    docs(stock): describe associated entity expiration

commit ace02a3133
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:10:14 2026 -0300

    test(stock): cover propagated reservation expiration

commit d69a6210f6
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 16:09:52 2026 -0300

    refactor(stock): propagate reservation expiration

commit 3c0b43fea3
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    docs(stock): document terminal reservation recovery

commit 9b0008626b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:47 2026 -0300

    test(stock): cover terminal reservation lifecycle

commit 61861e331a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:58:41 2026 -0300

    refactor(stock): make expired reservations terminal

commit 2cc9d7dd97
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:30 2026 -0300

    docs(stock): describe unified expiration ownership

commit f982bcead1
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:23 2026 -0300

    test(stock): cover authoritative reservation expiration

commit 7a85e1731d
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:28:17 2026 -0300

    refactor(stock): make reservation expiration authoritative

commit 6db99e775a
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:34 2026 -0300

    docs(stock): document centralized reservation flow

commit e5f7ba3615
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:29 2026 -0300

    test(stock): cover aggregate reservation lifecycle

commit 24bfef431b
Author: ncoronel <ncoronel@quo.ar>
Date:   Tue Aug 25 15:05:23 2026 -0300

    refactor(stock): centralize reservation aggregate
2026-08-26 10:30:16 -03:00
d0424c5589 feat(notification): enhance email logging and refactor event handling to use scalar identifiers 2026-08-26 09:38:19 -03:00
28d09dedab fix(queue): discard email jobs for deleted purchases 2026-08-26 09:15:11 -03:00
8220fb5aaf feat(notification): add PDF attachments to ticket availability emails 2026-08-26 08:54:15 -03:00
bf02d37a33 feat(sales): add Excel exports 2026-08-25 11:37:53 -03:00
78ce263849 feat(notification): update email branding to use website type for welcome and password reset emails 2026-08-25 11:03:06 -03:00
0c8419a5eb feat(notification): implement branding for emails using WebsiteType and update templates 2026-08-25 11:03:06 -03:00
843659583e Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-25 10:49:07 -03:00
c2921166bd feat(catalog): exclude out-of-stock items and update variant visibility logic 2026-08-25 10:49:05 -03:00
0f67e66b6b feat(category-visibility): add success messages for category visibility updates 2026-08-25 10:47:53 -03:00
725abed06a feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-25 10:47:53 -03:00
743939fc63 feat(category-visibility): add success messages for category visibility updates 2026-08-25 10:47:53 -03:00
eb50e65fef feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-25 10:47:53 -03:00
1cf13501d8 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-25 10:47:53 -03:00
2eeef8d392 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-25 10:47:53 -03:00
87a4fa6288 feat(catalog): add group_order field to catalog items and update related logic 2026-08-25 10:45:12 -03:00
07d2129410 feat(catalog): exclude out-of-stock items and update variant visibility logic 2026-08-25 09:32:56 -03:00
ceb1d1f1af Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 17:01:55 -03:00
885ab2a6e3 feat(category-visibility): add success messages for category visibility updates 2026-08-24 17:01:46 -03:00
47196a572a feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 17:01:46 -03:00
45bba9516d feat(category-visibility): add success messages for category visibility updates 2026-08-24 17:00:48 -03:00
c6da71894e feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 17:00:48 -03:00
75d3c819d9 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 17:00:48 -03:00
bd6d672df2 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 17:00:48 -03:00
6b06028771 fix(sales): render PDFs in client timezone 2026-08-24 16:42:38 -03:00
0aa423e90e feat(sales): validate PDF timezone parameter 2026-08-24 16:42:38 -03:00
e7bf2f887a Merge branch 'hotfix/change_history_time' 2026-08-24 16:13:19 -03:00
80a02fdc52 feat(sale): add changed_at serialization to SaleModificationResource 2026-08-24 16:12:55 -03:00
4162e2c1cc Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 16:05:15 -03:00
8d01620fa8 feat(sale-modification): add changed_at field serialization to ISO 8601 format 2026-08-24 16:05:10 -03:00
f8b8bab474 feat(category-visibility): add success messages for category visibility updates 2026-08-24 16:05:10 -03:00
53d1415b18 feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 16:05:10 -03:00
6ff9119086 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 16:05:10 -03:00
24e000b423 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 16:05:10 -03:00
c69ebe5f7c feat(sale-modification): add changed_at field serialization to ISO 8601 format 2026-08-24 15:59:00 -03:00
c3a2da607d Merge branch 'dev' into homo 2026-08-24 12:45:51 -03:00
d1c4acc7ae feat(category-visibility): add success messages for category visibility updates 2026-08-24 12:44:37 -03:00
f8c4cc7428 Merge branch 'dev' into homo 2026-08-24 12:36:57 -03:00
21cfbe2a46 feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 12:35:20 -03:00
abc060ab25 Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 11:32:09 -03:00
80adbd9ca9 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 11:31:26 -03:00
12c10bbe06 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 10:49:14 -03:00
36eb41a18a feat(invitation): update seat allocation structure and enhance provisioning logic 2026-08-22 14:16:57 +00:00
1dd2366957 feat(catalog): expose product unavailability messages 2026-08-21 16:47:15 -03:00
e8bcaa2026 feat(sales): handle in-review purchases as pending 2026-08-21 16:15:59 -03:00
5548fe8511 feat(purchase): restore in-review transfer lifecycle 2026-08-21 16:15:55 -03:00
dc0fbe06b8 fix(cart): invalidate checkout on mutation 2026-08-21 14:06:11 -03:00
afd69b8393 feat(checkout): track current cart purchase 2026-08-21 14:05:33 -03:00
a34d5cba1a test(sale): require purchase item snapshots 2026-08-21 13:42:30 -03:00
1650176aa0 refactor(checkout): keep source cart active 2026-08-21 13:42:30 -03:00
79e721ae63 refactor(checkout): disable purchase item editing 2026-08-21 11:03:48 -03:00
4e7e42d16e refactor(checkout): materialize purchase item snapshots at start 2026-08-21 11:03:39 -03:00
3a2dc8b7e0 feat(cart): implement restoration and reservation of source cart on expired checkout modification 2026-08-21 10:10:28 -03:00
0510c1aa12 test(checkout): cover source cart restoration 2026-08-21 09:29:23 -03:00
9d870be294 feat(checkout): restore source cart on cancellation 2026-08-21 09:29:23 -03:00
559bf5d264 Merge branch 'fix/expiration_purchase_messages' of https://gitea.quo.ar/tbianchini/shopit-back into fix/expiration_purchase_messages 2026-08-21 08:58:55 -03:00
eafb02ffd5 fix(purchase): enhance handling of cart items for created and pending payment purchases 2026-08-21 08:58:51 -03:00
786cf2db52 fix(cart): propagate expired checkout purchase errors 2026-08-21 08:58:51 -03:00
cb56e33685 fix(purchase): unify expired checkout errors 2026-08-21 08:58:51 -03:00
dd81e3b3f7 feat: enhance sale detail retrieval by including trashed cart items and update related tests 2026-08-21 08:58:18 -03:00
4d51531c5d Merge branch 'fix/expiration_purchase_messages' of https://gitea.quo.ar/tbianchini/shopit-back into fix/expiration_purchase_messages 2026-08-21 08:55:01 -03:00
7fd8a00054 fix(purchase): enhance handling of cart items for created and pending payment purchases 2026-08-21 08:54:58 -03:00
23163a6eb8 fix(cart): propagate expired checkout purchase errors 2026-08-21 08:50:59 -03:00
944cf8ee18 fix(purchase): unify expired checkout errors 2026-08-21 08:50:59 -03:00
8c09ca1204 feat: update sales query to use cart quantity when purchase items are absent and enhance related tests 2026-08-21 08:48:27 -03:00
98d502a8ef fix(purchase): enhance handling of cart items for created and pending payment purchases 2026-08-20 17:02:50 -03:00
318e6559cb fix(cart): propagate expired checkout purchase errors 2026-08-20 17:01:21 -03:00
9fd34f900f fix(purchase): unify expired checkout errors 2026-08-20 17:01:06 -03:00
ae6149df84 feat: enable full cart editing for Desfile tenant and update related tests 2026-08-20 15:13:23 -03:00
1776cfe581 Revert "feat: add session partitioned cookie support and update related tests"
This reverts commit 5ad18694e1.
2026-08-20 15:12:41 -03:00
5ad18694e1 feat: add session partitioned cookie support and update related tests 2026-08-20 14:47:51 -03:00
6775fb110c feat: simplify cart restoration logic in ReleaseCheckoutService and SourceCartService 2026-08-20 13:53:04 -03:00
9c46eff880 feat: add checkout editing policy to tenants and update related logic across services and resources 2026-08-20 12:38:22 -03:00
c8ce3b0da3 feat(checkout): enforce editing policy on items 2026-08-20 10:56:19 -03:00
d36a929f8c feat(tenant): add checkout editing policy 2026-08-20 10:56:13 -03:00
2de02dfd31 feat: add item editing preparation and enhance payment intent handling in PurchaseController 2026-08-20 09:46:32 -03:00
f9a766dee1 feat: enhance purchase loading logic to include cart items when purchase items are empty 2026-08-20 08:48:03 -03:00
8bc57cf0ff fix: update import statements in EditCheckoutService for clarity and consistency 2026-08-19 17:22:02 -03:00
a78c5cee58 Merge branch 'dev' into homo 2026-08-19 17:03:08 -03:00
cc22e33799 feat: implement cart editing policies and update related services
- Introduced CartEditingPolicy enum to manage cart editing rules.
- Updated Tenant model to use cart_editing_policy instead of a boolean flag.
- Refactored PurchaseItemSnapshotFactory to include variant details.
- Added PurchaseResponseLoader service to streamline purchase loading.
- Enhanced SourceCartService with methods to sync item quantities and selections.
- Updated StartCheckoutService to utilize the new PurchaseResponseLoader.
- Created new API routes for editing purchase items.
- Added tests to validate cart editing policies and their effects on item updates.
- Migrated existing data to the new cart editing policy structure.
- Updated language files to reflect changes in cart editing messages.
2026-08-19 16:59:30 -03:00
5c0b3503b7 feat(purchase): implement purchase limit enforcement and custom exception handling 2026-08-19 16:48:32 -03:00
8537d2ed0a feat(catalog): implement maximum addable quantity logic and user quota sharing across variants 2026-08-19 16:48:32 -03:00
d97cc12af6 Merge branch 'feature/cart_editing_policy' into dev 2026-08-19 16:47:30 -03:00
7a19b3a97a Merge branch 'dev' into homo 2026-08-19 15:28:38 -03:00
a94ac7a473 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-08-19 15:28:09 -03:00
161693509f feat(telepagos): enhance webhook handling 2026-08-19 15:26:44 -03:00
93c99afb13 feat(telepagos): enhance webhook handling and add TenantTransactionResetService 2026-08-19 15:23:22 -03:00
32d2b182c6 Merge branch 'feature/reserved_invitations' into dev 2026-08-19 14:33:38 -03:00
2632a80722 feat(invitation): implement InvitationPurchaseProvisioner and related migration and seeder updates 2026-08-19 14:33:08 -03:00
3cee9c28f8 Merge branch 'dev' into homo 2026-08-19 14:19:06 -03:00
2091b1361a Merge branch 'refactor/simplify_cart_snapshot' into dev 2026-08-19 14:18:17 -03:00
a2c5e687f9 feat(logging): record reservation cleanup attempts 2026-08-19 14:17:05 -03:00
172e14ae9b Squashed commit of the following:
commit 1dc4e29c69
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:58:03 2026 -0300

    refactor(reservations): unify expiration command

commit 093e894cc3
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:48:09 2026 -0300

    feat(cart): expire abandoned stock reservations

commit fdf0f3328f
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:53:12 2026 -0300

    refactor(stock): implement expiration for stock reservations and add configuration

commit 8d6bcdcc43
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:38:21 2026 -0300

    refactor(cart): invalidate payment on actual changes

commit 3206e293eb
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:24:48 2026 -0300

    refactor(cart): own checkout item editing

commit aed99bd05e
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:14:57 2026 -0300

    refactor(checkout): remove legacy purchase item reservations

commit f1649e0e4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:29 2026 -0300

    refactor(checkout): materialize purchase items on confirmation

commit e6c4b40a37
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:19 2026 -0300

    feat(inventory): add traceable cart stock reservations
2026-08-19 13:59:38 -03:00
58cbeae9d3 Squashed commit of the following:
commit 1dc4e29c69
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:58:03 2026 -0300

    refactor(reservations): unify expiration command

commit 093e894cc3
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:48:09 2026 -0300

    feat(cart): expire abandoned stock reservations

commit fdf0f3328f
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:53:12 2026 -0300

    refactor(stock): implement expiration for stock reservations and add configuration

commit 8d6bcdcc43
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:38:21 2026 -0300

    refactor(cart): invalidate payment on actual changes

commit 3206e293eb
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:24:48 2026 -0300

    refactor(cart): own checkout item editing

commit aed99bd05e
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:14:57 2026 -0300

    refactor(checkout): remove legacy purchase item reservations

commit f1649e0e4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:29 2026 -0300

    refactor(checkout): materialize purchase items on confirmation

commit e6c4b40a37
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:19 2026 -0300

    feat(inventory): add traceable cart stock reservations
2026-08-19 13:59:25 -03:00
1dc4e29c69 refactor(reservations): unify expiration command 2026-08-19 13:58:03 -03:00
093e894cc3 feat(cart): expire abandoned stock reservations 2026-08-19 13:48:09 -03:00
fdf0f3328f refactor(stock): implement expiration for stock reservations and add configuration 2026-08-19 13:43:08 -03:00
8d6bcdcc43 refactor(cart): invalidate payment on actual changes 2026-08-19 13:43:08 -03:00
3206e293eb refactor(cart): own checkout item editing 2026-08-19 13:43:08 -03:00
aed99bd05e refactor(checkout): remove legacy purchase item reservations 2026-08-19 13:43:08 -03:00
f1649e0e4b refactor(checkout): materialize purchase items on confirmation 2026-08-19 13:43:08 -03:00
e6c4b40a37 feat(inventory): add traceable cart stock reservations 2026-08-19 13:43:08 -03:00
15878cd9ba fix(logging): update Telepagos log path to include date directory 2026-08-19 13:40:40 -03:00
d6574bfd0d feat(logging): implement dedicated logging for Telepagos events and update logging configuration 2026-08-19 13:37:21 -03:00
16bc657a31 refactor(catalog): add ticket_label to item attributes and update related logic 2026-08-19 11:23:41 -03:00
7cea8d3495 feat(cart): enhance item update functionality to support variant changes and improve cart editing policies 2026-08-19 10:46:19 -03:00
014b5bb012 feat(cart): implement cart editing policies and update related functionality 2026-08-19 10:15:37 -03:00
67e6211857 merge: client-owned integrations into dev 2026-08-18 17:34:31 -03:00
58471cb13d fix(seed): assign Sonder tenant to its client 2026-08-18 17:33:05 -03:00
864bed0113 feat(onticket): group event tenants under shared client 2026-08-18 17:33:05 -03:00
ced7d594e8 refactor(integrations): move configuration ownership to clients 2026-08-18 17:32:44 -03:00
38e87fff6c feat(clients): add client ownership for tenants 2026-08-18 17:32:27 -03:00
a8973f6171 feat: separate tenant domain and base path, update related models, requests, and tests 2026-08-18 17:23:01 -03:00
8e26611097 feat: add display_cart_item_images feature to tenants and update related resources 2026-08-18 16:46:45 -03:00
817d0de6d2 feat(migration): move integrations from tenants to clients and update schema 2026-08-18 16:33:05 -03:00
a2298c9de2 feat: add cart editing feature to tenants
- Added 'cart_editing_enabled' attribute to Tenant model with default value true.
- Updated StoreTenantRequest and UpdateTenantRequest to include validation for 'cart_editing_enabled'.
- Modified TenantResource to expose 'cart_editing_enabled' in API responses.
- Created migration to add 'cart_editing_enabled' column to tenants table, defaulting to true, and set to false for specific tenant.
- Updated DesfilePuraTendenciaSeeder to set 'cart_editing_enabled' to false for the desfile tenant.
- Enhanced Postman collection generation script to include 'cart_editing_enabled' in tenant creation and update requests.
- Added tests to verify the correct behavior of 'cart_editing_enabled' during migrations and tenant provisioning.
2026-08-18 16:32:17 -03:00
d73b4d1daf feat: Introduce client management and integration updates
- Added client_id field to StoreTenantRequest and UpdateTenantRequest for tenant management.
- Updated TenantResource to include client_id in the response.
- Created migration to establish clients table and link tenants to clients.
- Migrated existing tenant integrations to client_integrations table.
- Grouped specific tenants under a shared client (OnTicket) in a new migration.
- Updated seeders to reflect new client structure and relationships.
- Adjusted integration configurations to require client instead of tenant.
- Added tests for client integration functionality and ensured existing tests reflect the new client structure.
2026-08-18 16:18:34 -03:00
e6785eb5af feat(seeder): add Desfile Pura Tendencia seeder and corresponding test 2026-08-18 15:44:20 -03:00
1a3f564afd fix(seeder): update catalog path to reflect new directory structure 2026-08-18 15:27:53 -03:00
387 changed files with 27835 additions and 3765 deletions

View File

@@ -8,6 +8,8 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=es
@@ -30,11 +32,19 @@ 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
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=stack
LOG_CHANNEL=daily
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_DAILY_DAYS=14
TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Models\ResetPasswordAttempt;
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
use App\Domains\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Authorization\Enums\RoleCode;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
@@ -22,10 +23,19 @@ class ValidateResetPasswordAttemptController extends Controller
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode(
$result = $this->resetPasswordAttemptService->validateCode(
$data['email'],
$data['codigo'],
)) {
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
);
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_expired'),
]);
}
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'),
]);

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
@@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model
public const REASON_STAFF_CREATED = 'staff_created';
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
public const STATUS_PENDING = 'pending';
public const STATUS_VALIDATED = 'validated';
@@ -31,6 +33,7 @@ class ResetPasswordAttempt extends Model
{
return [
'user_id' => 'integer',
'expires_at' => 'datetime',
];
}

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Auth\Requests;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
return true;
}
/**
* @return array<string, mixed>
*/
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use Illuminate\Support\Facades\Hash;
class AdminCredentialVerifier
{
public function verify(string $email, string $password): bool
{
$admin = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Admin->value)
->first();
return $admin !== null
&& ! $admin->locked_until?->isFuture()
&& Hash::check($password, $admin->getAuthPassword());
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
@@ -124,12 +125,12 @@ class GoogleAuthService
]);
}
$user = User::query()->where('google_id', $googleId)->first();
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
if ($user) {
return $user;
}
$user = User::query()->where('email', $email)->first();
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
if ($user) {
$user->forceFill(['google_id' => $googleId])->save();
@@ -155,16 +156,19 @@ class GoogleAuthService
$parts = parse_url($returnUrl);
if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|| ($parts['path'] ?? '') !== '') {
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
return false;
}
$scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
if ($host === null
|| $tenantDomain === null
|| $host !== $tenantDomain
|| $returnPath !== $tenant->base_path) {
return false;
}

View File

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

View File

@@ -12,6 +12,12 @@ use Throwable;
class ResetPasswordAttemptService
{
public const CODE_VALID = 'valid';
public const CODE_INVALID = 'invalid';
public const CODE_EXPIRED = 'expired';
public function createForEmail(
string $email,
string $tenantCode,
@@ -22,7 +28,8 @@ class ResetPasswordAttemptService
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query()
->where('email', $email)
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::User->value)
->lockForUpdate()
->first();
@@ -59,7 +66,7 @@ class ResetPasswordAttemptService
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('email', $email)
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::AdminApp->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
@@ -107,7 +114,7 @@ class ResetPasswordAttemptService
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('email', $email)
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Scanner->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
@@ -146,14 +153,15 @@ class ResetPasswordAttemptService
);
}
public function validateCode(string $email, string $code): bool
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
$user = User::query()
->where('email', $email)
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', $role->value)
->lockForUpdate()
->first();
@@ -169,14 +177,27 @@ class ResetPasswordAttemptService
'email_fingerprint' => $emailFingerprint,
]);
return false;
return self::CODE_INVALID;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset code validation failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return self::CODE_EXPIRED;
}
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
return true;
return self::CODE_VALID;
});
} catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [
@@ -188,14 +209,15 @@ class ResetPasswordAttemptService
}
}
public function resetPassword(string $email, string $code, string $password): bool
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
$user = User::query()
->where('email', $email)
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', $role->value)
->lockForUpdate()
->first();
@@ -214,6 +236,19 @@ class ResetPasswordAttemptService
return false;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return false;
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
@@ -270,6 +305,7 @@ class ResetPasswordAttemptService
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
]);
return $attempt->getKey();

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ class AdminAppBootstrapResource extends JsonResource
return [
'website_type_code' => $websiteType->codigo,
'site_title' => $websiteType->site_title,
'primary_color' => $websiteType->primary_color,
'secondary_color' => $websiteType->secondary_color,
'danger_color' => $websiteType->danger_color,
@@ -30,6 +31,7 @@ class AdminAppBootstrapResource extends JsonResource
'login_header_footer_color' => $websiteType->login_header_footer_color,
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
];
}
}

View File

@@ -11,7 +11,7 @@ class AdminAppBootstrapService
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->with(['siteLogo', 'footerLogo', 'favicon'])
->where('dominio', $domain)
->firstOrFail(),
];

View File

@@ -11,7 +11,7 @@ class ScannerBootstrapService
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo'])
->with(['siteLogo', 'footerLogo', 'favicon'])
->where('scanner_domain', $domain)
->firstOrFail(),
];

View File

@@ -14,14 +14,15 @@ class TenantBootstrapService
public function get(string $domain, string $path = '/'): Tenant
{
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
$tenantsByDomain = Tenant::query()
->whereIn('dominio', $candidateKeys)
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
$tenantsByBasePath = Tenant::query()
->where('dominio', $domain)
->whereIn('base_path', $candidateBasePaths)
->get()
->keyBy('dominio');
->keyBy('base_path');
$tenant = collect($candidateKeys)
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
$tenant = collect($candidateBasePaths)
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
->first(fn (?Tenant $candidate): bool => $candidate !== null);
if (! $tenant instanceof Tenant) {

View File

@@ -5,8 +5,11 @@ namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -24,6 +27,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'user_id',
'guest_token',
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
@@ -32,10 +38,26 @@ class Cart extends Model
protected $table = 'carritos';
public const STATUS_ACTIVE = 'active';
public const STATUS_CHECKOUT = 'checkout';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ABANDONED = 'abandoned';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
protected function casts(): array
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
@@ -63,6 +85,27 @@ class Cart extends Model
return $this->hasMany(CartItem::class, 'cart_id');
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'cart_id');
}
/** @return BelongsTo<Purchase, $this> */
public function currentPurchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
/** @return BelongsTo<StockReservation, $this> */
public function currentStockReservation(): BelongsTo
{
return $this->belongsTo(
StockReservation::class,
'current_stock_reservation_id',
);
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
@@ -85,14 +128,20 @@ class Cart extends Model
}
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
$this->assertUserPurchaseLimit(
$selectedItem,
$cartQuantity + $quantity,
heldQuantity: $cartQuantity,
maximumAddableCeiling: $availableQuantity,
);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
@@ -118,7 +167,7 @@ class Cart extends Model
$item->save();
}
$inventoryService->reserve($selectedItem, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -129,6 +178,7 @@ class Cart extends Model
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
?int $excludedPurchaseId = null,
): CartItem {
if ($quantity <= 0) {
throw ValidationException::withMessages([
@@ -141,7 +191,11 @@ class Cart extends Model
$quantity,
$variantId,
$updateVariant,
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
@@ -165,12 +219,15 @@ class Cart extends Model
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$nextAvailableQuantity,
);
$inventoryService->release($currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
@@ -186,12 +243,11 @@ class Cart extends Model
->lockForUpdate()
->first();
$inventoryService->reserve($nextSelection, $quantity);
if ($targetItem !== null) {
$targetItem->cantidad += $quantity;
$targetItem->save();
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
@@ -199,11 +255,13 @@ class Cart extends Model
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
}
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0) {
$otherVariantsQuantity = (int) $this->items()
@@ -213,11 +271,12 @@ class Cart extends Model
$this->assertUserPurchaseLimit(
$currentSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$availableQuantity,
);
}
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
@@ -227,14 +286,7 @@ class Cart extends Model
$item->cantidad = $quantity;
$item->save();
if ($delta > 0) {
$inventoryService->reserve($currentSelection, $delta);
}
if ($delta < 0) {
$inventoryService->release($currentSelection, abs($delta));
}
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -243,25 +295,79 @@ class Cart extends Model
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(CatalogInventoryService::class)->release(
$selectedItem,
$item->cantidad,
);
$item->delete();
app(StockReservationService::class)->syncCart($this);
});
}
private function invalidateCurrentCheckout(): void
{
$candidatePurchaseId = self::query()
->whereKey($this->getKey())
->value('current_purchase_id');
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var self $cart */
$cart = self::query()->lockForUpdate()->findOrFail($this->getKey());
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.checkout_in_progress'),
]);
}
$this->current_purchase_id = null;
return;
}
if ($currentPurchase === null) {
return;
}
if ($currentPurchase->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart' => __('api.cart.editing_disabled'),
]);
}
if (in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
]);
$this->current_purchase_id = null;
return;
}
app(StockReservationService::class)->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())
->update(['current_purchase_id' => null]);
$this->current_purchase_id = null;
}
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,
@@ -334,6 +440,9 @@ class Cart extends Model
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
?int $excludedPurchaseId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
): void {
if ($this->user_id === null) {
return;
@@ -347,6 +456,10 @@ class Cart extends Model
$catalogItem,
$this->user_id,
$cartQuantity,
$excludedPurchaseId,
$this->getKey(),
$heldQuantity,
$maximumAddableCeiling,
field: 'cantidad',
);
}

View File

@@ -4,6 +4,8 @@ namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -19,12 +21,16 @@ class CartItemResource extends JsonResource
{
$selectedItem = $this->selectedItem();
$imageUrl = null;
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$includeVariants = $tenant instanceof Tenant
&& $tenant->cart_editing_policy->allowsVariantChanges();
if ($selectedItem?->relationLoaded('attachments')) {
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
}
@@ -36,14 +42,27 @@ class CartItemResource extends JsonResource
'variant_id' => $this->variant_id,
'nombre' => $selectedItem?->getName(),
'imagen' => $imageUrl,
'variant' => $this->variant === null ? null : [
'id' => $this->variant->id,
'precio' => $this->formatMoney($this->variant->getPrice()),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $this->variant->inventory->availableStock(),
'values' => $this->variant->selectorOptions($this->catalogItem->itemAttributes),
],
'variant' => $this->variant === null ? null : $this->variantData($this->variant),
'variants' => $this->when(
$includeVariants,
fn () => $this->catalogItem
->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
),
];
}
/** @return array<string, mixed> */
protected function variantData(Variant $variant): array
{
return [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->precio ?? $this->catalogItem->precio),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectorOptions($this->catalogItem->itemAttributes),
];
}

View File

@@ -4,10 +4,14 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -21,13 +25,13 @@ class CartService
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart);
return $this->loadCart($cart, $tenant);
}
/**
@@ -46,7 +50,7 @@ class CartService
$cart->addItem($catalogItemId, $variantId, $quantity);
return [
'cart' => $this->loadCart($cart),
'cart' => $this->loadCart($cart, $tenant),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
@@ -59,34 +63,58 @@ class CartService
?int $variantId,
bool $updateVariant,
): Cart {
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_change_disabled'),
]);
}
if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
return $this->loadCart($cart);
return $this->loadCart($cart, $tenant);
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
if (! $tenant->cart_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'cart_item' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($cartItemId);
return $this->loadCart($cart);
return $this->loadCart($cart, $tenant);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
return cookie(
'guest_token',
$guestToken,
60 * 24 * 180,
'/',
config('session.domain'),
(bool) config('session.secure'),
true,
false,
config('session.same_site'),
$secure = (bool) config('session.secure');
$sameSite = config('session.same_site');
$partitioned = (bool) config('session.partitioned')
|| ($secure && strtolower((string) $sameSite) === 'none');
return Cookie::create(
name: 'guest_token',
value: $guestToken,
expire: now()->addDays(180),
path: '/',
domain: config('session.domain'),
secure: $secure,
httpOnly: true,
raw: false,
sameSite: $sameSite,
partitioned: $partitioned,
);
}
@@ -94,7 +122,7 @@ class CartService
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
'status' => Cart::STATUS_ACTIVE,
]);
$cart->setRelation('items', collect());
@@ -102,9 +130,9 @@ class CartService
return $cart;
}
protected function loadCart(Cart $cart): Cart
protected function loadCart(Cart $cart, Tenant $tenant): Cart
{
return $cart->fresh()->load([
$relations = [
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
@@ -113,7 +141,21 @@ class CartService
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
]);
];
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
$relations = [
...$relations,
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
];
}
return $cart->fresh()->load($relations);
}
/**
@@ -181,12 +223,14 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', 'active')
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
->first();
}
@@ -195,7 +239,7 @@ class CartService
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->findCart($tenant, $identity);
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
@@ -208,10 +252,73 @@ class CartService
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
'status' => Cart::STATUS_ACTIVE,
'origin' => Cart::ORIGIN_USER,
];
if ($identity['user_id'] !== null) {

View File

@@ -34,6 +34,10 @@ class GuestCartMergeService
->first();
if ($userCart !== null) {
$userCart->items()
->orderBy('id')
->pluck('id')
->each(fn (int $itemId) => $userCart->removeItem($itemId));
$userCart->update([
'status' => 'converted',
]);

View File

@@ -6,8 +6,8 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva.
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## Servicios
@@ -30,3 +30,9 @@ Bajo `/tenants/{tenant:codigo}`:
## Dependencias y reglas
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.

View File

@@ -17,17 +17,20 @@ use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Catalog\Services\FeaturedGroupService;
use App\Domains\Catalog\Services\VariantSelectionService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
class CatalogController extends Controller
{
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
{
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
@@ -37,7 +40,7 @@ class CatalogController extends Controller
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1),
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
))->resolve()
));
}
@@ -46,15 +49,17 @@ class CatalogController extends Controller
SearchCatalogItemsRequest $request,
Tenant $tenant,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
return CatalogSearchItemResource::collection(
$catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
)
$items = $catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items);
}
public function category(
@@ -62,17 +67,19 @@ class CatalogController extends Controller
Tenant $tenant,
Category $category,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): 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([
$items = $catalogService->categoryItems(
$tenant,
$category,
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items)->additional([
'category' => [
'id' => $category->id,
'nombre' => $category->nombre,
@@ -93,7 +100,11 @@ class CatalogController extends Controller
$page = (int) $request->validated('page', 1);
return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page));
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$page,
$this->userId($request),
));
}
public function show(
@@ -101,17 +112,19 @@ class CatalogController extends Controller
Tenant $tenant,
CatalogItem $catalogItem,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): 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,
)
$item = $catalogService->getDetail(
$catalogItem,
$variantId === null ? null : (int) $variantId,
);
$allowances->attach(collect([$item]), $this->userId($request));
return CatalogItemDetailResource::make($item);
}
public function variantOptions(
@@ -164,4 +177,11 @@ class CatalogController extends Controller
->response()
->setStatusCode(201);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();
return $userId === null ? null : (int) $userId;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Catalog\Exceptions;
use RuntimeException;
class StockReservationExpiredException extends RuntimeException
{
public function __construct()
{
parent::__construct(__('api.cart.reservation_expired'));
}
}

View File

@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
'type',
'slug',
'nombre',
'group_order',
'descripcion',
'precio',
'inventory_policy',
@@ -47,6 +48,7 @@ class CatalogItem extends Model
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false,
'group_order' => 0,
];
protected function casts(): array
@@ -56,6 +58,7 @@ class CatalogItem extends Model
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
@@ -172,17 +175,29 @@ class CatalogItem extends Model
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder
public function scopeWhereAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereDoesntHave('variants')
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
->whereDoesntHave('variants')
->where(function (Builder $inventoryQuery): void {
$inventoryQuery
->whereNull('catalog_items.inventory_id')
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
});
});
}

View File

@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'tenant_code',
'categoria_id',
'nombre',
'is_enabled',
])]
class Category extends Model
{
@@ -22,6 +23,10 @@ class Category extends Model
protected $table = 'categorias';
protected $attributes = [
'is_enabled' => true,
];
/**
* @return array<string, string>
*/
@@ -29,6 +34,7 @@ class Category extends Model
{
return [
'categoria_id' => 'integer',
'is_enabled' => 'boolean',
];
}

View File

@@ -11,9 +11,11 @@ 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\Support\Str;
#[Fillable([
'tenant_code',
'code',
'source_type',
'category_id',
'product_layout',
@@ -33,6 +35,29 @@ class FeaturedGroup extends Model
'source_type' => FeaturedGroupSource::Manual->value,
];
protected static function booted(): void
{
static::creating(function (FeaturedGroup $featuredGroup): void {
if (filled($featuredGroup->code)) {
return;
}
$baseCode = Str::slug($featuredGroup->group_name) ?: 'group';
$code = $baseCode;
$suffix = 2;
while (static::query()
->where('tenant_code', $featuredGroup->tenant_code)
->where('code', $code)
->exists()) {
$code = "{$baseCode}-{$suffix}";
$suffix++;
}
$featuredGroup->code = $code;
});
}
protected function casts(): array
{
return [

View File

@@ -5,6 +5,7 @@ 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\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
@@ -47,6 +48,12 @@ class Inventory extends Model
return $this->hasOne(Variant::class);
}
/** @return HasMany<StockReservationLine, $this> */
public function stockReservationLines(): HasMany
{
return $this->hasMany(StockReservationLine::class);
}
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);

View File

@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'allow_multi_select',
'sort_order',
'show_in_selector',
'ticket_label',
])]
class ItemAttribute extends Model
{

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'status',
'expires_at',
'committed_at',
'released_at',
'expired_at',
'release_reason',
])]
class StockReservation extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_COMMITTED = 'committed';
public const STATUS_RELEASED = 'released';
public const STATUS_EXPIRED = 'expired';
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'committed_at' => 'datetime',
'released_at' => 'datetime',
'expired_at' => 'datetime',
];
}
/** @return HasMany<StockReservationLine, $this> */
public function lines(): HasMany
{
return $this->hasMany(StockReservationLine::class);
}
/** @return HasOne<Cart, $this> */
public function currentCart(): HasOne
{
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
}
/** @return HasOne<Purchase, $this> */
public function purchase(): HasOne
{
return $this->hasOne(Purchase::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'stock_reservation_id',
'inventory_id',
'quantity',
'tracks_inventory',
])]
class StockReservationLine extends Model
{
protected function casts(): array
{
return [
'stock_reservation_id' => 'integer',
'inventory_id' => 'integer',
'quantity' => 'integer',
'tracks_inventory' => 'boolean',
];
}
/** @return BelongsTo<StockReservation, $this> */
public function reservation(): BelongsTo
{
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
}

View File

@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
),
],
'nombre' => ['required', 'string', 'max:255'],
'group_order' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],

View File

@@ -15,6 +15,7 @@ class OnTicketFeaturedGroupResource extends JsonResource
{
return [
'id' => $this->id,
'code' => $this->code,
'category_id' => $this->category_id,
'category_name' => $this->category->nombre,
'group_name' => $this->group_name,

View File

@@ -20,6 +20,7 @@ class CatalogFeaturedGroupResource extends JsonResource
{
return [
'id' => $this->id,
'code' => $this->code,
'title' => $this->group_name,
'layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -28,27 +29,48 @@ class CatalogFeaturedItemResource extends JsonResource
return $this->ticketSelectorData($catalogItem);
}
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
$availableStock = $catalogItem->availableStock();
$data = [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'stock_tecnico' => $catalogItem->availableStock(),
'maximum_addable_quantity' => $this->maximumAddable(
$availableStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$availableStock,
$remainingUserQuota,
),
'variants' => $catalogItem->visibleVariants()
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
])
: $variant->inventory->availableStock();
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$variantStock,
$remainingUserQuota,
),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
})
->values(),
];
@@ -58,6 +80,9 @@ class CatalogFeaturedItemResource extends JsonResource
/** @return array<string, mixed> */
private function ticketSelectorData(CatalogItem $catalogItem): array
{
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
@@ -65,28 +90,47 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
];
}
/** @return array<string, mixed> */
private function columnWithImageData(CatalogItem $catalogItem): array
{
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
];
}
private function firstImageUrl(CatalogItem $catalogItem): ?string
{
$attachment = $catalogItem->attachments->first()
?? $catalogItem->variants
?? $catalogItem->visibleVariants()
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
return $attachment?->getTemporaryUrl(1440);
}
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
{
return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota);
}
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
}
}

View File

@@ -6,6 +6,7 @@ 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 App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
@@ -37,15 +38,19 @@ class CatalogItemDetailResource extends JsonResource
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'attributes' => $this->attributesData(),
'stock_tecnico' => $this->when(
'maximum_addable_quantity' => $this->when(
$selectedVariant === null,
fn () => $this->availableStock(),
fn () => $this->maximumAddable($this->availableStock()),
),
'unavailable_message' => $this->when(
$selectedVariant === null,
fn () => $this->unavailableMessage($this->availableStock()),
),
'images' => $this->when(
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->visibleVariants()
'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
'selected_variant' => $this->when(
@@ -85,6 +90,7 @@ class CatalogItemDetailResource extends JsonResource
'nombre' => $attribute->nombre,
'sort_order' => $itemAttribute->sort_order,
'show_in_selector' => $itemAttribute->show_in_selector,
'ticket_label' => $itemAttribute->ticket_label,
'is_required' => $attribute->is_required,
'allow_multi_select' => $itemAttribute->allow_multi_select,
'metadata_schema' => $attribute->metadata_schema,
@@ -157,6 +163,7 @@ class CatalogItemDetailResource extends JsonResource
{
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
return [
'id' => $variant->id,
@@ -166,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->variantStock($variant),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $values,
];
}
@@ -185,4 +193,20 @@ class CatalogItemDetailResource extends JsonResource
? null
: $variant->inventory->availableStock();
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -14,8 +15,10 @@ class CatalogSearchItemResource extends JsonResource
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first()
?? $this->variants
?? $visibleVariants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
@@ -26,22 +29,44 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'stock_tecnico' => $this->availableStock(),
'variants' => $this->visibleVariants()
->map(fn (Variant $variant): array => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock),
'variants' => $visibleVariants
->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'values' => $variant->selectorOptions($this->itemAttributes),
])
: $variant->inventory?->availableStock();
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
->values(),
];
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
}

View File

@@ -12,6 +12,37 @@ use Illuminate\Support\Facades\DB;
class CatalogInventoryService
{
/** @return array<int, int> */
public function requirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
{
if ($quantity <= 0) {
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
}
return array_map(
fn (array $requirement): int => $requirement['quantity'] * $quantity,
$this->inventoryRequirements($selection),
);
}
/**
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
{
if ($quantity <= 0) {
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
}
return array_map(
fn (array $requirement): array => [
...$requirement,
'quantity' => $requirement['quantity'] * $quantity,
],
$this->inventoryRequirements($selection),
);
}
public function availableQuantity(CatalogItem|Variant $selection): ?int
{
if ($selection instanceof CatalogItem

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Collection;
class CatalogItemAllowanceService
{
private const USER_QUOTA_REACHED_MESSAGE = 'Alcanzaste el cupo máximo permitido para este producto.';
private const OUT_OF_STOCK_MESSAGE = 'Este producto no tiene stock disponible.';
public function __construct(
private readonly UserPurchaseLimitService $purchaseLimits,
) {}
/** @param Collection<int, CatalogItem> $catalogItems */
public function attach(Collection $catalogItems, ?int $userId): void
{
$remaining = $this->purchaseLimits->remainingByCatalogItem($catalogItems, $userId);
foreach ($catalogItems as $catalogItem) {
$catalogItem->setAttribute(
'remaining_user_quota',
$remaining->get($catalogItem->getKey()),
);
}
}
public function maximumAddableQuantity(?int $availableStock, ?int $remainingUserQuota): ?int
{
if ($availableStock === null) {
return $remainingUserQuota;
}
if ($remainingUserQuota === null) {
return $availableStock;
}
return min($availableStock, $remainingUserQuota);
}
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
{
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
return self::USER_QUOTA_REACHED_MESSAGE;
}
if ($availableStock !== null && $availableStock <= 0) {
return self::OUT_OF_STOCK_MESSAGE;
}
return null;
}
}

View File

@@ -38,6 +38,7 @@ class CatalogService
$attributeCodes = $data['attribute_codes'] ?? [];
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
$hiddenAttributeCodes = $data['hidden_attribute_codes'] ?? [];
$ticketAttributeLabels = $data['ticket_attribute_labels'] ?? [];
$components = $data['components'] ?? [];
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
@@ -73,6 +74,14 @@ class CatalogService
]);
}
if (array_diff(array_keys($ticketAttributeLabels), $attributeCodes) !== []) {
throw ValidationException::withMessages([
'ticket_attribute_labels' => [
__('api.catalog.ticket_label_attribute_not_on_item'),
],
]);
}
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
if ($type === CatalogItemType::Bundle) {
@@ -98,6 +107,7 @@ class CatalogService
$data['attribute_codes'],
$data['multi_select_attribute_codes'],
$data['hidden_attribute_codes'],
$data['ticket_attribute_labels'],
$data['components'],
$data['real_stock'],
$data['reserved_stock'],
@@ -124,6 +134,7 @@ class CatalogService
$attributeCodes,
$multiSelectAttributeCodes,
$hiddenAttributeCodes,
$ticketAttributeLabels,
)
: [];
@@ -194,6 +205,13 @@ class CatalogService
]);
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
@@ -220,7 +238,7 @@ class CatalogService
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereVariantsAvailable()
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -270,7 +288,7 @@ class CatalogService
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereVariantsAvailable()
->whereAvailable()
->with([
'attachments',
'inventory',
@@ -461,6 +479,7 @@ class CatalogService
* @param array<int, string> $attributeCodes
* @param array<int, string> $multiSelectAttributeCodes
* @param array<int, string> $hiddenAttributeCodes
* @param array<string, string> $ticketAttributeLabels
* @return array<string, ItemAttribute>
*/
private function createItemAttributes(
@@ -468,6 +487,7 @@ class CatalogService
array $attributeCodes,
array $multiSelectAttributeCodes = [],
array $hiddenAttributeCodes = [],
array $ticketAttributeLabels = [],
): array {
$itemAttributes = [];
$attributeCodes = array_values(array_unique($attributeCodes));
@@ -492,6 +512,7 @@ class CatalogService
'attribute_id' => $attribute->id,
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
'show_in_selector' => ! in_array($attributeCode, $hiddenAttributeCodes, true),
'ticket_label' => $ticketAttributeLabels[$attributeCode] ?? null,
]);
$itemAttributes[$attributeCode] = $itemAttribute;

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class ExpireStockReservationsService
{
private const BATCH_SIZE = 500;
public function __construct(
private readonly ReleaseCheckoutService $purchases,
private readonly StockReservationService $reservations,
) {}
/**
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
*/
public function expireOverdue(): array
{
$summary = [
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 0,
'failed' => 0,
];
$lastReservationId = 0;
do {
$reservationIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('id', '>', $lastReservationId)
->orderBy('id')
->limit(self::BATCH_SIZE)
->pluck('id');
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
try {
$owner = $this->expireReservation($lastReservationId);
if ($owner !== null) {
$summary[$owner]++;
}
} catch (Throwable $exception) {
$summary['failed']++;
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
'command' => 'reservations:expire',
'stock_reservation_id' => $lastReservationId,
'exception' => $exception,
]);
}
}
} while ($reservationIds->count() === self::BATCH_SIZE);
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $summary['purchases'],
'expired_cart_reservations' => $summary['cart_reservations'],
'expired_orphan_reservations' => $summary['orphan_reservations'],
'failed_reservations' => $summary['failed'],
'total_expired' => $summary['purchases']
+ $summary['cart_reservations']
+ $summary['orphan_reservations'],
]);
return $summary;
}
public function expireIfOverdue(int $reservationId): bool
{
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->find($reservationId);
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
return true;
}
if (! $this->isOverdue($reservation)) {
return false;
}
return $this->expireReservation($reservationId) !== null;
}
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
private function expireReservation(int $reservationId): ?string
{
$purchaseId = Purchase::query()
->where('stock_reservation_id', $reservationId)
->value('id');
if ($purchaseId !== null) {
return $this->expirePurchase((int) $purchaseId);
}
$cartId = Cart::query()
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->value('id');
if ($cartId !== null) {
return $this->expireCart((int) $cartId, $reservationId);
}
return $this->expireOrphan($reservationId);
}
/** @return 'purchases'|null */
private function expirePurchase(int $purchaseId): ?string
{
/** @var Purchase|null $purchase */
$purchase = Purchase::query()->find($purchaseId);
if ($purchase === null) {
return null;
}
$purchase = $this->purchases->expire($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
return 'purchases';
}
$reservation = $purchase->stockReservation;
if ($this->isOverdue($reservation)) {
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
}
return null;
}
/** @return 'cart_reservations'|null */
private function expireCart(int $cartId, int $reservationId): ?string
{
return DB::transaction(function () use ($cartId, $reservationId): ?string {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($cartId)
->where('current_stock_reservation_id', $reservationId)
->where('status', Cart::STATUS_ACTIVE)
->lockForUpdate()
->first();
if ($cart === null) {
return null;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
if (! $this->isOverdue($reservation)) {
return null;
}
$this->reservations->expire($reservation);
$cart->update(['status' => Cart::STATUS_EXPIRED]);
return 'cart_reservations';
});
}
/** @return 'orphan_reservations'|null */
private function expireOrphan(int $reservationId): ?string
{
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->find($reservationId);
if (! $this->isOverdue($reservation)) {
return null;
}
$this->reservations->expire($reservation);
return 'orphan_reservations';
}
private function isOverdue(?StockReservation $reservation): bool
{
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
}
}

View File

@@ -15,7 +15,11 @@ class FeaturedGroupService
private const ITEMS_PER_PAGE = 12;
/** @return array<array-key, mixed> */
public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array
public function __construct(
private readonly CatalogItemAllowanceService $allowances,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$query = $this->itemsQuery($featuredGroup);
@@ -26,12 +30,14 @@ class FeaturedGroupService
$items = $query->get();
$this->attachGroup($items, $featuredGroup);
$this->allowances->attach($items, $userId);
return CatalogFeaturedItemResource::collection($items)->resolve();
}
$paginator = $this->paginateItems($featuredGroup, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup);
$this->allowances->attach($paginator->getCollection(), $userId);
return CatalogFeaturedItemResource::collection($paginator)
->response()
@@ -43,7 +49,16 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereVariantsAvailable()
->whereAvailable()
->where(function (Builder $query): void {
$query
->whereDoesntHave('category')
->orWhereHas(
'category',
fn (Builder $categoryQuery): Builder => $categoryQuery
->where('is_enabled', true),
);
})
->with([
'inventory',
'attachments',
@@ -67,7 +82,9 @@ class FeaturedGroupService
FeaturedGroupSource::Category => $query
->where('catalog_items.category_id', $featuredGroup->category_id)
->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query
->orderBy('catalog_items.group_order')
->orderBy('catalog_items.id'),
};
}

View File

@@ -0,0 +1,493 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockReservationService
{
public const REASON_CART_EMPTY = 'cart_empty';
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
public const REASON_MANUAL_RELEASE = 'manual_release';
public function __construct(
private readonly CatalogInventoryService $inventory,
) {}
public function syncCart(Cart $cart): ?StockReservation
{
return DB::transaction(function () use ($cart): ?StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadSelections($items);
$requirements = $this->requirementsForItems($items);
$reservation = $lockedCart->current_stock_reservation_id === null
? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
if ($reservation !== null) {
$this->assertUsableCartReservation($reservation);
}
if ($requirements === []) {
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
$this->finalizeLocked(
$reservation,
StockReservation::STATUS_RELEASED,
self::REASON_CART_EMPTY,
);
}
$lockedCart->update(['current_stock_reservation_id' => null]);
$cart->current_stock_reservation_id = null;
return null;
}
if ($reservation === null) {
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
}
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
}
$currentLines = StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get()
->keyBy('inventory_id');
$inventoryIds = collect(array_keys($requirements))
->merge($currentLines->keys())
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$inventories = Inventory::query()
->whereKey($inventoryIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($inventoryIds as $inventoryId) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
$delta = $required - $previous;
if ($delta > 0
&& $requirements[$inventoryId]['tracks_inventory']
&& $inventory->availableStock() < $delta) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
}
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
}
}
foreach ($inventoryIds as $inventoryId) {
/** @var Inventory $inventory */
$inventory = $inventories->get($inventoryId);
$line = $currentLines->get($inventoryId);
$previous = (int) ($line?->quantity ?? 0);
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
$delta = $required - $previous;
if ($delta > 0) {
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
} elseif ($delta < 0) {
$inventory->release(abs($delta));
}
if ($required === 0) {
$line?->delete();
continue;
}
StockReservationLine::query()->updateOrCreate(
[
'stock_reservation_id' => $reservation->getKey(),
'inventory_id' => $inventoryId,
],
[
'quantity' => $required,
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
],
);
}
$reservation->update([
'expires_at' => $this->expiration(),
'release_reason' => null,
]);
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function attachToPurchase(
Cart $cart,
Purchase $purchase,
Carbon $expiresAt,
): StockReservation {
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
/** @var Purchase $lockedPurchase */
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($lockedCart->current_stock_reservation_id === null) {
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
$linkedPurchase = Purchase::query()
->where('stock_reservation_id', $reservation->getKey())
->whereKeyNot($lockedPurchase->getKey())
->exists();
if ($linkedPurchase) {
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
}
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
$reservation->update(['expires_at' => $expiresAt]);
$purchase->stock_reservation_id = $reservation->getKey();
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function commit(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
return;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
throw new StockReservationExpiredException;
}
$lines = $this->lockLines($reservation);
if ($lines->isEmpty()) {
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
}
$inventories = $this->lockInventories($lines);
foreach ($lines as $line) {
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
}
}
foreach ($lines as $line) {
$inventories->get($line->inventory_id)->buy(
(int) $line->quantity,
(bool) $line->tracks_inventory,
);
}
$reservation->update([
'status' => StockReservation::STATUS_COMMITTED,
'expires_at' => null,
'committed_at' => now(),
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
]);
});
}
public function releaseForPurchase(
Purchase $purchase,
string $status = StockReservation::STATUS_RELEASED,
?string $reason = null,
): void {
DB::transaction(function () use ($purchase, $status, $reason): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
if ($reservation !== null) {
$this->finalizeLocked($reservation, $status, $reason);
}
});
}
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
{
return DB::transaction(function () use ($purchase, $cart): StockReservation {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($purchase->stock_reservation_id === null
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
$this->assertUsableCartReservation($reservation);
$purchase->update(['stock_reservation_id' => null]);
$cart->update(['current_purchase_id' => null]);
$reservation->update(['expires_at' => $this->expiration()]);
return $reservation->fresh('lines');
});
}
public function assertCartReservationUsable(Cart $cart): void
{
DB::transaction(function () use ($cart): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($cart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
});
}
public function releaseCurrentCartReservation(
Cart $cart,
string $reason = self::REASON_CART_CHANGED,
): void {
DB::transaction(function () use ($cart, $reason): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
if ($reservation !== null) {
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
}
$cart->update(['current_stock_reservation_id' => null]);
});
}
public function expire(StockReservation $reservation): void
{
DB::transaction(function () use ($reservation): void {
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture()) {
return;
}
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
});
}
public function clearExpirationForReview(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
throw new StockReservationExpiredException;
}
$reservation->update(['expires_at' => null]);
});
}
/**
* @param Collection<int, CartItem> $items
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
private function requirementsForItems(Collection $items): array
{
$requirements = [];
foreach ($items as $item) {
$selection = $item->selectedItem();
if ($selection === null) {
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
}
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
if (isset($requirements[$inventoryId])) {
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
$requirements[$inventoryId]['tracks_inventory'] =
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
continue;
}
$requirements[$inventoryId] = $requirement;
}
}
ksort($requirements);
return $requirements;
}
/** @param Collection<int, CartItem> $items */
private function loadSelections(Collection $items): void
{
$items->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'catalogItem.bundleComponents.variant.catalogItem',
'variant.inventory',
'variant.catalogItem',
]);
}
/** @return Collection<int, StockReservationLine> */
private function lockLines(StockReservation $reservation): Collection
{
return StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get();
}
/**
* @param Collection<int, StockReservationLine> $lines
* @return Collection<int, Inventory>
*/
private function lockInventories(Collection $lines): Collection
{
return Inventory::query()
->whereKey($lines->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
}
private function finalizeLocked(
StockReservation $reservation,
string $status,
?string $reason,
): void {
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
return;
}
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
}
$lines = $this->lockLines($reservation);
$inventories = $this->lockInventories($lines);
foreach ($lines as $line) {
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
$inventory->release((int) $line->quantity);
}
$now = now();
$reservation->update([
'status' => $status,
'expires_at' => null,
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
]);
if ($status === StockReservation::STATUS_RELEASED) {
Cart::query()
->where('current_stock_reservation_id', $reservation->getKey())
->update(['current_stock_reservation_id' => null]);
}
}
private function assertUsableCartReservation(StockReservation $reservation): void
{
if ($reservation->status === StockReservation::STATUS_EXPIRED
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
throw new StockReservationExpiredException;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null) {
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
}
}
private function expiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
);
}
}

View File

@@ -9,6 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
@@ -17,6 +18,8 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Client\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Client\Requests\StoreClientRequest;
use App\Domains\Client\Requests\UpdateClientRequest;
use App\Domains\Client\Resources\ClientResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class ClientController extends Controller
{
public function index(): JsonResponse
{
return ClientResource::collection(
Client::query()->with('tenants')->latest()->paginateFromRequest()
)->response();
}
public function store(StoreClientRequest $request): JsonResponse
{
return ClientResource::make(
Client::query()->create($request->validated())->load('tenants')
)->response()->setStatusCode(201);
}
public function show(Client $client): ClientResource
{
return ClientResource::make($client->load('tenants'));
}
public function update(UpdateClientRequest $request, Client $client): ClientResource
{
$client->update($request->validated());
return ClientResource::make($client->fresh()->load('tenants'));
}
public function destroy(Client $client): Response
{
$client->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Client\Models;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['code', 'name'])]
class Client extends Model
{
public function getRouteKeyName(): string
{
return 'code';
}
/** @return HasMany<Tenant, $this> */
public function tenants(): HasMany
{
return $this->hasMany(Tenant::class);
}
/** @return HasMany<ClientIntegration, $this> */
public function integrations(): HasMany
{
return $this->hasMany(ClientIntegration::class);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Client\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:255', Rule::unique('clients', 'code')],
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Client\Requests;
use App\Domains\Client\Models\Client;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
/** @var Client|null $client */
$client = $this->route('client');
return [
'code' => ['sometimes', 'string', 'max:255', Rule::unique('clients', 'code')->ignore($client?->id)],
'name' => ['sometimes', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Client\Resources;
use App\Domains\Client\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Client */
class ClientResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->name,
'tenants' => $this->whenLoaded('tenants', fn () => $this->tenants->map(fn ($tenant): array => [
'id' => $tenant->id,
'codigo' => $tenant->codigo,
'nombre' => $tenant->nombre,
'dominio' => $tenant->dominio,
])),
];
}
}

View File

@@ -0,0 +1,6 @@
<?php
use App\Domains\Client\Controllers\ClientController;
use Illuminate\Support\Facades\Route;
Route::apiResource('clients', ClientController::class);

View File

@@ -0,0 +1,443 @@
<?php
namespace App\Domains\Desfile\Services;
use DateTimeInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
class InvitationPurchaseProvisioner
{
public const TENANT_CODE = 'desfile_pura_tendencia';
public const USER_EMAIL = 'invitados@puratendencia.com';
private const PAYMENT_METHOD = 'invitation';
/**
* @var list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>
*/
private const ALLOCATIONS = [
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
];
/**
* @param list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>|null $allocations
*/
public function provision(?array $allocations = null): void
{
if (! $this->prerequisitesExist()) {
return;
}
DB::transaction(function () use ($allocations): void {
$now = now();
$userId = $this->userId($now);
$purchaseId = $this->purchaseId($userId, $now);
$catalogItem = DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->where('slug', 'entrada')
->first();
if ($catalogItem === null) {
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
}
foreach ($allocations ?? self::ALLOCATIONS as $allocation) {
foreach (range($allocation['first_seat'], $allocation['last_seat']) as $seat) {
$variant = $this->variant(
(int) $catalogItem->id,
$allocation['sector'],
$allocation['row'],
$seat,
$allocation['type'],
);
$purchaseItemId = $this->createPurchaseItem(
$purchaseId,
$catalogItem,
$variant,
$allocation['sector'],
$allocation['row'],
$seat,
$allocation['type'],
$now,
);
$this->createTicketAndCommitStock(
$purchaseId,
$purchaseItemId,
$userId,
(int) $catalogItem->id,
$variant,
$now,
);
}
}
});
}
private function prerequisitesExist(): bool
{
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
return false;
}
if (! DB::table('roles')->where('codigo', 'user')->exists()) {
throw new RuntimeException('No se encontró el rol de usuario común.');
}
if (! DB::table('catalog_items')
->where('tenant_code', self::TENANT_CODE)
->where('slug', 'entrada')
->exists()) {
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
}
return true;
}
private function userId(DateTimeInterface $now): int
{
$user = DB::table('users')
->where('active_email', self::USER_EMAIL)
->where('rol_codigo', 'user')
->whereNull('deleted_at')
->first();
if ($user !== null) {
if ($user->tenant_codigo !== self::TENANT_CODE) {
throw new RuntimeException('El email de invitados ya pertenece a otro tenant.');
}
DB::table('users')->where('id', $user->id)->update([
'rol_codigo' => 'user',
'nombre_apellido' => 'Invitados Pura Tendencia',
'updated_at' => $now,
]);
return (int) $user->id;
}
return DB::table('users')->insertGetId([
'rol_codigo' => 'user',
'tenant_codigo' => self::TENANT_CODE,
'nombre_apellido' => 'Invitados Pura Tendencia',
'email' => self::USER_EMAIL,
'email_verified_at' => $now,
'password' => Hash::make(Str::random(64)),
'dni' => null,
'telefono' => null,
'remember_token' => null,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function purchaseId(int $userId, DateTimeInterface $now): int
{
$purchaseId = DB::table('compras')
->where('tenant_codigo', self::TENANT_CODE)
->where('user_id', $userId)
->where('payment_method', self::PAYMENT_METHOD)
->value('id');
if ($purchaseId !== null) {
DB::table('compras')->where('id', $purchaseId)->update([
'status' => 'paid',
'total' => 0,
'updated_at' => $now,
]);
return (int) $purchaseId;
}
return DB::table('compras')->insertGetId([
'tenant_codigo' => self::TENANT_CODE,
'user_id' => $userId,
'cart_id' => null,
'status' => 'paid',
'payment_method' => self::PAYMENT_METHOD,
'total' => 0,
'dni' => null,
'transfer_payer_dni' => null,
'telefono' => null,
'nombre_apellido' => 'Invitados Pura Tendencia',
'email' => self::USER_EMAIL,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function variant(
int $catalogItemId,
string $sector,
int $row,
int $seat,
string $type,
): object {
$variant = $this->findVariant($catalogItemId, $sector, $row, $seat);
if ($variant === null) {
$variant = $this->createVariant($catalogItemId, $sector, $row, $seat, $type);
}
$this->ensureAttributeOptions($catalogItemId, [
'tipo' => $type,
'sector' => $sector,
'fila' => (string) $row,
'asiento' => (string) $seat,
]);
$typeValueId = DB::table('variant_values as variant_value')
->join('item_attributes as item_attribute', 'item_attribute.id', '=', 'variant_value.item_attribute_id')
->join('attribute', 'attribute.id', '=', 'item_attribute.attribute_id')
->where('variant_value.variant_id', $variant->id)
->where('attribute.codigo', 'tipo')
->value('variant_value.id');
if ($typeValueId === null) {
throw new RuntimeException("La variante {$variant->id} no tiene definido el tipo de entrada.");
}
DB::table('variant_values')->where('id', $typeValueId)->update(['value' => $type]);
DB::table('variantes')->where('id', $variant->id)->update([
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
]);
return DB::table('variantes')->where('id', $variant->id)->first();
}
/** @param array<string, string> $values */
private function ensureAttributeOptions(int $catalogItemId, array $values): void
{
$attributes = DB::table('item_attributes')
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
->where('item_attributes.catalog_item_id', $catalogItemId)
->pluck('attribute.id', 'attribute.codigo');
foreach ($values as $code => $value) {
$attributeId = $attributes[$code] ?? null;
if ($attributeId === null) {
throw new RuntimeException("Falta el atributo {$code} en el catálogo de entradas.");
}
if (DB::table('attribute_options')
->where('attribute_id', $attributeId)
->where('value', $value)
->exists()) {
continue;
}
$lastSortOrder = (int) DB::table('attribute_options')
->where('attribute_id', $attributeId)
->max('sort_order');
DB::table('attribute_options')->insert([
'attribute_id' => $attributeId,
'validity_time_id' => null,
'value' => $value,
'label' => $value,
'sort_order' => $lastSortOrder + 1,
'metadata' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
private function findVariant(int $catalogItemId, string $sector, int $row, int $seat): ?object
{
$query = DB::table('variantes')->where('catalog_item_id', $catalogItemId);
foreach (['sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) {
$query->whereExists(fn ($subquery) => $subquery
->selectRaw('1')
->from('variant_values as selected_value')
->join('item_attributes as selected_item_attribute', 'selected_item_attribute.id', '=', 'selected_value.item_attribute_id')
->join('attribute as selected_attribute', 'selected_attribute.id', '=', 'selected_item_attribute.attribute_id')
->whereColumn('selected_value.variant_id', 'variantes.id')
->where('selected_attribute.codigo', $code)
->where('selected_value.value', $value));
}
return $query->first();
}
private function createVariant(
int $catalogItemId,
string $sector,
int $row,
int $seat,
string $type,
): object {
$itemAttributes = DB::table('item_attributes')
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
->where('item_attributes.catalog_item_id', $catalogItemId)
->pluck('item_attributes.id', 'attribute.codigo');
foreach (['tipo', 'sector', 'fila', 'asiento'] as $requiredCode) {
if (! isset($itemAttributes[$requiredCode])) {
throw new RuntimeException("Falta el atributo {$requiredCode} en el catálogo de entradas.");
}
}
$inventoryId = DB::table('inventories')->insertGetId([
'sold_units' => 0,
'reserved_stock' => 0,
'real_stock' => 1,
]);
$variantId = DB::table('variantes')->insertGetId([
'catalog_item_id' => $catalogItemId,
'event_date_id' => null,
'inventory_id' => $inventoryId,
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
'precio' => $this->catalogPrice($sector, $row),
]);
foreach (['tipo' => $type, 'sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) {
DB::table('variant_values')->insert([
'variant_id' => $variantId,
'item_attribute_id' => $itemAttributes[$code],
'value' => $value,
'created_at' => now(),
'updated_at' => now(),
]);
}
return DB::table('variantes')->where('id', $variantId)->first();
}
private function catalogPrice(string $sector, int $row): int
{
$prices = in_array($sector, ['A', 'C'], true)
? [1 => 250000, 2 => 200000, 3 => 100000, 4 => 75000, 5 => 50000]
: [1 => 240000, 2 => 190000, 3 => 90000, 4 => 65000, 5 => 40000];
return $prices[$row];
}
private function createPurchaseItem(
int $purchaseId,
object $catalogItem,
object $variant,
string $sector,
int $row,
int $seat,
string $type,
DateTimeInterface $now,
): int {
$existingId = DB::table('compra_items')
->where('compra_id', $purchaseId)
->where('source_variant_id', $variant->id)
->value('id');
if ($existingId !== null) {
return (int) $existingId;
}
$attributes = [
['name' => 'Tipo', 'value' => $type],
['name' => 'Sector', 'value' => $sector],
['name' => 'Fila', 'value' => (string) $row],
['name' => 'Asiento', 'value' => (string) $seat],
];
return DB::table('compra_items')->insertGetId([
'compra_id' => $purchaseId,
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => $variant->id,
'image_attachment_id' => DB::table('catalog_items_attachments')
->where('catalog_item_id', $catalogItem->id)
->whereNull('variant_id')
->orderBy('orden')
->value('attachment_id'),
'nombre' => $catalogItem->nombre,
'descripcion' => $variant->descripcion,
'slug' => $catalogItem->slug,
'item_nombre' => "Entrada (Sector {$sector}, Fila {$row}, Asiento {$seat})",
'variant_attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'cantidad' => 1,
'precio_unitario' => 0,
'discount_total' => 0,
'tax_total' => 0,
'total' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
private function createTicketAndCommitStock(
int $purchaseId,
int $purchaseItemId,
int $userId,
int $catalogItemId,
object $variant,
DateTimeInterface $now,
): void {
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
? ['source_purchase_item_id' => $purchaseItemId]
: ['source_purchase_id' => $purchaseId];
if (DB::table('tickets')
->where($purchaseReference)
->where('source_variant_id', $variant->id)
->exists()) {
return;
}
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) {
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
}
DB::table('inventories')->where('id', $inventory->id)->update([
'real_stock' => $inventory->real_stock - 1,
'sold_units' => $inventory->sold_units + 1,
]);
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed',
'committed_at' => $now,
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
}
DB::table('stock_reservation_lines')->insert([
'stock_reservation_id' => $reservationId,
'inventory_id' => $inventory->id,
'quantity' => 1,
'tracks_inventory' => true,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('tickets')->insert([
'tenant_code' => self::TENANT_CODE,
'ticket' => (string) Str::uuid(),
'name' => null,
'description' => null,
...$purchaseReference,
'source_catalog_item_id' => $catalogItemId,
'source_variant_id' => $variant->id,
'used_at' => null,
'scanner_user_id' => null,
'user_id' => $userId,
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Event\Services;
use App\Domains\Event\Models\EventDate;
class EffectiveEventDateResolver
{
public function resolve(EventDate $eventDate): ?EventDate
{
$current = $eventDate;
$visited = [];
while (true) {
$identity = $current->getKey() === null
? 'object:'.spl_object_id($current)
: 'key:'.$current->getKey();
if (isset($visited[$identity])) {
return null;
}
$visited[$identity] = true;
if ($current->rescheduled_to_event_date_id === null) {
return $current->suspended_at === null ? $current : null;
}
$current->loadMissing('rescheduledTo');
$current = $current->rescheduledTo;
if ($current === null) {
return null;
}
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateCategoryVisibilityRequest;
use App\Domains\FiestaFutbolInfantil\Services\CategoryVisibilityService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategoryVisibilityController extends Controller
{
public function __construct(private readonly CategoryVisibilityService $visibilityService) {}
public function show(Request $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
return response()->json([
'data' => [
'is_enabled' => $this->visibilityService->isEnabled($tenant, $category),
],
]);
}
public function update(UpdateCategoryVisibilityRequest $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
$model = $this->visibilityService->update(
$tenant,
$category,
$request->boolean('is_enabled'),
);
return response()->json([
'data' => [
'is_enabled' => $model->is_enabled,
],
'message' => $model->is_enabled
? 'Mostrar en sitio web se activo correctamente'
: 'Mostrar en sitio web se desactivo correctamente',
]);
}
private function category(Request $request): FiestaCategory
{
return FiestaCategory::from((string) $request->route('category'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Enums;
enum FiestaCategory: string
{
case Entries = 'entries';
case Foods = 'foods';
case Accommodations = 'accommodations';
case Merchandise = 'merchandise';
public function categoryName(): string
{
return match ($this) {
self::Entries => 'Entradas',
self::Foods => 'Comidas',
self::Accommodations => 'Alojamientos',
self::Merchandise => 'Merchandising',
};
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCategoryVisibilityRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'is_enabled' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Models\Category;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\Tenant\Models\Tenant;
class CategoryVisibilityService
{
public function isEnabled(Tenant $tenant, FiestaCategory $category): bool
{
return Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', $category->categoryName())
->value('is_enabled') ?? true;
}
public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category
{
$model = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => $category->categoryName(),
]);
$model->update(['is_enabled' => $isEnabled]);
return $model;
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
use App\Domains\FiestaFutbolInfantil\Controllers\CategoryVisibilityController;
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
@@ -9,6 +10,20 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
$visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void {
Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
};
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index');

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\TicketFilterFormResource;
use App\Domains\Forms\Services\TicketFilterFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TicketFilterFormController extends Controller
{
public function __construct(private readonly TicketFilterFormService $formService) {}
public function __invoke(Request $request): TicketFilterFormResource
{
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
return TicketFilterFormResource::make($this->formService->get($tenant));
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\TicketFormResource;
use App\Domains\Forms\Services\TicketFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TicketFormController extends Controller
{
public function __construct(protected TicketFormService $ticketFormService) {}
public function __invoke(Request $request): TicketFormResource
{
return TicketFormResource::make(
$this->ticketFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketFilterFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->resource['code'],
'action' => $this->resource['action'],
'method' => $this->resource['method'],
'fields' => $this->resource['fields'],
'columns' => $this->resource['columns'],
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
'categories' => $this->resource['categories'],
];
}
}

View File

@@ -6,25 +6,18 @@ use App\Domains\Purchase\Models\Purchase;
class SaleFormService
{
/** @return array{statuses: list<array{code: string, name: string}>} */
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
public function get(): array
{
$names = [
Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
];
return [
'statuses' => array_map(
fn (string $status): array => [
'code' => $status,
'name' => $names[$status],
fn (string $code, array $definition): array => [
'value' => $code,
'label' => $definition['name'],
'real_statuses' => $definition['statuses'],
],
Purchase::statuses(),
array_keys(Purchase::adminStatuses()),
array_values(Purchase::adminStatuses()),
),
];
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
class TicketFilterFormService
{
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
public function __construct(
private readonly TicketFormService $ticketFormService,
private readonly AdminAppTicketColumnService $columnService,
) {}
/** @return array<string, mixed> */
public function get(Tenant $tenant): array
{
$fields = $this->commonFields();
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
$fields = [
...$this->fiestaFutbolInfantilFields($tenant),
...$this->commonFields(includeDate: false),
];
}
return [
'code' => 'tickets_filter',
'action' => '/api/v1/adminapp/tenant/tickets',
'method' => 'GET',
'fields' => $fields,
'columns' => $this->columnService->publicColumns($tenant),
];
}
/** @return list<array<string, mixed>> */
private function fiestaFutbolInfantilFields(Tenant $tenant): array
{
$form = $this->ticketFormService->getForFilters($tenant);
return [
[
'name' => 'category',
'query_param' => 'category',
'label' => 'Categoría',
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => 'Categoría',
'options' => array_map(
fn (array $category): array => [
'value' => $category['value'],
'label' => $category['label'],
'children' => [
[
'field' => 'product',
'disabled' => $category['products'] === [],
'options' => array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'children' => [
[
'field' => 'type',
'disabled' => $product['types'] === [],
'options' => array_map(
fn (array $type): array => [
'value' => $type['value'],
'label' => $type['label'],
'children' => [[
'field' => 'size',
'disabled' => ($type['sizes'] ?? []) === [],
'options' => $type['sizes'] ?? [],
]],
],
$product['types'],
),
],
],
],
$category['products'],
),
],
[
'field' => 'date',
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
'options' => [],
],
],
],
$form['categories'],
),
],
$this->dependentSelect('product', 'Producto', 'category'),
$this->dependentSelect('type', 'Tipo', 'product'),
$this->dependentSelect('size', 'Talle', 'type'),
[
...$this->dependentSelect('date', 'Fecha', 'category'),
'type' => 'date',
],
];
}
/** @return list<array<string, mixed>> */
private function commonFields(bool $includeDate = true): array
{
return [
...($includeDate ? [[
'name' => 'date',
'query_param' => 'date',
'label' => 'Fecha',
'type' => 'date',
'required' => false,
'default' => null,
]] : []),
[
'name' => 'status',
'query_param' => 'status',
'label' => 'Estado',
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => 'Estado',
'options' => Ticket::statusOptions(),
],
];
}
/** @return array<string, mixed> */
private function dependentSelect(string $name, string $label, string $dependency): array
{
return [
'name' => $name,
'query_param' => $name,
'label' => $label,
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => $label,
'depends_on' => $dependency,
'disabled' => true,
'options' => [],
];
}
}

View File

@@ -0,0 +1,396 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Collection;
class TicketFormService
{
private const PRODUCT = 'product';
/**
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
*/
private const CATEGORY_PRESENTATIONS = [
'entradas' => [
'label' => null,
'product' => self::PRODUCT,
'type' => null,
'order' => 1,
],
'alojamientos' => [
'label' => 'Camping',
'product' => 'tipo_alojamiento',
'type' => null,
'order' => 2,
],
'camping' => [
'label' => null,
'product' => 'tipo_alojamiento',
'type' => null,
'order' => 2,
],
'comidas' => [
'label' => 'Comida',
'product' => 'event_date',
'type' => 'horario',
'order' => 3,
],
'comida' => [
'label' => null,
'product' => 'event_date',
'type' => 'horario',
'order' => 3,
],
'merchandising' => [
'label' => null,
'product' => self::PRODUCT,
'type' => 'color',
'order' => 4,
],
];
/**
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
*/
private const FILTER_CATEGORY_PRESENTATIONS = [
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
];
/**
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
public function get(Tenant $tenant): array
{
$items = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('has_tickets', true)
->whereHas('category')
->with($this->relations())
->orderBy('group_order')
->orderBy('nombre')
->get();
return $this->build($items, self::CATEGORY_PRESENTATIONS);
}
/**
* Return active catalog options plus soft-deleted sources still referenced by
* tickets, so historical tickets never become impossible to filter.
*
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
public function getForFilters(Tenant $tenant): array
{
$historicalVariantIds = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereNotNull('source_variant_id')
->distinct()
->pluck('source_variant_id')
->map(fn ($id): int => (int) $id)
->all();
$historicalCatalogItemIds = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereNotNull('source_catalog_item_id')
->distinct()
->pluck('source_catalog_item_id')
->map(fn ($id): int => (int) $id)
->merge(
Variant::withTrashed()
->whereKey($historicalVariantIds)
->pluck('catalog_item_id')
->map(fn ($id): int => (int) $id),
)
->unique()
->values()
->all();
$items = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->whereHas('category')
->where(function ($query) use ($historicalCatalogItemIds): void {
$query
->where(function ($activeQuery): void {
$activeQuery
->whereNull('catalog_items.deleted_at')
->where('has_tickets', true);
})
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
})
->with([
'category',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query
->withTrashed()
->where(function ($variantQuery) use ($historicalVariantIds): void {
$variantQuery
->whereNull('variantes.deleted_at')
->orWhereIn('variantes.id', $historicalVariantIds);
}),
'variants.definitions.itemAttribute.attribute.options',
'variants.eventDates',
'variants.eventDate',
])
->orderBy('group_order')
->orderBy('nombre')
->get();
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
}
/**
* @param Collection<int, CatalogItem> $items
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
{
$categories = [];
foreach ($items as $item) {
$sourceCategory = trim((string) $item->category?->nombre);
$categoryValue = mb_strtolower($sourceCategory);
$presentation = $presentations[$categoryValue] ?? [
'label' => null,
'product' => self::PRODUCT,
'type' => null,
'size' => null,
'order' => PHP_INT_MAX,
];
$categories[$categoryValue] ??= [
'value' => $categoryValue,
'label' => $presentation['label'] ?? $sourceCategory,
'order' => $presentation['order'],
'products' => [],
];
foreach ($this->products(
$item,
$presentation['product'],
$presentation['type'],
$presentation['size'] ?? null,
) as $product) {
$productValue = $product['value'];
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
'value' => $productValue,
'label' => $product['label'],
'types' => [],
'sizes' => [],
];
foreach ($product['types'] as $type) {
$existingProduct['types'][$type['value']] = $type;
}
foreach ($product['sizes'] as $size) {
$existingProduct['sizes'][$size['value']] = $size;
}
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
}
}
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
?: $left['label'] <=> $right['label']);
return [
'statuses' => Ticket::statusOptions(),
'categories' => array_values(array_map(
fn (array $category): array => [
'value' => $category['value'],
'label' => $category['label'],
'products' => array_values(array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'types' => array_values($product['types']),
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
],
$category['products'],
)),
],
$categories,
)),
];
}
/** @return list<string> */
private function relations(): array
{
return [
'category',
'itemAttributes.attribute.options',
'variants.definitions.itemAttribute.attribute.options',
'variants.eventDates',
'variants.eventDate',
];
}
/**
* @return list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>,
* sizes: list<array{value: string, label: string}>
* }>
*/
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
{
if ($productCode === self::PRODUCT) {
return [[
'value' => $item->slug,
'label' => $item->nombre,
'types' => $this->types($item, $typeCode, $sizeCode),
'sizes' => $this->types($item, $sizeCode),
]];
}
$products = [];
foreach ($item->variants as $variant) {
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
$productValue = $productOption['value'];
$products[$productValue] ??= [
'value' => $productValue,
'label' => $this->optionLabel($productOption['label'], $productCode),
'types' => [],
'sizes' => [],
];
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
$this->mergeTypeOption(
$products[$productValue]['types'],
$typeOption,
$variant,
$sizeCode,
);
}
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
}
}
}
return array_values(array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'types' => array_values($product['types']),
'sizes' => array_values($product['sizes']),
],
$products,
));
}
/** @return list<array<string, mixed>> */
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
{
$types = [];
foreach ($item->variants as $variant) {
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
}
}
return array_values(array_map(function (array $type) use ($sizeCode): array {
if ($sizeCode !== null) {
$type['sizes'] = array_values($type['sizes']);
}
return $type;
}, $types));
}
/**
* @param array<string, array<string, mixed>> $types
* @param array{value: string, label: string} $typeOption
*/
private function mergeTypeOption(
array &$types,
array $typeOption,
Variant $variant,
?string $sizeCode,
): void {
$typeValue = $typeOption['value'];
$types[$typeValue] ??= [
...$typeOption,
...($sizeCode !== null ? ['sizes' => []] : []),
];
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
}
}
/** @return list<array{value: string, label: string}> */
private function variantOptions(Variant $variant, ?string $attributeCode): array
{
if ($attributeCode === null) {
return [];
}
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
->get($attributeCode);
if ($selection === null) {
return [];
}
return array_is_list($selection) ? $selection : [$selection];
}
private function optionLabel(string $label, string $attributeCode): string
{
if ($attributeCode !== 'event_date') {
return $label;
}
[$day, $month] = array_pad(explode('/', $label), 2, null);
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
}
}

View File

@@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
@@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
- `GET /event`.
- `GET /sale`.
- `GET /staff`.
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
## Dependencias

View File

@@ -5,6 +5,8 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
@@ -13,6 +15,13 @@ Route::prefix('v1/adminapp/forms')
Route::get('event', EventFormController::class);
Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class);
Route::get('tickets-filter', TicketFilterFormController::class)
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.forms.tickets-filter');
Route::get(
'fiesta-futbol-infantil/ticket',
TicketFormController::class
);
Route::get(
'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class ClientIntegrationController extends Controller
{
public function __construct(
private readonly ClientIntegrationService $clientIntegrationService,
) {}
public function index(Client $client): JsonResponse
{
return response()->json($this->clientIntegrationService->getAllForClient($client));
}
public function show(Client $client, string $integrationCode): JsonResponse
{
$integration = $this->clientIntegrationService->getClientIntegration($client, $integrationCode);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
}
public function store(
ConfigureIntegrationRequest $request,
Client $client,
string $integrationCode,
): JsonResponse {
$integration = Integration::query()
->where('integration_code', $integrationCode)
->firstOrFail();
try {
$this->clientIntegrationService->updateOrCreateIntegration(
$client,
$integration,
$request->input('integration_data', []),
);
return response()->json([
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $exception) {
return response()->json([
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $exception->getMessage()]),
], 400);
}
}
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);
return response()->noContent();
}
}

View File

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

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
@@ -12,12 +13,12 @@ class TelepagosWebhookController extends Controller
/**
* Handle the incoming Telepagos webhook.
*/
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
{
try {
$cashinId = $request->validated('id');
$service->handleWebhook($tenantCodigo, $cashinId);
$service->handleWebhook($client, $cashinId);
return response()->json(['status' => 'success']);
} catch (\Exception $e) {

View File

@@ -1,60 +0,0 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
use App\Domains\Integration\Services\TenantIntegrationService;
use Illuminate\Routing\Controller;
class TenantIntegrationController extends Controller
{
protected TenantIntegrationService $tenantIntegrationService;
public function __construct(TenantIntegrationService $tenantIntegrationService)
{
$this->tenantIntegrationService = $tenantIntegrationService;
}
public function index(string $tenantCode)
{
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
}
public function show(string $tenantCode, string $integrationCode)
{
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
if (! $integration) {
return response()->json([
'code' => 'integration.not_configured',
'message' => __('api.integration.not_configured'),
], 404);
}
return response()->json($integration);
}
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
{
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
try {
$this->tenantIntegrationService->updateOrCreateIntegration(
$tenantCode,
$integration,
$request->input('integration_data', [])
);
return response()->json([
'code' => 'integration.configured',
'message' => __('api.integration.configured'),
]);
} catch (\Exception $e) {
return response()->json([
'code' => 'integration.validation_failed',
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
], 400);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Domains\Integration\Models;
use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model;
class TenantIntegration extends Model
{
protected $table = 'tenant_integration';
protected $fillable = [
'integration_code',
'tenant_code',
'integration_data',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
];
public function integration()
{
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
}
}

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class ConfigureIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return $this->user()?->can('manage', Integration::class) ?? false;
}
protected function prepareForValidation(): void
{
$this->integrationModel = Integration::query()
->where('integration_code', $this->route('integration_code'))
->first();
if (! $this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => __('api.integration.not_configured'),
]);
}
}
public function rules(): array
{
$rules = ['integration_data' => ['present', 'array']];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule;
}
return $rules;
}
}

View File

@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_tenant_configuration' => ['sometimes', 'boolean'],
'requires_configuration' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Domains\Integration\Requests;
use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreTenantIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation()
{
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
if (! $this->integrationModel) {
throw ValidationException::withMessages([
'integration_code' => __('api.integration.not_configured'),
]);
}
}
public function rules(): array
{
$rules = [];
// Dynamic validation rules based on the integration data schema
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.'.$field] = $rule;
}
}
return $rules;
}
}

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