From 5548fe851126b5a78a389e36aed9ec4cdf80c3ac Mon Sep 17 00:00:00 2001 From: ncoronel Date: Fri, 21 Aug 2026 16:15:55 -0300 Subject: [PATCH] feat(purchase): restore in-review transfer lifecycle --- .../Forms/Services/SaleFormService.php | 1 + .../Services/TelepagosWebhookService.php | 1 + app/Domains/Purchase/Models/Purchase.php | 3 ++ .../Checkout/CompleteCheckoutService.php | 10 +++- .../Checkout/ReleaseCheckoutService.php | 53 +++++++++++++++++-- .../Purchase/Services/CheckoutService.php | 5 ++ .../Services/UserPurchaseLimitService.php | 9 +++- app/Domains/Purchase/documentacion/README.md | 4 +- .../Forms/AdminAppSaleFormControllerTest.php | 5 +- tests/Feature/Purchase/StorePurchaseTest.php | 52 ++++++++++++++++-- tests/Unit/Forms/SaleFormServiceTest.php | 1 + 11 files changed, 130 insertions(+), 14 deletions(-) diff --git a/app/Domains/Forms/Services/SaleFormService.php b/app/Domains/Forms/Services/SaleFormService.php index 66b241f..8064968 100644 --- a/app/Domains/Forms/Services/SaleFormService.php +++ b/app/Domains/Forms/Services/SaleFormService.php @@ -12,6 +12,7 @@ class SaleFormService $names = [ Purchase::STATUS_CREATED => 'Creada', Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago', + Purchase::STATUS_IN_REVIEW => 'En revisión', Purchase::STATUS_PAID => 'Confirmada', Purchase::STATUS_CANCELLED => 'Cancelada', Purchase::STATUS_REJECTED => 'Rechazada', diff --git a/app/Domains/Integration/Services/TelepagosWebhookService.php b/app/Domains/Integration/Services/TelepagosWebhookService.php index 73327a3..05c4c3e 100644 --- a/app/Domains/Integration/Services/TelepagosWebhookService.php +++ b/app/Domains/Integration/Services/TelepagosWebhookService.php @@ -78,6 +78,7 @@ class TelepagosWebhookService ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, ]) ->where('payment_method', 'transfer') ->where('total', $amount) diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index 3acf53c..9a37c36 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -39,6 +39,8 @@ class Purchase extends Model public const STATUS_PENDING_PAYMENT = 'pending_payment'; + public const STATUS_IN_REVIEW = 'in_review'; + public const STATUS_PAID = 'paid'; public const STATUS_CANCELLED = 'cancelled'; @@ -55,6 +57,7 @@ class Purchase extends Model return [ self::STATUS_CREATED, self::STATUS_PENDING_PAYMENT, + self::STATUS_IN_REVIEW, self::STATUS_PAID, self::STATUS_CANCELLED, self::STATUS_REJECTED, diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index cc85bcf..e8cd277 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -55,6 +55,10 @@ class CompleteCheckoutService return $this->loadPurchase($purchase); } + if ($purchase->status === Purchase::STATUS_IN_REVIEW) { + return $this->loadPurchase($purchase); + } + $this->purchaseState->lockCurrentCart($purchase); if ( @@ -66,7 +70,10 @@ class CompleteCheckoutService ]); } - $purchase->update(['expires_at' => null]); + $purchase->update([ + 'status' => Purchase::STATUS_IN_REVIEW, + 'expires_at' => null, + ]); $this->reservations->syncPurchaseExpiration($purchase); return $this->loadPurchase($purchase); @@ -171,6 +178,7 @@ class CompleteCheckoutService { return in_array($purchase->status, [ Purchase::STATUS_PAID, + Purchase::STATUS_IN_REVIEW, Purchase::STATUS_CANCELLED, Purchase::STATUS_REJECTED, Purchase::STATUS_EXPIRED, diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index fb723fa..cbb17a8 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -22,6 +22,11 @@ class ReleaseCheckoutService return $this->release($purchase, Purchase::STATUS_CANCELLED); } + public function cancelFromAdmin(Purchase $purchase): Purchase + { + return $this->release($purchase, Purchase::STATUS_CANCELLED, allowInReviewCancellation: true); + } + public function expire(Purchase $purchase): Purchase { return $this->release($purchase, Purchase::STATUS_EXPIRED); @@ -61,9 +66,16 @@ class ReleaseCheckoutService return $expiredCount; } - private function release(Purchase $purchase, string $targetStatus): Purchase - { - return DB::transaction(function () use ($purchase, $targetStatus): Purchase { + private function release( + Purchase $purchase, + string $targetStatus, + bool $allowInReviewCancellation = false, + ): Purchase { + return DB::transaction(function () use ( + $purchase, + $targetStatus, + $allowInReviewCancellation, + ): Purchase { $purchase = $this->lockPurchase($purchase); if ($purchase->status === Purchase::STATUS_PAID) { @@ -76,6 +88,14 @@ class ReleaseCheckoutService ]); } + if ( + $purchase->status === Purchase::STATUS_IN_REVIEW + && $targetStatus === Purchase::STATUS_CANCELLED + && ! $allowInReviewCancellation + ) { + return $this->createNewCartWithoutCancelling($purchase); + } + if ($this->isAlreadyReleased($purchase)) { return $this->loadPurchase($purchase); } @@ -153,6 +173,33 @@ class ReleaseCheckoutService } } + private function createNewCartWithoutCancelling(Purchase $purchase): Purchase + { + return DB::transaction(function () use ($purchase): Purchase { + $purchase = $this->lockPurchase($purchase); + + if ($purchase->status !== Purchase::STATUS_IN_REVIEW || $purchase->user_id === null) { + return $this->loadPurchase($purchase); + } + + /** @var Cart|null $sourceCart */ + $sourceCart = $purchase->cart()->withTrashed()->lockForUpdate()->first(); + if ($sourceCart !== null && ! $sourceCart->trashed() && $sourceCart->status === 'active') { + $sourceCart->update(['status' => 'checkout']); + } + + Cart::query()->firstOrCreate([ + 'tenant_codigo' => $purchase->tenant_codigo, + 'user_id' => $purchase->user_id, + 'guest_token' => null, + 'status' => 'active', + 'origin' => Cart::ORIGIN_USER, + ]); + + return $this->loadPurchase($purchase); + }); + } + private function isAlreadyReleased(Purchase $purchase): bool { return in_array($purchase->status, [ diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index 90ad1bc..1a40c86 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -68,6 +68,11 @@ class CheckoutService return $this->releaser->cancel($purchase); } + public function cancelPurchaseFromAdmin(Purchase $purchase): Purchase + { + return $this->releaser->cancelFromAdmin($purchase); + } + public function expirePurchase(Purchase $purchase): Purchase { return $this->releaser->expire($purchase); diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 1e2b5ec..0db5a15 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -51,6 +51,7 @@ class UserPurchaseLimitService ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, Purchase::STATUS_PAID, ]) ->when( @@ -68,6 +69,7 @@ class UserPurchaseLimitService ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, ]) ->whereDoesntHave('items') ->when( @@ -133,6 +135,7 @@ class UserPurchaseLimitService ->whereIn('status', [ Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, Purchase::STATUS_PAID, ])) ->groupBy('source_catalog_item_id') @@ -143,7 +146,11 @@ class UserPurchaseLimitService ->whereIn('catalog_item_id', $ids) ->whereHas('cart.purchases', fn ($query) => $query ->where('user_id', $userId) - ->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT]) + ->whereIn('status', [ + Purchase::STATUS_CREATED, + Purchase::STATUS_PENDING_PAYMENT, + Purchase::STATUS_IN_REVIEW, + ]) ->whereDoesntHave('items')) ->groupBy('catalog_item_id') ->pluck('quantity', 'catalog_item_id'); diff --git a/app/Domains/Purchase/documentacion/README.md b/app/Domains/Purchase/documentacion/README.md index 8b7299c..46838f0 100644 --- a/app/Domains/Purchase/documentacion/README.md +++ b/app/Domains/Purchase/documentacion/README.md @@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca ## Modelo -- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`. +- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`. - `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra. - `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor. - `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional. @@ -22,7 +22,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca - `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra. - `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots. -Durante `created` y `pending_payment`, `PurchaseResource` publica las líneas del carrito con `items_source=cart`; una compra materializada publica `items_source=purchase`. Los datos descriptivos y económicos del checkout se resuelven siempre desde el catálogo vigente. +Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión. Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación. diff --git a/tests/Feature/Forms/AdminAppSaleFormControllerTest.php b/tests/Feature/Forms/AdminAppSaleFormControllerTest.php index b6d3e96..da33dd9 100644 --- a/tests/Feature/Forms/AdminAppSaleFormControllerTest.php +++ b/tests/Feature/Forms/AdminAppSaleFormControllerTest.php @@ -51,8 +51,9 @@ class AdminAppSaleFormControllerTest extends TestCase ->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED) ->assertJsonPath('data.statuses.0.name', 'Creada') ->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT) - ->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID) - ->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED); + ->assertJsonPath('data.statuses.2.code', Purchase::STATUS_IN_REVIEW) + ->assertJsonPath('data.statuses.3.code', Purchase::STATUS_PAID) + ->assertJsonPath('data.statuses.6.code', Purchase::STATUS_EXPIRED); } public function test_a_customer_cannot_get_the_sale_form(): void diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 28b61d2..9fda55a 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -838,7 +838,7 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_keeps_a_submitted_purchase_pending_payment_idempotently(): void + public function test_it_moves_a_submitted_purchase_to_review_idempotently(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -856,24 +856,66 @@ class StorePurchaseTest extends TestCase $this->actingAs($user, 'sanctum') ->postJson($url) ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW) ->assertJsonPath('data.expires_at', null); $this->assertDatabaseHas('compras', [ 'id' => $purchase->id, - 'status' => Purchase::STATUS_PENDING_PAYMENT, + 'status' => Purchase::STATUS_IN_REVIEW, 'expires_at' => null, ]); $this->actingAs($user, 'sanctum') ->postJson($url) ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT); + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); $this->actingAs($user, 'sanctum') ->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete") ->assertOk() - ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT); + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/payment-intent", [ + 'method' => 'qr', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('purchase'); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_IN_REVIEW, + 'payment_method' => 'transfer', + ]); + + $sourceCartId = $purchase->cart_id; + + $this->actingAs($user, 'sanctum') + ->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel") + ->assertOk() + ->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW); + + $this->assertDatabaseHas('compras', [ + 'id' => $purchase->id, + 'status' => Purchase::STATUS_IN_REVIEW, + ]); + $this->assertDatabaseHas('carritos', [ + 'id' => $sourceCartId, + 'status' => 'checkout', + 'current_purchase_id' => $purchase->id, + 'deleted_at' => null, + ]); + $this->assertDatabaseHas('carritos', [ + 'tenant_codigo' => 'sonder', + 'user_id' => $user->id, + 'status' => 'active', + 'current_purchase_id' => null, + 'deleted_at' => null, + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $purchase->id, + 'status' => 'active', + ]); } public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void diff --git a/tests/Unit/Forms/SaleFormServiceTest.php b/tests/Unit/Forms/SaleFormServiceTest.php index c6b1e4a..e2f5518 100644 --- a/tests/Unit/Forms/SaleFormServiceTest.php +++ b/tests/Unit/Forms/SaleFormServiceTest.php @@ -16,6 +16,7 @@ class SaleFormServiceTest extends TestCase $this->assertSame([ 'Creada', 'Esperando pago', + 'En revisión', 'Confirmada', 'Cancelada', 'Rechazada',