Compare commits

...

171 Commits

Author SHA1 Message Date
6d123cd403 docs(postman): regenerate collection after API cleanup 2026-08-27 12:11:08 -03:00
3164a00e6c docs(menu): describe internal-only configuration 2026-08-27 12:11:08 -03:00
36cbc8da29 docs(integration): remove deleted endpoint references 2026-08-27 12:11:08 -03:00
58e027cad5 docs(catalog): remove featured group admin references 2026-08-27 12:11:08 -03:00
971c5f6cd9 refactor(checkout): remove unused completion endpoint 2026-08-27 12:11:08 -03:00
2524f00dfd refactor(staff): restrict updates to PUT 2026-08-27 12:10:57 -03:00
045fcfab72 refactor(desfile): remove unused image deletion endpoint 2026-08-27 12:10:57 -03:00
cfe29b41a9 refactor(tenant): remove single website extra endpoint 2026-08-27 12:10:56 -03:00
bd600649b6 refactor(catalog): remove unused featured group admin API 2026-08-27 12:10:56 -03:00
7685089540 refactor(storage-test): remove unused S3 test endpoints 2026-08-27 12:10:41 -03:00
889e188deb refactor(mail-test): remove unused developer endpoint 2026-08-27 12:10:41 -03:00
4a166e3cbf refactor(telepagos): remove unused webhook endpoint 2026-08-27 12:10:33 -03:00
d8b3a354d9 refactor(integration): remove unused global CRUD API 2026-08-27 12:10:33 -03:00
44f75185a1 refactor(menu): remove unused management API 2026-08-27 12:10:33 -03:00
3adef9341e refactor(tenant): remove unused CRUD API layer 2026-08-27 12:10:23 -03:00
3b1698ffd1 refactor(client): remove unused CRUD API layer 2026-08-27 12:10:23 -03:00
c436302d7a refactor(routes): unregister unused backend APIs 2026-08-27 12:10:15 -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
22c6631652 Merge branch 'fix/website_types_favicons' into dev 2026-08-26 14:40:46 -03:00
bbe3cf82f5 feat(bootstrap): expose website type favicon 2026-08-26 14:37:25 -03:00
ff61a3d3b8 feat(tenant): share website type favicon attachment 2026-08-26 14:37:17 -03:00
6c5d49bf45 Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 11:51:00 -03:00
2596b5df18 test(auth): cover password reset expiration 2026-08-26 11:47:12 -03:00
733064c0dc feat(auth): expire stale password reset codes 2026-08-26 11:47:12 -03:00
99808c1053 feat(auth): persist password reset expiration 2026-08-26 11:47:12 -03:00
e2ad78b1fb Merge branch 'fixes/TL-1886' into dev 2026-08-26 11:40:21 -03:00
8ed8a11b7b fix: define featured group codes in seeders 2026-08-26 11:30:00 -03:00
dc33e5dc09 fix: consolidate purchase confirmation emails 2026-08-26 11:29:53 -03:00
414df97e9d Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

    refactor(reservations): unify expiration command

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

    feat(cart): expire abandoned stock reservations

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

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

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

    refactor(cart): invalidate payment on actual changes

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

    refactor(cart): own checkout item editing

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

    refactor(checkout): remove legacy purchase item reservations

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

    refactor(checkout): materialize purchase items on confirmation

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

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

    refactor(reservations): unify expiration command

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

    feat(cart): expire abandoned stock reservations

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

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

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

    refactor(cart): invalidate payment on actual changes

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

    refactor(cart): own checkout item editing

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

    refactor(checkout): remove legacy purchase item reservations

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

    refactor(checkout): materialize purchase items on confirmation

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

    feat(inventory): add traceable cart stock reservations
2026-08-19 13:59:25 -03:00
1dc4e29c69 refactor(reservations): unify expiration command 2026-08-19 13:58:03 -03:00
093e894cc3 feat(cart): expire abandoned stock reservations 2026-08-19 13:48:09 -03:00
fdf0f3328f refactor(stock): implement expiration for stock reservations and add configuration 2026-08-19 13:43:08 -03:00
8d6bcdcc43 refactor(cart): invalidate payment on actual changes 2026-08-19 13:43:08 -03:00
3206e293eb refactor(cart): own checkout item editing 2026-08-19 13:43:08 -03:00
aed99bd05e refactor(checkout): remove legacy purchase item reservations 2026-08-19 13:43:08 -03:00
f1649e0e4b refactor(checkout): materialize purchase items on confirmation 2026-08-19 13:43:08 -03:00
e6c4b40a37 feat(inventory): add traceable cart stock reservations 2026-08-19 13:43:08 -03:00
15878cd9ba fix(logging): update Telepagos log path to include date directory 2026-08-19 13:40:40 -03:00
d6574bfd0d feat(logging): implement dedicated logging for Telepagos events and update logging configuration 2026-08-19 13:37:21 -03:00
16bc657a31 refactor(catalog): add ticket_label to item attributes and update related logic 2026-08-19 11:23:41 -03:00
7cea8d3495 feat(cart): enhance item update functionality to support variant changes and improve cart editing policies 2026-08-19 10:46:19 -03:00
014b5bb012 feat(cart): implement cart editing policies and update related functionality 2026-08-19 10:15:37 -03:00
67e6211857 merge: client-owned integrations into dev 2026-08-18 17:34:31 -03:00
58471cb13d fix(seed): assign Sonder tenant to its client 2026-08-18 17:33:05 -03:00
864bed0113 feat(onticket): group event tenants under shared client 2026-08-18 17:33:05 -03:00
ced7d594e8 refactor(integrations): move configuration ownership to clients 2026-08-18 17:32:44 -03:00
38e87fff6c feat(clients): add client ownership for tenants 2026-08-18 17:32:27 -03:00
a8973f6171 feat: separate tenant domain and base path, update related models, requests, and tests 2026-08-18 17:23:01 -03:00
8e26611097 feat: add display_cart_item_images feature to tenants and update related resources 2026-08-18 16:46:45 -03:00
817d0de6d2 feat(migration): move integrations from tenants to clients and update schema 2026-08-18 16:33:05 -03:00
a2298c9de2 feat: add cart editing feature to tenants
- Added 'cart_editing_enabled' attribute to Tenant model with default value true.
- Updated StoreTenantRequest and UpdateTenantRequest to include validation for 'cart_editing_enabled'.
- Modified TenantResource to expose 'cart_editing_enabled' in API responses.
- Created migration to add 'cart_editing_enabled' column to tenants table, defaulting to true, and set to false for specific tenant.
- Updated DesfilePuraTendenciaSeeder to set 'cart_editing_enabled' to false for the desfile tenant.
- Enhanced Postman collection generation script to include 'cart_editing_enabled' in tenant creation and update requests.
- Added tests to verify the correct behavior of 'cart_editing_enabled' during migrations and tenant provisioning.
2026-08-18 16:32:17 -03:00
d73b4d1daf feat: Introduce client management and integration updates
- Added client_id field to StoreTenantRequest and UpdateTenantRequest for tenant management.
- Updated TenantResource to include client_id in the response.
- Created migration to establish clients table and link tenants to clients.
- Migrated existing tenant integrations to client_integrations table.
- Grouped specific tenants under a shared client (OnTicket) in a new migration.
- Updated seeders to reflect new client structure and relationships.
- Adjusted integration configurations to require client instead of tenant.
- Added tests for client integration functionality and ensured existing tests reflect the new client structure.
2026-08-18 16:18:34 -03:00
e6785eb5af feat(seeder): add Desfile Pura Tendencia seeder and corresponding test 2026-08-18 15:44:20 -03:00
1a3f564afd fix(seeder): update catalog path to reflect new directory structure 2026-08-18 15:27:53 -03:00
e619c51ac9 Refactor attachment cropping functionality to support multiple variants
- Introduced AttachmentCrop model to manage crop variants for attachments.
- Updated Attachment model to remove direct crop fields and establish relationships with AttachmentCrop.
- Modified AttachmentService to handle storing and updating multiple crop variants (desktop and mobile).
- Adjusted validation rules to accommodate new crop structure.
- Updated database migration to create attachment_crops table and migrate existing crop data.
- Refactored tests to ensure compatibility with the new cropping structure and validate multiple crop variants.
- Enhanced documentation to reflect changes in attachment handling and cropping capabilities.
2026-08-18 14:40:22 -03:00
c00b3d609c feat(attachment): update crop data columns to use decimal types and add migration for legacy data 2026-08-18 12:54:53 -03:00
9cfd49f233 feat(attachment): refactor image cropping functionality to use range objects and update related tests 2026-08-18 12:54:47 -03:00
58cc35fd61 feat(attachment): implement deletion of cropped attachments alongside original and update README 2026-08-18 12:20:47 -03:00
460ed528cf feat(attachment): add cropping functionality for images and update metadata storage 2026-08-18 12:04:39 -03:00
284 changed files with 15521 additions and 6683 deletions

View File

@@ -5,9 +5,7 @@ APP_DEBUG=false
APP_URL=http://localhost APP_URL=http://localhost
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30 PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15 STOCK_RESERVATION_EXPIRATION_MINUTES=30
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
FRONTEND_URLS=http://localhost:4200 FRONTEND_URLS=http://localhost:4200
APP_LOCALE=es APP_LOCALE=es
@@ -30,11 +28,19 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15 AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10 AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30 AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=stack LOG_CHANNEL=daily
LOG_STACK=single LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug 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_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,9 @@ use App\Domains\Attachable\Enums\AttachmentType;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
#[Fillable([ #[Fillable([
@@ -40,12 +43,34 @@ class Attachment extends Model
]; ];
} }
public function cropVariants(): HasMany
{
return $this->hasMany(AttachmentCrop::class);
}
public function desktopCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::DESKTOP);
}
public function mobileCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::MOBILE);
}
public function cropSource(): HasOne
{
return $this->hasOne(AttachmentCrop::class, 'cropped_attachment_id');
}
/** /**
* Get the pre-signed temporary S3 URL for this attachment. * Get the pre-signed temporary S3 URL for this attachment.
*/ */
public function getTemporaryUrl(int $expiresInMinutes = 10): string public function getTemporaryUrl(int $expiresInMinutes = 10): string
{ {
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl( return Storage::disk('s3')->temporaryUrl(
$this->path, $this->path,
now()->addMinutes($expiresInMinutes) now()->addMinutes($expiresInMinutes)
); );

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Attachable\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attachment_id',
'variant',
'crop_horizontal',
'crop_vertical',
'cropped_attachment_id',
])]
class AttachmentCrop extends Model
{
public const DESKTOP = 'desktop';
public const MOBILE = 'mobile';
public const VARIANTS = [self::DESKTOP, self::MOBILE];
protected function casts(): array
{
return [
'attachment_id' => 'integer',
'crop_horizontal' => 'array',
'crop_vertical' => 'array',
'cropped_attachment_id' => 'integer',
];
}
public function attachment(): BelongsTo
{
return $this->belongsTo(Attachment::class);
}
public function croppedAttachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'cropped_attachment_id');
}
}

View File

@@ -5,7 +5,9 @@ namespace App\Domains\Attachable\Services;
use App\Domains\Attachable\Enums\AttachmentType; use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException; use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Models\AttachmentCrop;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\Mime\MimeTypes; use Symfony\Component\Mime\MimeTypes;
@@ -13,6 +15,107 @@ use Throwable;
class AttachmentService class AttachmentService
{ {
/**
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
*/
public function storeCroppedImageVariants(
UploadedFile|string $image,
string $path,
array $crops,
): Attachment {
$crops = $this->validateCropVariants($crops);
$storedPaths = [];
try {
return DB::transaction(function () use (
$image,
$path,
$crops,
&$storedPaths,
): Attachment {
$original = $this->store($image, $path);
$storedPaths[] = $original->path;
$contents = $this->imageContents($image);
foreach ($crops as $variant => $crop) {
$cropped = $this->storeCropVariant($contents, $path, $crop);
$storedPaths[] = $cropped->path;
$original->cropVariants()->create([
'variant' => $variant,
'crop_horizontal' => $crop['crop_horizontal'],
'crop_vertical' => $crop['crop_vertical'],
'cropped_attachment_id' => $cropped->id,
]);
}
return $original->load('cropVariants.croppedAttachment');
});
} catch (Throwable $throwable) {
if ($storedPaths !== []) {
Storage::disk('s3')->delete(array_values(array_unique($storedPaths)));
}
throw $throwable;
}
}
/**
* @param array<string, array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>}> $crops
*/
public function updateImageCropVariants(
Attachment $original,
array $crops,
): Attachment {
if ($original->type !== AttachmentType::Image) {
throw new AttachmentStorageException('Only image attachments can be cropped.');
}
$crops = $this->validateCropVariants($crops);
$contents = Storage::disk('s3')->get($original->path);
if (! is_string($contents) || $contents === '') {
throw new AttachmentStorageException('The original image could not be read from the s3 disk.');
}
$directory = trim(str_replace('\\', '/', dirname($original->path)), './');
$directory = $directory !== '' ? $directory : 'attachments';
$previousCrops = $original->cropVariants()->with('croppedAttachment')->get();
$storedCrops = [];
try {
foreach ($crops as $variant => $crop) {
$storedCrops[$variant] = $this->storeCropVariant($contents, $directory, $crop);
}
DB::transaction(function () use ($original, $crops, $storedCrops): void {
foreach ($crops as $variant => $crop) {
$original->cropVariants()->updateOrCreate(
['variant' => $variant],
[
'crop_horizontal' => $crop['crop_horizontal'],
'crop_vertical' => $crop['crop_vertical'],
'cropped_attachment_id' => $storedCrops[$variant]->id,
]
);
}
});
} catch (Throwable $throwable) {
foreach ($storedCrops as $storedCrop) {
$this->delete($storedCrop);
}
throw $throwable;
}
foreach ($previousCrops as $previousCrop) {
if ($previousCrop->croppedAttachment !== null) {
$this->delete($previousCrop->croppedAttachment);
}
}
return $original->refresh()->load('cropVariants.croppedAttachment');
}
public function store( public function store(
UploadedFile|string $file, UploadedFile|string $file,
string $path, string $path,
@@ -57,13 +160,30 @@ class AttachmentService
public function delete(Attachment $attachment): void public function delete(Attachment $attachment): void
{ {
$deleted = Storage::disk('s3')->delete($attachment->path); $croppedAttachments = $attachment->cropVariants()
->with('croppedAttachment')
->get()
->pluck('croppedAttachment')
->filter();
$paths = $croppedAttachments
->pluck('path')
->prepend($attachment->path)
->filter()
->unique()
->values()
->all();
$deleted = Storage::disk('s3')->delete(
count($paths) === 1 ? $paths[0] : $paths
);
if (! $deleted) { if (! $deleted) {
throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.'); throw new AttachmentStorageException('No se pudieron eliminar los archivos del disco s3.');
} }
$attachment->delete(); DB::transaction(function () use ($attachment, $croppedAttachments): void {
$attachment->delete();
Attachment::query()->whereKey($croppedAttachments->pluck('id'))->delete();
});
} }
public function copy(Attachment $source, string $path): Attachment public function copy(Attachment $source, string $path): Attachment
@@ -107,6 +227,171 @@ class AttachmentService
return trim($path, '/'); return trim($path, '/');
} }
/**
* @param array<string, array{crop_horizontal?: mixed, crop_vertical?: mixed}> $crops
* @return array<string, array{crop_horizontal: array{start_percentage: float, end_percentage: float}, crop_vertical: array{start_percentage: float, end_percentage: float}}>
*/
protected function validateCropVariants(array $crops): array
{
$validated = [];
foreach (AttachmentCrop::VARIANTS as $variant) {
$crop = $crops[$variant] ?? null;
if (! is_array($crop)) {
throw new AttachmentStorageException("The {$variant} crop is required.");
}
$horizontal = $crop['crop_horizontal'] ?? null;
$vertical = $crop['crop_vertical'] ?? null;
if (! is_array($horizontal) || ! is_array($vertical)) {
throw new AttachmentStorageException(
"The {$variant} crop must contain crop_horizontal and crop_vertical ranges."
);
}
$validated[$variant] = [
'crop_horizontal' => $this->validateCropRange($horizontal, "{$variant} horizontal"),
'crop_vertical' => $this->validateCropRange($vertical, "{$variant} vertical"),
];
}
return $validated;
}
/**
* @param array{crop_horizontal: array<string, mixed>, crop_vertical: array<string, mixed>} $crop
*/
protected function storeCropVariant(string $contents, string $path, array $crop): Attachment
{
$croppedContents = $this->cropImage(
$contents,
$crop['crop_horizontal'],
$crop['crop_vertical'],
);
return $this->store(
'data:'.$croppedContents['mime_type'].';base64,'.base64_encode($croppedContents['contents']),
$path,
);
}
/**
* @param array{start_percentage?: mixed, end_percentage?: mixed} $range
* @return array{start_percentage: float, end_percentage: float}
*/
protected function validateCropRange(array $range, string $axis): array
{
$start = $range['start_percentage'] ?? null;
$end = $range['end_percentage'] ?? null;
if (! is_numeric($start) || ! is_numeric($end)) {
throw new AttachmentStorageException(
"The {$axis} crop range must contain numeric start_percentage and end_percentage values."
);
}
$start = (float) $start;
$end = (float) $end;
if (! is_finite($start) || ! is_finite($end) || $start < 0 || $end > 100 || $start >= $end) {
throw new AttachmentStorageException(
"The {$axis} crop range must satisfy 0 <= start_percentage < end_percentage <= 100."
);
}
return [
'start_percentage' => $start,
'end_percentage' => $end,
];
}
protected function imageContents(UploadedFile|string $image): string
{
if (is_string($image)) {
return $this->decodeBase64File($image)['data'];
}
$contents = file_get_contents($image->getRealPath());
if (! is_string($contents) || $contents === '') {
throw new AttachmentStorageException('The image content cannot be empty.');
}
return $contents;
}
/**
* @return array{contents: string, mime_type: string}
*/
protected function cropImage(
string $contents,
array $cropHorizontal,
array $cropVertical,
): array {
$source = @imagecreatefromstring($contents);
if ($source === false) {
throw new AttachmentStorageException('The attachment must contain a valid image.');
}
$width = imagesx($source);
$height = imagesy($source);
$x = min($width - 1, (int) floor($width * $cropHorizontal['start_percentage'] / 100));
$right = min($width, (int) ceil($width * $cropHorizontal['end_percentage'] / 100));
$y = min($height - 1, (int) floor($height * $cropVertical['start_percentage'] / 100));
$bottom = min($height, (int) ceil($height * $cropVertical['end_percentage'] / 100));
$cropped = imagecrop($source, [
'x' => $x,
'y' => $y,
'width' => $right - $x,
'height' => $bottom - $y,
]);
if ($cropped === false) {
throw new AttachmentStorageException('The image could not be cropped.');
}
return $this->encodeImage($cropped, $contents);
}
/**
* @return array{contents: string, mime_type: string}
*/
protected function encodeImage(\GdImage $image, string $originalContents): array
{
$mimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($originalContents);
$mimeType = is_string($mimeType) ? strtolower($mimeType) : '';
ob_start();
try {
$encoded = match ($mimeType) {
'image/jpeg' => imagejpeg($image, null, 90),
'image/gif' => imagegif($image),
'image/webp' => imagewebp($image, null, 90),
'image/avif' => function_exists('imageavif') && imageavif($image, null, 90),
default => imagepng($image),
};
$output = ob_get_contents();
} finally {
ob_end_clean();
}
if (! $encoded || ! is_string($output) || $output === '') {
throw new AttachmentStorageException('The cropped image could not be encoded.');
}
return [
'contents' => $output,
'mime_type' => match ($mimeType) {
'image/jpeg', 'image/gif', 'image/webp', 'image/avif' => $mimeType,
default => 'image/png',
},
];
}
protected function resolveFilename(UploadedFile|string $file, string $key): string protected function resolveFilename(UploadedFile|string $file, string $key): string
{ {
if ($file instanceof UploadedFile) { if ($file instanceof UploadedFile) {

View File

@@ -2,11 +2,12 @@
## Propósito ## Propósito
Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única. Para imágenes también permite guardar un original junto con variantes recortadas para desktop y mobile.
## Componentes principales ## Componentes principales
- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso. - `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso.
- `Models/AttachmentCrop.php`: relaciona un original con su crop desktop o mobile y la imagen generada.
- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos. - `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos.
- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos. - `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos.
- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento. - `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento.
@@ -25,5 +26,8 @@ No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog
## Consideraciones ## Consideraciones
- El directorio no puede quedar vacío después de normalizarlo. - El directorio no puede quedar vacío después de normalizarlo.
- Cada eje del crop guarda `start_percentage` y `end_percentage`, cumpliendo `0 <= start < end <= 100`.
- `attachment_crops` guarda una fila por variante con los rangos horizontal/vertical y el attachment procesado.
- Al eliminar el original mediante el servicio también se eliminan todas sus variantes recortadas.
- La eliminación se considera fallida si S3 no confirma el borrado. - La eliminación se considera fallida si S3 no confirma el borrado.
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos. - Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.

View File

@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
{ {
$data = $request->validated(); $data = $request->validated();
if (! $this->resetPasswordAttemptService->validateCode( $result = $this->resetPasswordAttemptService->validateCode(
$data['email'], $data['email'],
$data['codigo'], $data['codigo'],
)) { );
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_expired'),
]);
}
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'), '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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status'])] #[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
#[Hidden(['codigo'])] #[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model class ResetPasswordAttempt extends Model
{ {
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
{ {
return [ return [
'user_id' => 'integer', '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

@@ -155,16 +155,19 @@ class GoogleAuthService
$parts = parse_url($returnUrl); $parts = parse_url($returnUrl);
if (! is_array($parts) if (! is_array($parts)
|| ! isset($parts['scheme'], $parts['host']) || ! isset($parts['scheme'], $parts['host'])
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment']) || isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
|| ($parts['path'] ?? '') !== '') {
return false; return false;
} }
$scheme = strtolower($parts['scheme']); $scheme = strtolower($parts['scheme']);
$host = TenantDomainNormalizer::normalize($parts['host']); $host = TenantDomainNormalizer::normalize($parts['host']);
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio); $tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) { if ($host === null
|| $tenantDomain === null
|| $host !== $tenantDomain
|| $returnPath !== $tenant->base_path) {
return false; return false;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Domains\Catalog\Controllers\AdminApp;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class OnTicketFeaturedGroupController extends Controller
{
public function __construct(
private readonly OnTicketFeaturedGroupService $featuredGroupService,
) {}
public function index(Request $request): AnonymousResourceCollection
{
return OnTicketFeaturedGroupResource::collection(
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
);
}
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
{
$featuredGroup = $this->featuredGroupService->create(
$this->onTicketTenant($request),
$request->validated(),
);
return OnTicketFeaturedGroupResource::make($featuredGroup)
->response()
->setStatusCode(201);
}
public function update(
UpsertOnTicketFeaturedGroupRequest $request,
FeaturedGroup $featuredGroup,
): OnTicketFeaturedGroupResource {
$tenant = $this->onTicketTenant($request);
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
return OnTicketFeaturedGroupResource::make(
$this->featuredGroupService->update(
$tenant,
$featuredGroup,
$request->validated(),
)
);
}
private function onTicketTenant(Request $request): Tenant
{
$tenant = $request->user()->tenant()->firstOrFail();
abort_unless($tenant->website_type_code === 'onticket', 404);
return $tenant;
}
}

View File

@@ -17,17 +17,20 @@ use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource; use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource; use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource; use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Catalog\Services\CatalogService; use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Catalog\Services\FeaturedGroupService; use App\Domains\Catalog\Services\FeaturedGroupService;
use App\Domains\Catalog\Services\VariantSelectionService; use App\Domains\Catalog\Services\VariantSelectionService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
class CatalogController extends Controller 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() $featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
@@ -37,7 +40,7 @@ class CatalogController extends Controller
return response()->json($featuredGroups->map( return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource( fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup, $featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1), $featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
))->resolve() ))->resolve()
)); ));
} }
@@ -46,15 +49,17 @@ class CatalogController extends Controller
SearchCatalogItemsRequest $request, SearchCatalogItemsRequest $request,
Tenant $tenant, Tenant $tenant,
CatalogService $catalogService, CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection { ): AnonymousResourceCollection {
return CatalogSearchItemResource::collection( $items = $catalogService->search(
$catalogService->search( $tenant,
$tenant, $request->validated('q'),
$request->validated('q'), $tenant->search_items_per_page,
$tenant->search_items_per_page, (int) $request->validated('page', 1),
(int) $request->validated('page', 1),
)
); );
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items);
} }
public function category( public function category(
@@ -62,17 +67,19 @@ class CatalogController extends Controller
Tenant $tenant, Tenant $tenant,
Category $category, Category $category,
CatalogService $catalogService, CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection { ): AnonymousResourceCollection {
abort_unless($category->tenant_code === $tenant->codigo, 404); abort_unless($category->tenant_code === $tenant->codigo, 404);
return CatalogSearchItemResource::collection( $items = $catalogService->categoryItems(
$catalogService->categoryItems( $tenant,
$tenant, $category,
$category, $tenant->search_items_per_page,
$tenant->search_items_per_page, (int) $request->validated('page', 1),
(int) $request->validated('page', 1), );
) $allowances->attach($items->getCollection(), $this->userId($request));
)->additional([
return CatalogSearchItemResource::collection($items)->additional([
'category' => [ 'category' => [
'id' => $category->id, 'id' => $category->id,
'nombre' => $category->nombre, 'nombre' => $category->nombre,
@@ -93,7 +100,11 @@ class CatalogController extends Controller
$page = (int) $request->validated('page', 1); $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( public function show(
@@ -101,17 +112,19 @@ class CatalogController extends Controller
Tenant $tenant, Tenant $tenant,
CatalogItem $catalogItem, CatalogItem $catalogItem,
CatalogService $catalogService, CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource { ): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404); abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$variantId = $request->validated('variant_id'); $variantId = $request->validated('variant_id');
return CatalogItemDetailResource::make( $item = $catalogService->getDetail(
$catalogService->getDetail( $catalogItem,
$catalogItem, $variantId === null ? null : (int) $variantId,
$variantId === null ? null : (int) $variantId,
)
); );
$allowances->attach(collect([$item]), $this->userId($request));
return CatalogItemDetailResource::make($item);
} }
public function variantOptions( public function variantOptions(
@@ -164,4 +177,11 @@ class CatalogController extends Controller
->response() ->response()
->setStatusCode(201); ->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', 'type',
'slug', 'slug',
'nombre', 'nombre',
'group_order',
'descripcion', 'descripcion',
'precio', 'precio',
'inventory_policy', 'inventory_policy',
@@ -47,6 +48,7 @@ class CatalogItem extends Model
'inventory_policy' => InventoryPolicy::Tracked->value, 'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value, 'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false, 'has_tickets' => false,
'group_order' => 0,
]; ];
protected function casts(): array protected function casts(): array
@@ -56,6 +58,7 @@ class CatalogItem extends Model
'brand_id' => 'integer', 'brand_id' => 'integer',
'inventory_id' => 'integer', 'inventory_id' => 'integer',
'type' => CatalogItemType::class, 'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2', 'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class, 'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class, 'inventory_subject' => InventorySubject::class,
@@ -172,17 +175,29 @@ class CatalogItem extends Model
} }
/** @param Builder<CatalogItem> $query */ /** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder public function scopeWhereAvailable(Builder $query): Builder
{ {
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query $query
->whereDoesntHave('variants') ->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas( ->orWhereHas(
'variants.inventory', 'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock') ->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
); )
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
->whereDoesntHave('variants')
->where(function (Builder $inventoryQuery): void {
$inventoryQuery
->whereNull('catalog_items.inventory_id')
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
});
}); });
} }

View File

@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'tenant_code', 'tenant_code',
'categoria_id', 'categoria_id',
'nombre', 'nombre',
'is_enabled',
])] ])]
class Category extends Model class Category extends Model
{ {
@@ -22,6 +23,10 @@ class Category extends Model
protected $table = 'categorias'; protected $table = 'categorias';
protected $attributes = [
'is_enabled' => true,
];
/** /**
* @return array<string, string> * @return array<string, string>
*/ */
@@ -29,6 +34,7 @@ class Category extends Model
{ {
return [ return [
'categoria_id' => 'integer', '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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
'code',
'source_type', 'source_type',
'category_id', 'category_id',
'product_layout', 'product_layout',
@@ -33,6 +35,29 @@ class FeaturedGroup extends Model
'source_type' => FeaturedGroupSource::Manual->value, '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 protected function casts(): array
{ {
return [ return [

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Catalog\Requests\AdminApp;
use Illuminate\Foundation\Http\FormRequest;
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'category_name' => ['required', 'string', 'max:255'],
'is_featured' => ['required', 'boolean'],
];
}
}

View File

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

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Catalog\Resources\AdminApp;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
class OnTicketFeaturedGroupResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'category_id' => $this->category_id,
'category_name' => $this->category->nombre,
'group_name' => $this->group_name,
'is_featured' => $this->product_layout === ProductLayout::Row,
'type' => $this->source_type->value,
'product_layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,
'order' => $this->group_order,
];
}
}

View File

@@ -20,6 +20,7 @@ class CatalogFeaturedGroupResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'code' => $this->code,
'title' => $this->group_name, 'title' => $this->group_name,
'layout' => $this->product_layout->value, 'layout' => $this->product_layout->value,
'group_layout' => $this->group_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\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup; use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -28,27 +29,48 @@ class CatalogFeaturedItemResource extends JsonResource
return $this->ticketSelectorData($catalogItem); return $this->ticketSelectorData($catalogItem);
} }
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
$availableStock = $catalogItem->availableStock();
$data = [ $data = [
'id' => $catalogItem->id, 'id' => $catalogItem->id,
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion, 'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'stock_tecnico' => $catalogItem->availableStock(), 'maximum_addable_quantity' => $this->maximumAddable(
$availableStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$availableStock,
$remainingUserQuota,
),
'variants' => $catalogItem->visibleVariants() 'variants' => $catalogItem->visibleVariants()
->map(fn (Variant $variant): array => [ ->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
'id' => $variant->id, $variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null ? null
: $variant->inventory->availableStock(), : $variant->inventory->availableStock();
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
]) return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$variantStock,
$remainingUserQuota,
),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
})
->values(), ->values(),
]; ];
@@ -58,6 +80,9 @@ class CatalogFeaturedItemResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function ticketSelectorData(CatalogItem $catalogItem): array private function ticketSelectorData(CatalogItem $catalogItem): array
{ {
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [ return [
'id' => $catalogItem->id, 'id' => $catalogItem->id,
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
@@ -65,28 +90,47 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion, 'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem), 'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
]; ];
} }
/** @return array<string, mixed> */ /** @return array<string, mixed> */
private function columnWithImageData(CatalogItem $catalogItem): array private function columnWithImageData(CatalogItem $catalogItem): array
{ {
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [ return [
'id' => $catalogItem->id, 'id' => $catalogItem->id,
'type' => $catalogItem->type->value, 'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre, 'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio, 'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem), 'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
]; ];
} }
private function firstImageUrl(CatalogItem $catalogItem): ?string private function firstImageUrl(CatalogItem $catalogItem): ?string
{ {
$attachment = $catalogItem->attachments->first() $attachment = $catalogItem->attachments->first()
?? $catalogItem->variants ?? $catalogItem->visibleVariants()
->flatMap(fn (Variant $variant) => $variant->attachments) ->flatMap(fn (Variant $variant) => $variant->attachments)
->first(); ->first();
return $attachment?->getTemporaryUrl(1440); 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\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute; use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource; use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -37,15 +38,19 @@ class CatalogItemDetailResource extends JsonResource
'max_units_per_user' => $this->max_units_per_user, 'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets, 'has_tickets' => $this->has_tickets,
'attributes' => $this->attributesData(), 'attributes' => $this->attributesData(),
'stock_tecnico' => $this->when( 'maximum_addable_quantity' => $this->when(
$selectedVariant === null, $selectedVariant === null,
fn () => $this->availableStock(), fn () => $this->maximumAddable($this->availableStock()),
),
'unavailable_message' => $this->when(
$selectedVariant === null,
fn () => $this->unavailableMessage($this->availableStock()),
), ),
'images' => $this->when( 'images' => $this->when(
$selectedVariant === null, $selectedVariant === null,
fn () => $this->imageUrls($this->attachments), fn () => $this->imageUrls($this->attachments),
), ),
'variants' => $this->visibleVariants() 'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant)) ->map(fn (Variant $variant): array => $this->variantData($variant))
->values(), ->values(),
'selected_variant' => $this->when( 'selected_variant' => $this->when(
@@ -85,6 +90,7 @@ class CatalogItemDetailResource extends JsonResource
'nombre' => $attribute->nombre, 'nombre' => $attribute->nombre,
'sort_order' => $itemAttribute->sort_order, 'sort_order' => $itemAttribute->sort_order,
'show_in_selector' => $itemAttribute->show_in_selector, 'show_in_selector' => $itemAttribute->show_in_selector,
'ticket_label' => $itemAttribute->ticket_label,
'is_required' => $attribute->is_required, 'is_required' => $attribute->is_required,
'allow_multi_select' => $itemAttribute->allow_multi_select, 'allow_multi_select' => $itemAttribute->allow_multi_select,
'metadata_schema' => $attribute->metadata_schema, 'metadata_schema' => $attribute->metadata_schema,
@@ -157,6 +163,7 @@ class CatalogItemDetailResource extends JsonResource
{ {
$values = $variant->selectionOptions($this->itemAttributes); $values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates(); $eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
return [ return [
'id' => $variant->id, 'id' => $variant->id,
@@ -166,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(), 'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(), 'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''), 'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->variantStock($variant), 'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $values, 'values' => $values,
]; ];
} }
@@ -185,4 +193,20 @@ class CatalogItemDetailResource extends JsonResource
? null ? null
: $variant->inventory->availableStock(); : $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\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -14,8 +15,10 @@ class CatalogSearchItemResource extends JsonResource
/** @return array<string, mixed> */ /** @return array<string, mixed> */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first() $attachment = $this->attachments->first()
?? $this->variants ?? $visibleVariants
->flatMap(fn (Variant $variant) => $variant->attachments) ->flatMap(fn (Variant $variant) => $variant->attachments)
->first(); ->first();
@@ -26,22 +29,44 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion, 'descripcion' => $this->descripcion,
'precio' => $this->precio, 'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440), 'image' => $attachment?->getTemporaryUrl(1440),
'stock_tecnico' => $this->availableStock(), 'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'variants' => $this->visibleVariants() 'unavailable_message' => $this->unavailableMessage($availableStock),
->map(fn (Variant $variant): array => [ 'variants' => $visibleVariants
'id' => $variant->id, ->map(function (Variant $variant): array {
'event_date_id' => $variant->event_date_id, $variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
? null ? null
: $variant->inventory?->availableStock(), : $variant->inventory?->availableStock();
'values' => $variant->selectorOptions($this->itemAttributes),
]) return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
->values(), ->values(),
]; ];
} }
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,11 @@ class FeaturedGroupService
private const ITEMS_PER_PAGE = 12; private const ITEMS_PER_PAGE = 12;
/** @return array<array-key, mixed> */ /** @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) { if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$query = $this->itemsQuery($featuredGroup); $query = $this->itemsQuery($featuredGroup);
@@ -26,12 +30,14 @@ class FeaturedGroupService
$items = $query->get(); $items = $query->get();
$this->attachGroup($items, $featuredGroup); $this->attachGroup($items, $featuredGroup);
$this->allowances->attach($items, $userId);
return CatalogFeaturedItemResource::collection($items)->resolve(); return CatalogFeaturedItemResource::collection($items)->resolve();
} }
$paginator = $this->paginateItems($featuredGroup, $page); $paginator = $this->paginateItems($featuredGroup, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup); $this->attachGroup($paginator->getCollection(), $featuredGroup);
$this->allowances->attach($paginator->getCollection(), $userId);
return CatalogFeaturedItemResource::collection($paginator) return CatalogFeaturedItemResource::collection($paginator)
->response() ->response()
@@ -43,7 +49,16 @@ class FeaturedGroupService
{ {
$query = CatalogItem::query() $query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code) ->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([ ->with([
'inventory', 'inventory',
'attachments', 'attachments',
@@ -67,7 +82,9 @@ class FeaturedGroupService
FeaturedGroupSource::Category => $query FeaturedGroupSource::Category => $query
->where('catalog_items.category_id', $featuredGroup->category_id) ->where('catalog_items.category_id', $featuredGroup->category_id)
->orderBy('catalog_items.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

@@ -1,95 +0,0 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class OnTicketFeaturedGroupService
{
/** @return Collection<int, FeaturedGroup> */
public function forTenant(Tenant $tenant): Collection
{
return FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->where('source_type', FeaturedGroupSource::Category)
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
->with('category')
->orderBy('group_order')
->orderBy('id')
->get();
}
/** @param array{category_name: string, is_featured: bool} $data */
public function create(Tenant $tenant, array $data): FeaturedGroup
{
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
$category = $tenant->categories()->create([
'nombre' => $data['category_name'],
]);
$featuredGroup = FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'source_type' => FeaturedGroupSource::Category,
'category_id' => $category->id,
'product_layout' => $this->productLayout($data['is_featured']),
'group_layout' => GroupLayout::Paginated,
'group_name' => $data['category_name'],
'group_order' => $this->nextOrder($tenant),
]);
return $featuredGroup->setRelation('category', $category);
});
}
/** @param array{category_name: string, is_featured: bool} $data */
public function update(
Tenant $tenant,
FeaturedGroup $featuredGroup,
array $data,
): FeaturedGroup {
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
$featuredGroup = FeaturedGroup::query()
->whereKey($featuredGroup->getKey())
->where('tenant_code', $tenant->codigo)
->where('source_type', FeaturedGroupSource::Category)
->lockForUpdate()
->firstOrFail();
$category = Category::query()
->whereKey($featuredGroup->category_id)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->firstOrFail();
$category->update(['nombre' => $data['category_name']]);
$featuredGroup->update([
'group_name' => $data['category_name'],
'product_layout' => $this->productLayout($data['is_featured']),
'group_layout' => GroupLayout::Paginated,
]);
return $featuredGroup->setRelation('category', $category);
});
}
private function productLayout(bool $isFeatured): ProductLayout
{
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
}
private function nextOrder(Tenant $tenant): int
{
$maximumOrder = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->max('group_order');
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
}
}

View File

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

View File

@@ -9,6 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados. - `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. - `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado. - `Inventory` administra stock disponible, reservado y comprado.
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
- `Category` soporta jerarquía y categorías globales o propias del tenant. - `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas. - `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete. - `BundleComponent` representa los componentes de un paquete.
@@ -17,17 +18,14 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación. - `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario. - `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda. - `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
## Endpoints de tienda ## Endpoints de tienda
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados. Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
## Endpoints administrativos
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
## Dependencias y reglas ## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad. Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.

View File

@@ -1,15 +0,0 @@
<?php
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
->name('adminapp.featured-groups.index');
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
->name('adminapp.featured-groups.store');
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
->name('adminapp.featured-groups.update');
});

View File

@@ -15,5 +15,3 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']); Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
Route::post('catalog-items', [CatalogController::class, 'store']); Route::post('catalog-items', [CatalogController::class, 'store']);
}); });
require __DIR__.'/adminapp.php';

View File

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

View File

@@ -10,7 +10,6 @@ use App\Domains\Desfile\Services\EntryService;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EntryController extends Controller class EntryController extends Controller
{ {
@@ -48,10 +47,4 @@ class EntryController extends Controller
)); ));
} }
public function destroyImage(Request $request): Response
{
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
return response()->noContent();
}
} }

View File

@@ -166,19 +166,6 @@ class EntryService
return $this->current($tenant); return $this->current($tenant);
} }
public function deleteImage(Tenant $tenant): void
{
$attachment = DB::transaction(function () use ($tenant): Attachment {
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
$entry->allAttachments()->detach($attachment->id);
return $attachment;
});
$this->deleteIfUnused($attachment);
}
/** @return Collection<string, ItemAttribute> */ /** @return Collection<string, ItemAttribute> */
private function itemAttributes(CatalogItem $entry): Collection private function itemAttributes(CatalogItem $entry): Collection
{ {

View File

@@ -0,0 +1,431 @@
<?php
namespace App\Domains\Desfile\Services;
use DateTimeInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
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' => 16, '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'],
);
$this->createPurchaseItem(
$purchaseId,
$catalogItem,
$variant,
$allocation['sector'],
$allocation['row'],
$seat,
$allocation['type'],
$now,
);
$this->createTicketAndCommitStock(
$purchaseId,
$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,
): void {
if (DB::table('compra_items')
->where('compra_id', $purchaseId)
->where('source_variant_id', $variant->id)
->exists()) {
return;
}
$attributes = [
['name' => 'Tipo', 'value' => $type],
['name' => 'Sector', 'value' => $sector],
['name' => 'Fila', 'value' => (string) $row],
['name' => 'Asiento', 'value' => (string) $seat],
];
DB::table('compra_items')->insert([
'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 $userId,
int $catalogItemId,
object $variant,
DateTimeInterface $now,
): void {
if (DB::table('tickets')
->where('source_purchase_id', $purchaseId)
->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,
'source_purchase_id' => $purchaseId,
'source_catalog_item_id' => $catalogItemId,
'source_variant_id' => $variant->id,
'used_at' => null,
'scanner_user_id' => null,
'user_id' => $userId,
]);
}
}

View File

@@ -14,6 +14,4 @@ Route::prefix('v1/adminapp/tenant/desfile')
->name('adminapp.desfile.entries.image.replace'); ->name('adminapp.desfile.entries.image.replace');
Route::patch('entries/image', [EntryController::class, 'updateImage']) Route::patch('entries/image', [EntryController::class, 'updateImage'])
->name('adminapp.desfile.entries.image.update'); ->name('adminapp.desfile.entries.image.update');
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
->name('adminapp.desfile.entries.image.destroy');
}); });

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 <?php
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController; use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
use App\Domains\FiestaFutbolInfantil\Controllers\CategoryVisibilityController;
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController; use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController; use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController; use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
@@ -9,6 +10,20 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->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']) Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas') ->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index'); ->name('adminapp.fiesta-futbol-infantil.entries.index');

View File

@@ -12,10 +12,12 @@ class SaleFormService
$names = [ $names = [
Purchase::STATUS_CREATED => 'Creada', Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago', Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_IN_REVIEW => 'En revisión',
Purchase::STATUS_PAID => 'Confirmada', Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada', Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada', Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida', Purchase::STATUS_EXPIRED => 'Vencida',
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
]; ];
return [ return [

View File

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

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreIntegrationRequest;
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
use Illuminate\Routing\Controller;
class IntegrationController extends Controller
{
public function index()
{
return response()->json(Integration::all());
}
public function store(StoreIntegrationRequest $request)
{
$integration = Integration::create($request->validated());
return response()->json($integration, 201);
}
public function show(Integration $integration)
{
return response()->json($integration);
}
public function update(UpdateIntegrationRequest $request, Integration $integration)
{
$integration->update($request->validated());
return response()->json($integration->fresh());
}
public function destroy(Integration $integration)
{
$integration->delete();
return response()->noContent();
}
}

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class TelepagosWebhookController extends Controller
{
/**
* Handle the incoming Telepagos webhook.
*/
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
{
try {
$cashinId = $request->validated('id');
$service->handleWebhook($tenantCodigo, $cashinId);
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
}
}
}

View File

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

View File

@@ -2,16 +2,18 @@
namespace App\Domains\Integration\Models; namespace App\Domains\Integration\Models;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Casts\EncryptedIntegrationData; use App\Domains\Integration\Casts\EncryptedIntegrationData;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TenantIntegration extends Model class ClientIntegration extends Model
{ {
protected $table = 'tenant_integration'; protected $hidden = ['integration_data'];
protected $fillable = [ protected $fillable = [
'client_id',
'integration_code', 'integration_code',
'tenant_code',
'integration_data', 'integration_data',
]; ];
@@ -19,7 +21,14 @@ class TenantIntegration extends Model
'integration_data' => EncryptedIntegrationData::class, 'integration_data' => EncryptedIntegrationData::class,
]; ];
public function integration() /** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
/** @return BelongsTo<Integration, $this> */
public function integration(): BelongsTo
{ {
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code'); return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
} }

View File

@@ -13,16 +13,16 @@ class Integration extends Model
'name', 'name',
'url', 'url',
'integration_data_schema', 'integration_data_schema',
'requires_tenant_configuration', 'requires_client_configuration',
]; ];
protected $casts = [ protected $casts = [
'integration_data_schema' => 'array', 'integration_data_schema' => 'array',
'requires_tenant_configuration' => 'boolean', 'requires_client_configuration' => 'boolean',
]; ];
public function tenantIntegrations() public function clientIntegrations()
{ {
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code'); return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
} }
} }

View File

@@ -6,7 +6,7 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class StoreTenantIntegrationRequest extends FormRequest class StoreClientIntegrationRequest extends FormRequest
{ {
protected ?Integration $integrationModel = null; protected ?Integration $integrationModel = null;
@@ -15,10 +15,12 @@ class StoreTenantIntegrationRequest extends FormRequest
return true; return true;
} }
protected function prepareForValidation() protected function prepareForValidation(): void
{ {
$integrationCode = $this->route('integration_code'); $integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first(); $this->integrationModel = Integration::query()
->where('integration_code', $integrationCode)
->first();
if (! $this->integrationModel) { if (! $this->integrationModel) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -31,11 +33,8 @@ class StoreTenantIntegrationRequest extends FormRequest
{ {
$rules = []; $rules = [];
// Dynamic validation rules based on the integration data schema foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
if ($this->integrationModel && $this->integrationModel->integration_data_schema) { $rules['integration_data.'.$field] = $rule;
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
$rules['integration_data.'.$field] = $rule;
}
} }
return $rules; return $rules;

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_tenant_configuration' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -1,28 +0,0 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TelepagosWebhookRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'id' => ['required', 'string'],
];
}
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$integration = $this->route('integration');
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_tenant_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
];
}
}

View File

@@ -2,58 +2,54 @@
namespace App\Domains\Integration\Services; namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration; use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\PendingRequest;
use Exception; use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
abstract class BaseIntegrationService abstract class BaseIntegrationService
{ {
/** /**
* The unique code of the integration. * The unique code of the integration.
*
* @var string
*/ */
protected string $integrationCode; protected string $integrationCode;
/** /**
* The current tenant code. * The current tenant code.
*
* @var string
*/ */
protected string $tenantCode; protected string $tenantCode;
protected ?Tenant $tenant = null;
protected ?Client $clientContext = null;
/** /**
* The integration model instance. * The integration model instance.
*
* @var Integration|null
*/ */
protected ?Integration $integration = null; protected ?Integration $integration = null;
/** /**
* The tenant-specific integration model instance. * The client-owned integration configuration.
*
* @var TenantIntegration|null
*/ */
protected ?TenantIntegration $tenantIntegration = null; protected ?ClientIntegration $clientIntegration = null;
/** /**
* Set the integration code. * Set the integration code.
* *
* @param string $integrationCode
* @return $this * @return $this
*/ */
public function setIntegrationCode(string $integrationCode): self public function setIntegrationCode(string $integrationCode): self
{ {
$this->integrationCode = $integrationCode; $this->integrationCode = $integrationCode;
return $this; return $this;
} }
/** /**
* Get the integration code. * Get the integration code.
*
* @return string
*/ */
public function getIntegrationCode(): string public function getIntegrationCode(): string
{ {
@@ -63,53 +59,69 @@ abstract class BaseIntegrationService
/** /**
* Set the tenant code and load the integration models. * Set the tenant code and load the integration models.
* *
* @param string $tenantCode
* @return $this * @return $this
*
* @throws Exception * @throws Exception
*/ */
public function forTenant(string $tenantCode): self public function forTenant(string $tenantCode): self
{ {
$this->tenantCode = $tenantCode; $this->tenantCode = $tenantCode;
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
$this->clientContext = $this->tenant->client;
$this->loadIntegration(); $this->loadIntegration();
return $this;
}
public function forClient(Client|string $client): self
{
$this->clientContext = $client instanceof Client
? $client
: Client::query()->where('code', $client)->firstOrFail();
$this->tenant = null;
$this->loadIntegration();
return $this; return $this;
} }
/** /**
* Load the Integration and TenantIntegration models. * Load the integration definition and its client-owned configuration.
* *
* @throws Exception * @throws Exception
*/ */
protected function loadIntegration(): void protected function loadIntegration(): void
{ {
if (empty($this->integrationCode)) { if (empty($this->integrationCode)) {
throw new Exception("Integration code is not set."); throw new Exception('Integration code is not set.');
} }
$this->integration = Integration::where('integration_code', $this->integrationCode)->first(); $this->integration = Integration::where('integration_code', $this->integrationCode)->first();
if (!$this->integration) { if (! $this->integration) {
throw new Exception("Integration with code '{$this->integrationCode}' not found."); throw new Exception("Integration with code '{$this->integrationCode}' not found.");
} }
$this->tenantIntegration = TenantIntegration::where('tenant_code', $this->tenantCode) if (! $this->clientContext) {
throw new Exception('Client context is not set.');
}
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode) ->where('integration_code', $this->integrationCode)
->first(); ->first();
if (!$this->tenantIntegration && $this->integration->requires_tenant_configuration) { if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured."); throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
} }
} }
/** /**
* Build the request URL. * Build the request URL.
* *
* @param string $path
* @return string
* @throws Exception * @throws Exception
*/ */
public function getUrl(string $path = ''): string public function getUrl(string $path = ''): string
{ {
if (!$this->integration) { if (! $this->integration) {
throw new Exception("Integration is not loaded. Call forTenant() first."); throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
} }
$baseUrl = rtrim($this->integration->url, '/'); $baseUrl = rtrim($this->integration->url, '/');
@@ -119,25 +131,20 @@ abstract class BaseIntegrationService
} }
/** /**
* Get integration setting by key from tenant's integration data. * Get an integration setting from the client-owned configuration.
*
* @param string $key
* @param mixed $default
* @return mixed
*/ */
protected function getIntegrationSetting(string $key, mixed $default = null): mixed protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{ {
if (!$this->tenantIntegration || !$this->tenantIntegration->integration_data) { if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
return $default; return $default;
} }
return $this->tenantIntegration->integration_data[$key] ?? $default; return $this->clientIntegration->integration_data[$key] ?? $default;
} }
/** /**
* Get a pre-configured HTTP client builder. * Get a pre-configured HTTP client builder.
* *
* @return PendingRequest
* @throws Exception * @throws Exception
*/ */
public function client(): PendingRequest public function client(): PendingRequest
@@ -148,17 +155,13 @@ abstract class BaseIntegrationService
/** /**
* Get the headers for the integration. * Get the headers for the integration.
*
* @return array
*/ */
abstract public function getHeaders(): array; abstract public function getHeaders(): array;
/** /**
* Hook called after the integration is configured for the tenant. * Hook called after the integration is configured for the client.
* Can be used to validate credentials or perform initial setups. * Can be used to validate credentials or perform initial setups.
* Throw an Exception on failure. * Throw an Exception on failure.
*
* @return void
*/ */
public function onSetup(): void public function onSetup(): void
{ {

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\ClientIntegration;
use App\Domains\Integration\Models\Integration;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class ClientIntegrationService
{
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{
return $client->integrations()
->where('integration_code', $integrationCode)
->first();
}
/** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection
{
return $client->integrations()->with('integration')->get();
}
public function updateOrCreateIntegration(
Client $client,
Integration $integration,
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();
return $clientIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
return match ($integrationCode) {
'email' => new MailService,
'telepagos', 'telepagos_homo' => new TelepagosIntegrationService($integrationCode),
default => null,
};
}
}

View File

@@ -2,7 +2,9 @@
namespace App\Domains\Integration\Services; namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Exception; use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory; use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer; use Illuminate\Contracts\Mail\Mailer;
@@ -27,9 +29,7 @@ class MailService extends BaseIntegrationService
private ?Mailer $mailer = null; private ?Mailer $mailer = null;
private ?Tenant $tenant = null; private bool $usesClientMailer = false;
private bool $usesTenantMailer = false;
public function __construct(?MailFactory $mailFactory = null) public function __construct(?MailFactory $mailFactory = null)
{ {
@@ -40,16 +40,28 @@ class MailService extends BaseIntegrationService
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
$this->tenant = Tenant::query() if ($this->clientIntegration) {
->where('codigo', $tenantCode)
->firstOrFail();
if ($this->tenantIntegration) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesTenantMailer = true; $this->usesClientMailer = true;
} else { } else {
$this->mailer = $this->mailFactory->mailer(); $this->mailer = $this->mailFactory->mailer();
$this->usesTenantMailer = false; $this->usesClientMailer = false;
}
return $this;
}
public function forClient(Client|string $client): self
{
parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first();
if ($this->clientIntegration) {
$this->mailer = $this->resolveMailer();
$this->usesClientMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesClientMailer = false;
} }
return $this; return $this;
@@ -60,24 +72,35 @@ class MailService extends BaseIntegrationService
return []; 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) { if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.'); 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( $html = Blade::render(
<<<'BLADE' <<<'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 !!} {!! $content !!}
</x-mail.branded-layout> </x-mail.branded-layout>
BLADE, BLADE,
[ [
'tenant' => $this->tenant, 'branding' => $branding,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440), 'headerLogoUrl' => $brand instanceof WebsiteType
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440), ? $brand->siteLogo?->getTemporaryUrl(1440)
: $brand->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
'content' => $content, 'content' => $content,
], ],
); );
@@ -86,48 +109,93 @@ class MailService extends BaseIntegrationService
->subject($subject) ->subject($subject)
->html($html); ->html($html);
foreach ($attachments as $attachment) {
$mail->attachData(
$attachment['data'],
$attachment['name'],
['mime' => $attachment['mime']],
);
}
$this->mailer->to($recipient)->send($mail); $this->mailer->to($recipient)->send($mail);
} }
public function mailerName(): string public function mailerName(): string
{ {
return $this->usesTenantMailer return $this->usesClientMailer
? 'tenant-smtp' ? 'client-smtp'
: (string) config('mail.default'); : (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 public function onSetup(): void
{ {
if (! $this->tenant) { if (! $this->mailer || ! $this->clientContext) {
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
} }
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
if (! is_string($recipient) || $recipient === '') { if (! is_string($recipient) || $recipient === '') {
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del tenant.'); throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del cliente.');
} }
$this->send( $subject = 'Configuración de correo validada';
$recipient, $content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
'Configuración de correo validada', .'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
'<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>' .'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
.'<p>La integración SMTP de '.e($this->tenant->nombre).' fue configurada correctamente.</p>'
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>', if ($this->tenant) {
$this->send($recipient, $subject, $content);
return;
}
$this->mailer->to($recipient)->send(
(new Mailable)->subject($subject)->html($content)
); );
} }
private function resolveMailer(): Mailer private function resolveMailer(): Mailer
{ {
$data = $this->tenantIntegration?->integration_data; $data = $this->clientIntegration?->integration_data;
if (! is_array($data)) { if (! is_array($data)) {
throw new InvalidArgumentException('La configuración SMTP del tenant no es válida.'); throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
} }
foreach (self::REQUIRED_SMTP_FIELDS as $field) { foreach (self::REQUIRED_SMTP_FIELDS as $field) {
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') { if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del tenant."); throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del cliente.");
} }
} }
@@ -137,7 +205,7 @@ class MailService extends BaseIntegrationService
} }
$mailer = $this->mailFactory->build([ $mailer = $this->mailFactory->build([
'name' => "tenant-smtp-{$this->tenantCode}", 'name' => 'client-smtp-'.$this->clientContext?->id,
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => $data['MAIL_SCHEME'] ?? null, 'scheme' => $data['MAIL_SCHEME'] ?? null,
'host' => $data['MAIL_HOST'], 'host' => $data['MAIL_HOST'],
@@ -150,7 +218,7 @@ class MailService extends BaseIntegrationService
$mailer->alwaysFrom( $mailer->alwaysFrom(
$data['MAIL_FROM_ADDRESS'], $data['MAIL_FROM_ADDRESS'],
$data['MAIL_FROM_NAME'] ?? $this->tenant?->nombre, $data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
); );
return $mailer; return $mailer;

View File

@@ -2,39 +2,37 @@
namespace App\Domains\Integration\Services; namespace App\Domains\Integration\Services;
use Carbon\Carbon;
use Exception; use Exception;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class TelepagosIntegrationService extends BaseIntegrationService class TelepagosIntegrationService extends BaseIntegrationService
{ {
/** /**
* TelepagosIntegrationService constructor. * TelepagosIntegrationService constructor.
*
* @param string $integrationCode
*/ */
public function __construct(string $integrationCode = 'telepagos') public function __construct(string $integrationCode = 'telepagos')
{ {
// Force homologation code if not in production and using default // Force homologation code if not in production and using default
if ($integrationCode === 'telepagos' && !app()->environment('production')) { if ($integrationCode === 'telepagos' && ! app()->environment('production')) {
$integrationCode = 'telepagos_homo'; $integrationCode = 'telepagos_homo';
} }
$this->integrationCode = $integrationCode; $this->integrationCode = $integrationCode;
} }
/** /**
* Get the headers for Telepagos integration. * Get the headers for Telepagos integration.
* *
* @return array
* @throws Exception * @throws Exception
*/ */
public function getHeaders(): array public function getHeaders(): array
{ {
return [ return [
'Authorization' => 'Bearer ' . $this->getToken(), 'Authorization' => 'Bearer '.$this->getToken(),
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Accept' => 'application/json', 'Accept' => 'application/json',
]; ];
@@ -43,16 +41,15 @@ class TelepagosIntegrationService extends BaseIntegrationService
/** /**
* Get a valid token, either from cache or by performing a login. * Get a valid token, either from cache or by performing a login.
* *
* @return string
* @throws Exception * @throws Exception
*/ */
public function getToken(): string public function getToken(): string
{ {
if (!$this->tenantIntegration) { if (! $this->clientIntegration || ! $this->clientContext) {
throw new Exception("Tenant integration is not loaded. Call forTenant() first."); throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
} }
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}"; $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
$token = Cache::get($cacheKey); $token = Cache::get($cacheKey);
@@ -66,7 +63,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
/** /**
* Authenticate with Telepagos and cache the returned token. * Authenticate with Telepagos and cache the returned token.
* *
* @return string
* @throws Exception * @throws Exception
*/ */
public function login(): string public function login(): string
@@ -75,7 +71,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
$password = $this->getIntegrationSetting('password'); $password = $this->getIntegrationSetting('password');
if (empty($username) || empty($password)) { if (empty($username) || empty($password)) {
throw new Exception("Missing username or password in Telepagos integration settings."); throw new Exception('Missing username or password in Telepagos integration settings.');
} }
$url = $this->getUrl('/v2/auth/token'); $url = $this->getUrl('/v2/auth/token');
@@ -85,22 +81,20 @@ class TelepagosIntegrationService extends BaseIntegrationService
'password' => $password, 'password' => $password,
]); ]);
$data = $this->handleResponse($response, 'authentication', [ $data = $this->handleResponse($response, 'authentication');
'username' => $username,
]);
$token = $data['token'] ?? null; $token = $data['token'] ?? null;
$expiresAtStr = $data['expires_at'] ?? null; $expiresAtStr = $data['expires_at'] ?? null;
if (!$token || !$expiresAtStr) { if (! $token || ! $expiresAtStr) {
throw new Exception("Telepagos authentication response is missing token or expires_at."); throw new Exception('Telepagos authentication response is missing token or expires_at.');
} }
$expiresAt = Carbon::parse($expiresAtStr); $expiresAt = Carbon::parse($expiresAtStr);
// Calculate TTL and subtract a buffer of 60 seconds // Calculate TTL and subtract a buffer of 60 seconds
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60); $ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}"; $cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
Cache::put($cacheKey, $token, $ttlSeconds); Cache::put($cacheKey, $token, $ttlSeconds);
return $token; return $token;
@@ -108,21 +102,21 @@ class TelepagosIntegrationService extends BaseIntegrationService
/** /**
* Send a request to Telepagos, handling 401 Unauthorized for token refresh. * Send a request to Telepagos, handling 401 Unauthorized for token refresh.
*
* @param string $method
* @param string $endpoint
* @param array $data
* @return \Illuminate\Http\Client\Response
*/ */
protected function sendRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response protected function sendRequest(string $method, string $endpoint, array $data = []): Response
{ {
$response = $this->client()->$method($endpoint, $data); $response = $this->client()->$method($endpoint, $data);
if ($response->status() === 401) { if ($response->status() === 401) {
Log::info("Telepagos 401 Unauthorized. Refreshing token and retrying..."); Log::channel('telepagos')->info('Telepagos request returned 401; refreshing token and retrying.', [
'method' => strtoupper($method),
'endpoint' => $endpoint,
'client_id' => $this->clientContext?->id,
'integration_code' => $this->integrationCode,
]);
$this->clearToken(); $this->clearToken();
$response = $this->client()->$method($endpoint, $data); $response = $this->client()->$method($endpoint, $data);
} }
@@ -132,10 +126,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
/** /**
* Generate a QR code for cash-in. * Generate a QR code for cash-in.
* *
* @param float $amount
* @param string $concept
* @param string $description
* @return array
* @throws Exception * @throws Exception
*/ */
public function generateQr(float $amount, string $concept, string $description): array public function generateQr(float $amount, string $concept, string $description): array
@@ -154,8 +144,8 @@ class TelepagosIntegrationService extends BaseIntegrationService
/** /**
* Get the details of a cash-in payment. * Get the details of a cash-in payment.
* *
* @param int $cashinId * @param int $cashinId
* @return array *
* @throws Exception * @throws Exception
*/ */
public function getCashinDetails(string $cashinId): array public function getCashinDetails(string $cashinId): array
@@ -170,31 +160,29 @@ class TelepagosIntegrationService extends BaseIntegrationService
/** /**
* Get the account info. * Get the account info.
* *
* @return array
* @throws Exception * @throws Exception
*/ */
public function getAccountInfo(): array public function getAccountInfo(): array
{ {
$response = $this->sendRequest('get', '/v2/account/info'); $response = $this->sendRequest('get', '/v2/account/info');
return $this->handleResponse($response, 'get account info'); return $this->handleResponse($response, 'get account info');
} }
/** /**
* Handle the Telepagos API response, logging any failures and throwing Exceptions. * Handle the Telepagos API response, logging any failures and throwing Exceptions.
* *
* @param \Illuminate\Http\Client\Response $response
* @param string $actionDescription
* @param array $context
* @return array
* @throws Exception * @throws Exception
*/ */
protected function handleResponse(\Illuminate\Http\Client\Response $response, string $actionDescription, array $context = []): array protected function handleResponse(Response $response, string $actionDescription, array $context = []): array
{ {
if ($response->failed() || $response->json('status') !== 'ok') { if ($response->failed() || $response->json('status') !== 'ok') {
$errorMessage = $response->json('message') ?? $response->body(); $errorMessage = $response->json('message') ?? $response->body();
Log::error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([ Log::channel('telepagos')->error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
'response_status' => $response->status(), 'response_status' => $response->status(),
'response_body' => $response->json() ?? $response->body(), 'response_body' => $this->sanitizeForLog($response->json() ?? $response->body()),
'client_id' => $this->clientContext?->id,
'integration_code' => $this->integrationCode,
], $context)); ], $context));
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}"); throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
@@ -203,21 +191,46 @@ class TelepagosIntegrationService extends BaseIntegrationService
return $response->json() ?? []; return $response->json() ?? [];
} }
/**
* Remove credentials and tokens before serializing provider responses.
*/
protected function sanitizeForLog(mixed $value): mixed
{
if (! is_array($value)) {
return $value;
}
$sensitiveKeys = ['authorization', 'password', 'token', 'access_token', 'refresh_token'];
foreach ($value as $key => $item) {
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
$value[$key] = '[REDACTED]';
continue;
}
$value[$key] = $this->sanitizeForLog($item);
}
return $value;
}
/** /**
* Clear the cached token. * Clear the cached token.
*
* @return void
*/ */
public function clearToken(): void public function clearToken(): void
{ {
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}"; if (! $this->clientContext) {
return;
}
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
Cache::forget($cacheKey); Cache::forget($cacheKey);
} }
/** /**
* Perform initial setup validation for Telepagos. * Perform initial setup validation for Telepagos.
* *
* @return void
* @throws Exception * @throws Exception
*/ */
public function onSetup(): void public function onSetup(): void

View File

@@ -1,144 +0,0 @@
<?php
namespace App\Domains\Integration\Services;
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\Tenant\Models\Tenant;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TelepagosWebhookService
{
public function __construct(
private readonly CheckoutService $checkoutService,
) {}
/**
* Handle the Telepagos webhook notification.
*
* @throws Exception
*/
public function handleWebhook(string $tenantCodigo, string $cashinId): void
{
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo);
try {
$details = $telepagosService->getCashinDetails($cashinId);
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
$transferenciaOperationIds = [1, 3, 11];
$qrOperationIds = [31, 37, 47];
$compra = null;
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
if (! $cuit) {
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
return;
}
$dni = substr($cuit, 2, -1);
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
->where('transfer_payer_dni', $dni)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
])
->where('payment_method', 'transfer')
->where('total', $amount)
->latest()
->first();
if (! $compra) {
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
return;
}
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
if (! $qrOrderId) {
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
return;
}
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
if (! $telepagosQr) {
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
return;
}
$compra = $telepagosQr->compra;
if (! $compra) {
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
return;
}
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
return;
}
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
if ($amount !== $totalAmount) {
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
return;
}
} else {
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
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,
];
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPaidPurchase($compra);
});
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
} catch (Exception $e) {
Log::error('Telepagos webhook error: '.$e->getMessage());
throw $e;
}
}
protected function normalizeAmount(mixed $amount): string
{
return number_format((float) $amount, 2, '.', '');
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Models\TenantIntegration;
use Illuminate\Support\Facades\DB;
class TenantIntegrationService
{
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
{
return TenantIntegration::where('tenant_code', $tenantCode)
->where('integration_code', $integrationCode)
->first();
}
public function getAllForTenant(string $tenantCode)
{
return TenantIntegration::with('integration')
->where('tenant_code', $tenantCode)
->get();
}
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
{
return DB::transaction(function () use ($tenantCode, $integration, $data) {
$tenantIntegration = TenantIntegration::updateOrCreate(
[
'tenant_code' => $tenantCode,
'integration_code' => $integration->integration_code,
],
[
'integration_data' => $data,
]
);
$service = $this->resolveService($integration->integration_code);
if ($service) {
$service->forTenant($tenantCode)->onSetup();
}
return $tenantIntegration;
});
}
protected function resolveService(string $integrationCode): ?BaseIntegrationService
{
switch ($integrationCode) {
case 'email':
return new MailService;
case 'telepagos':
case 'telepagos_homo':
return new TelepagosIntegrationService($integrationCode);
default:
return null;
}
}
}

View File

@@ -2,28 +2,29 @@
## Propósito ## Propósito
Gestiona integraciones externas disponibles y su configuración por tenant. Incluye correo y pagos mediante Telepagos. Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos.
## Modelo y seguridad ## Modelo y seguridad
- `Integration`: definición global de una integración. - `Integration`: definición global de una integración.
- `TenantIntegration`: configuración y credenciales de una integración para un tenant. - `ClientIntegration`: configuración y credenciales de una integración para un cliente.
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos. - `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
- `TenantIntegrationService`: consulta y configura integraciones del tenant. - `ClientIntegrationService`: consulta y configura integraciones del cliente.
## Servicios externos ## Servicios externos
- `BaseIntegrationService`: base para resolver configuración, URL y cliente del tenant. - `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
- `MailService`: envío de correo usando la integración configurada. - `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros. - `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
## Endpoints ## Endpoints
- CRUD global bajo `/integrations`. - Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- Consulta y configuración por tenant bajo `/{tenant_code}/integrations`.
- `POST /webhooks/telepagos/{tenant_codigo}` para notificaciones del proveedor. ## Logging de Telepagos
Los eventos de autenticación, QR y consultas de cuenta se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
## Dependencias y reglas ## Dependencias y reglas
Se integra con `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. Las credenciales no deben exponerse en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra. Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs.

View File

@@ -1,21 +1,10 @@
<?php <?php
use App\Domains\Integration\Controllers\IntegrationController; use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\TenantIntegrationController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'integrations'], function () { Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [IntegrationController::class, 'index']); Route::get('/', [ClientIntegrationController::class, 'index']);
Route::post('/', [IntegrationController::class, 'store']); Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::get('/{integration}', [IntegrationController::class, 'show']); Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
Route::put('/{integration}', [IntegrationController::class, 'update']);
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
}); });
Route::group(['prefix' => '{tenant_code}/integrations'], function () {
Route::get('/', [TenantIntegrationController::class, 'index']);
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']);
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']);
});
Route::post('webhooks/telepagos/{tenant_codigo}', [\App\Domains\Integration\Controllers\TelepagosWebhookController::class, 'handle']);

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\MailTest\Controllers;
use App\Domains\MailTest\Requests\SendTestMailRequest;
use App\Domains\MailTest\Services\MailTestService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class MailTestController extends Controller
{
public function __construct(
protected MailTestService $mailTestService,
) {}
public function __invoke(SendTestMailRequest $request, string $tenantCode): JsonResponse
{
$tenant = Tenant::query()
->where('codigo', $tenantCode)
->firstOrFail();
return response()->json(
$this->mailTestService->send(
$tenant,
$request->validated('to'),
$request->validated('subject'),
$request->validated('message'),
)
);
}
}

View File

@@ -1,40 +0,0 @@
<?php
namespace App\Domains\MailTest\Mailables;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TestMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly string $mailSubject,
public readonly string $mailMessage,
public readonly Tenant $tenant,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: $this->mailSubject);
}
public function content(): Content
{
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
return new Content(
view: 'mail.test',
with: [
'tenant' => $this->tenant,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
],
);
}
}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Domains\MailTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendTestMailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'to' => ['required', 'string', 'email', 'max:255'],
'subject' => ['nullable', 'string', 'max:255'],
'message' => ['nullable', 'string', 'max:5000'],
];
}
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Domains\MailTest\Services;
use App\Domains\Integration\Services\MailService;
use App\Domains\Tenant\Models\Tenant;
class MailTestService
{
/**
* @return array<string, string>
*/
public function send(Tenant $tenant, string $recipient, ?string $subject = null, ?string $message = null): array
{
$subject ??= 'Prueba de correo de Shopit';
$message ??= 'Este es un correo de prueba enviado desde Shopit.';
$mailService = (new MailService)->forTenant($tenant->codigo);
$mailService->send(
$recipient,
$subject,
'<h1 style="margin: 0 0 20px;">'.e($subject).'</h1>'
.'<p>'.nl2br(e($message)).'</p>',
);
return [
'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient,
'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(),
'sent_at' => now()->toIso8601String(),
];
}
}

View File

@@ -1,24 +0,0 @@
# Dominio MailTest
## Propósito
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
## Componentes
- `MailTestController`: endpoint invocable de envío.
- `SendTestMailRequest`: valida destinatario y contenido requerido.
- `MailTestService`: coordina el envío de prueba.
- `TestMail`: mailable utilizado para construir el mensaje.
## Endpoint
- `POST /{tenant_code}/mail-test/send`.
## Dependencias
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
## Consideraciones
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.

View File

@@ -1,6 +0,0 @@
<?php
use App\Domains\MailTest\Controllers\MailTestController;
use Illuminate\Support\Facades\Route;
Route::post('{tenant_code}/mail-test/send', MailTestController::class);

View File

@@ -1,79 +0,0 @@
<?php
namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class MenuController extends Controller
{
public function index(): JsonResponse
{
$menues = Menu::all();
return response()->json($menues);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'code' => 'required|string|unique:menues,code',
'label' => 'required|string|max:255',
'parent_menu_code' => [
'nullable',
'string',
Rule::exists('menues', 'code'),
'different:code',
],
'content_type' => [
'sometimes',
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
],
'static_content_schema' => 'required_if:content_type,static|nullable|array',
'route' => 'required|string',
]);
$menu = Menu::create($validated);
return response()->json($menu, 201);
}
public function show(Menu $menu): JsonResponse
{
return response()->json($menu);
}
public function update(Request $request, Menu $menu): JsonResponse
{
$validated = $request->validate([
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
'label' => 'sometimes|required|string|max:255',
'parent_menu_code' => [
'nullable',
'string',
Rule::exists('menues', 'code'),
Rule::notIn([$menu->code]),
],
'content_type' => [
'sometimes',
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
],
'static_content_schema' => 'required_if:content_type,static|nullable|array',
'route' => 'sometimes|required|string',
]);
$menu->update($validated);
return response()->json($menu);
}
public function destroy(Menu $menu): JsonResponse
{
$menu->delete();
return response()->json(null, 204);
}
}

View File

@@ -1,32 +0,0 @@
<?php
namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
use App\Domains\Menu\Services\TenantMenuService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class TenantMenuController extends Controller
{
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
public function store(
StoreTenantMenuRequest $request,
string $tenantCode,
string $menuCode,
): JsonResponse {
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
$tenantMenu = $this->tenantMenuService->configure(
$tenant,
$menu,
$request->validated('static_content'),
);
return response()->json($tenantMenu);
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Domains\Menu\Requests;
use App\Domains\Menu\Models\Menu;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreTenantMenuRequest extends FormRequest
{
private ?Menu $menuModel = null;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->menuModel = Menu::query()
->where('code', $this->route('menu_code'))
->first();
if (! $this->menuModel) {
throw ValidationException::withMessages([
'menu_code' => __('api.menu.not_found'),
]);
}
}
public function rules(): array
{
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
return [
'static_content' => ['nullable', 'array'],
];
}
$rules = [
'static_content' => ['required', 'array'],
];
foreach ($this->menuModel->static_content_schema as $field => $rule) {
$rules["static_content.{$field}"] = $rule;
}
return $rules;
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Domains\Menu\Services;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class TenantMenuService
{
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
{
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
[
'tenant_code' => $tenant->codigo,
'menu_code' => $menu->code,
],
[
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
? $staticContent
: null,
],
));
}
}

View File

@@ -10,15 +10,6 @@ Define menús disponibles y permite configurar su contenido para cada tenant y r
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde. - `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
- `MenuRole`: asociación entre menú y rol autorizado. - `MenuRole`: asociación entre menú y rol autorizado.
## Servicios
`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático.
## Endpoints
- Recurso REST `/menues` mediante `MenuController`.
- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant.
## Dependencias y reglas ## Dependencias y reglas
Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`. Depende de `Tenant` y `Authorization`. Los menús se configuran mediante seeders y relaciones internas; no se exponen endpoints públicos de administración.

View File

@@ -1,12 +0,0 @@
<?php
use App\Domains\Menu\Controllers\MenuController;
use App\Domains\Menu\Controllers\TenantMenuController;
use Illuminate\Support\Facades\Route;
Route::apiResource('menues', MenuController::class);
Route::post(
'{tenant_code}/menues/{menu_code}',
[TenantMenuController::class, 'store']
);

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 App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit; use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit class SendPasswordResetEmail implements ShouldQueueAfterCommit
{ {
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
public function handle(PasswordResetRequested $event): void public function handle(PasswordResetRequested $event): void
{ {
try { app(NotificationMailService::class)->sendPasswordResetCode(
app(NotificationMailService::class)->sendPasswordResetCode( $event->attemptId,
$event->attemptId, $event->tenantCode,
$event->tenantCode, $event->channel,
$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\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
class SendPurchasePaidEmail implements ShouldQueueAfterCommit class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
{ {
use InteractsWithQueue; use InteractsWithQueue;
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
public function handle(PurchasePaid $event): void 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\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticket\Services\TicketPresentationResolver;
use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable;
class NotificationMailService class NotificationMailService
{ {
public function __construct( public function __construct(
private readonly MailService $mailService, private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService,
) {} ) {}
public function sendWelcome(int $userId, string $tenantCode): void public function sendWelcome(int $userId, string $tenantCode): void
{ {
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail(); $this->sendLogged('welcome', [
$user = User::query()->findOrFail($userId); '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 $this->mailService
->forTenant($tenantCode) ->forTenant($tenantCode)
->send( ->send(
$user->email, $user->email,
"Bienvenido a {$tenant->nombre}", "Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('tenant', 'user'))->render(), view('mail.notifications.welcome', compact('brand', 'user'))->render(),
); $brand,
);
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
});
} }
public function sendPasswordResetCode( public function sendPasswordResetCode(
@@ -38,91 +53,161 @@ class NotificationMailService
string $tenantCode, string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT, string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void { ): void {
$tenant = Tenant::query() $context = [
->with('websiteType') 'attempt_id' => $attemptId,
->where('codigo', $tenantCode) 'tenant_code' => $tenantCode,
->firstOrFail(); 'channel' => $channel,
$attempt = ResetPasswordAttempt::query() ];
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) { $this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [ $tenant = Tenant::query()
'attempt_id' => $attemptId, ->with('websiteType')
'tenant_code' => $tenantCode, ->where('codigo', $tenantCode)
'attempt_status' => $attempt->status, ->firstOrFail();
]); $attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
return; if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
} $this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
'user_id' => $attempt->user_id,
]));
$recoveryDomain = match ($channel) { return null;
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio, }
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$this->mailService $recoveryDomain = match ($channel) {
->forTenant($tenantCode) PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
->send( PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
$attempt->user->email, default => $tenant->dominio,
"Código para recuperar tu contraseña - {$tenant->nombre}", };
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(), $recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
); && $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$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 - {$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
{ {
$purchase = Purchase::query() $context = ['purchase_id' => $purchaseId];
->with(['tenant', 'user', 'items'])
->findOrFail($purchaseId);
$this->mailService $this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
->forTenant($purchase->tenant_codigo) $purchase = Purchase::query()
->send( ->with(['tenant', 'user', 'items'])
$this->recipientFor($purchase), ->find($purchaseId);
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
}
/** @param array<int, int> $ticketIds */ if ($purchase === null) {
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void $this->logSkipped('purchase_confirmed', array_merge($context, [
{ 'reason' => 'purchase_not_found',
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId); 'missing_model' => Purchase::class,
/** @var Collection<int, Ticket> $tickets */ ]));
$tickets = Ticket::query()
->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
if ($tickets->isEmpty()) { return null;
return; }
}
$this->mailService /** @var Collection<int, Ticket> $tickets */
->forTenant($purchase->tenant_codigo) $tickets = Ticket::query()
->send( ->where('source_purchase_id', $purchase->getKey())
$this->recipientFor($purchase), ->where('tenant_code', $purchase->tenant_codigo)
'Tus tickets ya están disponibles', ->with(TicketPresentationResolver::RELATIONS)
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(), ->get();
); $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),
"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 private function recipientFor(Purchase $purchase): string
{ {
return (string) ($purchase->email ?: $purchase->user?->email); 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. - `UserRegistered`: dispara el correo de bienvenida.
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente. - `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
- `PurchasePaid`: envía la confirmación de pago. - `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
## Componentes ## 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 ## 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. - 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. - 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. - Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.

View File

@@ -4,13 +4,13 @@ namespace App\Domains\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService; use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Requests\PaymentIntentRequest; use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest; use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest; use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest;
use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -24,13 +24,23 @@ class PurchaseController extends Controller
{ {
public function index(Request $request, Tenant $tenant): JsonResponse 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( return PurchaseResource::collection(
Purchase::query() Purchase::query()
->with('stockReservation')
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id) ->where('user_id', $request->user()->id)
->when($request->query('status'), function ($query, $status) { ->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
$query->where('status', $status); ->orderBy('status')
})
->latest() ->latest()
->paginateFromRequest() ->paginateFromRequest()
)->response(); )->response();
@@ -52,12 +62,16 @@ class PurchaseController extends Controller
return PurchaseResource::make($purchase)->response()->setStatusCode(201); 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 = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing('items')->loadCount('tickets'); $compra->loadMissing('items')->loadCount('tickets');
$compra->items->load('imageAttachment'); $responses->load($compra);
return PurchaseResource::make($compra); return PurchaseResource::make($compra);
} }
@@ -75,86 +89,69 @@ class PurchaseController extends Controller
); );
} }
public function updateItemQuantity( public function paymentIntent(
UpdatePurchaseItemQuantityRequest $request, PaymentIntentRequest $request,
Tenant $tenant, Tenant $tenant,
Purchase $compra, Purchase $compra,
PurchaseItem $item, PurchaseStateGuard $purchaseState,
CheckoutService $checkoutService, ): JsonResponse {
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->updateItemQuantity(
$compra,
$item,
(int) $request->validated('quantity'),
),
);
}
public function prepareItemEditing(
Request $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->prepareItemEditing($compra),
);
}
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$method = $request->validated('method'); $method = $request->validated('method');
$totalAmount = $compra->calculateCurrentTotalAmount(); $transferPayerDni = $method === 'transfer'
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
: null;
$purchaseUpdate = [ $updated = DB::transaction(function () use (
'payment_method' => $method, $compra,
'status' => Purchase::STATUS_PENDING_PAYMENT, $method,
'expires_at' => now()->addMinutes( $purchaseState,
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) $transferPayerDni,
), ): bool {
'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 {
/** @var Purchase|null $purchase */ /** @var Purchase|null $purchase */
$purchase = Purchase::query() $purchase = Purchase::query()
->whereKey($compra->getKey()) ->whereKey($compra->getKey())
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($purchase === null) {
return false;
}
$purchaseState->assertNotExpired($purchase);
if ( if (
$purchase === null ! in_array($purchase->status, [
|| ! in_array($purchase->status, [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
], true) ], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) { ) {
return false; 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); $purchase->update($purchaseUpdate);
return true; return true;
}); });
if ($updated === 0) { if (! $updated) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_payment'), 'purchase' => __('api.purchase.not_available_for_payment'),
]); ]);
} }
$compra->refresh(); $compra->refresh();
$totalAmount = (float) $compra->total;
if ($method === 'transfer') { if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
@@ -163,6 +160,11 @@ class PurchaseController extends Controller
try { try {
$accountInfo = $telepagosService->getAccountInfo(); $accountInfo = $telepagosService->getAccountInfo();
Log::channel('telepagos')->info('Telepagos account information retrieved.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
]);
return response()->json([ return response()->json([
'payment_method' => 'transfer', 'payment_method' => 'transfer',
'transfer_data' => [ 'transfer_data' => [
@@ -174,9 +176,10 @@ class PurchaseController extends Controller
], ],
]); ]);
} catch (\Exception $e) { } catch (\Exception $e) {
Log::error('Unable to retrieve TelePagos account information.', [ Log::channel('telepagos')->error('Unable to retrieve TelePagos account information.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id, 'purchase_id' => $compra->id,
'exception' => $e, 'error' => $e->getMessage(),
]); ]);
return response()->json([ return response()->json([
@@ -191,7 +194,11 @@ class PurchaseController extends Controller
$telepagosService->forTenant($tenant->codigo); $telepagosService->forTenant($tenant->codigo);
try { try {
Log::info("Generating QR for purchase ID: {$compra->id}, amount: {$totalAmount}"); Log::channel('telepagos')->info('Generating Telepagos QR.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
'amount' => $totalAmount,
]);
$qrResponse = $telepagosService->generateQr( $qrResponse = $telepagosService->generateQr(
$totalAmount, $totalAmount,
'Compra', 'Compra',
@@ -202,10 +209,17 @@ class PurchaseController extends Controller
'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''), 'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''),
'qr_code' => $qrResponse['qr_code'] ?? '', 'qr_code' => $qrResponse['qr_code'] ?? '',
]); ]);
} catch (\Exception $e) {
Log::error('Unable to generate TelePagos QR code.', [ Log::channel('telepagos')->info('Telepagos QR generated successfully.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id, 'purchase_id' => $compra->id,
'exception' => $e, 'qr_order_id' => $telepagosQr->qr_order_id,
]);
} catch (\Exception $e) {
Log::channel('telepagos')->error('Unable to generate TelePagos QR code.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
'error' => $e->getMessage(),
]); ]);
return response()->json([ return response()->json([
@@ -229,15 +243,6 @@ class PurchaseController extends Controller
], 400); ], 400);
} }
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->completePurchase($compra)
);
}
public function submitForReview( public function submitForReview(
Request $request, Request $request,
Tenant $tenant, Tenant $tenant,

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Purchase\Events; namespace App\Domains\Purchase\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
@@ -10,5 +9,5 @@ class PurchasePaid
{ {
use Dispatchable, SerializesModels; 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

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart; use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Logging\Models\Concerns\LogsValueChanges; use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid; use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
@@ -18,11 +19,11 @@ use Illuminate\Support\Facades\DB;
#[Fillable([ #[Fillable([
'cart_id', 'cart_id',
'stock_reservation_id',
'tenant_codigo', 'tenant_codigo',
'user_id', 'user_id',
'status', 'status',
'payment_method', 'payment_method',
'expires_at',
'total', 'total',
'dni', 'dni',
'transfer_payer_dni', 'transfer_payer_dni',
@@ -38,6 +39,8 @@ class Purchase extends Model
public const STATUS_PENDING_PAYMENT = 'pending_payment'; public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid'; public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled'; public const STATUS_CANCELLED = 'cancelled';
@@ -46,16 +49,20 @@ class Purchase extends Model
public const STATUS_EXPIRED = 'expired'; public const STATUS_EXPIRED = 'expired';
public const STATUS_SUPERSEDED = 'superseded';
/** @return list<string> */ /** @return list<string> */
public static function statuses(): array public static function statuses(): array
{ {
return [ return [
self::STATUS_CREATED, self::STATUS_CREATED,
self::STATUS_PENDING_PAYMENT, self::STATUS_PENDING_PAYMENT,
self::STATUS_IN_REVIEW,
self::STATUS_PAID, self::STATUS_PAID,
self::STATUS_CANCELLED, self::STATUS_CANCELLED,
self::STATUS_REJECTED, self::STATUS_REJECTED,
self::STATUS_EXPIRED, self::STATUS_EXPIRED,
self::STATUS_SUPERSEDED,
]; ];
} }
@@ -70,8 +77,8 @@ class Purchase extends Model
{ {
return [ return [
'cart_id' => 'integer', 'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2', 'total' => 'decimal:2',
]; ];
} }
@@ -116,6 +123,12 @@ class Purchase extends Model
return $this->hasMany(Ticket::class, 'source_purchase_id'); return $this->hasMany(Ticket::class, 'source_purchase_id');
} }
/** @return BelongsTo<StockReservation, $this> */
public function stockReservation(): BelongsTo
{
return $this->belongsTo(StockReservation::class);
}
/** /**
* @return HasOne<TelepagosQr, $this> * @return HasOne<TelepagosQr, $this>
*/ */
@@ -143,7 +156,7 @@ class Purchase extends Model
public function calculateCurrentTotalAmount(): float public function calculateCurrentTotalAmount(): float
{ {
if ($this->relationLoaded('items')) { if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
return (float) $this->getRelation('items')->sum('total'); return (float) $this->getRelation('items')->sum('total');
} }
@@ -180,7 +193,7 @@ class Purchase extends Model
'status' => self::STATUS_PAID, 'status' => self::STATUS_PAID,
]); ]);
PurchasePaid::dispatch($this); PurchasePaid::dispatch($this->getKey());
}); });
} }
} }

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Purchase\Models; namespace App\Domains\Purchase\Models;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -23,18 +25,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'discount_total', 'discount_total',
'tax_total', 'tax_total',
'total', 'total',
'reservation_status',
])] ])]
class PurchaseItem extends Model class PurchaseItem extends Model
{ {
use HasFactory; use HasFactory;
public const RESERVATION_ACTIVE = 'active';
public const RESERVATION_COMMITTED = 'committed';
public const RESERVATION_RELEASED = 'released';
protected $table = 'compra_items'; protected $table = 'compra_items';
protected function casts(): array protected function casts(): array
@@ -66,4 +61,16 @@ class PurchaseItem extends Model
{ {
return $this->belongsTo(Attachment::class, 'image_attachment_id'); 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();
}
} }

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