fix(purchase): unify expired checkout errors

This commit is contained in:
2026-08-20 17:01:06 -03:00
parent ae6149df84
commit 9fd34f900f
11 changed files with 174 additions and 18 deletions

View File

@@ -12,6 +12,7 @@ use App\Domains\Purchase\Requests\UpdatePurchaseItemRequest;
use App\Domains\Purchase\Resources\PurchaseResource; use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader; use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService; use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -132,42 +133,48 @@ class PurchaseController extends Controller
Tenant $tenant, Tenant $tenant,
Purchase $compra, Purchase $compra,
CheckoutService $checkoutService, CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState,
): JsonResponse { ): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$method = $request->validated('method'); $method = $request->validated('method');
$totalAmount = $compra->calculateCurrentTotalAmount(); $transferPayerDni = $method === 'transfer'
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
: null;
$purchaseUpdate = [ $updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
'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 {
/** @var Purchase|null $purchase */ /** @var Purchase|null $purchase */
$purchase = Purchase::query() $purchase = Purchase::query()
->whereKey($compra->getKey()) ->whereKey($compra->getKey())
->lockForUpdate() ->lockForUpdate()
->first(); ->first();
if ($purchase === null) {
return false;
}
$purchaseState->assertNotExpired($purchase);
if ( if (
$purchase === null ! in_array($purchase->status, [
|| ! in_array($purchase->status, [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
], true) ], true)
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) { ) {
return false; return false;
} }
$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); $purchase->update($purchaseUpdate);
return true; return true;
@@ -180,6 +187,7 @@ class PurchaseController extends Controller
} }
$compra->refresh(); $compra->refresh();
$totalAmount = (float) $compra->total;
$checkoutService->syncReservationExpiration($compra); $checkoutService->syncReservationExpiration($compra);
if ($method === 'transfer') { if ($method === 'transfer') {

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\CartItem; use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -15,12 +16,14 @@ class CompleteCheckoutService
private readonly StockReservationService $reservations, private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart, private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly PurchaseStateGuard $purchaseState,
) {} ) {}
public function complete(Purchase $purchase): Purchase public function complete(Purchase $purchase): Purchase
{ {
return DB::transaction(function () use ($purchase): Purchase { return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->payment_method === null) { if ($purchase->payment_method === null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -45,6 +48,7 @@ class CompleteCheckoutService
{ {
return DB::transaction(function () use ($purchase): Purchase { return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status === Purchase::STATUS_PAID) { if ($purchase->status === Purchase::STATUS_PAID) {
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);
@@ -70,6 +74,7 @@ class CompleteCheckoutService
{ {
DB::transaction(function () use ($purchase): void { DB::transaction(function () use ($purchase): void {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status === Purchase::STATUS_PAID) { if ($purchase->status === Purchase::STATUS_PAID) {
return; return;

View File

@@ -6,6 +6,7 @@ use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\CatalogInventoryService;
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\Services\PurchaseStateGuard;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -20,6 +21,7 @@ class EditCheckoutService
private readonly SourceCartService $sourceCart, private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots, private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly PurchaseResponseLoader $responses, private readonly PurchaseResponseLoader $responses,
private readonly PurchaseStateGuard $purchaseState,
) {} ) {}
/** @param array<string, string> $customerData */ /** @param array<string, string> $customerData */
@@ -27,6 +29,7 @@ class EditCheckoutService
{ {
return DB::transaction(function () use ($purchase, $customerData): Purchase { return DB::transaction(function () use ($purchase, $customerData): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase); $this->assertEditable($purchase);
$purchase->update($customerData); $purchase->update($customerData);
@@ -50,6 +53,7 @@ class EditCheckoutService
$updateVariant, $updateVariant,
): Purchase { ): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -182,6 +186,7 @@ class EditCheckoutService
{ {
return DB::transaction(function () use ($purchase): Purchase { return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase); $this->assertEditable($purchase);
if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsModification()) { if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsModification()) {
@@ -208,6 +213,7 @@ class EditCheckoutService
{ {
return DB::transaction(function () use ($purchase, $purchaseItem): Purchase { return DB::transaction(function () use ($purchase, $purchaseItem): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase); $this->assertEditable($purchase);
if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) { if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) {

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -14,6 +15,8 @@ class ReleaseCheckoutService
{ {
public function __construct( public function __construct(
private readonly StockReservationService $reservations, private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
private readonly PurchaseStateGuard $purchaseState,
) {} ) {}
public function cancel(Purchase $purchase): Purchase public function cancel(Purchase $purchase): Purchase
@@ -70,6 +73,10 @@ class ReleaseCheckoutService
return DB::transaction(function () use ($purchase, $targetStatus): Purchase { return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
$purchase = $this->lockPurchase($purchase); $purchase = $this->lockPurchase($purchase);
if ($targetStatus !== Purchase::STATUS_EXPIRED) {
$this->purchaseState->assertNotExpired($purchase);
}
if ($purchase->status === Purchase::STATUS_PAID) { if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) { if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase); return $this->loadPurchase($purchase);

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
class PurchaseStateGuard
{
public function assertNotExpired(Purchase $purchase): void
{
$hasExpiredStatus = $purchase->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;
}
}
}

View File

@@ -2,6 +2,7 @@
use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Purchase\Exceptions\InsufficientStockException; use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException; use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
use App\Domains\Ticket\Exceptions\TicketNotAvailableException; use App\Domains\Ticket\Exceptions\TicketNotAvailableException;
use App\Http\Middleware\EnsureAdminAppTenant; use App\Http\Middleware\EnsureAdminAppTenant;
@@ -102,6 +103,16 @@ return Application::configure(basePath: dirname(__DIR__))
'maximum_addable_quantity' => $exception->maximumAddableQuantity, 'maximum_addable_quantity' => $exception->maximumAddableQuantity,
], 422); ], 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) { $exceptions->render(function (ModelNotFoundException $exception, Request $request) {
if (! $request->is('api/*')) { if (! $request->is('api/*')) {
return null; return null;

View File

@@ -36,6 +36,7 @@ return [
'variant_required' => 'You must select a variant for this item.', 'variant_required' => 'You must select a variant for this item.',
], ],
'purchase' => [ 'purchase' => [
'expired' => 'The purchase has expired. Please start a new purchase.',
'variant_change_disabled' => 'Variant changes are disabled for this purchase.', 'variant_change_disabled' => 'Variant changes are disabled for this purchase.',
'source_required' => 'A cart or direct item is required.', 'source_required' => 'A cart or direct item is required.',
'payment_method_required' => 'The purchase payment method must be selected before finalizing.', 'payment_method_required' => 'The purchase payment method must be selected before finalizing.',

View File

@@ -36,6 +36,7 @@ return [
'variant_required' => 'Debe seleccionar una variante para este ítem.', 'variant_required' => 'Debe seleccionar una variante para este ítem.',
], ],
'purchase' => [ '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.', 'variant_change_disabled' => 'El cambio de variante está deshabilitado para esta compra.',
'source_required' => 'Se requiere un carrito o un producto directo.', '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.', 'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',

View File

@@ -0,0 +1,24 @@
<?php
namespace Tests\Feature\Purchase;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use Illuminate\Support\Facades\Route;
use Tests\TestCase;
class PurchaseExpiredExceptionResponseTest extends TestCase
{
public function test_it_returns_a_stable_api_error_for_an_expired_purchase(): void
{
Route::get('/api/test/purchase-expired', function (): never {
throw new PurchaseExpiredException;
});
$this->getJson('/api/test/purchase-expired')
->assertUnprocessable()
->assertExactJson([
'code' => 'purchase.expired',
'message' => __('api.purchase.expired'),
]);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace Tests\Unit\Purchase;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use Tests\TestCase;
class PurchaseStateGuardTest extends TestCase
{
private PurchaseStateGuard $guard;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}