Compare commits

...

197 Commits

Author SHA1 Message Date
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
71c914a389 Merge branch 'main' into homo 2026-08-31 11:35:52 +00:00
705b1247f5 Merge pull request 'homo_experimental' (#2) from homo_experimental into homo
Reviewed-on: https://gitea.quo.ar/tbianchini/shopit-back/pulls/2
2026-08-31 11:25:26 +00:00
b1f42775d6 feat(tickets): add TicketFormController, TicketFormService, and TicketFormResource with related routes and tests 2026-08-28 17:00:47 -03:00
0f6f39cce7 feat(tickets): enhance AdminAppTicketResource and service with purchase details and update tests 2026-08-28 16:05:56 -03:00
1ec7ab3e35 feat(tickets): implement AdminAppTicketResource and update ticket collection to use it 2026-08-28 15:17:35 -03:00
ba0e2dd00c feat(tickets): enhance ticket search functionality and add ticket counts to response 2026-08-28 15:14:11 -03:00
c792e7d306 feat(tickets): implement admin app ticket management with listing and search functionality 2026-08-28 15:10:23 -03:00
6ee3f22e41 feat(menu): add tickets menu and update related seeder and tests 2026-08-28 14:44:55 -03:00
d4ec34db1a fix: update seating allocation for VIP section and adjust related test assertions 2026-08-28 13:41:37 -03:00
351178d9da Merge branch 'feature/candidate_payments' into homo_experimental 2026-08-27 16:52:08 -03:00
b617285148 feat(payments): rename match reason from 'exact_amount_different_dni' to 'exact_amount_near_dni' 2026-08-27 16:40:32 -03:00
9bca41bfa5 feat(payments): filter candidates by DNI distance 2026-08-27 16:04:12 -03:00
5601285369 feat(payments): expose primary transfer candidate 2026-08-27 15:38:24 -03:00
6fb60c9a73 feat(payments): track transfer match candidates 2026-08-27 15:21:28 -03:00
c3f1c320a4 test: isolate PHPUnit database 2026-08-27 15:21:16 -03:00
899bf12457 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-08-27 10:11:24 -03:00
a460743ae0 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-27 10:11:14 -03:00
a4d7b1b789 Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

    refactor(reservations): unify expiration command

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

    feat(cart): expire abandoned stock reservations

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

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

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

    refactor(cart): invalidate payment on actual changes

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

    refactor(cart): own checkout item editing

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

    refactor(checkout): remove legacy purchase item reservations

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

    refactor(checkout): materialize purchase items on confirmation

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

    feat(inventory): add traceable cart stock reservations
2026-08-19 13:59:38 -03:00
1dc4e29c69 refactor(reservations): unify expiration command 2026-08-19 13:58:03 -03:00
093e894cc3 feat(cart): expire abandoned stock reservations 2026-08-19 13:48:09 -03:00
fdf0f3328f refactor(stock): implement expiration for stock reservations and add configuration 2026-08-19 13:43:08 -03:00
8d6bcdcc43 refactor(cart): invalidate payment on actual changes 2026-08-19 13:43:08 -03:00
3206e293eb refactor(cart): own checkout item editing 2026-08-19 13:43:08 -03:00
aed99bd05e refactor(checkout): remove legacy purchase item reservations 2026-08-19 13:43:08 -03:00
f1649e0e4b refactor(checkout): materialize purchase items on confirmation 2026-08-19 13:43:08 -03:00
e6c4b40a37 feat(inventory): add traceable cart stock reservations 2026-08-19 13:43:08 -03:00
7cea8d3495 feat(cart): enhance item update functionality to support variant changes and improve cart editing policies 2026-08-19 10:46:19 -03:00
014b5bb012 feat(cart): implement cart editing policies and update related functionality 2026-08-19 10:15:37 -03:00
235 changed files with 12484 additions and 1807 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
@@ -31,6 +32,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=daily
LOG_STACK=single
@@ -39,6 +41,10 @@ LOG_LEVEL=debug
LOG_DAILY_DAYS=14
TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

View File

@@ -1169,6 +1169,99 @@
},
"response": []
},
{
"name": "Prepare Item Editing Purchase",
"request": {
"method": "POST",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `POST /api/tenants/{tenant:codigo}/compras/{compra}/edit-items`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@prepareItemEditing`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/edit-items",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_code}}",
"compras",
"{{purchase_id}}",
"edit-items"
]
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
},
{
"name": "Update Item Purchase",
"request": {
"method": "PATCH",
"header": [
{
"key": "Accept",
"value": "application/json",
"type": "text"
},
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
}
],
"description": "Ruta Laravel: `PATCH /api/tenants/{tenant:codigo}/compras/{compra}/items/{item}`\n\nControlador: `App\\Domains\\Purchase\\Controllers\\PurchaseController@updateItem`\n\nRequiere autenticación Sanctum.",
"url": {
"raw": "{{base_url}}/api/tenants/{{tenant_code}}/compras/{{purchase_id}}/items/{{purchase_item_id}}",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"{{tenant_code}}",
"compras",
"{{purchase_id}}",
"items",
"{{purchase_item_id}}"
]
},
"body": {
"mode": "raw",
"raw": "{\n \"quantity\": 2,\n \"variant_id\": {{variant_id}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{token}}",
"type": "string"
}
]
}
},
"response": []
},
{
"name": "Create Payment Intent Purchase",
"request": {
@@ -2121,7 +2214,7 @@
},
"body": {
"mode": "raw",
"raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_enabled\": true,\n \"website_type_code\": \"shopit\"\n}",
"raw": "{\n \"client_id\": {{client_id}},\n \"codigo\": \"{{tenant_code}}\",\n \"nombre\": \"Tenant Demo\",\n \"dominio\": \"{{tenant_domain}}\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"secondary_color\": \"#2563EB\",\n \"danger_color\": \"#DC2626\",\n \"success_color\": \"#16A34A\",\n \"header_bg_color\": \"#FFFFFF\",\n \"footer_bg_color\": \"#111827\",\n \"header_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"footer_logo\": \"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==\",\n \"search_product_layout\": \"column_with_image\",\n \"search_group_layout\": \"paginated\",\n \"search_items_per_page\": 12,\n \"display_categories\": true,\n \"display_seach_bar\": true,\n \"display_cart\": true,\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\",\n \"website_type_code\": \"shopit\"\n}",
"options": {
"raw": {
"language": "json"
@@ -2187,7 +2280,7 @@
},
"body": {
"mode": "raw",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_enabled\": true\n}",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\"\n}",
"options": {
"raw": {
"language": "json"
@@ -2227,7 +2320,7 @@
},
"body": {
"mode": "raw",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_enabled\": true\n}",
"raw": "{\n \"nombre\": \"Tenant Demo Actualizado\",\n \"site_title\": \"ShopIt Demo\",\n \"primary_color\": \"#111827\",\n \"cart_editing_policy\": \"full\",\n \"checkout_editing_policy\": \"full\"\n}",
"options": {
"raw": {
"language": "json"

View File

@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
{
$data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode(
$result = $this->resetPasswordAttemptService->validateCode(
$data['email'],
$data['codigo'],
)) {
);
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_expired'),
]);
}
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'),
]);

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
{
return [
'user_id' => 'integer',
'expires_at' => 'datetime',
];
}

View File

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

View File

@@ -12,6 +12,12 @@ use Throwable;
class ResetPasswordAttemptService
{
public const CODE_VALID = 'valid';
public const CODE_INVALID = 'invalid';
public const CODE_EXPIRED = 'expired';
public function createForEmail(
string $email,
string $tenantCode,
@@ -146,12 +152,12 @@ class ResetPasswordAttemptService
);
}
public function validateCode(string $email, string $code): bool
public function validateCode(string $email, string $code): string
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
$user = User::query()
->where('email', $email)
->lockForUpdate()
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
'email_fingerprint' => $emailFingerprint,
]);
return false;
return self::CODE_INVALID;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset code validation failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return self::CODE_EXPIRED;
}
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
return true;
return self::CODE_VALID;
});
} catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [
@@ -214,6 +233,19 @@ class ResetPasswordAttemptService
return false;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return false;
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
]);
return $attempt->getKey();

View File

@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
'login_header_footer_color' => $websiteType->login_header_footer_color,
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
];
}
}

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
@@ -78,36 +77,6 @@ class CartController extends Controller
]);
}
public function updateCheckoutItem(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
Cart $cart,
CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateCheckoutItem(
$tenant,
$request,
$cart,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
),
)->additional([
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
@@ -27,6 +28,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'guest_token',
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
@@ -35,6 +38,16 @@ class Cart extends Model
protected $table = 'carritos';
public const STATUS_ACTIVE = 'active';
public const STATUS_CHECKOUT = 'checkout';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ABANDONED = 'abandoned';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
@@ -43,6 +56,8 @@ class Cart extends Model
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
@@ -76,6 +91,21 @@ class Cart extends Model
return $this->hasMany(Purchase::class, 'cart_id');
}
/** @return BelongsTo<Purchase, $this> */
public function currentPurchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
/** @return BelongsTo<StockReservation, $this> */
public function currentStockReservation(): BelongsTo
{
return $this->belongsTo(
StockReservation::class,
'current_stock_reservation_id',
);
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
@@ -98,14 +128,20 @@ class Cart extends Model
}
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
$this->assertUserPurchaseLimit(
$selectedItem,
$cartQuantity + $quantity,
heldQuantity: $cartQuantity,
maximumAddableCeiling: $availableQuantity,
);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
@@ -127,12 +163,11 @@ class Cart extends Model
'cantidad' => $quantity,
]);
} else {
app(StockReservationService::class)->ensure($item, $selectedItem);
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -158,6 +193,9 @@ class Cart extends Model
$updateVariant,
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
@@ -181,13 +219,15 @@ class Cart extends Model
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$nextAvailableQuantity,
);
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
@@ -204,11 +244,10 @@ class Cart extends Model
->first();
if ($targetItem !== null) {
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
$targetItem->cantidad += $quantity;
$targetItem->save();
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
@@ -216,12 +255,13 @@ class Cart extends Model
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
}
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0) {
$otherVariantsQuantity = (int) $this->items()
@@ -232,11 +272,11 @@ class Cart extends Model
$currentSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$availableQuantity,
);
}
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
@@ -244,16 +284,9 @@ class Cart extends Model
]);
}
if ($delta > 0) {
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
}
if ($delta < 0) {
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
}
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -262,26 +295,79 @@ class Cart extends Model
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(StockReservationService::class)->release(
$item,
$selectedItem,
$item->cantidad,
);
$item->delete();
app(StockReservationService::class)->syncCart($this);
});
}
private function invalidateCurrentCheckout(): void
{
$candidatePurchaseId = self::query()
->whereKey($this->getKey())
->value('current_purchase_id');
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var self $cart */
$cart = self::query()->lockForUpdate()->findOrFail($this->getKey());
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.checkout_in_progress'),
]);
}
$this->current_purchase_id = null;
return;
}
if ($currentPurchase === null) {
return;
}
if ($currentPurchase->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart' => __('api.cart.editing_disabled'),
]);
}
if (in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
]);
$this->current_purchase_id = null;
return;
}
app(StockReservationService::class)->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())
->update(['current_purchase_id' => null]);
$this->current_purchase_id = null;
}
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,
@@ -355,6 +441,8 @@ class Cart extends Model
CatalogItem|Variant $selectedItem,
int $cartQuantity,
?int $excludedPurchaseId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
): void {
if ($this->user_id === null) {
return;
@@ -369,6 +457,9 @@ class Cart extends Model
$this->user_id,
$cartQuantity,
$excludedPurchaseId,
$this->getKey(),
$heldQuantity,
$maximumAddableCeiling,
field: 'cantidad',
);
}

View File

@@ -3,13 +3,11 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
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([
'cart_id',
@@ -57,10 +55,4 @@ class CartItem extends Model
{
return $this->variant ?? $this->catalogItem;
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
}
}

View File

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

View File

@@ -4,9 +4,8 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -18,10 +17,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function show(Tenant $tenant, Request $request): Cart
{
$resolvedIdentity = $this->resolveIdentity($request);
@@ -30,13 +25,13 @@ class CartService
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart);
return $this->loadCart($cart, $tenant);
}
/**
@@ -55,7 +50,7 @@ class CartService
$cart->addItem($catalogItemId, $variantId, $quantity);
return [
'cart' => $this->loadCart($cart),
'cart' => $this->loadCart($cart, $tenant),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
@@ -68,135 +63,58 @@ class CartService
?int $variantId,
bool $updateVariant,
): Cart {
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_change_disabled'),
]);
}
if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
return $this->loadCart($cart);
}
public function updateCheckoutItem(
Tenant $tenant,
Request $request,
Cart $cart,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
$user = $request->user() ?? Auth::guard('sanctum')->user();
if (! $user instanceof User) {
throw new NotFoundHttpException('Checkout cart not found.');
}
return DB::transaction(function () use (
$tenant,
$user,
$cart,
$cartItemId,
$quantity,
$variantId,
$updateVariant,
): Cart {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->where('cart_id', $cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
])
->whereDoesntHave('items')
->lockForUpdate()
->first();
if ($purchase === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
if ($purchase->expires_at !== null && $purchase->expires_at->isPast()) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.not_editable'),
]);
}
/** @var Cart|null $checkoutCart */
$checkoutCart = Cart::query()
->whereKey($cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->where('status', 'checkout')
->lockForUpdate()
->first();
if ($checkoutCart === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
/** @var CartItem|null $cartItem */
$cartItem = $checkoutCart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
throw new NotFoundHttpException('Checkout item not found.');
}
$hasChanges = (int) $cartItem->cantidad !== $quantity
|| ($updateVariant && $cartItem->variant_id !== $variantId);
if (! $hasChanges) {
return $this->loadCart($checkoutCart);
}
$checkoutCart->updateItem(
$cartItemId,
$quantity,
$variantId,
$updateVariant,
$purchase->getKey(),
);
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'total' => $checkoutCart->getTotalAmount(),
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadCart($checkoutCart);
});
return $this->loadCart($cart, $tenant);
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
if (! $tenant->cart_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'cart_item' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($cartItemId);
return $this->loadCart($cart);
return $this->loadCart($cart, $tenant);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
return cookie(
'guest_token',
$guestToken,
60 * 24 * 180,
'/',
config('session.domain'),
(bool) config('session.secure'),
true,
false,
config('session.same_site'),
$secure = (bool) config('session.secure');
$sameSite = config('session.same_site');
$partitioned = (bool) config('session.partitioned')
|| ($secure && strtolower((string) $sameSite) === 'none');
return Cookie::create(
name: 'guest_token',
value: $guestToken,
expire: now()->addDays(180),
path: '/',
domain: config('session.domain'),
secure: $secure,
httpOnly: true,
raw: false,
sameSite: $sameSite,
partitioned: $partitioned,
);
}
@@ -204,7 +122,7 @@ class CartService
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
'status' => Cart::STATUS_ACTIVE,
]);
$cart->setRelation('items', collect());
@@ -212,9 +130,9 @@ class CartService
return $cart;
}
protected function loadCart(Cart $cart): Cart
protected function loadCart(Cart $cart, Tenant $tenant): Cart
{
return $cart->fresh()->load([
$relations = [
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
@@ -223,7 +141,21 @@ class CartService
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
]);
];
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
$relations = [
...$relations,
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
];
}
return $cart->fresh()->load($relations);
}
/**
@@ -291,12 +223,14 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', 'active')
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
->first();
}
@@ -305,7 +239,7 @@ class CartService
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->findCart($tenant, $identity);
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
@@ -318,10 +252,73 @@ class CartService
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
'status' => Cart::STATUS_ACTIVE,
'origin' => Cart::ORIGIN_USER,
];
if ($identity['user_id'] !== null) {

View File

@@ -1,123 +0,0 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use Illuminate\Support\Facades\DB;
class ExpireCartReservationsService
{
public function expireOverdue(): int
{
$expiredItems = 0;
$lastCartItemId = 0;
do {
$cartItemIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNull('purchase_id')
->whereNotNull('cart_item_id')
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('cart_item_id', '>', $lastCartItemId)
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
->select('cart_item_id')
->distinct()
->orderBy('cart_item_id')
->limit(500)
->pluck('cart_item_id');
foreach ($cartItemIds as $cartItemId) {
$lastCartItemId = (int) $cartItemId;
if ($this->expireCartItem($lastCartItemId)) {
$expiredItems++;
}
}
} while ($cartItemIds->count() === 500);
return $expiredItems;
}
private function expireCartItem(int $cartItemId): bool
{
/** @var CartItem|null $candidate */
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
if ($candidate === null) {
return false;
}
return DB::transaction(function () use ($candidate, $cartItemId): bool {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($candidate->cart_id)
->where('status', 'active')
->lockForUpdate()
->first();
if ($cart === null) {
return false;
}
/** @var CartItem|null $cartItem */
$cartItem = $cart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
return false;
}
$reservations = StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('inventory_id')
->lockForUpdate()
->get();
if (
$reservations->isEmpty()
|| $reservations->contains(
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture(),
)
) {
return false;
}
$inventories = Inventory::query()
->whereKey($reservations->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($reservations as $reservation) {
$inventory = $inventories->get($reservation->inventory_id)
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
$inventory->release((int) $reservation->quantity);
$reservation->update([
'quantity' => 0,
'status' => StockReservation::STATUS_EXPIRED,
'expires_at' => null,
'released_at' => now(),
]);
}
$cartItem->delete();
if (! $cart->items()->exists()) {
$cart->update(['status' => 'expired']);
$cart->delete();
}
return true;
});
}
}

View File

@@ -6,13 +6,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
@@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío.
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.

View File

@@ -10,9 +10,3 @@ Route::prefix('tenants/{tenant:codigo}')
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
});
Route::prefix('tenants/{tenant:codigo}')
->middleware('auth:sanctum')
->group(function (): void {
Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,10 +48,10 @@ class Inventory extends Model
return $this->hasOne(Variant::class);
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
/** @return HasMany<StockReservationLine, $this> */
public function stockReservationLines(): HasMany
{
return $this->hasMany(StockReservation::class);
return $this->hasMany(StockReservationLine::class);
}
public function availableStock(): int

View File

@@ -2,21 +2,20 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'inventory_id',
'cart_item_id',
'purchase_id',
'quantity',
'status',
'expires_at',
'committed_at',
'released_at',
'expired_at',
'release_reason',
])]
class StockReservation extends Model
{
@@ -31,31 +30,28 @@ class StockReservation extends Model
protected function casts(): array
{
return [
'inventory_id' => 'integer',
'cart_item_id' => 'integer',
'purchase_id' => 'integer',
'quantity' => 'integer',
'expires_at' => 'datetime',
'committed_at' => 'datetime',
'released_at' => 'datetime',
'expired_at' => 'datetime',
];
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
/** @return HasMany<StockReservationLine, $this> */
public function lines(): HasMany
{
return $this->belongsTo(Inventory::class);
return $this->hasMany(StockReservationLine::class);
}
/** @return BelongsTo<CartItem, $this> */
public function cartItem(): BelongsTo
/** @return HasOne<Cart, $this> */
public function currentCart(): HasOne
{
return $this->belongsTo(CartItem::class);
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
}
/** @return BelongsTo<Purchase, $this> */
public function purchase(): BelongsTo
/** @return HasOne<Purchase, $this> */
public function purchase(): HasOne
{
return $this->belongsTo(Purchase::class);
return $this->hasOne(Purchase::class);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -205,6 +205,13 @@ class CatalogService
]);
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
@@ -231,7 +238,7 @@ class CatalogService
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereVariantsAvailable()
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -281,7 +288,7 @@ class CatalogService
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereVariantsAvailable()
->whereAvailable()
->with([
'attachments',
'inventory',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer;
@@ -70,24 +72,35 @@ class MailService extends BaseIntegrationService
return [];
}
public function send(string|array $recipient, string $subject, string $content): void
{
/**
* @param array<int, array{data: string, name: string, mime: string}> $attachments
*/
public function send(
string|array $recipient,
string $subject,
string $content,
Tenant|WebsiteType|null $brand = null,
array $attachments = [],
): void {
if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$brand ??= $this->tenant;
$branding = $this->brandingFor($brand);
$html = Blade::render(
<<<'BLADE'
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
{!! $content !!}
</x-mail.branded-layout>
BLADE,
[
'tenant' => $this->tenant,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
'branding' => $branding,
'headerLogoUrl' => $brand instanceof WebsiteType
? $brand->siteLogo?->getTemporaryUrl(1440)
: $brand->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
'content' => $content,
],
);
@@ -96,6 +109,14 @@ class MailService extends BaseIntegrationService
->subject($subject)
->html($html);
foreach ($attachments as $attachment) {
$mail->attachData(
$attachment['data'],
$attachment['name'],
['mime' => $attachment['mime']],
);
}
$this->mailer->to($recipient)->send($mail);
}
@@ -106,6 +127,36 @@ class MailService extends BaseIntegrationService
: (string) config('mail.default');
}
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
private function brandingFor(Tenant|WebsiteType $brand): array
{
if ($brand instanceof WebsiteType) {
$brand->loadMissing(['siteLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#FF7006',
'body_color' => $brand->body_color ?? '#666666',
'background_color' => $brand->background_color ?? '#f8f8f8',
'surface_color' => $brand->surface_color ?? '#ffffff',
'header_bg_color' => $brand->surface_color ?? '#ffffff',
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
];
}
$brand->loadMissing(['headerLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
];
}
public function onSetup(): void
{
if (! $this->mailer || ! $this->clientContext) {

View File

@@ -7,7 +7,10 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\DniDistanceService;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -15,6 +18,7 @@ class TelepagosWebhookService
{
public function __construct(
private readonly CheckoutService $checkoutService,
private readonly DniDistanceService $dniDistance,
) {}
/**
@@ -39,6 +43,19 @@ class TelepagosWebhookService
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
$paymentData = [
'compra_id' => null,
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
'amount' => $amount,
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
'qr_order_id' => $qrOrderId,
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
];
$transferenciaOperationIds = [1, 3, 11];
$qrOperationIds = [31, 37, 47];
@@ -59,21 +76,57 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
->where('transfer_payer_dni', $dni)
$eligiblePurchases = Purchase::query()
->whereIn('tenant_codigo', $tenantCodes)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer')
->where('payment_method', 'transfer');
$purchases = (clone $eligiblePurchases)
->where('total', $amount)
->latest()
->limit(2)
->get();
->get()
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
->values();
$compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) {
$candidatePurchases = $this->findTransferCandidates(
$eligiblePurchases,
$dni,
$amount,
);
if ($candidatePurchases->isNotEmpty()) {
$payment = $this->storeTransferCandidates(
$paymentData,
$candidatePurchases,
$dni,
$amount,
);
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'telepagos_payment_id' => $payment->id,
'amount' => $amount,
'candidate_count' => $payment->candidates->count(),
'candidates' => $payment->candidates
->map(fn ($candidate): array => [
'purchase_id' => $candidate->compra_id,
'match_reason' => $candidate->match_reason,
'dni_distance' => $candidate->dni_distance,
'amount_difference' => $candidate->amount_difference,
'confidence' => $candidate->confidence,
])
->all(),
]);
}
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
@@ -163,18 +216,7 @@ class TelepagosWebhookService
return;
}
$paymentData = [
'compra_id' => $compra->id,
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
'amount' => $amount,
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
'qr_order_id' => $qrOrderId,
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
];
$paymentData['compra_id'] = $compra->id;
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
@@ -201,4 +243,91 @@ class TelepagosWebhookService
{
return number_format((float) $amount, 2, '.', '');
}
/**
* @param Builder<Purchase> $eligiblePurchases
* @return Collection<int, Purchase>
*/
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
{
$tolerancePercentage = max(
0,
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
);
$numericAmount = (float) $amount;
$tolerance = $numericAmount * ($tolerancePercentage / 100);
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
return (clone $eligiblePurchases)
->whereBetween('total', [$minimumAmount, $maximumAmount])
->latest()
->get()
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
if ($purchase->transfer_payer_dni === null) {
return false;
}
$purchaseAmount = $this->normalizeAmount($purchase->total);
$distance = $this->dniDistance->distance(
$dni,
(string) $purchase->transfer_payer_dni,
);
return $distance === 0
|| ($purchaseAmount === $amount && $distance <= 2);
})
->values();
}
/**
* @param array<string, mixed> $paymentData
* @param Collection<int, Purchase> $candidatePurchases
*/
private function storeTransferCandidates(
array $paymentData,
Collection $candidatePurchases,
string $dni,
string $amount,
): TelepagosPayment {
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
$payment = TelepagosPayment::create($paymentData);
$payment->candidates()->createMany(
$candidatePurchases
->map(function (Purchase $purchase) use ($dni, $amount): array {
$purchaseAmount = $this->normalizeAmount($purchase->total);
$dniDistance = $this->dniDistance->distance(
$dni,
(string) $purchase->transfer_payer_dni,
);
$dniMatches = $dniDistance === 0;
$amountMatches = $purchaseAmount === $amount;
return [
'compra_id' => $purchase->id,
'dni_matches' => $dniMatches,
'dni_distance' => $dniDistance,
'payment_dni' => $dni,
'purchase_dni' => $purchase->transfer_payer_dni,
'amount_matches' => $amountMatches,
'payment_amount' => $amount,
'purchase_amount' => $purchaseAmount,
'amount_difference' => $this->normalizeAmount(
abs((float) $purchaseAmount - (float) $amount),
),
'match_reason' => $amountMatches
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
: 'exact_dni_near_amount',
'confidence' => $amountMatches
? ($dniMatches ? 'exact' : 'medium')
: 'high',
];
})
->all(),
);
return $payment->load('candidates');
});
}
}

View File

@@ -28,10 +28,21 @@ class TestMail extends Mailable
{
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$branding = [
'name' => $this->tenant->nombre,
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
];
return new Content(
view: 'mail.test',
with: [
'tenant' => $this->tenant,
'branding' => $branding,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
],

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Notification\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TicketsAvailable
{
use Dispatchable, SerializesModels;
/**
* @param array<int, int> $ticketIds
*/
public function __construct(
public readonly Purchase $purchase,
public readonly array $ticketIds,
) {}
}

View File

@@ -6,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit
{
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
public function handle(PasswordResetRequested $event): void
{
try {
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'channel' => $event->channel,
'exception' => $exception,
]);
throw $exception;
}
}
}

View File

@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
public function handle(PurchasePaid $event): void
{
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(TicketsAvailable $event): void
{
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds);
}
}

View File

@@ -9,28 +9,43 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
class NotificationMailService
{
public function __construct(
private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService,
) {}
public function sendWelcome(int $userId, string $tenantCode): void
{
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$this->sendLogged('welcome', [
'user_id' => $userId,
'tenant_code' => $tenantCode,
], function () use ($userId, $tenantCode): array {
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$tenant->nombre}",
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand,
);
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
});
}
public function sendPasswordResetCode(
@@ -38,6 +53,13 @@ class NotificationMailService
string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void {
$context = [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'channel' => $channel,
];
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
@@ -47,13 +69,13 @@ class NotificationMailService
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
$this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
]);
'user_id' => $attempt->user_id,
]));
return;
return null;
}
$recoveryDomain = match ($channel) {
@@ -75,58 +97,116 @@ class NotificationMailService
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$tenant->nombre}",
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
"Código para recuperar tu contraseña - {$brand->nombre}",
view('mail.notifications.password-reset', [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
});
}
public function sendPurchasePaid(int $purchaseId): void
public function sendPurchaseConfirmed(int $purchaseId): void
{
$context = ['purchase_id' => $purchaseId];
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->findOrFail($purchaseId);
->find($purchaseId);
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
if ($purchase === null) {
$this->logSkipped('purchase_confirmed', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
return null;
}
/** @param array<int, int> $ticketIds */
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
{
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
$tickets = $purchase->tickets()
->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
if ($tickets->isEmpty()) {
return;
}
$attachments = $tickets->isEmpty()
? []
: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]];
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
'Tus tickets ya están disponibles',
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
"Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments,
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(),
];
});
}
private function recipientFor(Purchase $purchase): string
{
return (string) ($purchase->email ?: $purchase->user?->email);
}
/**
* @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send
*/
private function sendLogged(string $emailType, array $context, Closure $send): void
{
try {
$resultContext = $send();
if ($resultContext === null) {
return;
}
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
'email_type' => $emailType,
'mailer' => $this->mailService->mailerName(),
]));
} catch (Throwable $exception) {
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
'email_type' => $emailType,
'exception' => $exception,
]));
throw $exception;
}
}
/** @param array<string, mixed> $context */
private function logSkipped(string $emailType, array $context): void
{
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
'email_type' => $emailType,
]));
}
}

View File

@@ -8,12 +8,11 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
- `UserRegistered`: dispara el correo de bienvenida.
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
- `PurchasePaid`: envía la confirmación de pago.
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
## Componentes
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
## API y dependencias
@@ -23,4 +22,6 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
- La recuperación no se envía si el intento dejó de estar pendiente.
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.

View File

@@ -8,7 +8,9 @@ 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\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -22,13 +24,23 @@ class PurchaseController extends Controller
{
public function index(Request $request, Tenant $tenant): JsonResponse
{
$statusParam = $request->query('status');
$statuses = is_string($statusParam)
? collect(explode(',', $statusParam))
->map(fn (string $status): string => trim($status))
->filter()
->unique()
->values()
->all()
: [];
return PurchaseResource::collection(
Purchase::query()
->with('stockReservation')
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id)
->when($request->query('status'), function ($query, $status) {
$query->where('status', $status);
})
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
->orderBy('status')
->latest()
->paginateFromRequest()
)->response();
@@ -50,21 +62,16 @@ class PurchaseController extends Controller
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
}
public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource
{
public function show(
Request $request,
Tenant $tenant,
Purchase $compra,
PurchaseResponseLoader $responses,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing([
'items',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
])->loadCount('tickets');
$compra->items->load('imageAttachment');
$compra->loadMissing('items')->loadCount('tickets');
$responses->load($compra);
return PurchaseResource::make($compra);
}
@@ -86,43 +93,52 @@ class PurchaseController extends Controller
PaymentIntentRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState,
): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$method = $request->validated('method');
$totalAmount = $compra->calculateCurrentTotalAmount();
$transferPayerDni = $method === 'transfer'
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
: null;
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
),
'total' => $totalAmount,
];
if ($method === 'transfer') {
$purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'));
}
$updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool {
$updated = DB::transaction(function () use (
$compra,
$method,
$purchaseState,
$transferPayerDni,
): bool {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->whereKey($compra->getKey())
->lockForUpdate()
->first();
if ($purchase === null) {
return false;
}
$purchaseState->assertNotExpired($purchase);
if (
$purchase === null
|| ! in_array($purchase->status, [
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
return false;
}
$purchaseState->lockCurrentCart($purchase);
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
];
if ($transferPayerDni !== null) {
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
}
$purchase->update($purchaseUpdate);
return true;
@@ -135,7 +151,7 @@ class PurchaseController extends Controller
}
$compra->refresh();
$checkoutService->syncReservationExpiration($compra);
$totalAmount = (float) $compra->total;
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Purchase\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
@@ -10,5 +9,5 @@ class PurchasePaid
{
use Dispatchable, SerializesModels;
public function __construct(public readonly Purchase $purchase) {}
public function __construct(public readonly int $purchaseId) {}
}

View File

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

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Purchase\Exceptions;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Validation\ValidationException;
class PurchaseLimitExceededException extends ValidationException
{
public readonly int $catalogItemId;
public readonly string $catalogItemName;
public readonly int $maximumAddableQuantity;
public function __construct(CatalogItem $catalogItem, int $maximumAddableQuantity, string $field)
{
$this->catalogItemId = (int) $catalogItem->getKey();
$this->catalogItemName = $catalogItem->nombre;
$this->maximumAddableQuantity = $maximumAddableQuantity;
parent::__construct(validator([], []));
$message = trans_choice('api.purchase_limit.exceeded', $maximumAddableQuantity, [
'max' => $maximumAddableQuantity,
'product' => $this->catalogItemName,
]);
$this->message = $message;
$this->validator->errors()->add($field, $message);
}
}

View File

@@ -14,16 +14,17 @@ 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;
#[Fillable([
'cart_id',
'stock_reservation_id',
'tenant_codigo',
'user_id',
'status',
'payment_method',
'expires_at',
'total',
'dni',
'transfer_payer_dni',
@@ -39,6 +40,8 @@ class Purchase extends Model
public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
@@ -47,19 +50,93 @@ class Purchase extends Model
public const STATUS_EXPIRED = 'expired';
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
{
return [
self::STATUS_CREATED,
self::STATUS_PENDING_PAYMENT,
self::STATUS_IN_REVIEW,
self::STATUS_PAID,
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
self::STATUS_SUPERSEDED,
];
}
/**
* @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> */
@@ -71,8 +148,8 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
];
}
@@ -109,18 +186,21 @@ 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 HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
/** @return BelongsTo<StockReservation, $this> */
public function stockReservation(): BelongsTo
{
return $this->hasMany(StockReservation::class);
return $this->belongsTo(StockReservation::class);
}
/**
@@ -139,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) {
@@ -154,12 +240,7 @@ class Purchase extends Model
return (float) $this->getRelation('items')->sum('total');
}
$itemsTotal = (float) $this->items()->sum('total');
if ($itemsTotal > 0 || $this->items()->exists()) {
return $itemsTotal;
}
return (float) ($this->cart?->getTotalAmount() ?? $this->total ?? 0);
return (float) $this->items()->sum('total');
}
protected function valueChangeTenantCode(): string
@@ -192,7 +273,7 @@ class Purchase extends Model
'status' => self::STATUS_PAID,
]);
PurchasePaid::dispatch($this);
PurchasePaid::dispatch($this->getKey());
});
}
}

View File

@@ -3,10 +3,14 @@
namespace App\Domains\Purchase\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\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;
#[Fillable([
'compra_id',
@@ -54,9 +58,27 @@ 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 BelongsTo<Attachment, $this> */
public function imageAttachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'image_attachment_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id')->withTrashed();
}
/** @return BelongsTo<Variant, $this> */
public function sourceVariant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed();
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'compra_id',
@@ -40,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\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

@@ -2,15 +2,12 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin PurchaseItem|CartItem */
/** @mixin PurchaseItem */
class PurchaseItemResource extends JsonResource
{
/** @return array<string, mixed> */
@@ -19,11 +16,9 @@ class PurchaseItemResource extends JsonResource
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
if ($this->resource instanceof PurchaseItem) {
$imageUrl = $displayImage
? $this->imageAttachment?->getTemporaryUrl(1440)
: null;
$attributes = $this->variant_attributes ?? [];
return [
'id' => $this->id,
@@ -37,97 +32,11 @@ class PurchaseItemResource extends JsonResource
'descripcion' => $this->descripcion,
'slug' => $this->slug,
'imagen' => $imageUrl,
'attributes' => $attributes,
'attributes' => $this->variant_attributes ?? [],
],
];
}
$selectedItem = $this->selectedItem();
$catalogItem = $this->catalogItem;
$variant = $this->variant;
$quantity = (int) ($this->cantidad ?? 0);
$unitPrice = $this->resolveUnitPrice($selectedItem);
$lineTotal = $unitPrice * $quantity;
$imageUrl = $displayImage
? $this->resolveImageUrl($selectedItem, $catalogItem)
: null;
return [
'id' => $this->id,
'quantity' => $quantity,
'unit_price' => $this->formatMoney($unitPrice),
'line_total' => $this->formatMoney($lineTotal),
'source_catalog_item_id' => $this->catalog_item_id,
'source_variant_id' => $this->variant_id,
'item_details' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'descripcion' => $selectedItem->getDescription(),
'slug' => $catalogItem?->slug,
'imagen' => $imageUrl,
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
],
];
}
private function resolveUnitPrice(CatalogItem|Variant|null $selectedItem): float
{
return (float) ($selectedItem?->getPrice() ?? 0);
}
private function resolveImageUrl(
CatalogItem|Variant|null $selectedItem,
?CatalogItem $catalogItem,
): ?string {
$attachment = $selectedItem?->relationLoaded('attachments')
? $selectedItem->attachments->first()
: null;
if ($attachment === null && $catalogItem?->relationLoaded('attachments')) {
$attachment = $catalogItem->attachments->first();
}
return $attachment?->getTemporaryUrl(1440);
}
/** @return array<int, array{name: string, value: mixed}> */
private function resolveAttributes(Variant $variant): array
{
if (! $variant->relationLoaded('definitions')) {
return [];
}
$attributes = $variant->definitions
->groupBy('item_attribute_id')
->map(function ($definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$values = $definitions->pluck('value')->values();
return [
'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''),
'value' => $itemAttribute?->allow_multi_select
? $values->all()
: $values->first(),
];
})
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values();
$eventDates = $variant->relationLoaded('eventDates')
? $variant->selectedEventDates()
: collect();
if ($eventDates->isNotEmpty()) {
$attributes->prepend([
'name' => 'Fecha',
'value' => $eventDates
->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))
->values()
->all(),
]);
}
return $attributes->all();
}
private function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@@ -2,9 +2,9 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -18,25 +18,19 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
$purchaseItems = $this->resource->relationLoaded('items')
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$cartItems = $purchaseItems->isEmpty()
&& $this->resource->relationLoaded('cart')
&& $this->resource->getRelation('cart')?->relationLoaded('items')
? $this->resource->getRelation('cart')->getRelation('items')
: collect();
$items = $purchaseItems->isNotEmpty() ? $purchaseItems : $cartItems;
$itemsSource = $purchaseItems->isNotEmpty()
? 'purchase'
: ($cartItems->isNotEmpty() ? 'cart' : null);
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$paymentVerification = $this->resolvePaymentVerification();
$subtotal = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0,
)
: (float) ($this->total ?? 0);
@@ -45,7 +39,7 @@ class PurchaseResource extends JsonResource
? (float) $this->total
: ($items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0));
@@ -58,36 +52,33 @@ class PurchaseResource extends JsonResource
'created_at' => $this->created_at,
'status' => $this->status,
'payment_method' => $this->payment_method,
'expires_at' => $this->expires_at,
'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' => $itemsSource,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items' => PurchaseItemResource::collection($items),
'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|CartItem $item): float
protected function resolveItemSubtotal(PurchaseItem $item): float
{
if ($item instanceof CartItem) {
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
}
return (float) $item->precio_unitario * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
protected function resolveItemTotal(PurchaseItem $item): float
{
if ($item instanceof CartItem) {
return $this->resolveItemSubtotal($item);
}
return (float) ($item->total ?? 0);
}
@@ -95,4 +86,75 @@ class PurchaseResource extends JsonResource
{
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

@@ -5,6 +5,7 @@ namespace App\Domains\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 Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -14,13 +15,14 @@ class CompleteCheckoutService
public function __construct(
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly PurchaseStateGuard $purchaseState,
) {}
public function complete(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
@@ -32,6 +34,8 @@ class CompleteCheckoutService
return $this->loadPurchase($purchase);
}
$this->purchaseState->lockCurrentCart($purchase);
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
@@ -45,22 +49,28 @@ class CompleteCheckoutService
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
return $this->loadPurchase($purchase);
}
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
if ($purchase->status === Purchase::STATUS_IN_REVIEW) {
return $this->loadPurchase($purchase);
}
$this->purchaseState->lockCurrentCart($purchase);
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
}
$purchase->update(['expires_at' => null]);
$this->reservations->syncPurchaseExpiration($purchase);
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
]);
$this->reservations->clearExpirationForReview($purchase);
return $this->loadPurchase($purchase);
});
@@ -70,6 +80,7 @@ class CompleteCheckoutService
{
DB::transaction(function () use ($purchase): void {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
return;
@@ -79,25 +90,26 @@ class CompleteCheckoutService
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
Purchase::STATUS_SUPERSEDED,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
if ($purchase->items()->exists()) {
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart?->status === 'converted' && $cart->trashed()) {
return;
}
if (! $purchase->items()->exists()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$cart = $purchase->cart()->lockForUpdate()->first();
if ($cart === null || $cart->status !== 'checkout') {
$cart = $this->purchaseState->lockCurrentCart($purchase);
if ($cart->status === 'converted' && $cart->trashed()) {
return;
}
if (! in_array($cart->status, ['active', 'checkout'], true)) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
@@ -110,10 +122,32 @@ class CompleteCheckoutService
]);
}
$snapshotQuantities = $purchase->items()
->lockForUpdate()
->get(['source_catalog_item_id', 'source_variant_id', 'cantidad'])
->groupBy(fn ($item): string => $this->itemKey(
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
))
->map(fn (Collection $items): int => (int) $items->sum('cantidad'))
->sortKeys()
->all();
$cartQuantities = $cartItems
->groupBy(fn (CartItem $item): string => $this->itemKey(
(int) $item->catalog_item_id,
$item->variant_id === null ? null : (int) $item->variant_id,
))
->map(fn (Collection $items): int => (int) $items->sum('cantidad'))
->sortKeys()
->all();
if ($snapshotQuantities !== $cartQuantities) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->loadCartItems($cartItems);
$purchase->items()->createMany(
$this->snapshots->fromCartItems($cartItems),
);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
@@ -123,14 +157,15 @@ class CompleteCheckoutService
]);
}
}
try {
$this->reservations->commit($cartItem, $selection);
$this->reservations->commit($purchase);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$this->sourceCart->finalize($purchase);
});
@@ -140,9 +175,11 @@ class CompleteCheckoutService
{
return in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
Purchase::STATUS_SUPERSEDED,
], true);
}
@@ -154,16 +191,12 @@ class CompleteCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function itemKey(int $catalogItemId, ?int $variantId): string
{
return $catalogItemId.':'.($variantId ?? 'none');
}
/** @param Collection<int, CartItem> $cartItems */

View File

@@ -3,61 +3,39 @@
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EditCheckoutService
{
public function __construct(
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseStateGuard $purchaseState,
) {}
/** @param array<string, string> $customerData */
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->assertEditable($purchase);
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
$this->purchaseState->assertNotExpired($purchase);
$purchase->update($customerData);
return $this->loadPurchase($purchase);
});
}
private function assertEditable(Purchase $purchase): void
{
if (
! in_array($purchase->status, [
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| $this->hasExpired($purchase)
) {
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
}
private function hasExpired(Purchase $purchase): bool
{
return $purchase->expires_at !== null && $purchase->expires_at->isPast();
}
$this->purchaseState->lockCurrentCart($purchase);
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
$purchase->update($customerData);
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
return $this->responses->load($purchase);
});
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $purchase->payment_method === 'transfer'
) {
$relations[] = 'telepagosPaymentCandidates.payment';
}
return $purchase->load($relations);
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Domains\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 Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -12,49 +14,45 @@ class ReleaseCheckoutService
{
public function __construct(
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
) {}
public function cancel(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: true);
return $this->release($purchase, Purchase::STATUS_CANCELLED);
}
public function cancelWithoutRestoringCart(Purchase $purchase): Purchase
public function cancelFromAdmin(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, restoreCart: false);
return $this->release($purchase, Purchase::STATUS_CANCELLED, allowInReviewCancellation: true);
}
public function expire(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_EXPIRED, restoreCart: true);
return $this->release($purchase, Purchase::STATUS_EXPIRED);
}
public function expireOverdue(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
$purchase = $this->expire($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function release(Purchase $purchase, string $targetStatus, bool $restoreCart): Purchase
{
return DB::transaction(function () use ($purchase, $targetStatus, $restoreCart): Purchase {
private function release(
Purchase $purchase,
string $targetStatus,
bool $allowInReviewCancellation = false,
): Purchase {
return DB::transaction(function () use (
$purchase,
$targetStatus,
$allowInReviewCancellation,
): 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;
}
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
@@ -65,25 +63,51 @@ class ReleaseCheckoutService
]);
}
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $targetStatus === Purchase::STATUS_CANCELLED
&& ! $allowInReviewCancellation
) {
return $this->createNewCartWithoutCancelling($purchase);
}
if ($this->isAlreadyReleased($purchase)) {
return $this->loadPurchase($purchase);
}
$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);
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
&& (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true) || ! $this->hasOverdueActiveReservation($purchase))
) {
return $this->loadPurchase($purchase);
}
if ($purchase->items()->exists()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
$this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus);
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$purchase->update(['status' => $targetStatus]);
@@ -91,65 +115,105 @@ class ReleaseCheckoutService
});
}
private function releaseCartReservations(
private function releasePurchaseReservations(
Purchase $purchase,
bool $reservationReturnedToCart,
string $targetStatus,
?Cart $cart,
): void {
if ($reservationReturnedToCart) {
$this->reservations->detachFromPurchase($purchase);
return;
}
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart === null) {
return;
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$cartItems->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'variant.inventory',
'variant.catalogItem',
]);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
continue;
}
try {
$this->reservations->release(
$cartItem,
$selection,
(int) $cartItem->cantidad,
$this->reservations->releaseForPurchase(
$purchase,
$targetStatus === Purchase::STATUS_EXPIRED
? StockReservation::STATUS_EXPIRED
: StockReservation::STATUS_RELEASED,
$targetStatus === Purchase::STATUS_CANCELLED
? StockReservationService::REASON_PURCHASE_CANCELLED
: ($targetStatus === Purchase::STATUS_REJECTED
? StockReservationService::REASON_PAYMENT_REJECTED
: null),
);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
if ($cart === null) {
return;
}
if ($targetStatus === Purchase::STATUS_EXPIRED
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
]);
return;
}
if ($cart->status === Cart::STATUS_ACTIVE) {
$cartUpdate = [
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
];
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->update($cartUpdate);
return;
}
if ($cart->status !== Cart::STATUS_CHECKOUT) {
return;
}
if (! $cart->trashed()) {
$cart->update(['status' => 'converted']);
$cart->update(['status' => Cart::STATUS_CONVERTED]);
$cart->delete();
}
}
private function createNewCartWithoutCancelling(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status !== Purchase::STATUS_IN_REVIEW || $purchase->user_id === null) {
return $this->loadPurchase($purchase);
}
/** @var Cart|null $sourceCart */
$sourceCart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($sourceCart !== null && ! $sourceCart->trashed() && $sourceCart->status === 'active') {
$sourceCart->update(['status' => 'checkout']);
}
Cart::query()->firstOrCreate([
'tenant_codigo' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'guest_token' => null,
'status' => 'active',
'origin' => Cart::ORIGIN_USER,
]);
return $this->loadPurchase($purchase);
});
}
private function isAlreadyReleased(Purchase $purchase): bool
{
return in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
Purchase::STATUS_SUPERSEDED,
], true);
}
@@ -161,15 +225,17 @@ class ReleaseCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function hasOverdueActiveReservation(Purchase $purchase): bool
{
/** @var StockReservation|null $reservation */
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
}
}

View File

@@ -3,65 +3,10 @@
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
class SourceCartService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function restore(Purchase $purchase): bool
{
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null) {
return false;
}
if ($sourceCart->origin === Cart::ORIGIN_DIRECT_CHECKOUT) {
return false;
}
/** @var Cart|null $activeCart */
$activeCart = Cart::query()
->where('tenant_codigo', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->where('status', 'active')
->where('id', '!=', $sourceCart->getKey())
->lockForUpdate()
->first();
if ($activeCart !== null) {
$this->mergeIntoActiveCart($sourceCart, $activeCart);
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
if (! $sourceCart->trashed()) {
$sourceCart->delete();
}
return true;
}
if ($sourceCart->trashed()) {
$sourceCart->restore();
}
$sourceCart->update([
'status' => 'active',
'user_id' => $purchase->user_id,
'guest_token' => null,
]);
return true;
}
public function finalize(Purchase $purchase): void
{
$sourceCart = $this->findSourceCart($purchase);
@@ -89,30 +34,4 @@ class SourceCartService
->lockForUpdate()
->first();
}
private function mergeIntoActiveCart(Cart $sourceCart, Cart $activeCart): void
{
$sourceItems = $sourceCart->items()->lockForUpdate()->get();
foreach ($sourceItems as $sourceItem) {
/** @var CartItem|null $activeItem */
$activeItem = $activeCart->items()
->where('catalog_item_id', $sourceItem->catalog_item_id)
->where('variant_id', $sourceItem->variant_id)
->lockForUpdate()
->first();
if ($activeItem === null) {
$activeItem = $activeCart->items()->create([
'catalog_item_id' => $sourceItem->catalog_item_id,
'variant_id' => $sourceItem->variant_id,
'cantidad' => $sourceItem->cantidad,
]);
} else {
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
}
$this->reservations->transfer($sourceItem, $activeItem);
}
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Domains\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;
@@ -12,6 +13,7 @@ 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 Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -25,6 +27,8 @@ class StartCheckoutService
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -119,10 +123,16 @@ class StartCheckoutService
->each(function (Collection $catalogLines) use ($userId): void {
/** @var CatalogItem $catalogItem */
$catalogItem = $catalogLines->first()['catalog_item'];
$availableQuantities = $catalogLines
->map(fn (array $line): ?int => $this->inventory->availableQuantity($line['selection']));
$maximumAddableCeiling = $availableQuantities->contains(null)
? null
: (int) $availableQuantities->sum();
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
(int) $catalogLines->sum('quantity'),
maximumAddableCeiling: $maximumAddableCeiling,
field: 'direct_items',
);
});
@@ -161,21 +171,31 @@ class StartCheckoutService
'cantidad' => $line['quantity'],
]);
try {
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
throw new InsufficientStockException([
$this->unavailableItem($line, $availableQuantity),
]);
}
$cartItem->setRelation('catalogItem', $line['catalog_item']);
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
$cartItems->push($cartItem);
}
try {
$this->reservations->syncCart($cart);
} catch (\InvalidArgumentException) {
$unavailable = $resolvedLines
->map(function (array $line): ?array {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
return $availableQuantity !== null && $availableQuantity < $line['quantity']
? $this->unavailableItem($line, $availableQuantity)
: null;
})
->filter()
->values()
->all();
throw new InsufficientStockException($unavailable !== [] ? $unavailable : [
$this->unavailableItem($resolvedLines->first(), 0),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
@@ -185,14 +205,12 @@ class StartCheckoutService
),
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
foreach ($cartItems as $index => $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$resolvedLines->get($index)['selection'],
$purchase,
);
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase);
}
@@ -242,8 +260,9 @@ class StartCheckoutService
$this->loadCartItems($cartItems);
$this->verifyTenantItems($tenant, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
$cart->setRelation('items', $cartItems);
$this->reservations->syncCart($cart);
$purchase = $this->createPurchase(
$tenant,
@@ -252,42 +271,76 @@ class StartCheckoutService
$cart->getTotalAmount(),
$cart->getKey(),
);
foreach ($cartItems as $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$cartItem->selectedItem(),
$purchase,
);
}
// The purchase owns the reservation until checkout finishes. The cart is
// retained so it can be restored if the purchase is cancelled or expires.
$cart->update([
'status' => 'checkout',
'guest_token' => null,
]);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase);
}
private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart
{
/** @var Cart|null $candidate */
$candidate = Cart::query()->find($cartId);
$this->assertCartCanCheckout($candidate, $tenant, $userId);
$candidatePurchaseId = $candidate->current_purchase_id;
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var Cart|null $cart */
$cart = Cart::query()->lockForUpdate()->find($cartId);
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
throw new NotFoundHttpException('Cart not found for tenant.');
$this->assertCartCanCheckout($cart, $tenant, $userId);
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.checkout_in_progress'),
]);
}
if ($cart->status !== 'active') {
$currentPurchase = null;
}
if ($currentPurchase?->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
'cart_id' => __('api.purchase.checkout_in_progress'),
]);
}
if ($currentPurchase !== null && in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
]);
}
return $cart;
}
private function assertCartCanCheckout(?Cart $cart, Tenant $tenant, int $userId): void
{
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status === Cart::STATUS_EXPIRED) {
throw new StockReservationExpiredException;
}
if ($cart->status !== Cart::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
}
/** @param Collection<int, CartItem> $cartItems */
private function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
{
@@ -311,6 +364,7 @@ class StartCheckoutService
Tenant $tenant,
int $userId,
Collection $cartItems,
int $cartId,
): void {
$quantities = $cartItems
->groupBy('catalog_item_id')
@@ -332,6 +386,8 @@ class StartCheckoutService
$catalogItem,
$userId,
$quantity,
excludedCartId: $cartId,
heldQuantity: $quantity,
field: 'cart_id',
);
}
@@ -352,13 +408,17 @@ class StartCheckoutService
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'total' => $total,
]);
}
private function checkoutExpiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
);
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
@@ -376,15 +436,6 @@ class StartCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
return $this->responses->load($purchase);
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Domains\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;
@@ -23,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 */
@@ -68,23 +66,13 @@ class CheckoutService
return $this->releaser->cancel($purchase);
}
public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase
public function cancelPurchaseFromAdmin(Purchase $purchase): Purchase
{
return $this->releaser->cancelWithoutRestoringCart($purchase);
return $this->releaser->cancelFromAdmin($purchase);
}
public function expirePurchase(Purchase $purchase): Purchase
{
return $this->releaser->expire($purchase);
}
public function expireOverduePurchases(): int
{
return $this->releaser->expireOverdue();
}
public function syncReservationExpiration(Purchase $purchase): void
{
$this->reservations->syncPurchaseExpiration($purchase);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Domains\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,58 @@
<?php
namespace App\Domains\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 Illuminate\Validation\ValidationException;
class PurchaseStateGuard
{
public function assertNotExpired(Purchase $purchase): void
{
if ($purchase->status === Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = $purchase->relationLoaded('stockReservation')
? $purchase->getRelation('stockReservation')
: ($purchase->exists
? $purchase->stockReservation()->first()
: null);
if ($reservation !== null && (
$reservation->status === StockReservation::STATUS_EXPIRED
|| (
$reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture()
)
)) {
throw new PurchaseExpiredException;
}
}
public function lockCurrentCart(Purchase $purchase): Cart
{
/** @var Cart|null $cart */
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart === null || $cart->current_purchase_id !== $purchase->getKey()) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_current'),
]);
}
return $cart;
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class TenantTransactionResetService
{
/** @return array<string, int> */
public function preview(string $tenantCode): array
{
$this->ensureTenantExists($tenantCode);
$scope = $this->scope($tenantCode);
return [
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
'purchases' => $scope['purchase_ids']->count(),
'purchase_items' => DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count(),
'telepagos_payments' => DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count(),
'telepagos_qr' => DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(),
'carts' => $scope['cart_ids']->count(),
'cart_items' => $scope['cart_item_ids']->count(),
'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(),
'stock_reservations' => $this->reservationQuery($scope)->count(),
'purchase_changes' => DB::table('value_changes')
->where('tenant_code', $tenantCode)
->where('trackable_type', Purchase::class)
->count(),
'inventories' => $scope['inventory_ids']->count(),
];
}
/** @return array<string, int> */
public function reset(string $tenantCode): array
{
$this->ensureTenantExists($tenantCode);
return DB::transaction(function () use ($tenantCode): array {
$scope = $this->scope($tenantCode);
$purchaseItems = DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count();
$cartItems = $scope['cart_item_ids']->count();
$telepagosPayments = DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count();
$telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count();
$summary = [
'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(),
'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(),
'purchase_changes_deleted' => DB::table('value_changes')
->where('tenant_code', $tenantCode)
->where('trackable_type', Purchase::class)
->delete(),
'purchases_deleted' => DB::table('compras')->whereIn('id', $scope['purchase_ids'])->delete(),
'purchase_items_deleted' => $purchaseItems,
'telepagos_payments_deleted' => $telepagosPayments,
'telepagos_qr_deleted' => $telepagosQr,
'carts_deleted' => DB::table('carritos')->whereIn('id', $scope['cart_ids'])->delete(),
'cart_items_deleted' => $cartItems,
'inventories_reset' => $scope['inventory_ids']->count(),
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
];
DB::table('inventories')
->whereIn('id', $scope['inventory_ids'])
->update([
'real_stock' => DB::raw('real_stock + sold_units'),
'reserved_stock' => 0,
'sold_units' => 0,
]);
return $summary;
});
}
private function ensureTenantExists(string $tenantCode): void
{
if (! DB::table('tenants')->where('codigo', $tenantCode)->exists()) {
throw new InvalidArgumentException("El tenant {$tenantCode} no existe.");
}
}
/**
* @return array{
* purchase_ids: Collection<int, int>,
* cart_ids: Collection<int, int>,
* cart_item_ids: Collection<int, int>,
* inventory_ids: Collection<int, int>
* }
*/
private function scope(string $tenantCode): array
{
$catalogItemIds = DB::table('catalog_items')
->where('tenant_code', $tenantCode)
->pluck('id');
$purchaseIds = DB::table('compras')
->where('tenant_codigo', $tenantCode)
->pluck('id');
$cartIds = DB::table('carritos')
->where('tenant_codigo', $tenantCode)
->pluck('id');
$cartItemIds = DB::table('carrito_items')
->whereIn('cart_id', $cartIds)
->pluck('id');
$inventoryIds = DB::table('variantes')
->whereIn('catalog_item_id', $catalogItemIds)
->whereNotNull('inventory_id')
->pluck('inventory_id')
->merge(
DB::table('catalog_items')
->whereIn('id', $catalogItemIds)
->whereNotNull('inventory_id')
->pluck('inventory_id'),
)
->map(fn ($id): int => (int) $id)
->unique()
->values();
return [
'purchase_ids' => $purchaseIds->map(fn ($id): int => (int) $id),
'cart_ids' => $cartIds->map(fn ($id): int => (int) $id),
'cart_item_ids' => $cartItemIds->map(fn ($id): int => (int) $id),
'inventory_ids' => $inventoryIds,
];
}
/**
* @param array{
* purchase_ids: Collection<int, int>,
* cart_ids: Collection<int, int>,
* cart_item_ids: Collection<int, int>,
* inventory_ids: Collection<int, int>
* } $scope
*/
private function reservationQuery(array $scope): Builder
{
$reservationIds = DB::table('carritos')
->whereIn('id', $scope['cart_ids'])
->whereNotNull('current_stock_reservation_id')
->pluck('current_stock_reservation_id')
->merge(
DB::table('compras')
->whereIn('id', $scope['purchase_ids'])
->whereNotNull('stock_reservation_id')
->pluck('stock_reservation_id'),
)
->merge(
DB::table('stock_reservation_lines')
->whereIn('inventory_id', $scope['inventory_ids'])
->pluck('stock_reservation_id'),
)
->unique()
->values();
return DB::table('stock_reservations')
->whereIn('id', $reservationIds);
}
}

View File

@@ -4,10 +4,11 @@ namespace App\Domains\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 Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class UserPurchaseLimitService
{
@@ -16,6 +17,9 @@ class UserPurchaseLimitService
int $userId,
int $requestedQuantity,
?int $excludedPurchaseId = null,
?int $excludedCartId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
string $field = 'quantity',
): void {
DB::transaction(function () use (
@@ -23,6 +27,9 @@ class UserPurchaseLimitService
$userId,
$requestedQuantity,
$excludedPurchaseId,
$excludedCartId,
$heldQuantity,
$maximumAddableCeiling,
$field,
): void {
/** @var CatalogItem $catalogItem */
@@ -44,6 +51,7 @@ class UserPurchaseLimitService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
])
->when(
@@ -61,6 +69,7 @@ class UserPurchaseLimitService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->whereDoesntHave('items')
->when(
@@ -70,11 +79,104 @@ class UserPurchaseLimitService
})
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $requestedQuantity > $limit) {
throw ValidationException::withMessages([
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
]);
$reservedCartQuantity = (int) CartItem::query()
->where('catalog_item_id', $catalogItem->getKey())
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active')
->when(
$excludedCartId !== null,
fn ($query) => $query->whereKeyNot($excludedCartId),
))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
$remainingQuota = max(
0,
$limit - $purchasedQuantity - $checkoutQuantity - $reservedCartQuantity,
);
$maximumAddableQuantity = max(0, $remainingQuota - $heldQuantity);
throw new PurchaseLimitExceededException(
$catalogItem,
$maximumAddableCeiling === null
? $maximumAddableQuantity
: min($maximumAddableQuantity, $maximumAddableCeiling),
$field,
);
}
});
}
/**
* @param Collection<int, CatalogItem> $catalogItems
* @return Collection<int, int|null>
*/
public function remainingByCatalogItem(Collection $catalogItems, ?int $userId): Collection
{
$limits = $catalogItems
->unique('id')
->mapWithKeys(fn (CatalogItem $item): array => [$item->getKey() => $item->max_units_per_user]);
if ($userId === null || $limits->filter(fn ($limit) => $limit !== null)->isEmpty()) {
return $limits->map(fn (): ?int => null);
}
$ids = $limits->keys();
$purchased = PurchaseItem::query()
->selectRaw('source_catalog_item_id, SUM(cantidad) AS quantity')
->whereIn('source_catalog_item_id', $ids)
->whereHas('purchase', fn ($query) => $query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
]))
->groupBy('source_catalog_item_id')
->pluck('quantity', 'source_catalog_item_id');
$checkout = CartItem::query()
->selectRaw('catalog_item_id, SUM(cantidad) AS quantity')
->whereIn('catalog_item_id', $ids)
->whereHas('cart.purchases', fn ($query) => $query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->whereDoesntHave('items'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');
$reserved = CartItem::query()
->selectRaw('catalog_item_id, SUM(cantidad) AS quantity')
->whereIn('catalog_item_id', $ids)
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active'))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');
return $limits->map(function (?int $limit, int $catalogItemId) use ($purchased, $checkout, $reserved): ?int {
if ($limit === null) {
return null;
}
$used = (int) ($purchased[$catalogItemId] ?? 0)
+ (int) ($checkout[$catalogItemId] ?? 0)
+ (int) ($reserved[$catalogItemId] ?? 0);
return max(0, $limit - $used);
});
}
}

View File

@@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
## Modelo
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
@@ -15,16 +15,16 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
`CheckoutService` es la fachada estable. Delega en:
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `PurchaseItem`.
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
- `SourceCartService`: restaura o finaliza el carrito fuente.
- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
Durante `created` y `pending_payment`, `PurchaseResource` publica las líneas del carrito con `items_source=cart`; una compra materializada publica `items_source=purchase`. Los datos descriptivos y económicos del checkout se resuelven siempre desde el catálogo vigente.
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación.
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.

View File

@@ -3,22 +3,28 @@
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
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\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
protected AdminAppSaleExcelService $saleExcelService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
@@ -62,32 +68,59 @@ 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(),
)
);
}
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
public function downloadPdf(AdminAppSalePdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsPdf(Request $request): Response
public function downloadModificationsPdf(AdminAppSaleModificationPdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadExcel(AdminAppSalePdfRequest $request): StreamedResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsExcel(
AdminAppSaleModificationPdfRequest $request,
): StreamedResponse {
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
}

View File

@@ -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\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;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppSalePdfRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -23,6 +23,13 @@ class SaleModificationResource extends JsonResource
'attribute' => $this->attribute,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'admin_status' => is_string($this->new_value)
? Purchase::adminStatusFor($this->new_value)
: null,
'status_label' => is_string($this->new_value)
? Purchase::adminStatusNameFor($this->new_value)
: null,
'changed_at' => $this->changed_at->utc()->toIso8601String(),
'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value,
@@ -30,6 +37,8 @@ class SaleModificationResource extends JsonResource
'id' => $sale->id,
'customer_name' => $sale->nombre_apellido,
'status' => $sale->status,
'admin_status' => Purchase::adminStatusFor($sale->status),
'status_label' => Purchase::adminStatusNameFor($sale->status),
] : null,
'modified_by' => $user ? [
'id' => $user->id,

View File

@@ -20,6 +20,8 @@ class SaleResource extends JsonResource
'customer_name' => $this->nombre_apellido,
'quantity' => (int) ($this->quantity ?? 0),
'status' => $this->status,
'admin_status' => Purchase::adminStatusFor($this->status),
'status_label' => Purchase::adminStatusNameFor($this->status),
'total' => number_format((float) $this->total, 2, '.', ''),
'tickets_count' => $ticketsCount,
'has_generated_tickets' => $ticketsCount > 0,

View File

@@ -0,0 +1,206 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminAppSaleExcelService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): StreamedResponse
{
$generatedAt = now();
$spreadsheet = $this->spreadsheet($tenant, 'Historial de ventas');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Ventas');
$sheet->fromArray([
'ID',
'Fecha',
'Cliente',
'Cantidad',
'Estado',
'Importe',
'Tickets',
], null, 'A1');
foreach ($sales->values() as $index => $sale) {
$row = $index + 2;
$sheet->setCellValueExplicit("A{$row}", '#'.$sale->id, DataType::TYPE_STRING);
if ($sale->created_at) {
$sheet->setCellValue(
"B{$row}",
Date::dateTimeToExcel($sale->created_at->copy()->timezone($timeZone)),
);
}
$sheet->setCellValueExplicit(
"C{$row}",
$sale->nombre_apellido ?: 'Sin nombre',
DataType::TYPE_STRING,
);
$sheet->setCellValue("D{$row}", (int) ($sale->quantity ?? 0));
$sheet->setCellValue("E{$row}", $this->saleStatus($sale->status));
$sheet->setCellValue("F{$row}", (float) $sale->total);
$sheet->setCellValue("G{$row}", (int) ($sale->tickets_count ?? 0));
}
$lastRow = max(2, $sales->count() + 1);
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
$sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00');
$this->formatSheet($spreadsheet, 'A1:G1', "A1:G{$lastRow}", [
'A' => 13,
'B' => 20,
'C' => 32,
'D' => 12,
'E' => 22,
'F' => 16,
'G' => 12,
]);
return $this->download(
$spreadsheet,
'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(
Tenant $tenant,
Collection $modifications,
string $timeZone,
): StreamedResponse {
$generatedAt = now();
$spreadsheet = $this->spreadsheet($tenant, 'Historial de modificaciones de ventas');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Modificaciones');
$sheet->fromArray([
'Fecha',
'Hora',
'Venta',
'Cliente',
'Campo',
'Valor anterior',
'Valor nuevo',
'Modificado por',
], null, 'A1');
foreach ($modifications->values() as $index => $modification) {
$row = $index + 2;
$changedAt = $modification->changed_at->copy()->timezone($timeZone);
$sale = $modification->trackable;
$sheet->setCellValue("A{$row}", Date::dateTimeToExcel($changedAt));
$sheet->setCellValue("B{$row}", Date::dateTimeToExcel($changedAt));
$sheet->setCellValueExplicit(
"C{$row}",
'#'.$modification->trackable_id,
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"D{$row}",
$sale?->nombre_apellido ?: 'Sin nombre',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"E{$row}",
$modification->attribute,
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"F{$row}",
$modification->old_value ?? '-',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"G{$row}",
$modification->new_value ?? '-',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"H{$row}",
$modification->user?->nombre_apellido ?? 'Sistema',
DataType::TYPE_STRING,
);
}
$lastRow = max(2, $modifications->count() + 1);
$sheet->getStyle("A2:A{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('hh:mm:ss');
$this->formatSheet($spreadsheet, 'A1:H1', "A1:H{$lastRow}", [
'A' => 14,
'B' => 12,
'C' => 13,
'D' => 32,
'E' => 20,
'F' => 24,
'G' => 24,
'H' => 28,
]);
return $this->download(
$spreadsheet,
'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
);
}
private function spreadsheet(Tenant $tenant, string $title): Spreadsheet
{
$spreadsheet = new Spreadsheet;
$spreadsheet->getProperties()
->setCreator('Shopit')
->setTitle($title)
->setSubject($tenant->nombre);
return $spreadsheet;
}
/** @param array<string, int> $widths */
private function formatSheet(
Spreadsheet $spreadsheet,
string $headerRange,
string $filterRange,
array $widths,
): void {
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '26382E'],
],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($filterRange);
foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
}
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
{
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}
private function saleStatus(string $status): string
{
return Purchase::adminStatusNameFor($status) ?? $status;
}
}

View File

@@ -13,12 +13,14 @@ use Illuminate\Support\Collection;
class AdminAppSalePdfService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales): Response
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): Response
{
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant,
'sales' => $sales,
'generatedAt' => now(),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2,
@@ -30,23 +32,30 @@ class AdminAppSalePdfService
$this->addPageNumbers($pdf);
return $pdf->download(
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
{
public function downloadModifications(
Tenant $tenant,
Collection $modifications,
string $timeZone,
): Response {
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant,
'modifications' => $modifications,
'generatedAt' => now(),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}

View File

@@ -79,7 +79,7 @@ class AdminAppSaleService
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->cancelPurchaseWithoutRestoringCart($sale)
$this->checkoutService->cancelPurchaseFromAdmin($sale)
);
}
@@ -92,18 +92,24 @@ class AdminAppSaleService
return $this->salesQuery($tenant, $filters)->get();
}
/** @return LengthAwarePaginator<ValueChange> */
public function modifications(Tenant $tenant): LengthAwarePaginator
/**
* @param array<string, mixed> $filters
* @return LengthAwarePaginator<ValueChange>
*/
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
{
return $this->modificationsQuery($tenant)
return $this->modificationsQuery($tenant, $filters)
->paginateFromRequest()
->withQueryString();
}
/** @return Collection<int, ValueChange> */
public function modificationsForExport(Tenant $tenant): Collection
/**
* @param array<string, mixed> $filters
* @return Collection<int, ValueChange>
*/
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
{
return $this->modificationsQuery($tenant)->get();
return $this->modificationsQuery($tenant, $filters)->get();
}
/** @param array<string, mixed> $filters */
@@ -143,20 +149,66 @@ class AdminAppSaleService
)
->when(
$filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query->where('status', $status)
fn (Builder $query, string $status): Builder => $query->whereIn(
'status',
Purchase::realStatusesForAdminStatus($status),
)
)
->select('compras.*')
->selectRaw(
'(SELECT COALESCE(SUM(purchase_items.cantidad), 0) '
.'FROM compra_items AS purchase_items '
.'WHERE purchase_items.compra_id = compras.id) AS quantity',
)
->withSum('items as quantity', 'cantidad')
->withCount('tickets')
->orderBy($sortColumns[$sortBy], $sortDirection)
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
}
/** @return Builder<ValueChange> */
protected function modificationsQuery(Tenant $tenant): Builder
/**
* @param array<string, mixed> $filters
* @return Builder<ValueChange>
*/
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
{
return ValueChange::query()
->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass())
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search);
$query->where(function (Builder $query) use ($term): void {
$query
->where('trackable_id', 'like', "%{$term}%")
->orWhereHasMorph(
'trackable',
[Purchase::class],
function (Builder $sales) use ($term): void {
$sales
->where('nombre_apellido', 'like', "%{$term}%")
->orWhere('created_at', 'like', "%{$term}%");
},
);
});
})
->when(
$filters['id'] ?? null,
fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id)
)
->when(
$filters['sale_date'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereHasMorph(
'trackable',
[Purchase::class],
fn (Builder $sales): Builder => $sales->whereDate('created_at', $date),
)
)
->when(
$filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query
->where('attribute', 'status')
->whereIn('new_value', Purchase::realStatusesForAdminStatus($status))
)
->with(['trackable', 'user'])
->orderByDesc('changed_at')
->orderByDesc('id');
@@ -171,8 +223,23 @@ class AdminAppSaleService
protected function saleForResponse(Purchase $sale): Purchase
{
return $sale->refresh()
->loadSum('items as quantity', 'cantidad')
->loadCount('tickets');
/** @var Purchase */
return Purchase::query()
->select('compras.*')
->selectRaw($this->quantityExpression())
->withCount('tickets')
->findOrFail($sale->getKey());
}
protected function quantityExpression(): string
{
return <<<'SQL'
COALESCE(
(SELECT SUM(compra_items.cantidad)
FROM compra_items
WHERE compra_items.compra_id = compras.id),
0
) AS quantity
SQL;
}
}

View File

@@ -8,7 +8,9 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación de ventas.
- `AdminAppSaleModificationIndexRequest`: valida los filtros compartidos por el historial y sus exportaciones.
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
- `SaleController`: entrada HTTP del panel.
@@ -16,8 +18,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `GET /sales` y `GET /sales/pdf`.
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
## Dependencias
@@ -25,4 +27,6 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
## Consideraciones
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.

View File

@@ -8,8 +8,10 @@ Route::prefix('v1/adminapp/tenant')
->group(function (): void {
Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/excel', [SaleController::class, 'downloadExcel']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
Route::get('sales/modifications/excel', [SaleController::class, 'downloadModificationsExcel']);
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use DateTimeZone;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidTimezone implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
try {
new DateTimeZone($value);
} catch (Exception) {
$fail(__('validation.timezone'));
}
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Tenant\Enums;
enum CartEditingPolicy: string
{
case Disabled = 'disabled';
case QuantityAndRemove = 'quantity_and_remove';
case Full = 'full';
public function allowsQuantityChanges(): bool
{
return $this !== self::Disabled;
}
public function allowsRemoval(): bool
{
return $this !== self::Disabled;
}
public function allowsVariantChanges(): bool
{
return $this === self::Full;
}
public function allowsModification(): bool
{
return $this->allowsRemoval()
|| $this->allowsQuantityChanges()
|| $this->allowsVariantChanges();
}
}

View File

@@ -11,6 +11,7 @@ use App\Domains\Client\Models\Client;
use App\Domains\Event\Models\EventDate;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Enums\CartEditingPolicy;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -26,6 +27,8 @@ use Illuminate\Support\Facades\Schema;
'dominio',
'base_path',
'site_title',
'address',
'phone',
'primary_color',
'secondary_color',
'danger_color',
@@ -44,7 +47,8 @@ use Illuminate\Support\Facades\Schema;
'display_categories',
'display_seach_bar',
'display_cart',
'cart_editing_enabled',
'cart_editing_policy',
'checkout_editing_policy',
'display_cart_item_images',
'scanner_category_validation_enabled',
'event_title',
@@ -63,7 +67,8 @@ class Tenant extends Model
'display_categories' => true,
'display_seach_bar' => true,
'display_cart' => true,
'cart_editing_enabled' => true,
'cart_editing_policy' => CartEditingPolicy::Full->value,
'checkout_editing_policy' => CartEditingPolicy::Disabled->value,
'display_cart_item_images' => true,
'scanner_category_validation_enabled' => true,
];
@@ -114,7 +119,8 @@ class Tenant extends Model
'display_categories' => 'boolean',
'display_seach_bar' => 'boolean',
'display_cart' => 'boolean',
'cart_editing_enabled' => 'boolean',
'cart_editing_policy' => CartEditingPolicy::class,
'checkout_editing_policy' => CartEditingPolicy::class,
'display_cart_item_images' => 'boolean',
'scanner_category_validation_enabled' => 'boolean',
];

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Requests;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Tenant\Enums\CartEditingPolicy;
use App\Domains\Tenant\Services\WebsiteExtraService;
use App\Domains\Tenant\Support\TenantDomainNormalizer;
use Closure;
@@ -81,6 +82,8 @@ class StoreTenantRequest extends FormRequest
->where('dominio', $this->input('dominio')),
],
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
@@ -107,7 +110,11 @@ class StoreTenantRequest extends FormRequest
'display_categories' => ['sometimes', 'boolean'],
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_enabled' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'website_type_code' => [

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