Compare commits

..

33 Commits

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
bbe3cf82f5 feat(bootstrap): expose website type favicon 2026-08-26 14:37:25 -03:00
ff61a3d3b8 feat(tenant): share website type favicon attachment 2026-08-26 14:37:17 -03:00
6c5d49bf45 Merge branch 'feature/set_reset_password_expiration' into dev 2026-08-26 11:51:00 -03:00
2596b5df18 test(auth): cover password reset expiration 2026-08-26 11:47:12 -03:00
733064c0dc feat(auth): expire stale password reset codes 2026-08-26 11:47:12 -03:00
99808c1053 feat(auth): persist password reset expiration 2026-08-26 11:47:12 -03:00
46 changed files with 1283 additions and 48 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -343,9 +343,9 @@ class StockReservationService
}); });
} }
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void public function clearExpirationForReview(Purchase $purchase): void
{ {
DB::transaction(function () use ($purchase, $expiresAt): void { DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */ /** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey()); $purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) { if ($purchase->stock_reservation_id === null) {
@@ -363,7 +363,7 @@ class StockReservationService
throw new StockReservationExpiredException; throw new StockReservationExpiredException;
} }
$reservation->update(['expires_at' => $expiresAt]); $reservation->update(['expires_at' => null]);
}); });
} }

View File

@@ -7,7 +7,10 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\TelepagosPayment; use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr; use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\DniDistanceService;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -15,6 +18,7 @@ class TelepagosWebhookService
{ {
public function __construct( public function __construct(
private readonly CheckoutService $checkoutService, private readonly CheckoutService $checkoutService,
private readonly DniDistanceService $dniDistance,
) {} ) {}
/** /**
@@ -41,7 +45,6 @@ class TelepagosWebhookService
$paymentData = [ $paymentData = [
'compra_id' => null, 'compra_id' => null,
'matched_purchase_ids' => null,
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null, 'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null, 'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
'amount' => $amount, 'amount' => $amount,
@@ -73,24 +76,55 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo'); $tenantCodes = $client->tenants()->pluck('codigo');
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes) $eligiblePurchases = Purchase::query()
->where('transfer_payer_dni', $dni) ->whereIn('tenant_codigo', $tenantCodes)
->whereIn('status', [ ->whereIn('status', [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW, Purchase::STATUS_IN_REVIEW,
]) ])
->where('payment_method', 'transfer') ->where('payment_method', 'transfer');
$purchases = (clone $eligiblePurchases)
->where('total', $amount) ->where('total', $amount)
->latest() ->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; $compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) { if (! $compra) {
if ($purchases->count() > 1) { $candidatePurchases = $this->findTransferCandidates(
TelepagosPayment::create($paymentData); $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.', [ Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
@@ -209,4 +243,91 @@ class TelepagosWebhookService
{ {
return number_format((float) $amount, 2, '.', ''); 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

@@ -93,7 +93,6 @@ class PurchaseController extends Controller
PaymentIntentRequest $request, PaymentIntentRequest $request,
Tenant $tenant, Tenant $tenant,
Purchase $compra, Purchase $compra,
CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState, PurchaseStateGuard $purchaseState,
): JsonResponse { ): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
@@ -103,7 +102,6 @@ class PurchaseController extends Controller
: null; : null;
$updated = DB::transaction(function () use ( $updated = DB::transaction(function () use (
$checkoutService,
$compra, $compra,
$method, $method,
$purchaseState, $purchaseState,
@@ -142,12 +140,6 @@ class PurchaseController extends Controller
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
} }
$purchase->update($purchaseUpdate); $purchase->update($purchaseUpdate);
$checkoutService->refreshReservationExpiration(
$purchase,
now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)),
),
);
return true; return true;
}); });

View File

@@ -145,6 +145,12 @@ class Purchase extends Model
return $this->hasMany(TelepagosPayment::class, 'compra_id'); 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 public function getTotalAmount(): float
{ {
if ($this->total !== null) { 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\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
'compra_id', 'compra_id',
'matched_purchase_ids',
'cuit_buyer', 'cuit_buyer',
'cvu_buyer', 'cvu_buyer',
'amount', 'amount',
@@ -30,7 +30,6 @@ class TelepagosPayment extends Model
{ {
return [ return [
'compra_id' => 'integer', 'compra_id' => 'integer',
'matched_purchase_ids' => 'array',
'amount' => 'decimal:2', 'amount' => 'decimal:2',
]; ];
} }
@@ -42,4 +41,10 @@ class TelepagosPayment extends Model
{ {
return $this->belongsTo(Purchase::class, 'compra_id'); 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\Purchase;
use App\Domains\Purchase\Models\PurchaseItem; use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,12 +18,15 @@ class PurchaseResource extends JsonResource
*/ */
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items') $items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items') ? $this->resource->getRelation('items')
: collect(); : collect();
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes()) $ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count') ? (int) $this->resource->getAttribute('tickets_count')
: null; : null;
$paymentVerification = $this->resolvePaymentVerification();
$subtotal = $items->isNotEmpty() $subtotal = $items->isNotEmpty()
? $items->reduce( ? $items->reduce(
@@ -48,7 +52,11 @@ class PurchaseResource extends JsonResource
'created_at' => $this->created_at, 'created_at' => $this->created_at,
'status' => $this->status, 'status' => $this->status,
'payment_method' => $this->payment_method, 'payment_method' => $this->payment_method,
'expires_at' => $this->stockReservation?->expires_at, 'expires_at' => $expiresAt,
'expires_in_seconds' => $expiresAt === null
? null
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
'server_time' => $serverTime,
'dni' => $this->dni, 'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni, 'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono, 'telefono' => $this->telefono,
@@ -58,6 +66,7 @@ class PurchaseResource extends JsonResource
'items' => PurchaseItemResource::collection($items), 'items' => PurchaseItemResource::collection($items),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount), 'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0), 'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
'subtotal' => $this->formatMoney($subtotal), 'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total), 'total' => $this->formatMoney($total),
]; ];
@@ -77,4 +86,75 @@ class PurchaseResource extends JsonResource
{ {
return number_format((float) ($amount ?? 0), 2, '.', ''); 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

@@ -70,7 +70,7 @@ class CompleteCheckoutService
$purchase->update([ $purchase->update([
'status' => Purchase::STATUS_IN_REVIEW, 'status' => Purchase::STATUS_IN_REVIEW,
]); ]);
$this->reservations->refreshForPurchase($purchase, null); $this->reservations->clearExpirationForReview($purchase);
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
}); });

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Domains\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

@@ -82,6 +82,7 @@ class WebsiteTypeService
&& $previousLogo->id !== $websiteType->site_logo && $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo && $previousLogo->id !== $websiteType->footer_logo
&& $previousLogo->id !== $websiteType->favicon_id && $previousLogo->id !== $websiteType->favicon_id
&& ! $this->isReferencedByWebsiteType($previousLogo)
) { ) {
$this->attachmentService->delete($previousLogo); $this->attachmentService->delete($previousLogo);
} }
@@ -90,4 +91,16 @@ class WebsiteTypeService
return $websiteType; return $websiteType;
}); });
} }
private function isReferencedByWebsiteType(Attachment $attachment): bool
{
return WebsiteType::query()
->where(function ($query) use ($attachment): void {
$query
->where('site_logo', $attachment->id)
->orWhere('footer_logo', $attachment->id)
->orWhere('favicon_id', $attachment->id);
})
->exists();
}
} }

View File

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

View File

@@ -8,4 +8,9 @@ return [
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30), 'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440), '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,96 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
return new class extends Migration
{
private const FILENAME = 'onticket_favicon.svg';
/** @var list<string> */
private const WEBSITE_TYPE_CODES = ['shopit', 'onticket'];
public function up(): void
{
$websiteTypes = DB::table('website_type')
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
->get(['codigo', 'favicon_id']);
if ($websiteTypes->isEmpty()) {
return;
}
$faviconIds = $websiteTypes
->pluck('favicon_id')
->filter()
->unique()
->values();
if (
$faviconIds->count() === 1
&& DB::table('attachments')
->where('id', $faviconIds->first())
->where('filename', self::FILENAME)
->exists()
&& $websiteTypes->every(
fn (object $websiteType): bool => $websiteType->favicon_id === $faviconIds->first()
)
) {
return;
}
$sourcePath = public_path('images/website_types/'.self::FILENAME);
if (! is_file($sourcePath)) {
throw new RuntimeException("Favicon not found at path: {$sourcePath}");
}
$contents = file_get_contents($sourcePath);
if ($contents === false) {
throw new RuntimeException("Could not read favicon at path: {$sourcePath}");
}
$key = (string) Str::uuid();
$storedPath = "website-types/{$key}.svg";
if (! Storage::disk('s3')->put($storedPath, $contents)) {
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
}
try {
DB::transaction(function () use ($contents, $key, $storedPath): void {
$attachmentId = DB::table('attachments')->insertGetId([
'key' => $key,
'path' => $storedPath,
'filename' => self::FILENAME,
'type' => 'image',
'mime_type' => 'image/svg+xml',
'extension' => 'svg',
'size' => strlen($contents),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('website_type')
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
->update([
'favicon_id' => $attachmentId,
'updated_at' => now(),
]);
});
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
}
public function down(): void
{
// The shared attachment may be in use outside these website types.
// Keep this data migration irreversible to avoid deleting an active asset.
}
};

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

@@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(), 'footer_logo' => $this->onTicketFooterLogo(),
'favicon' => $this->onTicketFavicon(),
], ],
); );
@@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(), 'footer_logo' => $this->onTicketFooterLogo(),
'favicon' => $shopIt->favicon()->firstOrFail()->key,
], ],
); );
@@ -175,4 +177,21 @@ class WebsiteTypeSeeder extends Seeder
true, true,
); );
} }
private function onTicketFavicon(): UploadedFile
{
$path = public_path('images/website_types/onticket_favicon.svg');
if (! file_exists($path)) {
throw new RuntimeException("OnTicket favicon not found at path: {$path}");
}
return new UploadedFile(
$path,
'onticket_favicon.svg',
'image/svg+xml',
null,
true,
);
}
} }

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.', 'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.', 'password_updated' => 'Password updated successfully.',
'reset_code_invalid' => 'The code you entered is invalid.', '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.', 'reset_code_valid' => 'Code validated successfully.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.', 'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.', 'request_expired' => 'The authentication request expired. Please try again.',

View File

@@ -12,6 +12,7 @@ return [
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.', 'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.', 'password_updated' => 'Contraseña modificada correctamente.',
'reset_code_invalid' => 'El código ingresado es inválido.', '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.', 'reset_code_valid' => 'Código validado correctamente.',
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.', '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.', 'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',

View File

@@ -19,6 +19,7 @@
</source> </source>
<php> <php>
<env name="APP_ENV" value="testing"/> <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_MAINTENANCE_DRIVER" value="file"/>
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/> <env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/> <env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>

View File

@@ -0,0 +1,3 @@
<svg width="63" height="36" viewBox="0 0 63 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M44.64 0H17.94C8.05 0 0 8.05 0 17.94C0 27.83 8.05 35.88 17.94 35.88H44.64C54.53 35.88 62.58 27.83 62.58 17.94C62.58 8.05 54.53 0 44.64 0ZM44.62 31.78C36.98 31.78 30.79 25.59 30.79 17.95C30.79 10.31 36.98 4.12 44.62 4.12C52.26 4.12 58.45 10.31 58.45 17.95C58.45 25.59 52.26 31.78 44.62 31.78Z" fill="#FF7006"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

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

View File

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

View File

@@ -99,6 +99,30 @@ class ResetPasswordControllerTest extends TestCase
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status); $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 public function test_it_validates_password_confirmation_and_strength(): void
{ {
$this->postJson('/api/password/reset', [ $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 public function test_it_rejects_an_expired_or_already_validated_attempt(): void
{ {
$user = User::factory()->create(['email' => 'ada@example.com']); $user = User::factory()->create(['email' => 'ada@example.com']);

View File

@@ -60,9 +60,9 @@ class TelepagosWebhookTest extends TestCase
->assertJsonValidationErrors(['transfer_payer_dni']); ->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.payment_expiration_minutes.transfer', 60); config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond(); $now = now()->startOfSecond();
$this->travelTo($now); $this->travelTo($now);
@@ -106,7 +106,7 @@ class TelepagosWebhookTest extends TestCase
$this->assertDatabaseHas('stock_reservations', [ $this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id, 'id' => $purchase->stock_reservation_id,
'status' => 'active', 'status' => 'active',
'expires_at' => $now->copy()->addMinutes(60)->toDateTimeString(), 'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
]); ]);
$this->travelBack(); $this->travelBack();
@@ -341,8 +341,9 @@ class TelepagosWebhookTest extends TestCase
->firstOrFail(); ->firstOrFail();
$this->assertEqualsCanonicalizing( $this->assertEqualsCanonicalizing(
[$firstPurchase->id, $secondPurchase->id, $thirdPurchase->id], [$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', [ $this->assertDatabaseHas('compras', [
'id' => $firstPurchase->id, 'id' => $firstPurchase->id,
'status' => Purchase::STATUS_PENDING_PAYMENT, 'status' => Purchase::STATUS_PENDING_PAYMENT,
@@ -357,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 public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void
{ {
$tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar'); $tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');

View File

@@ -0,0 +1,61 @@
<?php
namespace Tests\Feature\Migrations;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class SetWebsiteTypeFaviconTest extends TestCase
{
use RefreshDatabase;
public function test_it_uploads_one_favicon_and_shares_the_attachment_between_website_types(): void
{
Storage::fake('s3');
DB::table('website_type')->insert([
[
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'created_at' => now(),
'updated_at' => now(),
],
[
'codigo' => 'onticket',
'nombre' => 'OnTicket',
'created_at' => now(),
'updated_at' => now(),
],
]);
$migration = require database_path(
'migrations/2026_08_26_000000_set_website_type_favicon.php'
);
$migration->up();
$migration->up();
$faviconIds = DB::table('website_type')
->whereIn('codigo', ['shopit', 'onticket'])
->pluck('favicon_id');
$this->assertCount(2, $faviconIds);
$this->assertNotNull($faviconIds->first());
$this->assertSame(1, $faviconIds->unique()->count());
$attachment = DB::table('attachments')->where('id', $faviconIds->first())->first();
$this->assertNotNull($attachment);
$this->assertSame('onticket_favicon.svg', $attachment->filename);
$this->assertSame('image/svg+xml', $attachment->mime_type);
$this->assertSame('svg', $attachment->extension);
$this->assertSame(1, DB::table('attachments')->where('filename', 'onticket_favicon.svg')->count());
Storage::disk('s3')->assertExists($attachment->path);
$this->assertSame(
file_get_contents(public_path('images/website_types/onticket_favicon.svg')),
Storage::disk('s3')->get($attachment->path),
);
}
}

View File

@@ -796,12 +796,18 @@ class StorePurchaseTest extends TestCase
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void 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'); $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create(); $user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00'); $variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]); $purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
$this->travel(10)->minutes();
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [ ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
'dni' => '987654321', 'dni' => '987654321',
@@ -818,6 +824,12 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT, 'status' => Purchase::STATUS_PENDING_PAYMENT,
'dni' => '987654321', '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 public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void

View File

@@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase
$this->seed(WebsiteTypeSeeder::class); $this->seed(WebsiteTypeSeeder::class);
$this->assertSame(2, WebsiteType::query()->count()); $this->assertSame(2, WebsiteType::query()->count());
$this->assertSame(4, Attachment::query()->count()); $this->assertSame(5, Attachment::query()->count());
$expectedPresentation = [ $expectedPresentation = [
'primary_color' => '#FF7006', 'primary_color' => '#FF7006',
@@ -49,6 +49,8 @@ class WebsiteTypeSeederTest extends TestCase
Storage::disk('s3')->assertExists($shopIt->siteLogo->path); Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename); $this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
Storage::disk('s3')->assertExists($shopIt->footerLogo->path); Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
$this->assertSame('onticket_favicon.svg', $shopIt->favicon->filename);
Storage::disk('s3')->assertExists($shopIt->favicon->path);
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all()); $this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre); $this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
$this->assertSame([ $this->assertSame([
@@ -79,6 +81,8 @@ class WebsiteTypeSeederTest extends TestCase
Storage::disk('s3')->assertExists($onTicket->footerLogo->path); Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo); $this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo); $this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
$this->assertSame($shopIt->favicon_id, $onTicket->favicon_id);
$this->assertSame('onticket_favicon.svg', $onTicket->favicon->filename);
$this->assertEqualsCanonicalizing( $this->assertEqualsCanonicalizing(
['heroConfig', 'eventConfig', 'additionalInfoConfig'], ['heroConfig', 'eventConfig', 'additionalInfoConfig'],
$onTicket->extras->pluck('codigo')->all(), $onTicket->extras->pluck('codigo')->all(),

View File

@@ -14,6 +14,7 @@ class BootstrapAdminAppControllerTest extends TestCase
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
{ {
$footerLogo = Attachment::factory()->create(); $footerLogo = Attachment::factory()->create();
$favicon = Attachment::factory()->create();
WebsiteType::query()->create([ WebsiteType::query()->create([
'codigo' => 'shopit', 'codigo' => 'shopit',
@@ -31,6 +32,7 @@ class BootstrapAdminAppControllerTest extends TestCase
'border_color' => '#eaeaea', 'border_color' => '#eaeaea',
'login_header_footer_color' => '#313131', 'login_header_footer_color' => '#313131',
'footer_logo' => $footerLogo->id, 'footer_logo' => $footerLogo->id,
'favicon_id' => $favicon->id,
]); ]);
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST') $this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
@@ -41,6 +43,7 @@ class BootstrapAdminAppControllerTest extends TestCase
->assertJsonPath('data.login_header_footer_color', '#313131') ->assertJsonPath('data.login_header_footer_color', '#313131')
->assertJsonPath('data.site_logo', null) ->assertJsonPath('data.site_logo', null)
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440)) ->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
->assertJsonMissingPath('data.forms') ->assertJsonMissingPath('data.forms')
->assertJsonMissingPath('data.codigo') ->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre') ->assertJsonMissingPath('data.nombre')

View File

@@ -14,6 +14,7 @@ class BootstrapScannerControllerTest extends TestCase
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
{ {
$siteLogo = Attachment::factory()->create(); $siteLogo = Attachment::factory()->create();
$favicon = Attachment::factory()->create();
WebsiteType::query()->create([ WebsiteType::query()->create([
'codigo' => 'shopit', 'codigo' => 'shopit',
@@ -32,6 +33,7 @@ class BootstrapScannerControllerTest extends TestCase
'border_color' => '#eaeaea', 'border_color' => '#eaeaea',
'login_header_footer_color' => '#313131', 'login_header_footer_color' => '#313131',
'site_logo' => $siteLogo->id, 'site_logo' => $siteLogo->id,
'favicon_id' => $favicon->id,
]); ]);
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST') $this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
@@ -40,6 +42,7 @@ class BootstrapScannerControllerTest extends TestCase
->assertJsonPath('data.primary_color', '#112233') ->assertJsonPath('data.primary_color', '#112233')
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440)) ->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
->assertJsonPath('data.footer_logo', null) ->assertJsonPath('data.footer_logo', null)
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
->assertJsonMissingPath('data.codigo') ->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre') ->assertJsonMissingPath('data.nombre')
->assertJsonMissingPath('data.dominio') ->assertJsonMissingPath('data.dominio')

View File

@@ -69,4 +69,31 @@ class WebsiteTypeServiceTest extends TestCase
'favicon_id' => $favicon->id, 'favicon_id' => $favicon->id,
]); ]);
} }
public function test_it_keeps_a_shared_favicon_when_one_website_type_replaces_it(): void
{
Storage::fake('s3');
$service = app(WebsiteTypeService::class);
$shopIt = $service->create([
'codigo' => 'shopit',
'nombre' => 'ShopIt',
'favicon' => UploadedFile::fake()->image('shared-favicon.png', 32, 32),
]);
$sharedFavicon = $shopIt->favicon()->firstOrFail();
$onTicket = $service->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
'favicon' => $sharedFavicon->key,
]);
$service->updateOrCreate(
['codigo' => 'shopit'],
['favicon' => UploadedFile::fake()->image('shopit-favicon.png', 32, 32)],
);
$this->assertSame($sharedFavicon->id, $onTicket->fresh()->favicon_id);
$this->assertDatabaseHas('attachments', ['id' => $sharedFavicon->id]);
Storage::disk('s3')->assertExists($sharedFavicon->path);
}
} }

View File

@@ -2,9 +2,25 @@
namespace Tests; namespace Tests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use RuntimeException;
abstract class TestCase extends BaseTestCase 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

@@ -17,11 +17,13 @@ class AdminAppBootstrapResourceTest extends TestCase
]); ]);
$websiteType->setRelation('siteLogo', null); $websiteType->setRelation('siteLogo', null);
$websiteType->setRelation('footerLogo', null); $websiteType->setRelation('footerLogo', null);
$websiteType->setRelation('favicon', null);
$data = AdminAppBootstrapResource::make([ $data = AdminAppBootstrapResource::make([
'website_type' => $websiteType, 'website_type' => $websiteType,
])->resolve(request()); ])->resolve(request());
$this->assertSame('shopit', $data['website_type_code']); $this->assertSame('shopit', $data['website_type_code']);
$this->assertNull($data['favicon']);
$this->assertArrayNotHasKey('forms', $data); $this->assertArrayNotHasKey('forms', $data);
} }
} }

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('/'));
}
}