diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index 930ef48..4d25a08 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -12,6 +12,7 @@ use App\Domains\Purchase\Requests\UpdatePurchaseItemRequest; use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader; use App\Domains\Purchase\Services\CheckoutService; +use App\Domains\Purchase\Services\PurchaseStateGuard; use App\Domains\Tenant\Models\Tenant; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -132,42 +133,48 @@ class PurchaseController extends Controller Tenant $tenant, Purchase $compra, CheckoutService $checkoutService, + PurchaseStateGuard $purchaseState, ): JsonResponse { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $method = $request->validated('method'); - $totalAmount = $compra->calculateCurrentTotalAmount(); + $transferPayerDni = $method === 'transfer' + ? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')) + : null; - $purchaseUpdate = [ - 'payment_method' => $method, - 'status' => Purchase::STATUS_PENDING_PAYMENT, - 'expires_at' => now()->addMinutes( - max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30)) - ), - 'total' => $totalAmount, - ]; - - if ($method === 'transfer') { - $purchaseUpdate['transfer_payer_dni'] = preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni')); - } - - $updated = DB::transaction(function () use ($compra, $purchaseUpdate): bool { + $updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool { /** @var Purchase|null $purchase */ $purchase = Purchase::query() ->whereKey($compra->getKey()) ->lockForUpdate() ->first(); + if ($purchase === null) { + return false; + } + + $purchaseState->assertNotExpired($purchase); + if ( - $purchase === null - || ! in_array($purchase->status, [ + ! in_array($purchase->status, [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, ], true) - || ($purchase->expires_at !== null && $purchase->expires_at->isPast()) ) { return false; } + $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(), + ]; + + if ($transferPayerDni !== null) { + $purchaseUpdate['transfer_payer_dni'] = $transferPayerDni; + } $purchase->update($purchaseUpdate); return true; @@ -180,6 +187,7 @@ class PurchaseController extends Controller } $compra->refresh(); + $totalAmount = (float) $compra->total; $checkoutService->syncReservationExpiration($compra); if ($method === 'transfer') { diff --git a/app/Domains/Purchase/Exceptions/PurchaseExpiredException.php b/app/Domains/Purchase/Exceptions/PurchaseExpiredException.php new file mode 100644 index 0000000..629148f --- /dev/null +++ b/app/Domains/Purchase/Exceptions/PurchaseExpiredException.php @@ -0,0 +1,13 @@ +lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->payment_method === null) { throw ValidationException::withMessages([ @@ -45,6 +48,7 @@ class CompleteCheckoutService { return DB::transaction(function () use ($purchase): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->status === Purchase::STATUS_PAID) { return $this->loadPurchase($purchase); @@ -70,6 +74,7 @@ class CompleteCheckoutService { DB::transaction(function () use ($purchase): void { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->status === Purchase::STATUS_PAID) { return; diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 4b6aa33..58e7ec9 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Variant; use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; +use App\Domains\Purchase\Services\PurchaseStateGuard; use App\Domains\Purchase\Services\UserPurchaseLimitService; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; @@ -20,6 +21,7 @@ class EditCheckoutService private readonly SourceCartService $sourceCart, private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseResponseLoader $responses, + private readonly PurchaseStateGuard $purchaseState, ) {} /** @param array $customerData */ @@ -27,6 +29,7 @@ class EditCheckoutService { return DB::transaction(function () use ($purchase, $customerData): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); $this->assertEditable($purchase); $purchase->update($customerData); @@ -50,6 +53,7 @@ class EditCheckoutService $updateVariant, ): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { throw ValidationException::withMessages([ @@ -182,6 +186,7 @@ class EditCheckoutService { return DB::transaction(function () use ($purchase): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); $this->assertEditable($purchase); if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsModification()) { @@ -208,6 +213,7 @@ class EditCheckoutService { return DB::transaction(function () use ($purchase, $purchaseItem): Purchase { $purchase = $this->lockPurchase($purchase); + $this->purchaseState->assertNotExpired($purchase); $this->assertEditable($purchase); if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) { diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index bdf17a4..77870d6 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -5,6 +5,7 @@ namespace App\Domains\Purchase\Services\Checkout; use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; +use App\Domains\Purchase\Services\PurchaseStateGuard; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; @@ -14,6 +15,8 @@ class ReleaseCheckoutService { public function __construct( private readonly StockReservationService $reservations, + private readonly SourceCartService $sourceCart, + private readonly PurchaseStateGuard $purchaseState, ) {} public function cancel(Purchase $purchase): Purchase @@ -70,6 +73,10 @@ class ReleaseCheckoutService return DB::transaction(function () use ($purchase, $targetStatus): Purchase { $purchase = $this->lockPurchase($purchase); + if ($targetStatus !== Purchase::STATUS_EXPIRED) { + $this->purchaseState->assertNotExpired($purchase); + } + if ($purchase->status === Purchase::STATUS_PAID) { if ($targetStatus === Purchase::STATUS_EXPIRED) { return $this->loadPurchase($purchase); diff --git a/app/Domains/Purchase/Services/PurchaseStateGuard.php b/app/Domains/Purchase/Services/PurchaseStateGuard.php new file mode 100644 index 0000000..45e8b92 --- /dev/null +++ b/app/Domains/Purchase/Services/PurchaseStateGuard.php @@ -0,0 +1,24 @@ +status === Purchase::STATUS_EXPIRED; + $hasExpiredByTime = in_array($purchase->status, [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + ], true) + && $purchase->expires_at !== null + && $purchase->expires_at->isPast(); + + if ($hasExpiredStatus || $hasExpiredByTime) { + throw new PurchaseExpiredException; + } + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index da41ac0..6159d8d 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Purchase\Exceptions\InsufficientStockException; +use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Http\Middleware\EnsureAdminAppTenant; @@ -102,6 +103,16 @@ return Application::configure(basePath: dirname(__DIR__)) 'maximum_addable_quantity' => $exception->maximumAddableQuantity, ], 422); }); + $exceptions->render(function (PurchaseExpiredException $exception, Request $request) { + if (! $request->is('api/*')) { + return null; + } + + return response()->json([ + 'code' => 'purchase.expired', + 'message' => $exception->getMessage(), + ], 422); + }); $exceptions->render(function (ModelNotFoundException $exception, Request $request) { if (! $request->is('api/*')) { return null; diff --git a/lang/en/api.php b/lang/en/api.php index aa37385..f0356ed 100644 --- a/lang/en/api.php +++ b/lang/en/api.php @@ -36,6 +36,7 @@ return [ 'variant_required' => 'You must select a variant for this item.', ], 'purchase' => [ + 'expired' => 'The purchase has expired. Please start a new purchase.', 'variant_change_disabled' => 'Variant changes are disabled for this purchase.', 'source_required' => 'A cart or direct item is required.', 'payment_method_required' => 'The purchase payment method must be selected before finalizing.', diff --git a/lang/es/api.php b/lang/es/api.php index 1fb1596..bfa17c4 100644 --- a/lang/es/api.php +++ b/lang/es/api.php @@ -36,6 +36,7 @@ return [ 'variant_required' => 'Debe seleccionar una variante para este ítem.', ], 'purchase' => [ + 'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.", 'variant_change_disabled' => 'El cambio de variante está deshabilitado para esta compra.', 'source_required' => 'Se requiere un carrito o un producto directo.', 'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.', diff --git a/tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php b/tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php new file mode 100644 index 0000000..49e4522 --- /dev/null +++ b/tests/Feature/Purchase/PurchaseExpiredExceptionResponseTest.php @@ -0,0 +1,24 @@ +getJson('/api/test/purchase-expired') + ->assertUnprocessable() + ->assertExactJson([ + 'code' => 'purchase.expired', + 'message' => __('api.purchase.expired'), + ]); + } +} diff --git a/tests/Unit/Purchase/PurchaseStateGuardTest.php b/tests/Unit/Purchase/PurchaseStateGuardTest.php new file mode 100644 index 0000000..f083fdd --- /dev/null +++ b/tests/Unit/Purchase/PurchaseStateGuardTest.php @@ -0,0 +1,56 @@ +guard = new PurchaseStateGuard; + } + + public function test_it_rejects_a_purchase_with_expired_status(): void + { + $purchase = (new Purchase)->forceFill([ + 'status' => Purchase::STATUS_EXPIRED, + 'expires_at' => null, + ]); + + $this->expectException(PurchaseExpiredException::class); + + $this->guard->assertNotExpired($purchase); + } + + public function test_it_rejects_an_active_purchase_when_its_deadline_has_passed(): void + { + $purchase = (new Purchase)->forceFill([ + 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'expires_at' => now()->subMinute(), + ]); + + $this->expectException(PurchaseExpiredException::class); + + $this->guard->assertNotExpired($purchase); + } + + public function test_it_does_not_treat_a_paid_purchase_as_expired_by_time(): void + { + $purchase = (new Purchase)->forceFill([ + 'status' => Purchase::STATUS_PAID, + 'expires_at' => now()->subMinute(), + ]); + + $this->guard->assertNotExpired($purchase); + + $this->assertTrue(true); + } +}