feat(purchase): restore in-review transfer lifecycle
This commit is contained in:
parent
dc0fbe06b8
commit
5548fe8511
|
|
@ -12,6 +12,7 @@ class SaleFormService
|
||||||
$names = [
|
$names = [
|
||||||
Purchase::STATUS_CREATED => 'Creada',
|
Purchase::STATUS_CREATED => 'Creada',
|
||||||
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
||||||
|
Purchase::STATUS_IN_REVIEW => 'En revisión',
|
||||||
Purchase::STATUS_PAID => 'Confirmada',
|
Purchase::STATUS_PAID => 'Confirmada',
|
||||||
Purchase::STATUS_CANCELLED => 'Cancelada',
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
Purchase::STATUS_REJECTED => 'Rechazada',
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ class TelepagosWebhookService
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
])
|
])
|
||||||
->where('payment_method', 'transfer')
|
->where('payment_method', 'transfer')
|
||||||
->where('total', $amount)
|
->where('total', $amount)
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ class Purchase extends Model
|
||||||
|
|
||||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||||
|
|
||||||
|
public const STATUS_IN_REVIEW = 'in_review';
|
||||||
|
|
||||||
public const STATUS_PAID = 'paid';
|
public const STATUS_PAID = 'paid';
|
||||||
|
|
||||||
public const STATUS_CANCELLED = 'cancelled';
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
@ -55,6 +57,7 @@ class Purchase extends Model
|
||||||
return [
|
return [
|
||||||
self::STATUS_CREATED,
|
self::STATUS_CREATED,
|
||||||
self::STATUS_PENDING_PAYMENT,
|
self::STATUS_PENDING_PAYMENT,
|
||||||
|
self::STATUS_IN_REVIEW,
|
||||||
self::STATUS_PAID,
|
self::STATUS_PAID,
|
||||||
self::STATUS_CANCELLED,
|
self::STATUS_CANCELLED,
|
||||||
self::STATUS_REJECTED,
|
self::STATUS_REJECTED,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,10 @@ class CompleteCheckoutService
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_IN_REVIEW) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
$this->purchaseState->lockCurrentCart($purchase);
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
if (
|
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);
|
$this->reservations->syncPurchaseExpiration($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
|
|
@ -171,6 +178,7 @@ class CompleteCheckoutService
|
||||||
{
|
{
|
||||||
return in_array($purchase->status, [
|
return in_array($purchase->status, [
|
||||||
Purchase::STATUS_PAID,
|
Purchase::STATUS_PAID,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
Purchase::STATUS_CANCELLED,
|
Purchase::STATUS_CANCELLED,
|
||||||
Purchase::STATUS_REJECTED,
|
Purchase::STATUS_REJECTED,
|
||||||
Purchase::STATUS_EXPIRED,
|
Purchase::STATUS_EXPIRED,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,11 @@ class ReleaseCheckoutService
|
||||||
return $this->release($purchase, Purchase::STATUS_CANCELLED);
|
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
|
public function expire(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
||||||
|
|
@ -61,9 +66,16 @@ class ReleaseCheckoutService
|
||||||
return $expiredCount;
|
return $expiredCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function release(Purchase $purchase, string $targetStatus): Purchase
|
private function release(
|
||||||
{
|
Purchase $purchase,
|
||||||
return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
|
string $targetStatus,
|
||||||
|
bool $allowInReviewCancellation = false,
|
||||||
|
): Purchase {
|
||||||
|
return DB::transaction(function () use (
|
||||||
|
$purchase,
|
||||||
|
$targetStatus,
|
||||||
|
$allowInReviewCancellation,
|
||||||
|
): Purchase {
|
||||||
$purchase = $this->lockPurchase($purchase);
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_PAID) {
|
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)) {
|
if ($this->isAlreadyReleased($purchase)) {
|
||||||
return $this->loadPurchase($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
|
private function isAlreadyReleased(Purchase $purchase): bool
|
||||||
{
|
{
|
||||||
return in_array($purchase->status, [
|
return in_array($purchase->status, [
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ class CheckoutService
|
||||||
return $this->releaser->cancel($purchase);
|
return $this->releaser->cancel($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function cancelPurchaseFromAdmin(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return $this->releaser->cancelFromAdmin($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
public function expirePurchase(Purchase $purchase): Purchase
|
public function expirePurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $this->releaser->expire($purchase);
|
return $this->releaser->expire($purchase);
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ class UserPurchaseLimitService
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
Purchase::STATUS_PAID,
|
Purchase::STATUS_PAID,
|
||||||
])
|
])
|
||||||
->when(
|
->when(
|
||||||
|
|
@ -68,6 +69,7 @@ class UserPurchaseLimitService
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
])
|
])
|
||||||
->whereDoesntHave('items')
|
->whereDoesntHave('items')
|
||||||
->when(
|
->when(
|
||||||
|
|
@ -133,6 +135,7 @@ class UserPurchaseLimitService
|
||||||
->whereIn('status', [
|
->whereIn('status', [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
Purchase::STATUS_PAID,
|
Purchase::STATUS_PAID,
|
||||||
]))
|
]))
|
||||||
->groupBy('source_catalog_item_id')
|
->groupBy('source_catalog_item_id')
|
||||||
|
|
@ -143,7 +146,11 @@ class UserPurchaseLimitService
|
||||||
->whereIn('catalog_item_id', $ids)
|
->whereIn('catalog_item_id', $ids)
|
||||||
->whereHas('cart.purchases', fn ($query) => $query
|
->whereHas('cart.purchases', fn ($query) => $query
|
||||||
->where('user_id', $userId)
|
->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'))
|
->whereDoesntHave('items'))
|
||||||
->groupBy('catalog_item_id')
|
->groupBy('catalog_item_id')
|
||||||
->pluck('quantity', 'catalog_item_id');
|
->pluck('quantity', 'catalog_item_id');
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
|
||||||
|
|
||||||
## Modelo
|
## 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.
|
- `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.
|
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
- `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.
|
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
||||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
- `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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,9 @@ class AdminAppSaleFormControllerTest extends TestCase
|
||||||
->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED)
|
->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED)
|
||||||
->assertJsonPath('data.statuses.0.name', 'Creada')
|
->assertJsonPath('data.statuses.0.name', 'Creada')
|
||||||
->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT)
|
->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT)
|
||||||
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID)
|
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_IN_REVIEW)
|
||||||
->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED);
|
->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
|
public function test_a_customer_cannot_get_the_sale_form(): void
|
||||||
|
|
|
||||||
|
|
@ -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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
@ -856,24 +856,66 @@ class StorePurchaseTest extends TestCase
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson($url)
|
->postJson($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW)
|
||||||
->assertJsonPath('data.expires_at', null);
|
->assertJsonPath('data.expires_at', null);
|
||||||
|
|
||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_IN_REVIEW,
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson($url)
|
->postJson($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT);
|
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
->postJson("/api/tenants/sonder/compras/{$purchase->id}/complete")
|
||||||
->assertOk()
|
->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
|
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class SaleFormServiceTest extends TestCase
|
||||||
$this->assertSame([
|
$this->assertSame([
|
||||||
'Creada',
|
'Creada',
|
||||||
'Esperando pago',
|
'Esperando pago',
|
||||||
|
'En revisión',
|
||||||
'Confirmada',
|
'Confirmada',
|
||||||
'Cancelada',
|
'Cancelada',
|
||||||
'Rechazada',
|
'Rechazada',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue