33 Commits
main ... dev

Author SHA1 Message Date
351178d9da Merge branch 'feature/candidate_payments' into homo_experimental 2026-08-27 16:52:08 -03:00
b617285148 feat(payments): rename match reason from 'exact_amount_different_dni' to 'exact_amount_near_dni' 2026-08-27 16:40:32 -03:00
9bca41bfa5 feat(payments): filter candidates by DNI distance 2026-08-27 16:04:12 -03:00
5601285369 feat(payments): expose primary transfer candidate 2026-08-27 15:38:24 -03:00
6fb60c9a73 feat(payments): track transfer match candidates 2026-08-27 15:21:28 -03:00
c3f1c320a4 test: isolate PHPUnit database 2026-08-27 15:21:16 -03:00
899bf12457 Merge branch 'dev' of https://gitea.quo.ar/tbianchini/shopit-back into dev 2026-08-27 10:11:24 -03:00
a460743ae0 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-27 10:11:14 -03:00
a4d7b1b789 Squashed commit of the following:
commit cd99a6489b
Merge: ebcbbb1 e63a036
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 26 10:12:44 2026 -0300

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

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

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

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate

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

    fix(cart): reconcile expired cart mutations

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

    docs(stock): describe associated entity expiration

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

    test(stock): cover propagated reservation expiration

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

    refactor(stock): propagate reservation expiration

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

    docs(stock): document terminal reservation recovery

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

    test(stock): cover terminal reservation lifecycle

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

    refactor(stock): make expired reservations terminal

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

    docs(stock): describe unified expiration ownership

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

    test(stock): cover authoritative reservation expiration

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

    refactor(stock): make reservation expiration authoritative

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

    docs(stock): document centralized reservation flow

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

    test(stock): cover aggregate reservation lifecycle

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

    refactor(stock): centralize reservation aggregate
2026-08-27 10:06:41 -03:00
a650de79c1 feat(purchase): add expiration time calculations to PurchaseResource and implement related tests 2026-08-26 17:00:17 -03:00
8f4fc39858 feat(env): remove unused purchase expiration variables from .env.example 2026-08-26 16:43:02 -03:00
09554b9f80 feat(purchase): refactor stock reservation expiration handling and update related tests 2026-08-26 16:36:26 -03:00
0d2198a3db Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 15:57:15 -03:00
52732da960 feat(auth): use environment variable for password reset expiration 2026-08-26 15:56:08 -03:00
22c6631652 Merge branch 'fix/website_types_favicons' into dev 2026-08-26 14:40:46 -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
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
62 changed files with 2876 additions and 804 deletions

View File

@@ -8,6 +8,7 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200
@@ -31,6 +32,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=daily
LOG_STACK=single

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
@@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
@@ -36,6 +38,16 @@ class Cart extends Model
protected $table = 'carritos';
public const STATUS_ACTIVE = 'active';
public const STATUS_CHECKOUT = 'checkout';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ABANDONED = 'abandoned';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
@@ -45,6 +57,7 @@ class Cart extends Model
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
@@ -84,6 +97,15 @@ class Cart extends Model
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
/** @return BelongsTo<StockReservation, $this> */
public function currentStockReservation(): BelongsTo
{
return $this->belongsTo(
StockReservation::class,
'current_stock_reservation_id',
);
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
@@ -107,6 +129,7 @@ class Cart extends Model
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
@@ -140,12 +163,11 @@ class Cart extends Model
'cantidad' => $quantity,
]);
} else {
app(StockReservationService::class)->ensure($item, $selectedItem);
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -172,6 +194,7 @@ class Cart extends Model
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
@@ -205,7 +228,6 @@ class Cart extends Model
$nextAvailableQuantity,
);
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
@@ -222,11 +244,10 @@ class Cart extends Model
->first();
if ($targetItem !== null) {
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
$targetItem->cantidad += $quantity;
$targetItem->save();
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
@@ -234,7 +255,7 @@ class Cart extends Model
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
}
@@ -263,16 +284,9 @@ class Cart extends Model
]);
}
if ($delta > 0) {
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
}
if ($delta < 0) {
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
}
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -282,6 +296,7 @@ class Cart extends Model
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
@@ -289,17 +304,8 @@ class Cart extends Model
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(StockReservationService::class)->release(
$item,
$selectedItem,
$item->cantidad,
);
$item->delete();
app(StockReservationService::class)->syncCart($this);
});
}
@@ -341,13 +347,20 @@ class Cart extends Model
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->current_purchase_id = null;
return;
}
app(StockReservationService::class)->detachFromPurchase($currentPurchase);
app(StockReservationService::class)->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())

View File

@@ -3,13 +3,11 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'cart_id',
@@ -57,10 +55,4 @@ class CartItem extends Model
{
return $this->variant ?? $this->catalogItem;
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
}
}

View File

@@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
@@ -22,7 +25,7 @@ class CartService
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
@@ -119,7 +122,7 @@ class CartService
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
'status' => Cart::STATUS_ACTIVE,
]);
$cart->setRelation('items', collect());
@@ -220,12 +223,14 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', 'active')
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
->first();
}
@@ -234,7 +239,7 @@ class CartService
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->findCart($tenant, $identity);
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
@@ -247,10 +252,73 @@ class CartService
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
'status' => Cart::STATUS_ACTIVE,
'origin' => Cart::ORIGIN_USER,
];
if ($identity['user_id'] !== null) {

View File

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

View File

@@ -6,13 +6,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
@@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.
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

@@ -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

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

View File

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

View File

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

View File

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

View File

@@ -2,50 +2,184 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class ExpireStockReservationsService
{
private const BATCH_SIZE = 500;
public function __construct(
private readonly CheckoutService $checkout,
private readonly ExpireCartReservationsService $carts,
private readonly ReleaseCheckoutService $purchases,
private readonly StockReservationService $reservations,
) {}
/**
* @return array{purchases: int, cart_items: int}
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
*/
public function expireOverdue(): array
{
$expiredPurchases = null;
$expiredCartItems = null;
$summary = [
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 0,
'failed' => 0,
];
$lastReservationId = 0;
try {
$expiredPurchases = $this->checkout->expireOverduePurchases();
$expiredCartItems = $this->carts->expireOverdue();
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');
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_items' => $expiredCartItems,
'total_expired' => $expiredPurchases + $expiredCartItems,
]);
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
return [
'purchases' => $expiredPurchases,
'cart_items' => $expiredCartItems,
];
} catch (Throwable $exception) {
Log::channel('commands')->error('Stock reservation cleanup failed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_items' => $expiredCartItems,
'exception' => $exception,
]);
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);
throw $exception;
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

@@ -2,262 +2,486 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockReservationService
{
public const REASON_CART_EMPTY = 'cart_empty';
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
public const REASON_MANUAL_RELEASE = 'manual_release';
public function __construct(
private readonly CatalogInventoryService $inventory,
) {}
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
public function syncCart(Cart $cart): ?StockReservation
{
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
$this->inventory->reserve($selection, $quantity);
$this->recordIncrease($cartItem, $selection, $quantity);
});
}
return DB::transaction(function () use ($cart): ?StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadSelections($items);
$requirements = $this->requirementsForItems($items);
public function release(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus = StockReservation::STATUS_RELEASED,
): void {
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
$this->ensure($cartItem, $selection);
$this->inventory->release($selection, $quantity);
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
});
}
$reservation = $lockedCart->current_stock_reservation_id === null
? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
public function commit(
CartItem $cartItem,
CatalogItem|Variant $selection,
Purchase $purchase,
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
$this->inventory->commit($selection, (int) $cartItem->cantidad);
if ($reservation !== null) {
$this->assertUsableCartReservation($reservation);
}
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if (
$reservation === null
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->purchase_id !== $purchase->getKey()
|| $reservation->quantity !== $quantity
) {
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
if ($requirements === []) {
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
$this->finalizeLocked(
$reservation,
StockReservation::STATUS_RELEASED,
self::REASON_CART_EMPTY,
);
}
$reservation->update([
'status' => StockReservation::STATUS_COMMITTED,
'committed_at' => now(),
'expires_at' => null,
]);
$lockedCart->update(['current_stock_reservation_id' => null]);
$cart->current_stock_reservation_id = null;
return null;
}
});
}
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
{
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $quantity,
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
$reservation->update([
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
}
}
}
public function attachToPurchase(
CartItem $cartItem,
CatalogItem|Variant $selection,
Purchase $purchase,
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => $purchase->getKey(),
'expires_at' => $purchase->expires_at,
]);
});
}
public function detachFromPurchase(Purchase $purchase): void
{
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => null,
'expires_at' => $this->expiration(),
]);
}
public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void
{
DB::transaction(function () use ($cartItem, $selection): void {
$requirements = $this->inventory->requirementsFor(
$selection,
(int) $cartItem->cantidad,
);
$activeReservations = StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
$currentLines = StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get()
->keyBy('inventory_id');
$hasCompleteReservation = collect($requirements)->every(
fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity,
);
if ($hasCompleteReservation) {
return;
}
if ($activeReservations->isNotEmpty()) {
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
}
$this->inventory->reserve($selection, (int) $cartItem->cantidad);
$this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad);
});
}
public function syncPurchaseExpiration(Purchase $purchase): void
{
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update(['expires_at' => $purchase->expires_at]);
}
public function transfer(CartItem $source, CartItem $target): void
{
DB::transaction(function () use ($source, $target): void {
$sourceReservations = StockReservation::query()
->where('cart_item_id', $source->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('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();
->get()
->keyBy('id');
foreach ($sourceReservations as $sourceReservation) {
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_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 ($targetReservation === null) {
$sourceItemQuantity = (int) $source->cantidad;
$targetItemQuantity = (int) $target->fresh()->cantidad;
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
$sourceReservation->update([
'cart_item_id' => $target->getKey(),
'purchase_id' => null,
'quantity' => $perItemQuantity * $targetItemQuantity,
'expires_at' => $this->expiration(),
]);
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;
}
$targetReservation->update([
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$sourceReservation->delete();
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);
}
});
}
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
{
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
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 ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
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.');
}
$reservation->update([
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
/** @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');
});
}
private function recordDecrease(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus,
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 {
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
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;
}
$remaining = $reservation->quantity - $requiredQuantity;
$reservation->update([
'quantity' => $remaining,
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
'released_at' => $remaining === 0 ? now() : null,
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
]);
/** @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 lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
private function assertUsableCartReservation(StockReservation $reservation): void
{
return StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('inventory_id', $inventoryId)
->lockForUpdate()
->first();
if ($reservation->status === StockReservation::STATUS_EXPIRED
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
throw new StockReservationExpiredException;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null) {
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
}
}
private function expiration(): Carbon

View File

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

View File

@@ -22,7 +22,7 @@ class InvitationPurchaseProvisioner
private const ALLOCATIONS = [
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
];
@@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner
if ($purchaseId !== null) {
DB::table('compras')->where('id', $purchaseId)->update([
'status' => 'paid',
'expires_at' => null,
'total' => 0,
'updated_at' => $now,
]);
@@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner
'cart_id' => null,
'status' => 'paid',
'payment_method' => self::PAYMENT_METHOD,
'expires_at' => null,
'total' => 0,
'dni' => null,
'transfer_payer_dni' => null,
@@ -392,15 +390,27 @@ class InvitationPurchaseProvisioner
'sold_units' => $inventory->sold_units + 1,
]);
DB::table('stock_reservations')->insert([
$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,
'cart_item_id' => null,
'purchase_id' => $purchaseId,
'quantity' => 1,
'status' => 'committed',
'expires_at' => null,
'committed_at' => $now,
'released_at' => null,
'tracks_inventory' => true,
'created_at' => $now,
'updated_at' => $now,
]);

View File

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

View File

@@ -36,6 +36,7 @@ class PurchaseController extends Controller
return PurchaseResource::collection(
Purchase::query()
->with('stockReservation')
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id)
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
@@ -92,7 +93,6 @@ class PurchaseController extends Controller
PaymentIntentRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState,
): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
@@ -101,7 +101,12 @@ class PurchaseController extends Controller
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
: null;
$updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
$updated = DB::transaction(function () use (
$compra,
$method,
$purchaseState,
$transferPayerDni,
): bool {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->whereKey($compra->getKey())
@@ -128,9 +133,6 @@ class PurchaseController extends Controller
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
),
'total' => $purchase->calculateCurrentTotalAmount(),
];
@@ -150,7 +152,6 @@ class PurchaseController extends Controller
$compra->refresh();
$totalAmount = (float) $compra->total;
$checkoutService->syncReservationExpiration($compra);
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;

View File

@@ -19,11 +19,11 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'stock_reservation_id',
'tenant_codigo',
'user_id',
'status',
'payment_method',
'expires_at',
'total',
'dni',
'transfer_payer_dni',
@@ -77,8 +77,8 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
];
}
@@ -123,10 +123,10 @@ class Purchase extends Model
return $this->hasMany(Ticket::class, 'source_purchase_id');
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
/** @return BelongsTo<StockReservation, $this> */
public function stockReservation(): BelongsTo
{
return $this->hasMany(StockReservation::class);
return $this->belongsTo(StockReservation::class);
}
/**
@@ -145,6 +145,12 @@ class Purchase extends Model
return $this->hasMany(TelepagosPayment::class, 'compra_id');
}
/** @return HasMany<TelepagosPaymentCandidate, $this> */
public function telepagosPaymentCandidates(): HasMany
{
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
}
public function getTotalAmount(): float
{
if ($this->total !== null) {

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Resources;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,12 +18,15 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$paymentVerification = $this->resolvePaymentVerification();
$subtotal = $items->isNotEmpty()
? $items->reduce(
@@ -48,7 +52,11 @@ class PurchaseResource extends JsonResource
'created_at' => $this->created_at,
'status' => $this->status,
'payment_method' => $this->payment_method,
'expires_at' => $this->expires_at,
'expires_at' => $expiresAt,
'expires_in_seconds' => $expiresAt === null
? null
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
'server_time' => $serverTime,
'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono,
@@ -58,6 +66,7 @@ class PurchaseResource extends JsonResource
'items' => PurchaseItemResource::collection($items),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),
];
@@ -77,4 +86,75 @@ class PurchaseResource extends JsonResource
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
/** @return array<string, mixed>|null */
private function resolvePaymentVerification(): ?array
{
if (
$this->status !== Purchase::STATUS_IN_REVIEW
|| $this->payment_method !== 'transfer'
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
) {
return null;
}
$candidates = $this->resource
->getRelation('telepagosPaymentCandidates')
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
->values();
/** @var TelepagosPaymentCandidate|null $primary */
$primary = $candidates->first();
return [
'status' => $primary === null ? 'pending' : 'candidate',
'candidate_count' => $candidates->count(),
'primary' => $primary === null ? null : [
'reason' => $primary->match_reason,
'dni_distance' => $primary->dni_distance,
'payment_amount' => $this->formatMoney($primary->payment_amount),
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
'amount_difference' => $this->formatMoney($primary->amount_difference),
'confidence' => $primary->confidence,
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
],
'reasons' => $candidates
->pluck('match_reason')
->unique()
->values()
->all(),
];
}
private function comparePaymentCandidates(
TelepagosPaymentCandidate $left,
TelepagosPaymentCandidate $right,
): int {
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
<=> $this->paymentCandidateRank($right->match_reason);
if ($reasonComparison !== 0) {
return $reasonComparison;
}
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
if ($differenceComparison !== 0) {
return $differenceComparison;
}
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
}
private function paymentCandidateRank(string $reason): int
{
return match ($reason) {
'ambiguous_exact_match' => 0,
'exact_dni_near_amount' => 1,
'exact_amount_near_dni' => 2,
default => 3,
};
}
}

View File

@@ -61,10 +61,7 @@ class CompleteCheckoutService
$this->purchaseState->lockCurrentCart($purchase);
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
@@ -72,9 +69,8 @@ class CompleteCheckoutService
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null,
]);
$this->reservations->syncPurchaseExpiration($purchase);
$this->reservations->clearExpirationForReview($purchase);
return $this->loadPurchase($purchase);
});
@@ -161,13 +157,14 @@ class CompleteCheckoutService
]);
}
try {
$this->reservations->commit($cartItem, $selection, $purchase);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
try {
$this->reservations->commit($purchase);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->sourceCart->finalize($purchase);
@@ -194,7 +191,7 @@ class CompleteCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function itemKey(int $catalogItemId, ?int $variantId): string

View File

@@ -8,6 +8,15 @@ class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
return $purchase->load(['tenant', 'items.imageAttachment']);
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $purchase->payment_method === 'transfer'
) {
$relations[] = 'telepagosPaymentCandidates.payment';
}
return $purchase->load($relations);
}
}

View File

@@ -5,11 +5,10 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Throwable;
class ReleaseCheckoutService
{
@@ -32,40 +31,6 @@ class ReleaseCheckoutService
return $this->release($purchase, Purchase::STATUS_EXPIRED);
}
public function expireOverdue(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
try {
$purchase = $this->expire($purchase);
} catch (Throwable $exception) {
Log::channel('commands')->error('Failed to expire overdue purchase.', [
'command' => 'reservations:expire',
'purchase_id' => $purchase->getKey(),
'tenant_codigo' => $purchase->tenant_codigo,
'cart_id' => $purchase->cart_id,
'status' => $purchase->status,
'expires_at' => $purchase->expires_at,
'exception' => $exception,
]);
return;
}
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function release(
Purchase $purchase,
string $targetStatus,
@@ -78,6 +43,11 @@ class ReleaseCheckoutService
): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
@@ -100,14 +70,31 @@ class ReleaseCheckoutService
return $this->loadPurchase($purchase);
}
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active'
&& in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($purchase, $cart);
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
&& (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true) || ! $this->hasOverdueActiveReservation($purchase))
) {
return $this->loadPurchase($purchase);
}
$this->releasePurchaseReservations($purchase, $targetStatus);
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$purchase->update(['status' => $targetStatus]);
@@ -115,60 +102,67 @@ class ReleaseCheckoutService
});
}
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
{
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
private function releasePurchaseReservations(
Purchase $purchase,
string $targetStatus,
?Cart $cart,
): void {
try {
$this->reservations->releaseForPurchase(
$purchase,
$targetStatus === Purchase::STATUS_EXPIRED
? StockReservation::STATUS_EXPIRED
: StockReservation::STATUS_RELEASED,
$targetStatus === Purchase::STATUS_CANCELLED
? StockReservationService::REASON_PURCHASE_CANCELLED
: ($targetStatus === Purchase::STATUS_REJECTED
? StockReservationService::REASON_PAYMENT_REJECTED
: null),
);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
if ($cart === null) {
return;
}
if ($cart->status === 'active') {
$this->reservations->detachFromPurchase($purchase);
if ($targetStatus === Purchase::STATUS_EXPIRED
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->update(['current_purchase_id' => null]);
return;
}
if ($cart->status !== 'checkout') {
return;
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$cartItems->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'variant.inventory',
'variant.catalogItem',
]);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
continue;
}
try {
$this->reservations->release(
$cartItem,
$selection,
(int) $cartItem->cantidad,
$targetStatus === Purchase::STATUS_EXPIRED
? StockReservation::STATUS_EXPIRED
: StockReservation::STATUS_RELEASED,
);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
]);
}
return;
}
if ($cart->status === Cart::STATUS_ACTIVE) {
$cartUpdate = [
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
];
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->update($cartUpdate);
return;
}
if ($cart->status !== Cart::STATUS_CHECKOUT) {
return;
}
if (! $cart->trashed()) {
$cart->update(['status' => 'converted']);
$cart->update(['status' => Cart::STATUS_CONVERTED]);
$cart->delete();
}
}
@@ -218,6 +212,17 @@ class ReleaseCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function hasOverdueActiveReservation(Purchase $purchase): bool
{
/** @var StockReservation|null $reservation */
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
@@ -12,6 +13,7 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -169,21 +171,31 @@ class StartCheckoutService
'cantidad' => $line['quantity'],
]);
try {
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
throw new InsufficientStockException([
$this->unavailableItem($line, $availableQuantity),
]);
}
$cartItem->setRelation('catalogItem', $line['catalog_item']);
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
$cartItems->push($cartItem);
}
try {
$this->reservations->syncCart($cart);
} catch (\InvalidArgumentException) {
$unavailable = $resolvedLines
->map(function (array $line): ?array {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
return $availableQuantity !== null && $availableQuantity < $line['quantity']
? $this->unavailableItem($line, $availableQuantity)
: null;
})
->filter()
->values()
->all();
throw new InsufficientStockException($unavailable !== [] ? $unavailable : [
$this->unavailableItem($resolvedLines->first(), 0),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
@@ -194,19 +206,12 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
foreach ($cartItems as $index => $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$resolvedLines->get($index)['selection'],
$purchase,
);
}
return $this->loadPurchase($purchase);
}
@@ -257,6 +262,7 @@ class StartCheckoutService
$this->verifyTenantItems($tenant, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
$cart->setRelation('items', $cartItems);
$this->reservations->syncCart($cart);
$purchase = $this->createPurchase(
$tenant,
@@ -266,16 +272,9 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
foreach ($cartItems as $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$cartItem->selectedItem(),
$purchase,
);
}
return $this->loadPurchase($purchase);
}
@@ -316,9 +315,9 @@ class StartCheckoutService
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
}
@@ -331,7 +330,11 @@ class StartCheckoutService
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status !== 'active') {
if ($cart->status === Cart::STATUS_EXPIRED) {
throw new StockReservationExpiredException;
}
if ($cart->status !== Cart::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
@@ -405,13 +408,17 @@ class StartCheckoutService
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'total' => $total,
]);
}
private function checkoutExpiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
);
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
@@ -23,7 +22,6 @@ class CheckoutService
private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser,
private readonly StockReservationService $reservations,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -77,14 +75,4 @@ class CheckoutService
{
return $this->releaser->expire($purchase);
}
public function expireOverduePurchases(): int
{
return $this->releaser->expireOverdue();
}
public function syncReservationExpiration(Purchase $purchase): void
{
$this->reservations->syncPurchaseExpiration($purchase);
}
}

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Validation\ValidationException;
@@ -11,15 +12,32 @@ class PurchaseStateGuard
{
public function assertNotExpired(Purchase $purchase): void
{
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
$hasExpiredByTime = in_array($purchase->status, [
if ($purchase->status === Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
&& $purchase->expires_at !== null
&& $purchase->expires_at->isPast();
], true)) {
return;
}
if ($hasExpiredStatus || $hasExpiredByTime) {
/** @var StockReservation|null $reservation */
$reservation = $purchase->relationLoaded('stockReservation')
? $purchase->getRelation('stockReservation')
: ($purchase->exists
? $purchase->stockReservation()->first()
: null);
if ($reservation !== null && (
$reservation->status === StockReservation::STATUS_EXPIRED
|| (
$reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture()
)
)) {
throw new PurchaseExpiredException;
}
}

View File

@@ -135,11 +135,25 @@ class TenantTransactionResetService
*/
private function reservationQuery(array $scope): Builder
{
$reservationIds = DB::table('carritos')
->whereIn('id', $scope['cart_ids'])
->whereNotNull('current_stock_reservation_id')
->pluck('current_stock_reservation_id')
->merge(
DB::table('compras')
->whereIn('id', $scope['purchase_ids'])
->whereNotNull('stock_reservation_id')
->pluck('stock_reservation_id'),
)
->merge(
DB::table('stock_reservation_lines')
->whereIn('inventory_id', $scope['inventory_ids'])
->pluck('stock_reservation_id'),
)
->unique()
->values();
return DB::table('stock_reservations')
->where(function (Builder $query) use ($scope): void {
$query->whereIn('inventory_id', $scope['inventory_ids'])
->orWhereIn('purchase_id', $scope['purchase_ids'])
->orWhereIn('cart_item_id', $scope['cart_item_ids']);
});
->whereIn('id', $reservationIds);
}
}

View File

@@ -88,9 +88,9 @@ class UserPurchaseLimitService
$excludedCartId !== null,
fn ($query) => $query->whereKeyNot($excludedCartId),
))
->whereHas('stockReservations', fn ($query) => $query
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereNull('purchase_id'))
->whereDoesntHave('purchase'))
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
@@ -161,9 +161,9 @@ class UserPurchaseLimitService
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active'))
->whereHas('stockReservations', fn ($query) => $query
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereNull('purchase_id'))
->whereDoesntHave('purchase'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');

View File

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

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
@@ -113,6 +114,16 @@ return Application::configure(basePath: dirname(__DIR__))
'message' => $exception->getMessage(),
], 422);
});
$exceptions->render(function (StockReservationExpiredException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'code' => 'stock_reservation.expired',
'message' => $exception->getMessage(),
], 422);
});
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;

View File

@@ -96,7 +96,7 @@ return [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => 60,
'expire' => (int) env('AUTH_PASSWORD_RESET_EXPIRATION_MINUTES', 60),
'throttle' => 60,
],
],

View File

@@ -8,4 +8,9 @@ return [
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
],
'transfer_candidate_amount_tolerance_percentage' => (float) env(
'PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE',
5,
),
];

View File

@@ -0,0 +1,32 @@
<?php
use App\Domains\Auth\Models\ResetPasswordAttempt;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('reset_password_attempts', function (Blueprint $table): void {
$table->timestamp('expires_at')->nullable()->after('status');
});
// Attempts created before this migration did not have an expiration instant.
DB::table('reset_password_attempts')
->whereIn('status', [
ResetPasswordAttempt::STATUS_PENDING,
ResetPasswordAttempt::STATUS_VALIDATED,
])
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
}
public function down(): void
{
Schema::table('reset_password_attempts', function (Blueprint $table): void {
$table->dropColumn('expires_at');
});
}
};

View File

@@ -0,0 +1,180 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::rename('stock_reservations', 'stock_reservation_lines');
Schema::create('stock_reservations', function (Blueprint $table): void {
$table->id();
$table->string('status')->default('active');
$table->dateTime('expires_at')->nullable();
$table->dateTime('committed_at')->nullable();
$table->dateTime('released_at')->nullable();
$table->dateTime('expired_at')->nullable();
$table->string('release_reason')->nullable();
$table->timestamps();
$table->index(['status', 'expires_at']);
});
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->foreignId('stock_reservation_id')->nullable()->after('id');
$table->boolean('tracks_inventory')->default(true)->after('quantity');
});
Schema::table('carritos', function (Blueprint $table): void {
$table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id');
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('stock_reservation_id')->nullable()->after('cart_id');
});
$cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id');
$unlimitedInventoryIds = DB::table('catalog_items')
->where('inventory_policy', 'unlimited')
->whereNotNull('inventory_id')
->pluck('inventory_id')
->merge(
DB::table('variantes')
->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id')
->where('catalog_items.inventory_policy', 'unlimited')
->pluck('variantes.inventory_id'),
)
->map(fn ($id): int => (int) $id)
->unique();
$legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get();
$groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string {
if ($row->purchase_id !== null) {
return 'purchase:'.$row->purchase_id;
}
$cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id);
return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId;
});
foreach ($groups as $key => $rows) {
$statuses = $rows->pluck('status');
$status = $statuses->contains('active')
? 'active'
: ($statuses->contains('committed')
? 'committed'
: ($statuses->contains('expired') ? 'expired' : 'released'));
$first = $rows->first();
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => $status,
'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null,
'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null,
'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null,
'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null,
'release_reason' => null,
'created_at' => $first->created_at,
'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at,
]);
foreach ($rows->groupBy('inventory_id') as $inventoryRows) {
$line = $inventoryRows->first();
DB::table('stock_reservation_lines')->where('id', $line->id)->update([
'stock_reservation_id' => $reservationId,
'quantity' => $inventoryRows->sum('quantity'),
'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id),
]);
DB::table('stock_reservation_lines')
->whereIn('id', $inventoryRows->pluck('id')->skip(1))
->delete();
}
if (str_starts_with($key, 'purchase:')) {
$purchaseId = (int) substr($key, strlen('purchase:'));
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
$cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id');
$isCurrent = $cartId !== null
&& (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId;
if ($status === 'active' && $isCurrent) {
DB::table('carritos')->where('id', $cartId)->update([
'current_stock_reservation_id' => $reservationId,
]);
}
} elseif (str_starts_with($key, 'cart:') && $status === 'active') {
DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([
'current_stock_reservation_id' => $reservationId,
]);
}
}
if (DB::getDriverName() !== 'sqlite') {
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->dropForeign('stock_reservations_cart_item_id_foreign');
$table->dropForeign('stock_reservations_purchase_id_foreign');
$table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique');
$table->dropIndex('stock_reservations_purchase_id_status_index');
$table->dropIndex('stock_reservations_status_expires_at_index');
});
}
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change();
$table->dropColumn([
'cart_item_id',
'purchase_id',
'status',
'expires_at',
'committed_at',
'released_at',
]);
$table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk')
->references('id')
->on('stock_reservations')
->cascadeOnDelete();
$table->unique(
['stock_reservation_id', 'inventory_id'],
'reservation_lines_reservation_inventory_unique',
);
});
Schema::table('carritos', function (Blueprint $table): void {
$table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk')
->references('id')
->on('stock_reservations')
->nullOnDelete();
$table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique');
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk')
->references('id')
->on('stock_reservations')
->nullOnDelete();
$table->unique('stock_reservation_id', 'purchases_stock_reservation_unique');
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropUnique('purchases_stock_reservation_unique');
$table->dropForeign('purchases_stock_reservation_fk');
$table->dropColumn('stock_reservation_id');
});
Schema::table('carritos', function (Blueprint $table): void {
$table->dropUnique('carts_current_stock_reservation_unique');
$table->dropForeign('carts_current_stock_reservation_fk');
$table->dropColumn('current_stock_reservation_id');
});
Schema::dropIfExists('stock_reservation_lines');
Schema::dropIfExists('stock_reservations');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::table('compras')
->whereNotNull('stock_reservation_id')
->orderBy('id')
->chunkById(500, function ($purchases): void {
foreach ($purchases as $purchase) {
DB::table('stock_reservations')
->where('id', $purchase->stock_reservation_id)
->where('status', 'active')
->update(['expires_at' => $purchase->expires_at]);
}
});
Schema::table('compras', function (Blueprint $table): void {
$table->dropIndex(['expires_at']);
$table->dropColumn('expires_at');
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->timestamp('expires_at')->nullable()->index()->after('payment_method');
});
DB::table('compras')
->whereNotNull('stock_reservation_id')
->orderBy('id')
->chunkById(500, function ($purchases): void {
foreach ($purchases as $purchase) {
DB::table('compras')
->where('id', $purchase->id)
->update([
'expires_at' => DB::table('stock_reservations')
->where('id', $purchase->stock_reservation_id)
->value('expires_at'),
]);
}
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
$expiredReservationIds = fn ($query) => $query
->select('id')
->from('stock_reservations')
->where('status', 'expired');
DB::table('compras')
->whereIn('status', ['created', 'pending_payment'])
->whereIn('stock_reservation_id', $expiredReservationIds)
->update(['status' => 'expired']);
DB::table('carritos')
->whereIn('status', ['active', 'checkout'])
->whereIn('current_stock_reservation_id', $expiredReservationIds)
->update(['status' => 'expired']);
}
public function down(): void
{
// Terminal business states cannot be reversed without inventing their prior state.
}
};

View File

@@ -0,0 +1,141 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('telepagos_payment_candidates', function (Blueprint $table) {
$table->id();
$table->foreignId('telepagos_payment_id')
->constrained('telepagos_payments')
->cascadeOnDelete();
$table->foreignId('compra_id')->constrained('compras')->cascadeOnDelete();
$table->boolean('dni_matches');
$table->boolean('amount_matches');
$table->decimal('payment_amount', 10, 2);
$table->decimal('purchase_amount', 10, 2);
$table->decimal('amount_difference', 10, 2);
$table->string('match_reason');
$table->string('confidence');
$table->timestamps();
$table->unique(
['telepagos_payment_id', 'compra_id'],
'telepagos_payment_candidate_unique',
);
});
$this->migrateExistingCandidates();
Schema::table('telepagos_payments', function (Blueprint $table) {
$table->dropColumn('matched_purchase_ids');
});
}
public function down(): void
{
Schema::table('telepagos_payments', function (Blueprint $table) {
$table->json('matched_purchase_ids')->nullable()->after('compra_id');
});
DB::table('telepagos_payments')
->whereNull('compra_id')
->orderBy('id')
->each(function (object $payment): void {
$candidateIds = DB::table('telepagos_payment_candidates')
->where('telepagos_payment_id', $payment->id)
->pluck('compra_id')
->all();
if ($candidateIds !== []) {
DB::table('telepagos_payments')
->where('id', $payment->id)
->update(['matched_purchase_ids' => json_encode($candidateIds)]);
}
});
Schema::dropIfExists('telepagos_payment_candidates');
}
private function migrateExistingCandidates(): void
{
DB::table('telepagos_payments')
->whereNull('compra_id')
->whereNotNull('matched_purchase_ids')
->orderBy('id')
->each(function (object $payment): void {
$candidateIds = json_decode((string) $payment->matched_purchase_ids, true);
if (! is_array($candidateIds)) {
return;
}
$paymentAmount = number_format((float) $payment->amount, 2, '.', '');
$payerDni = $payment->cuit_buyer
? substr((string) $payment->cuit_buyer, 2, -1)
: null;
foreach ($candidateIds as $candidateId) {
$purchase = DB::table('compras')->find($candidateId);
if ($purchase === null) {
continue;
}
$purchaseAmount = number_format((float) $purchase->total, 2, '.', '');
$dniMatches = $payerDni !== null && $purchase->transfer_payer_dni === $payerDni;
$amountMatches = $purchaseAmount === $paymentAmount;
DB::table('telepagos_payment_candidates')->insertOrIgnore([
'telepagos_payment_id' => $payment->id,
'compra_id' => $purchase->id,
'dni_matches' => $dniMatches,
'amount_matches' => $amountMatches,
'payment_amount' => $paymentAmount,
'purchase_amount' => $purchaseAmount,
'amount_difference' => number_format(
abs((float) $purchaseAmount - (float) $paymentAmount),
2,
'.',
'',
),
'match_reason' => $this->matchReason($dniMatches, $amountMatches),
'confidence' => $this->confidence($dniMatches, $amountMatches),
'created_at' => $payment->created_at,
'updated_at' => $payment->updated_at,
]);
}
});
}
private function matchReason(bool $dniMatches, bool $amountMatches): string
{
if ($dniMatches && $amountMatches) {
return 'ambiguous_exact_match';
}
if ($dniMatches) {
return 'exact_dni_near_amount';
}
if ($amountMatches) {
return 'exact_amount_different_dni';
}
return 'legacy_candidate';
}
private function confidence(bool $dniMatches, bool $amountMatches): string
{
if ($dniMatches && $amountMatches) {
return 'exact';
}
return $dniMatches ? 'high' : 'medium';
}
};

View File

@@ -0,0 +1,61 @@
<?php
use App\Domains\Purchase\Services\DniDistanceService;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('telepagos_payment_candidates', function (Blueprint $table) {
$table->unsignedTinyInteger('dni_distance')->nullable()->after('dni_matches');
$table->string('payment_dni', 8)->nullable()->after('dni_distance');
$table->string('purchase_dni', 8)->nullable()->after('payment_dni');
});
$dniDistance = new DniDistanceService;
DB::table('telepagos_payment_candidates as candidate')
->join('telepagos_payments as payment', 'payment.id', '=', 'candidate.telepagos_payment_id')
->join('compras as purchase', 'purchase.id', '=', 'candidate.compra_id')
->select([
'candidate.id',
'candidate.match_reason',
'payment.cuit_buyer',
'purchase.transfer_payer_dni',
])
->orderBy('candidate.id')
->each(function (object $candidate) use ($dniDistance): void {
if ($candidate->cuit_buyer === null || $candidate->transfer_payer_dni === null) {
return;
}
$payerDni = substr((string) $candidate->cuit_buyer, 2, -1);
$distance = $dniDistance->distance($payerDni, (string) $candidate->transfer_payer_dni);
if ($candidate->match_reason === 'exact_amount_different_dni' && $distance > 2) {
DB::table('telepagos_payment_candidates')->where('id', $candidate->id)->delete();
return;
}
DB::table('telepagos_payment_candidates')
->where('id', $candidate->id)
->update([
'dni_distance' => $distance,
'payment_dni' => $payerDni,
'purchase_dni' => $candidate->transfer_payer_dni,
]);
});
}
public function down(): void
{
Schema::table('telepagos_payment_candidates', function (Blueprint $table) {
$table->dropColumn(['dni_distance', 'payment_dni', 'purchase_dni']);
});
}
};

View File

@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('telepagos_payment_candidates')
->where('match_reason', 'exact_amount_different_dni')
->update(['match_reason' => 'exact_amount_near_dni']);
}
public function down(): void
{
DB::table('telepagos_payment_candidates')
->where('match_reason', 'exact_amount_near_dni')
->update(['match_reason' => 'exact_amount_different_dni']);
}
};

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.',
'reset_code_invalid' => 'The code you entered is invalid.',
'reset_code_expired' => 'The password recovery code expired. Request a new one.',
'reset_code_valid' => 'Code validated successfully.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.',
@@ -34,6 +35,7 @@ return [
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.',
'variant_required' => 'You must select a variant for this item.',
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
],
'purchase' => [
'expired' => 'The purchase has expired. Please start a new purchase.',

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.',
'reset_code_invalid' => 'El código ingresado es inválido.',
'reset_code_expired' => 'El código de recuperación expiró. Solicitá uno nuevo.',
'reset_code_valid' => 'Código validado correctamente.',
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
@@ -34,6 +35,7 @@ return [
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.',
'variant_required' => 'Debe seleccionar una variante para este ítem.',
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
],
'purchase' => [
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",

View File

@@ -19,6 +19,7 @@
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="DB_DATABASE" value="shopit_test" force="true"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>

View File

@@ -15,7 +15,9 @@ Artisan::command('reservations:expire', function (): void {
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
$this->info("Expired purchases: {$expired['purchases']}");
$this->info("Expired cart items: {$expired['cart_items']}");
$this->info("Expired cart reservations: {$expired['cart_reservations']}");
$this->info("Expired orphan reservations: {$expired['orphan_reservations']}");
$this->info("Failed reservations: {$expired['failed']}");
})->purpose('Release expired stock reservations from purchases and abandoned carts');
Schedule::command('reservations:expire')

View File

@@ -69,6 +69,10 @@ class CreateResetPasswordAttemptControllerTest extends TestCase
$this->assertTrue($attempt->user->is($user));
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
$this->assertTrue($attempt->expires_at->between(
now()->addMinutes(59),
now()->addMinutes(60),
));
Event::assertDispatched(
PasswordResetRequested::class,
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id

View File

@@ -18,7 +18,9 @@ class ResetPasswordAttemptTest extends TestCase
'id',
'user_id',
'codigo',
'reason',
'status',
'expires_at',
], Schema::getColumnListing('reset_password_attempts'));
}
@@ -28,12 +30,14 @@ class ResetPasswordAttemptTest extends TestCase
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => '123456',
'expires_at' => now()->addHour(),
]);
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
$this->assertTrue($attempt->user->is($user));
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
$this->assertFalse($attempt->usesTimestamps());
$this->assertTrue($attempt->expires_at->isFuture());
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
}

View File

@@ -99,6 +99,30 @@ class ResetPasswordControllerTest extends TestCase
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
}
public function test_an_expired_validated_attempt_cannot_reset_the_password(): void
{
$user = User::factory()->create([
'email' => 'ada@example.com',
'password' => 'OldSecret!123',
]);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => '1234',
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
'expires_at' => now()->subSecond(),
]);
$this->postJson('/api/password/reset', [
'email' => 'ada@example.com',
'codigo' => '1234',
'password' => 'NewSecret!456',
'password_confirmation' => 'NewSecret!456',
])->assertUnprocessable()
->assertJsonValidationErrors('codigo');
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $attempt->fresh()->status);
}
public function test_it_validates_password_confirmation_and_strength(): void
{
$this->postJson('/api/password/reset', [

View File

@@ -49,6 +49,27 @@ class ValidateResetPasswordAttemptControllerTest extends TestCase
);
}
public function test_it_expires_an_attempt_and_returns_the_expired_code_message(): void
{
$user = User::factory()->create(['email' => 'ada@example.com']);
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => '1234',
'expires_at' => now()->subSecond(),
]);
$this->postJson('/api/password/reset-attempts/validate', [
'email' => 'ada@example.com',
'codigo' => '1234',
])->assertUnprocessable()
->assertJsonValidationErrors('codigo')
->assertJsonPath('errors.codigo.0', __('api.auth.reset_code_expired'));
$this->assertSame(
ResetPasswordAttempt::STATUS_EXPIRED,
$attempt->fresh()->status,
);
}
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
{
$user = User::factory()->create(['email' => 'ada@example.com']);

View File

@@ -5,10 +5,12 @@ namespace Tests\Feature\Cart;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
@@ -28,6 +30,81 @@ class CartControllerTest extends TestCase
]));
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
$this->assertTrue(Schema::hasColumns('stock_reservations', [
'status',
'expires_at',
'committed_at',
'released_at',
'expired_at',
'release_reason',
]));
$this->assertFalse(Schema::hasColumn('stock_reservations', 'quantity'));
$this->assertTrue(Schema::hasColumns('stock_reservation_lines', [
'stock_reservation_id',
'inventory_id',
'quantity',
'tracks_inventory',
]));
$this->assertTrue(Schema::hasColumn('carritos', 'current_stock_reservation_id'));
$this->assertTrue(Schema::hasColumn('compras', 'stock_reservation_id'));
$this->assertFalse(Schema::hasColumn('compras', 'expires_at'));
}
public function test_it_aggregates_shared_inventory_into_one_cart_reservation_line(): void
{
$tenant = $this->createTenant('acme');
$firstItem = $this->createDirectItem($tenant, 10, '10.00');
$secondItem = app(CatalogService::class)->create([
'tenant_code' => $tenant->codigo,
'type' => 'bundle',
'slug' => 'second-shared-item',
'nombre' => 'Second shared item',
'precio' => '20.00',
'components' => [[
'catalog_item_id' => $firstItem->id,
'quantity' => 1,
]],
]);
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $firstItem->id,
'cantidad' => 2,
])->assertOk();
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
$this->call(
'POST',
'/api/tenants/acme/cart/items',
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'catalog_item_id' => $secondItem->id,
'cantidad' => 3,
]),
)->assertOk();
$reservationId = (int) $firstResponse->json('data.id');
$reservationId = (int) Cart::query()
->findOrFail($reservationId)
->current_stock_reservation_id;
$this->assertDatabaseCount('stock_reservations', 1);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'stock_reservation_id' => $reservationId,
'inventory_id' => $firstItem->inventory_id,
'quantity' => 5,
]);
$this->assertDatabaseCount('stock_reservation_lines', 1);
$this->assertDatabaseHas('inventories', [
'id' => $firstItem->inventory_id,
'reserved_stock' => 5,
]);
}
public function test_it_adds_a_catalog_item_without_a_variant(): void
@@ -74,9 +151,11 @@ class CartControllerTest extends TestCase
'id' => $item->inventory_id,
'reserved_stock' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
'status' => 'active',
]);
}
@@ -96,38 +175,44 @@ class CartControllerTest extends TestCase
])->assertOk();
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
'status' => 'active',
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
]);
$this->travelBack();
}
public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void
public function test_it_expires_a_cart_reservation_and_automatically_replaces_the_cart(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
$item = $this->createDirectItem($tenant, 10, '49.90');
$response = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])->assertOk();
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])->assertOk();
$cartId = $response->json('data.id');
$cartItemId = $response->json('data.items.0.id');
$cart = Cart::query()->findOrFail($cartId);
$reservationId = $cart->current_stock_reservation_id;
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->travel(31)->minutes();
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 1')
->expectsOutput('Expired cart reservations: 1')
->assertSuccessful();
$this->assertDatabaseHas('inventories', [
@@ -135,28 +220,141 @@ class CartControllerTest extends TestCase
'real_stock' => 10,
'reserved_stock' => 0,
]);
$this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]);
$this->assertSoftDeleted('carritos', [
$this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]);
$this->assertDatabaseHas('carritos', [
'id' => $cartId,
'status' => 'expired',
'status' => Cart::STATUS_EXPIRED,
'current_stock_reservation_id' => $reservationId,
'deleted_at' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $item->inventory_id,
'cart_item_id' => null,
'purchase_id' => null,
'quantity' => 0,
'id' => $reservationId,
'status' => 'expired',
'expires_at' => null,
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
]);
$currentCart = $this->getJson('/api/tenants/acme/cart')
->assertOk()
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
->assertJsonPath('data.items', [])
->assertJsonMissingPath('data.stock_reservation')
->assertJsonMissingPath('data.current_stock_reservation_id');
$newCartId = $currentCart->json('data.id');
$this->assertNotSame($cartId, $newCartId);
$this->assertDatabaseHas('carritos', [
'id' => $cartId,
'status' => Cart::STATUS_ABANDONED,
'current_stock_reservation_id' => $reservationId,
]);
$this->assertDatabaseHas('carritos', [
'id' => $newCartId,
'status' => Cart::STATUS_ACTIVE,
'current_stock_reservation_id' => null,
]);
$this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.id', $newCartId)
->assertJsonPath('data.items.0.cantidad', 1);
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->travelBack();
}
public function test_it_replaces_an_overdue_cart_before_the_expiration_job_runs(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
$item = $this->createDirectItem($tenant, 10, '49.90');
$original = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])->assertOk();
$originalCartId = $original->json('data.id');
$this->travel(31)->minutes();
$replacement = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
->assertJsonPath('data.items.0.cantidad', 1);
$this->assertNotSame($originalCartId, $replacement->json('data.id'));
$this->assertDatabaseHas('carritos', [
'id' => $originalCartId,
'status' => Cart::STATUS_ABANDONED,
]);
$this->assertDatabaseHas('inventories', [
'id' => $item->inventory_id,
'reserved_stock' => 1,
]);
$this->travelBack();
}
public function test_expired_cart_mutations_return_the_expiration_error_instead_of_not_found(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '49.90');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
$cartItemId = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
])
->assertOk()
->json('data.items.0.id');
$this->travel(31)->minutes();
$expectedError = [
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
];
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
'cantidad' => 3,
])
->assertUnprocessable()
->assertExactJson($expectedError);
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
'cantidad' => 2,
'variant_id' => $secondVariant->id,
])
->assertUnprocessable()
->assertExactJson($expectedError);
$this->deleteJson("/api/tenants/acme/cart/items/{$cartItemId}")
->assertUnprocessable()
->assertExactJson($expectedError);
$this->travelBack();
}
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
{
$tenant = $this->createTenant('acme');
@@ -212,10 +410,11 @@ class CartControllerTest extends TestCase
'id' => $variant->inventory_id,
'reserved_stock' => 5,
]);
$this->assertDatabaseHas('stock_reservations', [
'cart_item_id' => $response->json('data.items.0.id'),
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $variant->inventory_id,
'quantity' => 5,
]);
$this->assertDatabaseHas('stock_reservations', [
'status' => 'active',
]);
}
@@ -229,6 +428,7 @@ class CartControllerTest extends TestCase
$this->createPurchaseItem($tenant, $user, $item, 1);
$this->actingAs($user, 'sanctum')
->withHeader('Accept-Language', 'es')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
@@ -237,6 +437,7 @@ class CartControllerTest extends TestCase
->assertJsonPath('data.items.0.cantidad', 2);
$this->actingAs($user, 'sanctum')
->withHeader('Accept-Language', 'es')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
@@ -381,10 +582,12 @@ class CartControllerTest extends TestCase
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('stock_reservations', [
'cart_item_id' => null,
'inventory_id' => $variant->inventory_id,
'quantity' => 0,
'status' => 'released',
'release_reason' => 'cart_empty',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $variant->inventory_id,
'quantity' => 5,
]);
}

View File

@@ -60,8 +60,12 @@ class TelepagosWebhookTest extends TestCase
->assertJsonValidationErrors(['transfer_payer_dni']);
}
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void
public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
{
config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond();
$this->travelTo($now);
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$this->configureTelepagosIntegration($tenant);
$user = User::factory()->create();
@@ -99,6 +103,13 @@ class TelepagosWebhookTest extends TestCase
'payment_method' => 'transfer',
'status' => Purchase::STATUS_PENDING_PAYMENT,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id,
'status' => 'active',
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
]);
$this->travelBack();
}
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
@@ -198,11 +209,16 @@ class TelepagosWebhookTest extends TestCase
'compra_id' => $newerPurchase->id,
'cantidad' => 2,
]);
$newerReservationId = $newerPurchase->fresh()->stock_reservation_id;
$this->assertNotNull($newerReservationId);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $newerPurchase->id,
'id' => $newerReservationId,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'stock_reservation_id' => $newerReservationId,
'inventory_id' => $variant->inventory_id,
'quantity' => 2,
'status' => 'active',
]);
$this->assertSoftDeleted('carritos', [
@@ -325,8 +341,9 @@ class TelepagosWebhookTest extends TestCase
->firstOrFail();
$this->assertEqualsCanonicalizing(
[$firstPurchase->id, $secondPurchase->id, $thirdPurchase->id],
$payment->matched_purchase_ids,
$payment->candidates()->pluck('compra_id')->all(),
);
$this->assertSame(3, $payment->candidates()->where('match_reason', 'ambiguous_exact_match')->count());
$this->assertDatabaseHas('compras', [
'id' => $firstPurchase->id,
'status' => Purchase::STATUS_PENDING_PAYMENT,
@@ -341,6 +358,183 @@ class TelepagosWebhookTest extends TestCase
]);
}
public function test_transfer_webhook_records_near_amount_only_with_exact_dni_or_exact_amount_with_different_dni(): void
{
config(['purchase.transfer_candidate_amount_tolerance_percentage' => 5]);
$tenant = $this->createTenant('candidates', 'Candidates', 'candidates.com.ar');
$this->configureTelepagosIntegration($tenant);
$nearAmountVariant = $this->createVariantForTenant('candidates', 10, '52.00', 'near');
$exactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'exact');
$distanceTwoExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'distance-two');
$farExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'far-exact');
$nearAmountDifferentDniVariant = $this->createVariantForTenant('candidates', 10, '51.00', 'near-other-dni');
$outsideRangeVariant = $this->createVariantForTenant('candidates', 10, '100.00', 'outside');
$nearAmountPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$nearAmountVariant->id,
1,
'12345678',
);
$exactAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$exactAmountVariant->id,
1,
'12345687',
);
$farExactAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$farExactAmountVariant->id,
1,
'87654321',
);
$distanceTwoExactAmountPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$distanceTwoExactAmountVariant->id,
1,
'12345087',
);
$nearAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$nearAmountDifferentDniVariant->id,
1,
'87654321',
);
$outsideRangePurchase = $this->createPendingTransferPurchase(
$tenant,
User::factory()->create()->id,
$outsideRangeVariant->id,
1,
'12345678',
);
Http::fake([
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
'status' => 'ok',
'token' => 'test-token',
'expires_at' => now()->addHour()->toIso8601String(),
]),
'https://api.telepagos.com.ar/v2/payment/cashin/candidates' => Http::response([
'status' => 'ok',
'data' => [
'amount' => 50,
'operation_id' => 1,
'transaction_id' => 'tx-candidates',
'buyer' => ['cuit' => '20123456789'],
],
]),
]);
$this->postJson('/api/webhooks/telepagos/candidates', ['id' => 'candidates'])
->assertOk()
->assertJsonPath('status', 'success');
$payment = TelepagosPayment::query()
->where('transaction_id', 'tx-candidates')
->firstOrFail();
$this->assertNull($payment->compra_id);
$this->assertEqualsCanonicalizing([
$nearAmountPurchase->id,
$exactAmountDifferentDniPurchase->id,
$distanceTwoExactAmountPurchase->id,
], $payment->candidates()->pluck('compra_id')->all());
$this->assertDatabaseHas('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $nearAmountPurchase->id,
'dni_matches' => true,
'dni_distance' => 0,
'payment_dni' => '12345678',
'purchase_dni' => '12345678',
'amount_matches' => false,
'payment_amount' => 50,
'purchase_amount' => 52,
'amount_difference' => 2,
'match_reason' => 'exact_dni_near_amount',
'confidence' => 'high',
]);
$this->assertDatabaseHas('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $exactAmountDifferentDniPurchase->id,
'dni_matches' => false,
'dni_distance' => 1,
'payment_dni' => '12345678',
'purchase_dni' => '12345687',
'amount_matches' => true,
'payment_amount' => 50,
'purchase_amount' => 50,
'amount_difference' => 0,
'match_reason' => 'exact_amount_near_dni',
'confidence' => 'medium',
]);
$this->assertDatabaseHas('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $distanceTwoExactAmountPurchase->id,
'dni_matches' => false,
'dni_distance' => 2,
'payment_dni' => '12345678',
'purchase_dni' => '12345087',
'amount_matches' => true,
'match_reason' => 'exact_amount_near_dni',
]);
$this->assertDatabaseMissing('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $farExactAmountDifferentDniPurchase->id,
]);
$this->assertDatabaseMissing('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $nearAmountDifferentDniPurchase->id,
]);
$this->assertDatabaseMissing('telepagos_payment_candidates', [
'telepagos_payment_id' => $payment->id,
'compra_id' => $outsideRangePurchase->id,
]);
$this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $nearAmountPurchase->fresh()->status);
$this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $exactAmountDifferentDniPurchase->fresh()->status);
$higherPriorityPayment = TelepagosPayment::query()->create([
'cuit_buyer' => '20123456789',
'amount' => 52,
'operation_id' => 1,
'transaction_id' => 'tx-primary-candidate',
]);
$higherPriorityPayment->candidates()->create([
'compra_id' => $nearAmountPurchase->id,
'dni_matches' => true,
'dni_distance' => 0,
'payment_dni' => '12345678',
'purchase_dni' => '12345678',
'amount_matches' => true,
'payment_amount' => 52,
'purchase_amount' => 52,
'amount_difference' => 0,
'match_reason' => 'ambiguous_exact_match',
'confidence' => 'exact',
]);
app(CheckoutService::class)->submitForReview($nearAmountPurchase->fresh());
$buyer = User::query()->findOrFail($nearAmountPurchase->user_id);
$this->actingAs($buyer, 'sanctum')
->getJson("/api/tenants/candidates/compras/{$nearAmountPurchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW)
->assertJsonPath('data.payment_verification.status', 'candidate')
->assertJsonPath('data.payment_verification.candidate_count', 2)
->assertJsonPath('data.payment_verification.primary.reason', 'ambiguous_exact_match')
->assertJsonPath('data.payment_verification.primary.amount_difference', '0.00')
->assertJsonPath('data.payment_verification.primary.confidence', 'exact')
->assertJsonPath('data.payment_verification.reasons.0', 'ambiguous_exact_match')
->assertJsonPath('data.payment_verification.reasons.1', 'exact_dni_near_amount');
}
public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void
{
$tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');

View File

@@ -32,6 +32,82 @@ class StorePurchaseTest extends TestCase
Queue::fake();
}
public function test_checkout_keeps_the_cart_reservation_and_refreshes_its_expiration(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 5);
config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond();
$this->travelTo($now);
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $user->id,
'status' => 'active',
]);
$cart->addItem($variant->catalog_item_id, $variant->id, 2);
$reservationId = $cart->fresh()->current_stock_reservation_id;
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'active',
'expires_at' => $now->copy()->addMinutes(5)->toDateTimeString(),
]);
$this->travel(2)->minutes();
$purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [
'cart_id' => $cart->id,
]);
$this->assertSame($reservationId, $purchase->stock_reservation_id);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'active',
'expires_at' => $now->copy()->addMinutes(32)->toDateTimeString(),
]);
$this->travelBack();
}
public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $user->id,
'status' => 'active',
]);
$cart->addItem($variant->catalog_item_id, $variant->id, 2);
$reservationId = $cart->fresh()->current_stock_reservation_id;
$expiredAt = now()->subMinute()->startOfSecond();
$cart->currentStockReservation()->update(['expires_at' => $expiredAt]);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'cart_id' => $cart->id,
])
->assertUnprocessable()
->assertExactJson([
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
]);
$this->assertDatabaseCount('compras', 0);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'active',
'expires_at' => $expiredAt->toDateTimeString(),
]);
$this->assertDatabaseHas('carritos', [
'id' => $cart->id,
'current_stock_reservation_id' => $reservationId,
]);
}
public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@@ -119,12 +195,7 @@ class StorePurchaseTest extends TestCase
'precio_unitario' => '50.00',
'total' => '100.00',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $inventory->id,
'purchase_id' => $purchaseId,
'quantity' => 2,
'status' => 'active',
]);
$this->assertPurchaseReservation($purchaseId, $inventory->id, 2, 'active');
$this->assertDatabaseHas('carritos', [
'id' => $cartId,
'user_id' => $user->id,
@@ -168,12 +239,7 @@ class StorePurchaseTest extends TestCase
'id' => $cartId,
'current_purchase_id' => $replacementPurchaseId,
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $inventory->id,
'purchase_id' => $replacementPurchaseId,
'quantity' => 2,
'status' => 'active',
]);
$this->assertPurchaseReservation($replacementPurchaseId, $inventory->id, 2, 'active');
$this->assertDatabaseHas('inventories', [
'id' => $inventory->id,
'reserved_stock' => 2,
@@ -227,12 +293,7 @@ class StorePurchaseTest extends TestCase
'precio_unitario' => '50.00',
'total' => '150.00',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $variant->inventory_id,
'purchase_id' => $response->json('data.id'),
'quantity' => 3,
'status' => 'active',
]);
$this->assertPurchaseReservation($response->json('data.id'), $variant->inventory_id, 3, 'active');
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 10,
@@ -244,10 +305,7 @@ class StorePurchaseTest extends TestCase
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $response->json('data.id'),
'status' => 'released',
]);
$this->assertPurchaseReservation($response->json('data.id'), $variant->inventory_id, 3, 'released');
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 10,
@@ -296,18 +354,8 @@ class StorePurchaseTest extends TestCase
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$this->assertDatabaseCount('compra_items', 2);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $firstVariant->inventory_id,
'purchase_id' => $purchaseId,
'quantity' => 1,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $secondVariant->inventory_id,
'purchase_id' => $purchaseId,
'quantity' => 1,
'status' => 'active',
]);
$this->assertPurchaseReservation($purchaseId, $firstVariant->inventory_id, 1, 'active');
$this->assertPurchaseReservation($purchaseId, $secondVariant->inventory_id, 1, 'active');
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 1,
@@ -430,7 +478,7 @@ class StorePurchaseTest extends TestCase
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$firstVariant = $this->createVariantForTenant('sonder', 20, '50.00');
$firstVariant->catalogItem->update(['max_units_per_user' => 3]);
$firstVariant->catalogItem->update(['max_units_per_user' => 4]);
$secondInventory = Inventory::query()->create(['real_stock' => 20]);
$secondVariant = Variant::query()->create([
'catalog_item_id' => $firstVariant->catalog_item_id,
@@ -443,6 +491,7 @@ class StorePurchaseTest extends TestCase
]);
$cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2);
$cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 2);
$firstVariant->catalogItem->update(['max_units_per_user' => 3]);
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
@@ -468,6 +517,7 @@ class StorePurchaseTest extends TestCase
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3);
$reservationId = $purchase->stock_reservation_id;
$activeCart = Cart::query()
->where('user_id', $user->id)
->where('status', 'active')
@@ -500,27 +550,36 @@ class StorePurchaseTest extends TestCase
'id' => $variant->inventory_id,
'reserved_stock' => 3,
]);
$activeCart->refresh();
$this->assertSame($reservationId, $activeCart->current_stock_reservation_id);
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'stock_reservation_id' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'cart_item_id' => $cartItemId,
'purchase_id' => null,
'quantity' => 3,
'id' => $activeCart->current_stock_reservation_id,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'stock_reservation_id' => $activeCart->current_stock_reservation_id,
'inventory_id' => $variant->inventory_id,
'quantity' => 3,
]);
$this->assertSame(1, $activeCart->items()->count());
}
public function test_it_reassigns_a_terminal_purchase_reservation_and_rejects_a_late_confirmation(): void
public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$previousPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$cart = $previousPurchase->cart;
$previousReservationId = $previousPurchase->stock_reservation_id;
$previousPurchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'transfer',
'expires_at' => now()->addMinutes(30),
]);
$currentPurchase = app(CheckoutService::class)->startCheckout(
@@ -532,17 +591,22 @@ class StorePurchaseTest extends TestCase
$this->assertDatabaseHas('compras', [
'id' => $previousPurchase->id,
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->assertDatabaseHas('carritos', [
'id' => $cart->id,
'current_purchase_id' => $currentPurchase->id,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $currentPurchase->id,
'quantity' => 2,
'status' => 'active',
$this->assertSame($previousReservationId, $currentPurchase->stock_reservation_id);
$this->assertDatabaseHas('compras', [
'id' => $previousPurchase->id,
'stock_reservation_id' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $previousReservationId,
'status' => 'active',
'release_reason' => null,
]);
$this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active');
try {
app(CheckoutService::class)->confirmPaidPurchase($previousPurchase->fresh());
@@ -556,11 +620,7 @@ class StorePurchaseTest extends TestCase
'reserved_stock' => 2,
'sold_units' => 0,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $currentPurchase->id,
'quantity' => 2,
'status' => 'active',
]);
$this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active');
}
public function test_removing_a_checkout_item_supersedes_the_purchase_and_restores_the_user_quota(): void
@@ -589,11 +649,12 @@ class StorePurchaseTest extends TestCase
'current_purchase_id' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $variant->inventory_id,
'cart_item_id' => null,
'purchase_id' => null,
'quantity' => 0,
'status' => 'released',
'release_reason' => 'cart_empty',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $variant->inventory_id,
'quantity' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
@@ -607,7 +668,7 @@ class StorePurchaseTest extends TestCase
$this->assertSame(3, $remaining);
}
public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void
public function test_it_expires_the_purchase_and_preserves_the_expired_cart_items(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -618,7 +679,7 @@ class StorePurchaseTest extends TestCase
->where('status', 'active')
->firstOrFail();
$cartItemId = $purchase->cart->items()->firstOrFail()->id;
$purchase->update(['expires_at' => now()->subMinute()]);
$purchase->stockReservation()->update(['expires_at' => now()->subMinute()]);
$this->artisan('reservations:expire')->assertSuccessful();
@@ -628,22 +689,60 @@ class StorePurchaseTest extends TestCase
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 3,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('carritos', [
'id' => $activeCart->id,
'user_id' => $user->id,
'status' => 'active',
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => $purchase->stock_reservation_id,
'deleted_at' => null,
]);
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired');
$this->assertSame(1, $activeCart->items()->count());
$this->actingAs($user, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
'cart_id' => $activeCart->id,
])
->assertUnprocessable()
->assertExactJson([
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
]);
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
->assertUnprocessable()
->assertJsonPath('code', 'purchase.expired');
}
public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
$purchase->stockReservation()->update(['expires_at' => now()->subMinute()]);
$this->actingAs($user, 'sanctum')
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
->assertUnprocessable()
->assertExactJson([
'code' => 'stock_reservation.expired',
'message' => __('api.cart.reservation_expired'),
]);
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_CREATED,
'stock_reservation_id' => $purchase->stock_reservation_id,
]);
$this->assertDatabaseHas('stock_reservations', [
'cart_item_id' => $cartItemId,
'purchase_id' => null,
'quantity' => 3,
'id' => $purchase->stock_reservation_id,
'status' => 'active',
]);
$this->assertSame(1, $activeCart->items()->count());
}
public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void
@@ -697,12 +796,18 @@ class StorePurchaseTest extends TestCase
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
{
config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond();
$this->travelTo($now);
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
$this->travel(10)->minutes();
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
'dni' => '987654321',
@@ -719,6 +824,12 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT,
'dni' => '987654321',
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id,
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
]);
$this->travelBack();
}
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void
@@ -848,7 +959,6 @@ class StorePurchaseTest extends TestCase
$purchase->update([
'payment_method' => 'transfer',
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->addMinutes(30),
]);
$url = "/api/tenants/sonder/compras/{$purchase->id}/review";
@@ -862,6 +972,10 @@ class StorePurchaseTest extends TestCase
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_IN_REVIEW,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id,
'status' => 'active',
'expires_at' => null,
]);
@@ -912,10 +1026,7 @@ class StorePurchaseTest extends TestCase
'current_purchase_id' => null,
'deleted_at' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $purchase->id,
'status' => 'active',
]);
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 2, 'active');
}
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
@@ -936,7 +1047,7 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_expires_an_abandoned_purchase_without_mutating_its_active_cart(): void
public function test_it_expires_a_purchase_and_its_associated_cart(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -947,7 +1058,7 @@ class StorePurchaseTest extends TestCase
->where('status', 'active')
->firstOrFail();
$this->assertNotNull($purchase->expires_at);
$this->assertNotNull($purchase->stockReservation->expires_at);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 3,
@@ -957,7 +1068,7 @@ class StorePurchaseTest extends TestCase
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 1')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->assertDatabaseHas('compras', [
@@ -968,28 +1079,26 @@ class StorePurchaseTest extends TestCase
'compra_id' => $purchase->id,
'cantidad' => 3,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => null,
'quantity' => 3,
'status' => 'active',
]);
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired');
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'real_stock' => 10,
'reserved_stock' => 3,
'reserved_stock' => 0,
'sold_units' => 0,
]);
$this->assertDatabaseHas('carritos', [
'id' => $activeCart->id,
'user_id' => $user->id,
'status' => 'active',
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => $purchase->stock_reservation_id,
'deleted_at' => null,
]);
$this->assertSame(1, $activeCart->items()->count());
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
}
@@ -1006,6 +1115,7 @@ class StorePurchaseTest extends TestCase
'source_catalog_item_id' => $variant->catalog_item_id,
'source_variant_id' => $variant->id,
'nombre' => 'Inconsistent item',
'item_nombre' => 'Inconsistent item',
'slug' => 'inconsistent-item',
'cantidad' => 1,
'precio_unitario' => '50.00',
@@ -1018,7 +1128,7 @@ class StorePurchaseTest extends TestCase
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 2')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->assertDatabaseHas('compras', [
@@ -1160,12 +1270,7 @@ class StorePurchaseTest extends TestCase
'reserved_stock' => 0,
'sold_units' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $purchase->id,
'inventory_id' => $variant->inventory_id,
'quantity' => 2,
'status' => 'committed',
]);
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 2, 'committed');
$this->assertSoftDeleted('carritos', [
'id' => $purchase->cart_id,
@@ -1440,6 +1545,26 @@ class StorePurchaseTest extends TestCase
]);
}
protected function assertPurchaseReservation(
int $purchaseId,
int $inventoryId,
int $quantity,
string $status,
): void {
$reservationId = Purchase::query()->findOrFail($purchaseId)->stock_reservation_id;
$this->assertNotNull($reservationId);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => $status,
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'stock_reservation_id' => $reservationId,
'inventory_id' => $inventoryId,
'quantity' => $quantity,
]);
}
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
{
$hdrKey = (string) Str::uuid();

View File

@@ -520,8 +520,10 @@ class AdminAppSaleControllerTest extends TestCase
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertSoftDeleted('carritos', ['id' => $sourceCartId]);
$reservationId = $purchase->fresh()->stock_reservation_id;
$this->assertNotNull($reservationId);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $purchase->id,
'id' => $reservationId,
'status' => 'released',
]);
}

View File

@@ -218,13 +218,13 @@ class DesfilePuraTendenciaSeederTest extends TestCase
->whereIn('variant_id', (clone $variants)->pluck('id'))
->count());
$this->assertSame(330, (clone $variants)->whereNull('event_date_id')->count());
$this->assertSame(283, DB::table('inventories')
$this->assertSame(284, DB::table('inventories')
->whereIn('id', (clone $variants)->pluck('inventory_id'))
->where('real_stock', 1)
->where('reserved_stock', 0)
->where('sold_units', 0)
->count());
$this->assertSame(47, DB::table('inventories')
$this->assertSame(46, DB::table('inventories')
->whereIn('id', (clone $variants)->pluck('inventory_id'))
->where('real_stock', 0)
->where('reserved_stock', 0)
@@ -289,33 +289,30 @@ class DesfilePuraTendenciaSeederTest extends TestCase
$items = DB::table('compra_items')->where('compra_id', $purchase->id);
$this->assertSame(49, (clone $items)->count());
$this->assertSame(49, (clone $items)
$this->assertSame(48, (clone $items)->count());
$this->assertSame(48, (clone $items)
->where('precio_unitario', 0)
->where('discount_total', 0)
->where('tax_total', 0)
->where('total', 0)
->count());
$this->assertSame(49, DB::table('tickets')
$this->assertSame(48, DB::table('tickets')
->where('source_purchase_id', $purchase->id)
->where('user_id', $user->id)
->where('source_catalog_item_id', $catalogItemId)
->count());
$this->assertSame(48, DB::table('stock_reservations')
->where('purchase_id', $purchase->id)
$this->assertSame(1, DB::table('stock_reservations')
->where('id', $purchase->stock_reservation_id)
->where('status', 'committed')
->count());
$this->assertSame(48, DB::table('stock_reservation_lines')
->where('stock_reservation_id', $purchase->stock_reservation_id)
->count());
foreach ([
['sector' => 'A', 'fila' => '1', 'tipo' => 'NORMAL', 'count' => 16],
['sector' => 'A', 'fila' => '3', 'tipo' => 'NORMAL', 'count' => 14],
[
'sector' => 'C',
'fila' => '1',
'tipo' => 'VIP + LUNCH',
'count' => 17,
'seats' => array_map('strval', range(1, 17)),
],
['sector' => 'C', 'fila' => '1', 'tipo' => 'VIP + LUNCH', 'count' => 16],
['sector' => 'C', 'fila' => '3', 'tipo' => 'NORMAL', 'count' => 2, 'seats' => ['6', '7']],
] as $allocation) {
$allocatedVariants = DB::table('variantes')

View File

@@ -2,9 +2,25 @@
namespace Tests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use RuntimeException;
abstract class TestCase extends BaseTestCase
{
//
public function createApplication(): Application
{
$app = parent::createApplication();
$connection = (string) $app['config']->get('database.default');
$database = (string) $app['config']->get("database.connections.{$connection}.database");
$usesInMemorySqlite = $connection === 'sqlite' && $database === ':memory:';
if (! $usesInMemorySqlite && ! str_ends_with(strtolower($database), '_test')) {
throw new RuntimeException(
"Unsafe test database [{$database}]. Tests may only use an in-memory SQLite database or a database ending in _test.",
);
}
return $app;
}
}

View File

@@ -2,77 +2,102 @@
namespace Tests\Unit\Catalog;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use RuntimeException;
use Tests\TestCase;
class ExpireStockReservationsServiceTest extends TestCase
{
public function test_it_expires_purchases_before_abandoned_cart_items(): void
use RefreshDatabase;
public function test_it_expires_an_orphan_reservation_and_releases_its_inventory(): void
{
$checkout = \Mockery::mock(CheckoutService::class);
$checkout->shouldReceive('expireOverduePurchases')
->once()
->ordered()
->andReturn(2);
$inventory = Inventory::query()->create([
'real_stock' => 10,
'reserved_stock' => 2,
]);
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]);
StockReservationLine::query()->create([
'stock_reservation_id' => $reservation->id,
'inventory_id' => $inventory->id,
'quantity' => 2,
'tracks_inventory' => true,
]);
$carts = \Mockery::mock(ExpireCartReservationsService::class);
$carts->shouldReceive('expireOverdue')
->once()
->ordered()
->andReturn(3);
$logger = \Mockery::mock(LoggerInterface::class);
Log::shouldReceive('channel')
->once()
->with('commands')
->andReturn($logger);
$logger->shouldReceive('info')
->once()
->with('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => 2,
'expired_cart_items' => 3,
'total_expired' => 5,
]);
$result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
$result = app(ExpireStockReservationsService::class)->expireOverdue();
$this->assertSame([
'purchases' => 2,
'cart_items' => 3,
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 1,
'failed' => 0,
], $result);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservation->id,
'status' => StockReservation::STATUS_EXPIRED,
'expires_at' => null,
]);
$this->assertDatabaseHas('inventories', [
'id' => $inventory->id,
'reserved_stock' => 0,
]);
}
public function test_it_logs_failed_cleanup_attempts_and_rethrows_the_error(): void
public function test_one_failed_reservation_does_not_stop_the_remaining_batch(): void
{
$exception = new RuntimeException('Unable to clean carts.');
$checkout = \Mockery::mock(CheckoutService::class);
$checkout->shouldReceive('expireOverduePurchases')->once()->andReturn(2);
$first = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinutes(2),
]);
$second = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]);
$carts = \Mockery::mock(ExpireCartReservationsService::class);
$carts->shouldReceive('expireOverdue')->once()->andThrow($exception);
$reservations = \Mockery::mock(StockReservationService::class);
$reservations->shouldReceive('expire')
->twice()
->andReturnUsing(function (StockReservation $reservation) use ($first): void {
if ($reservation->is($first)) {
throw new RuntimeException('Broken reservation.');
}
$logger = \Mockery::mock(LoggerInterface::class);
Log::shouldReceive('channel')
->once()
->with('commands')
->andReturn($logger);
$logger->shouldReceive('error')
->once()
->with('Stock reservation cleanup failed.', [
'command' => 'reservations:expire',
'expired_purchases' => 2,
'expired_cart_items' => null,
'exception' => $exception,
]);
$reservation->update([
'status' => StockReservation::STATUS_EXPIRED,
'expires_at' => null,
'expired_at' => now(),
]);
});
$this->expectExceptionObject($exception);
$service = new ExpireStockReservationsService(
\Mockery::mock(ReleaseCheckoutService::class),
$reservations,
);
(new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
$result = $service->expireOverdue();
$this->assertSame([
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 1,
'failed' => 1,
], $result);
$this->assertDatabaseHas('stock_reservations', [
'id' => $first->id,
'status' => StockReservation::STATUS_ACTIVE,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $second->id,
'status' => StockReservation::STATUS_EXPIRED,
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Tests\Unit\Purchase;
use App\Domains\Purchase\Services\DniDistanceService;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class DniDistanceServiceTest extends TestCase
{
/** @return iterable<string, array{string, string, int}> */
public static function distances(): iterable
{
yield 'exact' => ['40123456', '40123456', 0];
yield 'leading transposition' => ['40123456', '04123456', 1];
yield 'trailing transposition' => ['40123456', '40123465', 1];
yield 'single substitution' => ['40123456', '40123856', 1];
yield 'substitution and transposition' => ['12345678', '12345087', 2];
yield 'seven digits normalized with leading zero' => ['04123456', '4123456', 0];
yield 'more than two edits' => ['40123456', '87654321', 7];
}
#[DataProvider('distances')]
public function test_it_calculates_damerau_levenshtein_distance(
string $left,
string $right,
int $expected,
): void {
$this->assertSame($expected, (new DniDistanceService)->distance($left, $right));
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Tests\Unit\Purchase;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Resources\PurchaseResource;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class PurchaseResourceTest extends TestCase
{
public function test_it_exposes_the_remaining_checkout_time_using_the_server_clock(): void
{
$now = now()->startOfSecond();
$this->travelTo($now);
$expiresAt = $now->copy()->addMinutes(12);
$resource = $this->resourceFor(Purchase::STATUS_PENDING_PAYMENT, $expiresAt);
$this->assertTrue($expiresAt->equalTo($resource['expires_at']));
$this->assertSame(720, $resource['expires_in_seconds']);
$this->assertTrue($now->equalTo($resource['server_time']));
$this->travelBack();
}
public function test_it_clamps_an_overdue_checkout_to_zero_seconds(): void
{
$now = now()->startOfSecond();
$this->travelTo($now);
$resource = $this->resourceFor(
Purchase::STATUS_CREATED,
$now->copy()->subSecond(),
);
$this->assertSame(0, $resource['expires_in_seconds']);
$this->travelBack();
}
public function test_it_exposes_null_expiration_after_the_purchase_enters_review(): void
{
$resource = $this->resourceFor(
Purchase::STATUS_IN_REVIEW,
null,
);
$this->assertNull($resource['expires_at']);
$this->assertNull($resource['expires_in_seconds']);
}
/** @return array<string, mixed> */
private function resourceFor(string $status, ?Carbon $expiresAt): array
{
$purchase = (new Purchase)->forceFill([
'status' => $status,
'total' => '0.00',
]);
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $expiresAt,
]));
return (new PurchaseResource($purchase))->toArray(Request::create('/'));
}
}

View File

@@ -2,6 +2,7 @@
namespace Tests\Unit\Purchase;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
@@ -22,7 +23,6 @@ class PurchaseStateGuardTest extends TestCase
{
$purchase = (new Purchase)->forceFill([
'status' => Purchase::STATUS_EXPIRED,
'expires_at' => null,
]);
$this->expectException(PurchaseExpiredException::class);
@@ -34,8 +34,11 @@ class PurchaseStateGuardTest extends TestCase
{
$purchase = (new Purchase)->forceFill([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->subMinute(),
]);
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]));
$this->expectException(PurchaseExpiredException::class);
@@ -46,8 +49,11 @@ class PurchaseStateGuardTest extends TestCase
{
$purchase = (new Purchase)->forceFill([
'status' => Purchase::STATUS_PAID,
'expires_at' => now()->subMinute(),
]);
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]));
$this->guard->assertNotExpired($purchase);