Compare commits

..

218 Commits

Author SHA1 Message Date
fcb8386735 feat(checkout): return event purchase summary 2026-09-21 10:59:39 -03:00
6d077647ba feat(storefront): configure checkout summary type 2026-09-21 10:59:28 -03:00
14c81fec1b feat(migrations): add migration for grouping Pyme Rural tenants and updating client associations 2026-09-21 09:35:40 -03:00
9a14b8fbbd feat(menus): add storefront category permissions 2026-09-21 09:24:19 -03:00
c665222837 refactor(bootstrap): separate event category data 2026-09-21 09:24:18 -03:00
d61a5d459d style(tenants): align footer background colors 2026-09-21 09:16:39 -03:00
8395c7f24d feat(menus): assign event routes to OnTicket 2026-09-21 09:16:33 -03:00
79d402e210 feat(events): add event categories and filtering 2026-09-21 09:16:28 -03:00
b0702b7e15 feat(catalog): add sales_end_at field to catalog items and implement sale logic
feat(event): include catalog items in event resource and update event service
test(seeder): add FiestaTradicionArrufoSeeder for event and catalog item setup
config: set event timezone for local event dates
2026-09-18 16:37:40 -03:00
dc5134a6ec feat(events): add exact_location field to events and update related logic 2026-09-18 14:48:25 -03:00
801d25b218 test(events): add explicit OnTicket demo event seeder 2026-09-18 14:33:35 -03:00
f6e8716218 feat(events): expose published events to storefront 2026-09-18 14:33:28 -03:00
8d2250d247 feat(events): support event cover attachments 2026-09-18 14:33:21 -03:00
22ef5619f3 Refactor Catalog API: Remove Create Catalog Item Endpoint and Update Related Logic
- Removed the "Create Catalog" endpoint from the Postman collection.
- Updated the CatalogController to handle tenant-specific catalog items based on active events.
- Refactored CatalogItem model to automatically set event_id based on tenant's active_event_id during creation.
- Deleted StoreCatalogItemRequest and CatalogItemResource as they are no longer needed.
- Updated various services and controllers to utilize the new tenant-based catalog item retrieval logic.
- Added tests to ensure that catalog reads are limited to items associated with the active event.
2026-09-18 13:51:13 -03:00
cdc17a13e2 feat(catalog): add event_id to catalog items and backfill existing records 2026-09-18 12:19:03 -03:00
ee9f2ba538 fix(event): backfill active event social media 2026-09-18 11:32:10 -03:00
0170a2eabb feat(event): restore event model and migrate tenant event data 2026-09-18 11:31:51 -03:00
1e67d27a90 refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared 2026-09-18 10:15:57 -03:00
1241e1f7e8 refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared 2026-09-18 10:14:02 -03:00
4659c1049d feat(tenant): update display_categories to true for onticket multi-event storefront 2026-09-18 09:49:24 -03:00
49be167029 feat(onticket): add immersive hero configuration and carousel, update tenant seeder and tests 2026-09-18 09:39:25 -03:00
164187f47b feat(tenant): add hero carousel images and update related services and tests 2026-09-17 16:47:49 -03:00
5aad43f2e3 feat(migration): enhance foreign key constraints and ensure migration integrity 2026-09-17 16:13:06 -03:00
eb1e4df63f feat(tenant): enhance storefront website types with header_type and multi-event support 2026-09-17 16:02:21 -03:00
131eb1a7ee docs(api): update split website type contracts 2026-09-17 14:25:59 -03:00
486129a92f test(tenant): cover split website types and migration 2026-09-17 14:25:54 -03:00
6bc784519f feat(tenant): separate admin and storefront website types 2026-09-17 14:25:46 -03:00
b9b5f8ec9c feat(tenant): add onticket tenant seeding with branding and assets 2026-09-17 13:48:12 -03:00
8595300071 Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-09-17 12:31:49 -03:00
5503559899 feat(refund): implement backfill command for historical ticket refunds reconciliation 2026-09-17 12:29:33 -03:00
7323707424 Merge pull request 'feature/refund' (#7) from feature/refund into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/7
2026-09-17 15:19:27 +00:00
830f65c402 feat(refund): add support for event date suspension in cart invalidation process 2026-09-17 11:42:33 -03:00
df6892c662 feat(checkout): enhance cancellation logic to handle unavailable variants and update cart status 2026-09-17 11:34:26 -03:00
de08ee4ac7 feat(cart): implement InvalidateEventDateCartsService to handle cart invalidation for rescheduled event dates 2026-09-17 10:27:27 -03:00
1269ebcda0 feat(ticket): exclude cancelled status from common ticket filter fields 2026-09-17 09:38:57 -03:00
04e54fefad feat(notification): update ticket and order number formatting in event suspension emails 2026-09-16 16:35:51 -03:00
a46bf905e8 feat(refund): enhance refund process by adding backfill for refunded_units and updating inventory restoration logic 2026-09-16 15:00:41 -03:00
353fd34db2 feat(inventory): add refunded_units to inventory model and update related services 2026-09-16 15:00:28 -03:00
f8a3296821 feat(event): add effective date resolution for rescheduled events and update tests 2026-09-16 14:27:35 -03:00
9aa0c1f3b9 feat(entry): enhance variant filtering in all method to exclude replaced and disabled variants 2026-09-16 10:14:54 -03:00
708037677a feat(entry): add validation for single non-replaced variant in EntryResource and update tests 2026-09-16 10:08:39 -03:00
e6b5da366c feat(entry): filter out replaced variants in EntryResource to improve data accuracy 2026-09-16 09:55:28 -03:00
69bea10519 feat(entry): simplify variant data handling in EntryResource by removing replaced variant logic 2026-09-16 09:50:16 -03:00
3d6548ce4a feat(entry): refine variant selection logic to exclude replaced variants and add unit tests for replacement behavior 2026-09-16 09:34:02 -03:00
8ad9b3a4e3 Revert "feat(variants): implement restoration of previously suspended variants with usable dates"
This reverts commit 3a4f6b64d2.
2026-09-16 09:08:43 -03:00
3a4f6b64d2 feat(variants): implement restoration of previously suspended variants with usable dates 2026-09-16 09:05:21 -03:00
44178d72a5 feat(variants): enhance disableForSuspension method to manage variant replacements and inventory merging 2026-09-16 08:56:01 -03:00
d02ad5fce2 feat(event): add per-user date change notices 2026-09-15 17:06:12 -03:00
69f2dbe056 refactor(event): remove date notices from tenant config 2026-09-15 17:06:06 -03:00
24d87623cc feat(event): implement EventDateGroupingService to group historical dates and update related resources and tests 2026-09-15 16:28:39 -03:00
51937ca93f feat(event): add EventDateInfoFormatter to format event date messages and implement related tests 2026-09-15 16:08:42 -03:00
dcf4383fbf feat(variants): implement inventory cloning for historical stock updates and adjust related tests 2026-09-15 15:15:18 -03:00
ff43ba32f3 feat(food): add endpoint to update historical stock and separate current and historical food variants 2026-09-15 15:03:41 -03:00
9fa4da7de2 feat(variants): add filtering for inactive event dates in visibleVariants method 2026-09-14 16:52:34 -03:00
c27d9628fd feat(event): filter event dates to exclude rescheduled and suspended entries 2026-09-14 16:48:06 -03:00
b1e09b71ad feat(migrations): implement backfill for legacy refunds and add migration tests 2026-09-14 15:13:16 -03:00
7d45734ad7 feat(forms): add entry form event dates 2026-09-14 15:00:29 -03:00
91022c5897 fix(forms): hide inactive food event dates 2026-09-14 15:00:23 -03:00
61314d5166 feat(storefront): expose event date notices 2026-09-14 14:49:30 -03:00
0d54887602 feat(event): audit suspended event dates 2026-09-14 14:19:29 -03:00
54827fbc58 feat(event): record date reschedules 2026-09-14 14:13:46 -03:00
18a0d14fa8 feat(event): add date reschedule history 2026-09-14 14:13:39 -03:00
b4a7b80430 test: cover event variant replacements 2026-09-14 14:11:24 -03:00
2bd3bf9dc1 feat(checkout): route carts to replacement variants 2026-09-14 14:09:49 -03:00
e42bc545a5 feat(event): replace variants when dates change 2026-09-14 14:09:42 -03:00
aa14129fe7 feat(catalog): track variant replacements 2026-09-14 14:07:55 -03:00
289ceba3af fix(tests): isolate databases in memory and reject persistent connections 2026-09-14 12:35:34 -03:00
28ab6c9ae4 fix(ticket): guard legacy refund amount removal 2026-09-14 12:31:03 -03:00
67deca095e refactor(ticket): use refunds as amount source 2026-09-14 12:29:13 -03:00
caed44fcc8 feat(ticket): persist and expose refund details 2026-09-14 11:57:31 -03:00
adec5d1725 feat(ticket): add refund transaction model 2026-09-14 11:57:06 -03:00
a62e989bb4 feat(ticket): enhance ticket name resolution to follow event date reschedules 2026-09-14 11:33:20 -03:00
9c14153199 feat(notification): implement idempotent email delivery system and update related services 2026-09-14 09:41:40 -03:00
756f4dad0a feat(notification): email event date changes 2026-09-14 08:53:51 -03:00
205d77bc0b feat(event): dispatch date change notifications 2026-09-14 08:53:45 -03:00
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
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
722 changed files with 24695 additions and 4705 deletions

View File

@@ -8,6 +8,7 @@ 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
@@ -44,6 +45,7 @@ COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
EMAIL_DELIVERY_LEASE_SECONDS=300
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

View File

@@ -1,19 +1,29 @@
# Project Conventions
## Test database safety
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
## Architecture
This project uses a domain-oriented structure under `app/Domains`.
This project uses a domain-oriented structure grouped by business area under
`app/Domains`. Cross-cutting modules live under `app/Shared`.
Each domain must live in:
Business domains must live in:
```text
app/Domains/<DomainName>/
app/Domains/<Area>/<DomainName>/
```
The current areas are `Core`, `Commerce`, and `Ticketing`. Cross-cutting
modules live in `app/Shared/<ModuleName>/`.
Supported folders inside each domain:
```text
app/Domains/<DomainName>/
app/Domains/<Area>/<DomainName>/
├── Models/
├── Controllers/
├── Requests/
@@ -28,22 +38,28 @@ app/Domains/<DomainName>/
- Root API routes live in `routes/api.php`.
- `routes/api.php` should act as an aggregator only.
- Each domain defines its own routes in `app/Domains/<DomainName>/routes/api.php`.
- Each business domain defines its own routes in
`app/Domains/<Area>/<DomainName>/routes/api.php`.
- Shared modules that expose routes define them in
`app/Shared/<ModuleName>/routes/api.php`.
## Namespaces
Use this namespace pattern:
```php
App\Domains\<DomainName>\Models
App\Domains\<DomainName>\Controllers
App\Domains\<DomainName>\Requests
App\Domains\<DomainName>\Resources
App\Domains\<DomainName>\Services
App\Domains\<DomainName>\Policies
App\Domains\<DomainName>\Exceptions
App\Domains\<Area>\<DomainName>\Models
App\Domains\<Area>\<DomainName>\Controllers
App\Domains\<Area>\<DomainName>\Requests
App\Domains\<Area>\<DomainName>\Resources
App\Domains\<Area>\<DomainName>\Services
App\Domains\<Area>\<DomainName>\Policies
App\Domains\<Area>\<DomainName>\Exceptions
```
For cross-cutting modules, use `App\Shared\<ModuleName>\...`. Reusable enums
and rules live directly under `App\Shared`.
## Responsibilities
- `Models/`: Eloquent models and model relationships.
@@ -74,13 +90,19 @@ App\Domains\<DomainName>\Exceptions
- Namespace, folder, and class names must stay aligned.
- Route files should be named `api.php`.
## Current Domains
## Current Areas and Modules
- `Tenant`
- `Product`
- `Core`: `Administrator`, `Auth`, `Authorization`, `Bootstrap`, `Client`,
`Menu`, `Staff`, `Tenant`.
- `Commerce`: `Cart`, `Catalog`, `Purchase`, `Sale`.
- `Ticketing`: `Desfile`, `Event`, `FiestaFutbolInfantil`, `Ticket`.
- `Shared`: `Attachable`, `Forms`, `Integration`, `Logging`, `MailTest`,
`Notification`, `StorageTest`, plus shared enums and rules.
## Notes For Future Changes
- New features should be added inside the corresponding domain first.
- If a concern does not belong to an existing domain, create a new domain under `app/Domains`.
- Add new business features to the appropriate area under `app/Domains`.
- Keep generic reusable code in `app/Shared`; do not use it as a catch-all for
business features.
- Avoid placing new business code in `app/Http/Controllers` or `app/Models`.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()),
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'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, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'values' => $variant->selectorOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),
])
->values()),
];
}
}

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Cart\Controllers;
namespace App\Domains\Commerce\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Cart\Requests\AddCartItemRequest;
use App\Domains\Commerce\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Commerce\Cart\Resources\CartResource;
use App\Domains\Commerce\Cart\Services\CartService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Domains\Cart\Models;
namespace App\Domains\Commerce\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 App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -375,7 +375,7 @@ class Cart extends Model
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
->forTenantCatalog($this->tenant()->firstOrFail());
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
@@ -400,6 +400,17 @@ class Cart extends Model
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -430,6 +441,12 @@ class Cart extends Model
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Cart\Models;
namespace App\Domains\Commerce\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Cart\Requests;
namespace App\Domains\Commerce\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Cart\Requests;
namespace App\Domains\Commerce\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Cart\Resources;
namespace App\Domains\Commerce\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 App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Cart\Resources;
namespace App\Domains\Commerce\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Cart\Services;
namespace App\Domains\Commerce\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 App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Cart\Services;
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use Illuminate\Support\Facades\DB;
class GuestCartMergeService

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(
Tenant $tenant,
Collection $eventDateIds,
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
): void {
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
$reason,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Cart\Controllers\CartController;
use App\Domains\Commerce\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Catalog\Controllers\AdminApp;
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Commerce\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -58,7 +58,7 @@ class OnTicketFeaturedGroupController extends Controller
{
$tenant = $request->user()->tenant()->firstOrFail();
abort_unless($tenant->website_type_code === 'onticket', 404);
abort_unless(in_array($tenant->storefront_website_type_code, ['onticket', 'onticket_multi_event'], true), 404);
return $tenant;
}

View File

@@ -1,27 +1,25 @@
<?php
namespace App\Domains\Catalog\Controllers;
namespace App\Domains\Commerce\Catalog\Controllers;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\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\Domains\Commerce\Cart\Services\CartService;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Commerce\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Commerce\Catalog\Requests\CategoryPageRequest;
use App\Domains\Commerce\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Commerce\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
use App\Domains\Commerce\Catalog\Services\VariantSelectionService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -40,7 +38,7 @@ class CatalogController extends Controller
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
$featuredGroupService->itemsResponse($featuredGroup, $tenant, 1, $this->userId($request)),
))->resolve()
));
}
@@ -102,6 +100,7 @@ class CatalogController extends Controller
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$tenant,
$page,
$this->userId($request),
));
@@ -114,7 +113,8 @@ class CatalogController extends Controller
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$variantId = $request->validated('variant_id');
@@ -134,7 +134,8 @@ class CatalogController extends Controller
VariantSelectionService $variantSelectionService,
CartService $cartService,
): CatalogVariantOptionsResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$includedVariantId = null;
$cartItemId = $request->validated('cart_item_id');
@@ -163,21 +164,6 @@ class CatalogController extends Controller
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum CatalogItemType: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum FeaturedGroupSource: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum GroupLayout: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum InventoryPolicy: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum InventorySubject: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Enums;
namespace App\Domains\Commerce\Catalog\Enums;
enum ProductLayout: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Exceptions;
namespace App\Domains\Commerce\Catalog\Exceptions;
use RuntimeException;

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Shared\Enums\FieldType;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -59,6 +59,8 @@ class Attribute extends Model
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->orderBy('date')
->orderBy('time_start');
}

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
use App\Domains\Ticketing\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,14 +1,15 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Enums\InventorySubject;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -30,6 +31,7 @@ use Illuminate\Support\Collection;
'group_order',
'descripcion',
'precio',
'sales_end_at',
'inventory_policy',
'inventory_subject',
'max_units_per_user',
@@ -51,15 +53,30 @@ class CatalogItem extends Model
'group_order' => 0,
];
protected static function booted(): void
{
static::creating(function (CatalogItem $item): void {
if ($item->event_id !== null || $item->tenant_code === null) {
return;
}
$item->event_id = Tenant::query()
->where('codigo', $item->tenant_code)
->value('active_event_id');
});
}
protected function casts(): array
{
return [
'event_id' => 'integer',
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2',
'sales_end_at' => 'datetime',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
'max_units_per_user' => 'integer',
@@ -73,6 +90,21 @@ class CatalogItem extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** @param Builder<CatalogItem> $query */
public function scopeForTenantCatalog(Builder $query, Tenant $tenant): Builder
{
return $query
->where('catalog_items.tenant_code', $tenant->codigo)
->when($tenant->active_event_id !== null, fn (Builder $query): Builder => $query
->where('catalog_items.event_id', $tenant->active_event_id));
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
@@ -161,6 +193,10 @@ class CatalogItem extends Model
public function isAvailable(): bool
{
if (! $this->isSaleOpen()) {
return false;
}
if ($this->type === CatalogItemType::Bundle) {
$availableStock = $this->availableStock();
@@ -178,12 +214,31 @@ class CatalogItem extends Model
public function scopeWhereAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query->whereNull('catalog_items.sales_end_at')
->orWhere('catalog_items.sales_end_at', '>', now());
})->where(function (Builder $query): void {
$query
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $selectionQuery): void {
$selectionQuery
->whereDoesntHave('variants')
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id'));
});
})
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
'variants',
fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
)
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
@@ -201,13 +256,22 @@ class CatalogItem extends Model
});
}
public function isSaleOpen(): bool
{
return $this->sales_end_at === null || now()->lt($this->sales_end_at);
}
/** @return Collection<int, Variant> */
public function visibleVariants(?int $includedVariantId = null): Collection
{
return $this->variants
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $this))
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
&& (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
))))
->values();
}

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'sold_units',
'refunded_units',
'reserved_stock',
'real_stock',
])]
@@ -23,6 +24,7 @@ class Inventory extends Model
protected $attributes = [
'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0,
'real_stock' => 0,
];
@@ -31,6 +33,7 @@ class Inventory extends Model
{
return [
'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer',
'real_stock' => 'integer',
];
@@ -42,10 +45,10 @@ class Inventory extends Model
return $this->hasOne(CatalogItem::class);
}
/** @return HasOne<Variant, $this> */
public function variant(): HasOne
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasOne(Variant::class);
return $this->hasMany(Variant::class);
}
/** @return HasMany<StockReservationLine, $this> */

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -20,6 +20,8 @@ use Illuminate\Support\Str;
'catalog_item_id',
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion',
'precio',
])]
@@ -37,6 +39,8 @@ class Variant extends Model
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'replaced_by_variant_id' => 'integer',
'sales_disabled_at' => 'datetime',
'precio' => 'decimal:2',
];
}
@@ -76,6 +80,40 @@ class Variant extends Model
return $this->belongsTo(Inventory::class);
}
/** @return BelongsTo<Variant, $this> */
public function replacement(): BelongsTo
{
return $this->belongsTo(self::class, 'replaced_by_variant_id');
}
/** @return HasMany<Variant, $this> */
public function replacedVariants(): HasMany
{
return $this->hasMany(self::class, 'replaced_by_variant_id');
}
public function isSellable(): bool
{
return $this->sales_disabled_at === null
&& $this->replaced_by_variant_id === null
&& $this->catalogItem->isSaleOpen()
&& $this->hasOnlyActiveEventDates();
}
public function hasOnlyActiveEventDates(): bool
{
if (! $this->exists
&& $this->event_date_id === null
&& ! $this->relationLoaded('eventDates')) {
return true;
}
return $this->selectedEventDates()->every(
fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
&& $eventDate->suspended_at === null,
);
}
/** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Models;
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests\AdminApp;
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Requests;
namespace App\Domains\Commerce\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Resources\AdminApp;
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Shared\Enums\FieldType;
use App\Domains\Ticketing\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\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 App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Catalog\Resources;
namespace App\Domains\Commerce\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\BundleComponent;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Facades\DB;
class CatalogInventoryService
@@ -63,9 +63,14 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory');
return $selection->variants->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
return $selection->variants
->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory)
: 'id:'.$variant->inventory_id)
->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
}
$requirements = $this->inventoryRequirements($selection);

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Collection;
class CatalogItemAllowanceService

View File

@@ -1,18 +1,18 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use App\Shared\Attachable\Models\Attachment;
use App\Shared\Attachable\Services\AttachmentService;
use App\Domains\Commerce\Catalog\Enums\CatalogItemType;
use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
@@ -207,7 +207,8 @@ class CatalogService
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
@@ -237,7 +238,7 @@ class CatalogService
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
@@ -286,7 +287,7 @@ class CatalogService
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->where('category_id', $category->id)
->whereAvailable()
->with([
@@ -324,9 +325,13 @@ class CatalogService
->findOrFail($variant->catalog_item_id);
$variant->delete();
if (! $catalogItem->variants()->exists()) {
$sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
$this->delete($catalogItem);
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]);
}
@@ -364,6 +369,8 @@ class CatalogService
$componentItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $bundle->tenant_code)
->when($bundle->event_id !== null, fn (Builder $query): Builder => $query
->where('event_id', $bundle->event_id))
->first();
if ($componentItem === null) {
@@ -399,7 +406,11 @@ class CatalogService
]);
}
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'),

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\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 App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;

View File

@@ -1,12 +1,13 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Commerce\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedItemResource;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
@@ -19,10 +20,10 @@ class FeaturedGroupService
private readonly CatalogItemAllowanceService $allowances,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
public function itemsResponse(FeaturedGroup $featuredGroup, Tenant $tenant, int $page, ?int $userId = null): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$query = $this->itemsQuery($featuredGroup);
$query = $this->itemsQuery($featuredGroup, $tenant);
if ($featuredGroup->group_layout === GroupLayout::Single) {
$query->limit(1);
@@ -35,7 +36,7 @@ class FeaturedGroupService
return CatalogFeaturedItemResource::collection($items)->resolve();
}
$paginator = $this->paginateItems($featuredGroup, $page);
$paginator = $this->paginateItems($featuredGroup, $tenant, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup);
$this->allowances->attach($paginator->getCollection(), $userId);
@@ -45,10 +46,10 @@ class FeaturedGroupService
}
/** @return Builder<CatalogItem> */
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
private function itemsQuery(FeaturedGroup $featuredGroup, Tenant $tenant): Builder
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->forTenantCatalog($tenant)
->whereAvailable()
->where(function (Builder $query): void {
$query
@@ -90,9 +91,10 @@ class FeaturedGroupService
private function paginateItems(
FeaturedGroup $featuredGroup,
Tenant $tenant,
int $page,
): LengthAwarePaginator {
$paginator = $this->itemsQuery($featuredGroup)->paginate(
$paginator = $this->itemsQuery($featuredGroup, $tenant)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,

View File

@@ -1,13 +1,13 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Commerce\Catalog\Enums\GroupLayout;
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;

View File

@@ -1,14 +1,14 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\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 App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\StockReservationLine;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -19,6 +19,10 @@ class StockReservationService
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
@@ -343,9 +347,9 @@ class StockReservationService
});
}
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void
public function clearExpirationForReview(Purchase $purchase): void
{
DB::transaction(function () use ($purchase, $expiresAt): void {
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
@@ -363,7 +367,7 @@ class StockReservationService
throw new StockReservationExpiredException;
}
$reservation->update(['expires_at' => $expiresAt]);
$reservation->update(['expires_at' => null]);
});
}

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Domains\Commerce\Catalog\Services;
use App\Domains\Commerce\Catalog\Models\BundleComponent;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\StockReservationLine;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($source): void {
$query->where('event_date_id', $source->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $source->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
$destinationDateIds = $variant->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id === (int) $source->getKey()
? (int) $destination->getKey()
: (int) $id)
->unique()
->sort()
->values();
$replacement = $this->findEquivalent($variant, $destinationDateIds)
?? $this->cloneWithDates($variant, $destinationDateIds);
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
return $replacement;
})->values();
}
public function disableForSuspension(EventDate $eventDate): void
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void {
$query->where('event_date_id', $eventDate->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
foreach ($variants as $variant) {
$remainingDateIds = $variant->selectedEventDates()
->filter(fn (EventDate $date): bool => $date->suspended_at === null
&& $date->rescheduled_to_event_date_id === null)
->pluck('id')
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
if ($remainingDateIds->isEmpty()) {
$variant->update(['sales_disabled_at' => now()]);
continue;
}
$replacement = $this->findEquivalent($variant, $remainingDateIds);
if ($replacement === null) {
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
} else {
$this->mergeInventoryInto($variant, $replacement);
}
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
}
}
/** @param Collection<int, int> $eventDateIds */
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
{
$definitionSignature = $this->definitionSignature($source);
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
return Variant::query()
->where('catalog_item_id', $source->catalog_item_id)
->whereKeyNot($source->getKey())
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDates', 'eventDate', 'definitions'])
->orderBy('id')
->lockForUpdate()
->get()
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
&& $candidate->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->sort()
->values()
->all() === $dateSignature
);
}
/** @param Collection<int, int> $eventDateIds */
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
{
$replacementInventory = $this->cloneInventory($source);
$replacement = $source->replicate([
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
]);
$replacement->inventory_id = $replacementInventory->getKey();
$replacement->event_date_id = $eventDateIds->count() === 1
? $eventDateIds->first()
: null;
$replacement->save();
$replacement->eventDates()->sync($eventDateIds->all());
$replacement->definitions()->createMany(
$source->definitions
->map(fn ($definition): array => [
'item_attribute_id' => $definition->item_attribute_id,
'value' => $definition->value,
])
->all(),
);
$attachments = $source->allAttachments
->mapWithKeys(fn ($attachment): array => [
$attachment->getKey() => [
'orden' => $attachment->pivot->orden,
'is_enabled' => $attachment->pivot->is_enabled,
],
])
->all();
$replacement->allAttachments()->sync($attachments);
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
}
private function cloneInventory(Variant $source): Inventory
{
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
$sourceInventory = Inventory::query()
->whereKey($source->inventory_id)
->lockForUpdate()
->firstOrFail();
$reservedStock = (int) $activeLines->sum('quantity');
if ($sourceInventory->reserved_stock !== $reservedStock) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$replacementInventory = Inventory::query()->create([
'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock,
'real_stock' => $sourceInventory->real_stock,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]);
}
$sourceInventory->update(['reserved_stock' => 0]);
return $replacementInventory;
}
private function mergeInventoryInto(Variant $source, Variant $destination): void
{
if ($source->inventory_id === $destination->inventory_id) {
return;
}
$inventories = Inventory::query()
->whereKey([$source->inventory_id, $destination->inventory_id])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$sourceInventory = $inventories->get($source->inventory_id);
$destinationInventory = $inventories->get($destination->inventory_id);
if ($sourceInventory === null || $destinationInventory === null) {
throw new \LogicException('No se encontró el inventario de una variante.');
}
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]);
}
$sourceInventory->update([
'real_stock' => 0,
'reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
}
/** @return list<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Catalog\Services;
namespace App\Domains\Commerce\Catalog\Services;
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\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Collection;
class VariantSelectionService

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Catalog\Controllers\CatalogController;
use App\Domains\Commerce\Catalog\Controllers\CatalogController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
@@ -13,7 +13,6 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});
require __DIR__.'/adminapp.php';

View File

@@ -1,17 +1,17 @@
<?php
namespace App\Domains\Purchase\Controllers;
namespace App\Domains\Commerce\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Tenant\Models\Tenant;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Commerce\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Commerce\Purchase\Resources\PurchaseResource;
use App\Domains\Commerce\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -93,7 +93,6 @@ class PurchaseController extends Controller
PaymentIntentRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState,
): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
@@ -103,7 +102,6 @@ class PurchaseController extends Controller
: null;
$updated = DB::transaction(function () use (
$checkoutService,
$compra,
$method,
$purchaseState,
@@ -142,12 +140,6 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
}
$purchase->update($purchaseUpdate);
$checkoutService->refreshReservationExpiration(
$purchase,
now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)),
),
);
return true;
});

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Events;
namespace App\Domains\Commerce\Purchase\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Exceptions;
namespace App\Domains\Commerce\Purchase\Exceptions;
use RuntimeException;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Exceptions;
namespace App\Domains\Commerce\Purchase\Exceptions;
use RuntimeException;

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Purchase\Exceptions;
namespace App\Domains\Commerce\Purchase\Exceptions;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use Illuminate\Validation\ValidationException;
class PurchaseLimitExceededException extends ValidationException

View File

@@ -1,19 +1,20 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Shared\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Commerce\Purchase\Events\PurchasePaid;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
@@ -51,6 +52,14 @@ class Purchase extends Model
public const STATUS_SUPERSEDED = 'superseded';
public const ADMIN_STATUS_INCOMPLETE = 'incomplete';
public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment';
public const ADMIN_STATUS_CONFIRMED = 'confirmed';
public const ADMIN_STATUS_CANCELLED = 'cancelled';
/** @return list<string> */
public static function statuses(): array
{
@@ -66,6 +75,68 @@ class Purchase extends Model
];
}
/**
* @return array<string, array{name: string, statuses: list<string>}>
*/
public static function adminStatuses(): array
{
return [
self::ADMIN_STATUS_INCOMPLETE => [
'name' => 'Por completar datos',
'statuses' => [self::STATUS_CREATED],
],
self::ADMIN_STATUS_AWAITING_PAYMENT => [
'name' => 'Esperando pago',
'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW],
],
self::ADMIN_STATUS_CONFIRMED => [
'name' => 'Confirmado',
'statuses' => [self::STATUS_PAID],
],
self::ADMIN_STATUS_CANCELLED => [
'name' => 'Anulado',
'statuses' => [
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
self::STATUS_SUPERSEDED,
],
],
];
}
/** @return list<string> */
public static function adminStatusCodes(): array
{
return array_keys(self::adminStatuses());
}
/** @return list<string> */
public static function realStatusesForAdminStatus(string $adminStatus): array
{
return self::adminStatuses()[$adminStatus]['statuses'] ?? [];
}
public static function adminStatusFor(string $realStatus): ?string
{
foreach (self::adminStatuses() as $adminStatus => $definition) {
if (in_array($realStatus, $definition['statuses'], true)) {
return $adminStatus;
}
}
return null;
}
public static function adminStatusNameFor(string $realStatus): ?string
{
$adminStatus = self::adminStatusFor($realStatus);
return $adminStatus === null
? null
: self::adminStatuses()[$adminStatus]['name'];
}
protected $table = 'compras';
/** @var array<int, string> */
@@ -115,12 +186,15 @@ class Purchase extends Model
return $this->hasMany(PurchaseItem::class, 'compra_id');
}
/**
* @return HasMany<Ticket, $this>
*/
public function tickets(): HasMany
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */
public function tickets(): HasManyThrough
{
return $this->hasMany(Ticket::class, 'source_purchase_id');
return $this->hasManyThrough(
Ticket::class,
PurchaseItem::class,
'compra_id',
'source_purchase_item_id',
);
}
/** @return BelongsTo<StockReservation, $this> */
@@ -145,6 +219,12 @@ class Purchase extends Model
return $this->hasMany(TelepagosPayment::class, 'compra_id');
}
/** @return HasMany<TelepagosPaymentCandidate, $this> */
public function telepagosPaymentCandidates(): HasMany
{
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
}
public function getTotalAmount(): float
{
if ($this->total !== null) {

View File

@@ -1,14 +1,17 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'compra_id',
@@ -56,6 +59,18 @@ class PurchaseItem extends Model
return $this->belongsTo(Purchase::class, 'compra_id');
}
/** @return HasMany<Ticket, $this> */
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_purchase_item_id');
}
/** @return HasMany<TicketRefund, $this> */
public function ticketRefunds(): HasMany
{
return $this->hasMany(TicketRefund::class);
}
/** @return BelongsTo<Attachment, $this> */
public function imageAttachment(): BelongsTo
{

View File

@@ -1,15 +1,15 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'compra_id',
'matched_purchase_ids',
'cuit_buyer',
'cvu_buyer',
'amount',
@@ -30,7 +30,6 @@ class TelepagosPayment extends Model
{
return [
'compra_id' => 'integer',
'matched_purchase_ids' => 'array',
'amount' => 'decimal:2',
];
}
@@ -42,4 +41,10 @@ class TelepagosPayment extends Model
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
/** @return HasMany<TelepagosPaymentCandidate, $this> */
public function candidates(): HasMany
{
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Commerce\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'telepagos_payment_id',
'compra_id',
'dni_matches',
'dni_distance',
'payment_dni',
'purchase_dni',
'amount_matches',
'payment_amount',
'purchase_amount',
'amount_difference',
'match_reason',
'confidence',
])]
class TelepagosPaymentCandidate extends Model
{
protected $table = 'telepagos_payment_candidates';
protected function casts(): array
{
return [
'telepagos_payment_id' => 'integer',
'compra_id' => 'integer',
'dni_matches' => 'boolean',
'dni_distance' => 'integer',
'amount_matches' => 'boolean',
'payment_amount' => 'decimal:2',
'purchase_amount' => 'decimal:2',
'amount_difference' => 'decimal:2',
];
}
public function payment(): BelongsTo
{
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Models;
namespace App\Domains\Commerce\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Requests;
namespace App\Domains\Commerce\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Requests;
namespace App\Domains\Commerce\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Purchase\Requests;
namespace App\Domains\Commerce\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;

View File

@@ -1,9 +1,10 @@
<?php
namespace App\Domains\Purchase\Resources;
namespace App\Domains\Commerce\Purchase\Resources;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -14,7 +15,11 @@ class PurchaseItemResource extends JsonResource
public function toArray(Request $request): array
{
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$usesEventSummary = $tenant instanceof Tenant
&& $tenant->storefrontWebsiteType?->checkout_summary_type
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
$displayImage = ! $tenant instanceof Tenant
|| (! $usesEventSummary && $tenant->display_cart_item_images);
$imageUrl = $displayImage
? $this->imageAttachment?->getTemporaryUrl(1440)
@@ -31,7 +36,7 @@ class PurchaseItemResource extends JsonResource
'nombre' => $this->item_nombre,
'descripcion' => $this->descripcion,
'slug' => $this->slug,
'imagen' => $imageUrl,
'imagen' => $this->when(! $usesEventSummary, $imageUrl),
'attributes' => $this->variant_attributes ?? [],
],
];

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Domains\Commerce\Purchase\Resources;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Purchase\Models\TelepagosPaymentCandidate;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Purchase
*/
class PurchaseResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$paymentVerification = $this->resolvePaymentVerification();
$tenant = $request->route('tenant');
$usesEventSummary = $tenant instanceof Tenant
&& $tenant->storefrontWebsiteType?->checkout_summary_type
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
/** @var Event|null $checkoutEvent */
$checkoutEvent = $usesEventSummary
? $items
->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event)
->filter()
->unique('id')
->first()
: null;
$subtotal = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0,
)
: (float) ($this->total ?? 0);
$total = $this->status === Purchase::STATUS_PAID && $this->total !== null
? (float) $this->total
: ($items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0));
return [
'id' => $this->id,
'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status,
'payment_method' => $this->payment_method,
'expires_at' => $expiresAt,
'expires_in_seconds' => $expiresAt === null
? null
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
'server_time' => $serverTime,
'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items' => PurchaseItemResource::collection($items),
'event' => $this->when($usesEventSummary, fn () => $checkoutEvent === null
? null
: [
'id' => $checkoutEvent->id,
'title' => $checkoutEvent->title,
'image' => $checkoutEvent->attachment?->getTemporaryUrl(1440),
]),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),
];
}
protected function resolveItemSubtotal(PurchaseItem $item): float
{
return (float) $item->precio_unitario * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem $item): float
{
return (float) ($item->total ?? 0);
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
/** @return array<string, mixed>|null */
private function resolvePaymentVerification(): ?array
{
if (
$this->status !== Purchase::STATUS_IN_REVIEW
|| $this->payment_method !== 'transfer'
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
) {
return null;
}
$candidates = $this->resource
->getRelation('telepagosPaymentCandidates')
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
->values();
/** @var TelepagosPaymentCandidate|null $primary */
$primary = $candidates->first();
return [
'status' => $primary === null ? 'pending' : 'candidate',
'candidate_count' => $candidates->count(),
'primary' => $primary === null ? null : [
'reason' => $primary->match_reason,
'dni_distance' => $primary->dni_distance,
'payment_amount' => $this->formatMoney($primary->payment_amount),
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
'amount_difference' => $this->formatMoney($primary->amount_difference),
'confidence' => $primary->confidence,
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
],
'reasons' => $candidates
->pluck('match_reason')
->unique()
->values()
->all(),
];
}
private function comparePaymentCandidates(
TelepagosPaymentCandidate $left,
TelepagosPaymentCandidate $right,
): int {
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
<=> $this->paymentCandidateRank($right->match_reason);
if ($reasonComparison !== 0) {
return $reasonComparison;
}
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
if ($differenceComparison !== 0) {
return $differenceComparison;
}
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
}
private function paymentCandidateRank(string $reason): int
{
return match ($reason) {
'ambiguous_exact_match' => 0,
'exact_dni_near_amount' => 1,
'exact_amount_near_dni' => 2,
default => 3,
};
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -20,7 +20,7 @@ class CatalogSelectionResolver
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->forTenantCatalog($tenant)
->lockForUpdate()
->first();
@@ -28,6 +28,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if (! $catalogItem->isSaleOpen()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => ['La venta de este producto finalizó.'],
]);
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
@@ -41,6 +47,17 @@ class CatalogSelectionResolver
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
@@ -70,6 +87,12 @@ class CatalogSelectionResolver
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')],
]);
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -70,7 +70,7 @@ class CompleteCheckoutService
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
]);
$this->reservations->refreshForPurchase($purchase, null);
$this->reservations->clearExpirationForReview($purchase);
return $this->loadPurchase($purchase);
});

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Enums\InventorySubject;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
class InsufficientStockMessageBuilder
{

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Shared\Attachable\Models\Attachment;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
$purchase->loadMissing('tenant.storefrontWebsiteType');
$usesEventSummary = $purchase->tenant?->storefrontWebsiteType?->checkout_summary_type
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
$relations = [
'tenant',
'items.sourceCatalogItem.event.attachment',
'stockReservation',
];
if (! $usesEventSummary) {
$relations[] = 'items.imageAttachment';
}
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $purchase->payment_method === 'transfer'
) {
$relations[] = 'telepagosPaymentCandidates.payment';
}
return $purchase->load($relations);
}
}

View File

@@ -1,12 +1,13 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -43,6 +44,11 @@ class ReleaseCheckoutService
): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus === Purchase::STATUS_CANCELLED) {
return $this->loadPurchase($purchase);
}
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
@@ -72,13 +78,30 @@ class ReleaseCheckoutService
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
// Leaving checkout is allowed at the exact instant the reservation
// expires. Finish the expiration while holding the purchase lock so
// the request is idempotent with the scheduled expiration job.
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $this->hasOverdueActiveReservation($purchase)) {
$targetStatus = Purchase::STATUS_EXPIRED;
}
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active'
&& in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($purchase, $cart);
if ($this->hasUnavailableVariants($cart)) {
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$cart->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
} else {
$this->reservations->returnToCart($purchase, $cart);
}
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase);
@@ -102,6 +125,17 @@ class ReleaseCheckoutService
});
}
private function hasUnavailableVariants(Cart $cart): bool
{
return $cart->items()
->whereNotNull('variant_id')
->with(['variant.eventDate', 'variant.eventDates'])
->lockForUpdate()
->get()
->contains(fn (CartItem $item): bool => $item->variant === null
|| ! $item->variant->isSellable());
}
private function releasePurchaseReservations(
Purchase $purchase,
string $targetStatus,

View File

@@ -1,9 +1,9 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Purchase\Models\Purchase;
class SourceCartService
{

View File

@@ -1,18 +1,19 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
namespace App\Domains\Commerce\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Cart\Services\CartVariantReplacementService;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -29,6 +30,7 @@ class StartCheckoutService
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly CartVariantReplacementService $variantReplacements,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -250,6 +252,7 @@ class StartCheckoutService
int $cartId,
): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId);
$this->variantReplacements->replaceHistoricalVariants($cart);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
@@ -356,6 +359,13 @@ class StartCheckoutService
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
if ($tenant->active_event_id !== null
&& $item->catalogItem->event_id !== $tenant->active_event_id) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
}
}

View File

@@ -1,15 +1,13 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Commerce\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Commerce\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Commerce\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
@@ -24,7 +22,6 @@ class CheckoutService
private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser,
private readonly StockReservationService $reservations,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -78,9 +75,4 @@ class CheckoutService
{
return $this->releaser->expire($purchase);
}
public function refreshReservationExpiration(Purchase $purchase, ?Carbon $expiresAt): void
{
$this->reservations->refreshForPurchase($purchase, $expiresAt);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Domains\Commerce\Purchase\Services;
/**
* Measures likely DNI typing errors using the optimal-string-alignment
* variant of the Damerau-Levenshtein distance.
*
* The returned value is the minimum number of single-character edits needed
* to transform one DNI into the other. Supported edits are insertion,
* deletion, substitution and transposition of two adjacent digits.
*/
class DniDistanceService
{
/**
* Calculate the edit distance between two normalized DNI strings.
*
* Each matrix cell [row][column] stores the minimum edits required to
* transform the first $row digits of $left into the first $column digits
* of $right. The bottom-right cell therefore contains the final distance.
*/
public function distance(string $left, string $right): int
{
$left = $this->normalize($left);
$right = $this->normalize($right);
$leftLength = strlen($left);
$rightLength = strlen($right);
$matrix = [];
// Transforming a prefix into an empty string requires deleting every digit.
for ($row = 0; $row <= $leftLength; $row++) {
$matrix[$row] = [$row];
}
// Transforming an empty string into a prefix requires inserting every digit.
for ($column = 0; $column <= $rightLength; $column++) {
$matrix[0][$column] = $column;
}
for ($row = 1; $row <= $leftLength; $row++) {
for ($column = 1; $column <= $rightLength; $column++) {
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
$deletionDistance = $matrix[$row - 1][$column] + 1;
$insertionDistance = $matrix[$row][$column - 1] + 1;
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
// Keep the cheapest way to align the two prefixes at this position.
$matrix[$row][$column] = min(
$deletionDistance,
$insertionDistance,
$substitutionDistance,
);
// Count two adjacent inverted digits as one edit instead of two substitutions.
if (
$row > 1
&& $column > 1
&& $left[$row - 1] === $right[$column - 2]
&& $left[$row - 2] === $right[$column - 1]
) {
$matrix[$row][$column] = min(
$matrix[$row][$column],
$matrix[$row - 2][$column - 2] + 1,
);
}
}
}
return $matrix[$leftLength][$rightLength];
}
/**
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
* the leading zero that is present when the DNI is extracted from a CUIT.
*/
public function normalize(string $dni): string
{
$digits = preg_replace('/\D+/', '', $dni) ?? '';
return str_pad($digits, 8, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService
{
public function totalForTenant(Tenant $tenant): string
{
$total = TicketRefund::query()
->whereHas(
'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
)
->sum('amount');
return number_format((float) $total, 2, '.', '');
}
}

View File

@@ -1,11 +1,11 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Validation\ValidationException;
class PurchaseStateGuard

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -65,9 +65,10 @@ class TenantTransactionResetService
DB::table('inventories')
->whereIn('id', $scope['inventory_ids'])
->update([
'real_stock' => DB::raw('real_stock + sold_units'),
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
'reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
return $summary;

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Domains\Purchase\Services;
namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Purchase\Exceptions\PurchaseLimitExceededException;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

View File

@@ -1,6 +1,6 @@
<?php
use App\Domains\Purchase\Controllers\PurchaseController;
use App\Domains\Commerce\Purchase\Controllers\PurchaseController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {

View File

@@ -1,17 +1,18 @@
<?php
namespace App\Domains\Sale\Controllers\AdminApp;
namespace App\Domains\Commerce\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Domains\Commerce\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Commerce\Sale\Requests\AdminAppSaleModificationIndexRequest;
use App\Domains\Commerce\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Commerce\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Commerce\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -34,6 +35,7 @@ class SaleController extends Controller
$this->saleService->sales($tenant, $request->validated())
)->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
'refunded_total' => $this->saleService->refundedTotal($tenant),
]);
}
@@ -67,11 +69,13 @@ class SaleController extends Controller
return new SaleResource($this->saleService->cancel($tenant, $sale));
}
public function modifications(Request $request): AnonymousResourceCollection
{
public function modifications(
AdminAppSaleModificationIndexRequest $request,
): AnonymousResourceCollection {
return SaleModificationResource::collection(
$this->saleService->modifications(
$request->user()->tenant()->firstOrFail()
$request->user()->tenant()->firstOrFail(),
$request->validated(),
)
);
}
@@ -93,7 +97,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
@@ -116,7 +120,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Sale\Requests;
namespace App\Domains\Commerce\Sale\Requests;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -20,7 +20,7 @@ class AdminAppSaleIndexRequest extends FormRequest
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())],
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
'page' => ['sometimes', 'integer', 'min:1'],

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Commerce\Sale\Requests;
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
$rules = parent::rules();
unset($rules['sort_by'], $rules['sort_direction']);
return $rules;
}
}

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