Squashed commit of the following:

commit 1dc4e29c69
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:58:03 2026 -0300

    refactor(reservations): unify expiration command

commit 093e894cc3
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 13:48:09 2026 -0300

    feat(cart): expire abandoned stock reservations

commit fdf0f3328f
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:53:12 2026 -0300

    refactor(stock): implement expiration for stock reservations and add configuration

commit 8d6bcdcc43
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:38:21 2026 -0300

    refactor(cart): invalidate payment on actual changes

commit 3206e293eb
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:24:48 2026 -0300

    refactor(cart): own checkout item editing

commit aed99bd05e
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:14:57 2026 -0300

    refactor(checkout): remove legacy purchase item reservations

commit f1649e0e4b
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:29 2026 -0300

    refactor(checkout): materialize purchase items on confirmation

commit e6c4b40a37
Author: ncoronel <ncoronel@quo.ar>
Date:   Wed Aug 19 12:06:19 2026 -0300

    feat(inventory): add traceable cart stock reservations
This commit is contained in:
2026-08-19 13:59:38 -03:00
parent 15878cd9ba
commit 172e14ae9b
45 changed files with 1391 additions and 544 deletions

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
@@ -77,6 +78,36 @@ class CartController extends Controller
]);
}
public function updateCheckoutItem(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
Cart $cart,
CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateCheckoutItem(
$tenant,
$request,
$cart,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
),
)->additional([
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(

View File

@@ -7,6 +7,8 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -24,6 +26,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'user_id',
'guest_token',
'status',
'origin',
])]
class Cart extends Model
{
@@ -32,6 +35,10 @@ class Cart extends Model
protected $table = 'carritos';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
protected function casts(): array
{
return [
@@ -63,6 +70,12 @@ class Cart extends Model
return $this->hasMany(CartItem::class, 'cart_id');
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'cart_id');
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
@@ -114,11 +127,12 @@ class Cart extends Model
'cantidad' => $quantity,
]);
} else {
app(StockReservationService::class)->ensure($item, $selectedItem);
$item->cantidad += $quantity;
$item->save();
}
$inventoryService->reserve($selectedItem, $quantity);
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
return $item->fresh();
});
@@ -129,6 +143,7 @@ class Cart extends Model
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
?int $excludedPurchaseId = null,
): CartItem {
if ($quantity <= 0) {
throw ValidationException::withMessages([
@@ -141,6 +156,7 @@ class Cart extends Model
$quantity,
$variantId,
$updateVariant,
$excludedPurchaseId,
): CartItem {
/** @var CartItem $item */
$item = $this->items()
@@ -168,9 +184,10 @@ class Cart extends Model
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
);
$inventoryService->release($currentSelection, $item->cantidad);
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
@@ -186,11 +203,11 @@ class Cart extends Model
->lockForUpdate()
->first();
$inventoryService->reserve($nextSelection, $quantity);
if ($targetItem !== null) {
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
$targetItem->cantidad += $quantity;
$targetItem->save();
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
$item->delete();
return $targetItem->fresh();
@@ -199,6 +216,7 @@ class Cart extends Model
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
return $item->fresh();
}
@@ -213,6 +231,7 @@ class Cart extends Model
$this->assertUserPurchaseLimit(
$currentSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
);
}
@@ -225,17 +244,17 @@ class Cart extends Model
]);
}
$item->cantidad = $quantity;
$item->save();
if ($delta > 0) {
$inventoryService->reserve($currentSelection, $delta);
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
}
if ($delta < 0) {
$inventoryService->release($currentSelection, abs($delta));
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
}
$item->cantidad = $quantity;
$item->save();
return $item->fresh();
});
}
@@ -254,7 +273,8 @@ class Cart extends Model
$item->variant_id,
true,
);
app(CatalogInventoryService::class)->release(
app(StockReservationService::class)->release(
$item,
$selectedItem,
$item->cantidad,
);
@@ -334,6 +354,7 @@ class Cart extends Model
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
?int $excludedPurchaseId = null,
): void {
if ($this->user_id === null) {
return;
@@ -347,6 +368,7 @@ class Cart extends Model
$catalogItem,
$this->user_id,
$cartQuantity,
$excludedPurchaseId,
field: 'cantidad',
);
}

View File

@@ -3,11 +3,13 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'cart_id',
@@ -55,4 +57,10 @@ class CartItem extends Model
{
return $this->variant ?? $this->catalogItem;
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
}
}

View File

@@ -4,15 +4,24 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function show(Tenant $tenant, Request $request): Cart
{
$resolvedIdentity = $this->resolveIdentity($request);
@@ -66,6 +75,107 @@ class CartService
return $this->loadCart($cart);
}
public function updateCheckoutItem(
Tenant $tenant,
Request $request,
Cart $cart,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
$user = $request->user() ?? Auth::guard('sanctum')->user();
if (! $user instanceof User) {
throw new NotFoundHttpException('Checkout cart not found.');
}
return DB::transaction(function () use (
$tenant,
$user,
$cart,
$cartItemId,
$quantity,
$variantId,
$updateVariant,
): Cart {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->where('cart_id', $cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
])
->whereDoesntHave('items')
->lockForUpdate()
->first();
if ($purchase === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
if ($purchase->expires_at !== null && $purchase->expires_at->isPast()) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.not_editable'),
]);
}
/** @var Cart|null $checkoutCart */
$checkoutCart = Cart::query()
->whereKey($cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->where('status', 'checkout')
->lockForUpdate()
->first();
if ($checkoutCart === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
/** @var CartItem|null $cartItem */
$cartItem = $checkoutCart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
throw new NotFoundHttpException('Checkout item not found.');
}
$hasChanges = (int) $cartItem->cantidad !== $quantity
|| ($updateVariant && $cartItem->variant_id !== $variantId);
if (! $hasChanges) {
return $this->loadCart($checkoutCart);
}
$checkoutCart->updateItem(
$cartItemId,
$quantity,
$variantId,
$updateVariant,
$purchase->getKey(),
);
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'total' => $checkoutCart->getTotalAmount(),
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadCart($checkoutCart);
});
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
$identity = $this->requireIdentity($request);

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use Illuminate\Support\Facades\DB;
class ExpireCartReservationsService
{
public function expireOverdue(): int
{
$expiredItems = 0;
$lastCartItemId = 0;
do {
$cartItemIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNull('purchase_id')
->whereNotNull('cart_item_id')
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('cart_item_id', '>', $lastCartItemId)
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
->select('cart_item_id')
->distinct()
->orderBy('cart_item_id')
->limit(500)
->pluck('cart_item_id');
foreach ($cartItemIds as $cartItemId) {
$lastCartItemId = (int) $cartItemId;
if ($this->expireCartItem($lastCartItemId)) {
$expiredItems++;
}
}
} while ($cartItemIds->count() === 500);
return $expiredItems;
}
private function expireCartItem(int $cartItemId): bool
{
/** @var CartItem|null $candidate */
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
if ($candidate === null) {
return false;
}
return DB::transaction(function () use ($candidate, $cartItemId): bool {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($candidate->cart_id)
->where('status', 'active')
->lockForUpdate()
->first();
if ($cart === null) {
return false;
}
/** @var CartItem|null $cartItem */
$cartItem = $cart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
return false;
}
$reservations = StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('inventory_id')
->lockForUpdate()
->get();
if (
$reservations->isEmpty()
|| $reservations->contains(
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture(),
)
) {
return false;
}
$inventories = Inventory::query()
->whereKey($reservations->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($reservations as $reservation) {
$inventory = $inventories->get($reservation->inventory_id)
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
$inventory->release((int) $reservation->quantity);
$reservation->update([
'quantity' => 0,
'status' => StockReservation::STATUS_EXPIRED,
'expires_at' => null,
'released_at' => now(),
]);
}
$cartItem->delete();
if (! $cart->items()->exists()) {
$cart->update(['status' => 'expired']);
$cart->delete();
}
return true;
});
}
}

View File

@@ -34,6 +34,10 @@ class GuestCartMergeService
->first();
if ($userCart !== null) {
$userCart->items()
->orderBy('id')
->pluck('id')
->each(fn (int $itemId) => $userCart->removeItem($itemId));
$userCart->update([
'status' => 'converted',
]);

View File

@@ -7,11 +7,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
@@ -30,3 +31,7 @@ Bajo `/tenants/{tenant:codigo}`:
## Dependencias y reglas
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío.

View File

@@ -10,3 +10,9 @@ Route::prefix('tenants/{tenant:codigo}')
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
});
Route::prefix('tenants/{tenant:codigo}')
->middleware('auth:sanctum')
->group(function (): void {
Route::patch('checkout-carts/{cart}/items/{cartItem}', [CartController::class, 'updateCheckoutItem']);
});

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
@@ -47,6 +48,12 @@ class Inventory extends Model
return $this->hasOne(Variant::class);
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
}
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'inventory_id',
'cart_item_id',
'purchase_id',
'quantity',
'status',
'expires_at',
'committed_at',
'released_at',
])]
class StockReservation extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_COMMITTED = 'committed';
public const STATUS_RELEASED = 'released';
public const STATUS_EXPIRED = 'expired';
protected function casts(): array
{
return [
'inventory_id' => 'integer',
'cart_item_id' => 'integer',
'purchase_id' => 'integer',
'quantity' => 'integer',
'expires_at' => 'datetime',
'committed_at' => 'datetime',
'released_at' => 'datetime',
];
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return BelongsTo<CartItem, $this> */
public function cartItem(): BelongsTo
{
return $this->belongsTo(CartItem::class);
}
/** @return BelongsTo<Purchase, $this> */
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class);
}
}

View File

@@ -12,6 +12,19 @@ use Illuminate\Support\Facades\DB;
class CatalogInventoryService
{
/** @return array<int, int> */
public function requirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
{
if ($quantity <= 0) {
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
}
return array_map(
fn (array $requirement): int => $requirement['quantity'] * $quantity,
$this->inventoryRequirements($selection),
);
}
public function availableQuantity(CatalogItem|Variant $selection): ?int
{
if ($selection instanceof CatalogItem

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
class ExpireStockReservationsService
{
public function __construct(
private readonly CheckoutService $checkout,
private readonly ExpireCartReservationsService $carts,
) {}
/**
* @return array{purchases: int, cart_items: int}
*/
public function expireOverdue(): array
{
return [
'purchases' => $this->checkout->expireOverduePurchases(),
'cart_items' => $this->carts->expireOverdue(),
];
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class StockReservationService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
) {}
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
{
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
$this->inventory->reserve($selection, $quantity);
$this->recordIncrease($cartItem, $selection, $quantity);
});
}
public function release(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus = StockReservation::STATUS_RELEASED,
): void {
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
$this->ensure($cartItem, $selection);
$this->inventory->release($selection, $quantity);
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
});
}
public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void
{
DB::transaction(function () use ($cartItem, $selection): void {
$this->ensure($cartItem, $selection);
$this->inventory->commit($selection, (int) $cartItem->cantidad);
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
}
$reservation->update([
'status' => StockReservation::STATUS_COMMITTED,
'committed_at' => now(),
'expires_at' => null,
]);
}
});
}
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
{
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
$reservation->update([
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
}
}
public function attachToPurchase(
CartItem $cartItem,
CatalogItem|Variant $selection,
Purchase $purchase,
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => $purchase->getKey(),
'expires_at' => $purchase->expires_at,
]);
});
}
public function detachFromPurchase(Purchase $purchase): void
{
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => null,
'expires_at' => $this->expiration(),
]);
}
public function syncPurchaseExpiration(Purchase $purchase): void
{
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update(['expires_at' => $purchase->expires_at]);
}
public function transfer(CartItem $source, CartItem $target): void
{
DB::transaction(function () use ($source, $target): void {
$sourceReservations = StockReservation::query()
->where('cart_item_id', $source->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('inventory_id')
->lockForUpdate()
->get();
foreach ($sourceReservations as $sourceReservation) {
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
if ($targetReservation === null) {
$sourceItemQuantity = (int) $source->cantidad;
$targetItemQuantity = (int) $target->fresh()->cantidad;
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
$sourceReservation->update([
'cart_item_id' => $target->getKey(),
'purchase_id' => null,
'quantity' => $perItemQuantity * $targetItemQuantity,
'expires_at' => $this->expiration(),
]);
continue;
}
$targetReservation->update([
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$sourceReservation->delete();
}
});
}
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
{
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
}
$reservation->update([
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
}
private function recordDecrease(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus,
): void {
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
}
$remaining = $reservation->quantity - $requiredQuantity;
$reservation->update([
'quantity' => $remaining,
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
'released_at' => $remaining === 0 ? now() : null,
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
]);
}
}
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
{
return StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('inventory_id', $inventoryId)
->lockForUpdate()
->first();
}
private function expiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
);
}
}

View File

@@ -9,6 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
@@ -17,6 +18,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.

View File

@@ -4,11 +4,9 @@ namespace App\Domains\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
@@ -56,7 +54,16 @@ class PurchaseController extends Controller
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing('items')->loadCount('tickets');
$compra->loadMissing([
'items',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
])->loadCount('tickets');
$compra->items->load('imageAttachment');
return PurchaseResource::make($compra);
@@ -75,39 +82,12 @@ class PurchaseController extends Controller
);
}
public function updateItemQuantity(
UpdatePurchaseItemQuantityRequest $request,
Tenant $tenant,
Purchase $compra,
PurchaseItem $item,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->updateItemQuantity(
$compra,
$item,
(int) $request->validated('quantity'),
),
);
}
public function prepareItemEditing(
Request $request,
public function paymentIntent(
PaymentIntentRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->prepareItemEditing($compra),
);
}
public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse
{
): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$method = $request->validated('method');
$totalAmount = $compra->calculateCurrentTotalAmount();
@@ -148,13 +128,14 @@ class PurchaseController extends Controller
return true;
});
if ($updated === 0) {
if (! $updated) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_payment'),
]);
}
$compra->refresh();
$checkoutService->syncReservationExpiration($compra);
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
@@ -116,6 +117,12 @@ class Purchase extends Model
return $this->hasMany(Ticket::class, 'source_purchase_id');
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
}
/**
* @return HasOne<TelepagosQr, $this>
*/
@@ -143,11 +150,16 @@ class Purchase extends Model
public function calculateCurrentTotalAmount(): float
{
if ($this->relationLoaded('items')) {
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
return (float) $this->getRelation('items')->sum('total');
}
return (float) $this->items()->sum('total');
$itemsTotal = (float) $this->items()->sum('total');
if ($itemsTotal > 0 || $this->items()->exists()) {
return $itemsTotal;
}
return (float) ($this->cart?->getTotalAmount() ?? $this->total ?? 0);
}
protected function valueChangeTenantCode(): string

View File

@@ -23,18 +23,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
'discount_total',
'tax_total',
'total',
'reservation_status',
])]
class PurchaseItem extends Model
{
use HasFactory;
public const RESERVATION_ACTIVE = 'active';
public const RESERVATION_COMMITTED = 'committed';
public const RESERVATION_RELEASED = 'released';
protected $table = 'compra_items';
protected function casts(): array

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePurchaseItemQuantityRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'quantity' => ['required', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -57,22 +57,12 @@ class PurchaseItemResource extends JsonResource
'quantity' => $quantity,
'unit_price' => $this->formatMoney($unitPrice),
'line_total' => $this->formatMoney($lineTotal),
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'product' => $catalogItem === null ? null : [
'id' => $catalogItem->id,
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'slug' => $catalogItem->slug,
'imagen' => $imageUrl,
],
'variant' => $variant === null ? null : [
'id' => $variant->id,
'attributes' => $this->resolveAttributes($variant),
],
'source_catalog_item_id' => $this->catalog_item_id,
'source_variant_id' => $this->variant_id,
'item_details' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'descripcion' => $selectedItem->getDescription(),
'slug' => $catalogItem?->slug,
'imagen' => $imageUrl,
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
],

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
@@ -17,26 +18,37 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
$items = $this->resource->relationLoaded('items')
$purchaseItems = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$cartItems = $purchaseItems->isEmpty()
&& $this->resource->relationLoaded('cart')
&& $this->resource->getRelation('cart')?->relationLoaded('items')
? $this->resource->getRelation('cart')->getRelation('items')
: collect();
$items = $purchaseItems->isNotEmpty() ? $purchaseItems : $cartItems;
$itemsSource = $purchaseItems->isNotEmpty()
? 'purchase'
: ($cartItems->isNotEmpty() ? 'cart' : null);
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$subtotal = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0,
)
: (float) ($this->total ?? 0);
$total = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0);
$total = $this->status === Purchase::STATUS_PAID && $this->total !== null
? (float) $this->total
: ($items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0));
return [
'id' => $this->id,
@@ -52,7 +64,7 @@ class PurchaseResource extends JsonResource
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items_source' => $itemsSource,
'items' => PurchaseItemResource::collection($items),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
@@ -61,13 +73,21 @@ class PurchaseResource extends JsonResource
];
}
protected function resolveItemSubtotal(PurchaseItem $item): float
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
{
if ($item instanceof CartItem) {
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
}
return (float) $item->precio_unitario * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem $item): float
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
{
if ($item instanceof CartItem) {
return $this->resolveItemSubtotal($item);
}
return (float) ($item->total ?? 0);
}

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -79,13 +78,4 @@ class CatalogSelectionResolver
return $variant;
}
public function resolvePurchaseItem(Tenant $tenant, PurchaseItem $item): CatalogItem|Variant
{
return $this->resolve(
$tenant,
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
);
}
}

View File

@@ -2,18 +2,19 @@
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CompleteCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly CatalogSelectionResolver $selections,
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots,
) {}
public function complete(Purchase $purchase): Purchase
@@ -59,6 +60,7 @@ class CompleteCheckoutService
}
$purchase->update(['expires_at' => null]);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadPurchase($purchase);
});
@@ -83,25 +85,51 @@ class CompleteCheckoutService
]);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
if ($purchase->items()->exists()) {
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart?->status === 'converted' && $cart->trashed()) {
return;
}
foreach ($items as $item) {
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
try {
$this->inventory->commit($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException) {
$cart = $purchase->cart()->lockForUpdate()->first();
if ($cart === null || $cart->status !== 'checkout') {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->loadCartItems($cartItems);
$purchase->items()->createMany(
$this->snapshots->fromCartItems($cartItems),
);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_COMMITTED,
]);
try {
$this->reservations->commit($cartItem, $selection);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$this->sourceCart->finalize($purchase);
@@ -126,6 +154,30 @@ class CompleteCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
$cartItems->load([
'catalogItem.inventory',
'catalogItem.attachments',
'variant.inventory',
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
'variant.eventDates',
'variant.eventDate',
]);
}
}

View File

@@ -2,24 +2,12 @@
namespace App\Domains\Purchase\Services\Checkout;
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\UserPurchaseLimitService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EditCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
) {}
/** @param array<string, string> $customerData */
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
{
@@ -33,115 +21,6 @@ class EditCheckoutService
});
}
public function updateItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): Purchase {
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
$difference = $quantity - (int) $purchaseItem->cantidad;
if ($difference !== 0) {
$this->adjustReservation($purchase, $purchaseItem, $quantity, $difference);
$purchaseItem->update([
'cantidad' => $quantity,
'total' => (float) $purchaseItem->precio_unitario * $quantity,
]);
$this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity);
}
$purchase->update([
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
}
public function prepareItemEditing(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->assertEditable($purchase);
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
return $this->loadPurchase($purchase);
});
}
private function adjustReservation(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
int $difference,
): void {
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem);
try {
if ($difference > 0) {
$otherItemQuantity = (int) $purchase->items()
->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id)
->whereKeyNot($purchaseItem->getKey())
->sum('cantidad');
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
(int) $purchase->user_id,
$otherItemQuantity + $quantity,
$purchase->getKey(),
);
$this->inventory->reserve($selection, $difference);
} else {
$this->inventory->release($selection, abs($difference));
}
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'quantity' => __('api.purchase.insufficient_stock'),
]);
}
}
private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem
{
/** @var PurchaseItem|null $lockedItem */
$lockedItem = $purchase->items()
->whereKey($item->getKey())
->lockForUpdate()
->first();
if ($lockedItem === null) {
throw new NotFoundHttpException('Purchase item not found.');
}
if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'item' => __('api.purchase.item_not_editable'),
]);
}
return $lockedItem;
}
private function assertEditable(Purchase $purchase): void
{
if (
@@ -170,6 +49,15 @@ class EditCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory
@@ -38,7 +37,6 @@ class PurchaseItemSnapshotFactory
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
'reservation_status' => PurchaseItem::RESERVATION_ACTIVE,
];
})
->all();

View File

@@ -2,17 +2,16 @@
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ReleaseCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly CatalogSelectionResolver $selections,
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
) {}
@@ -77,38 +76,71 @@ class ReleaseCheckoutService
return $this->loadPurchase($purchase);
}
$items = $purchase->items()
->where('reservation_status', PurchaseItem::RESERVATION_ACTIVE)
->lockForUpdate()
->get();
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
foreach ($items as $item) {
if (! $reservationReturnedToCart) {
$this->releaseInventory($purchase, $item);
}
$item->update([
'reservation_status' => PurchaseItem::RESERVATION_RELEASED,
if ($purchase->items()->exists()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
$this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus);
$purchase->update(['status' => $targetStatus]);
return $this->loadPurchase($purchase);
});
}
private function releaseInventory(Purchase $purchase, PurchaseItem $item): void
{
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
private function releaseCartReservations(
Purchase $purchase,
bool $reservationReturnedToCart,
string $targetStatus,
): void {
if ($reservationReturnedToCart) {
$this->reservations->detachFromPurchase($purchase);
try {
$this->inventory->release($selection, (int) $item->cantidad);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
return;
}
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart === null) {
return;
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$cartItems->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'variant.inventory',
'variant.catalogItem',
]);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
continue;
}
try {
$this->reservations->release(
$cartItem,
$selection,
(int) $cartItem->cantidad,
$targetStatus === Purchase::STATUS_EXPIRED
? StockReservation::STATUS_EXPIRED
: StockReservation::STATUS_RELEASED,
);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
if (! $cart->trashed()) {
$cart->update(['status' => 'converted']);
$cart->delete();
}
}
@@ -129,6 +161,15 @@ class ReleaseCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
}
}

View File

@@ -4,11 +4,15 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
class SourceCartService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function restore(Purchase $purchase): bool
{
$sourceCart = $this->findSourceCart($purchase);
@@ -17,6 +21,10 @@ class SourceCartService
return false;
}
if ($sourceCart->origin === Cart::ORIGIN_DIRECT_CHECKOUT) {
return false;
}
/** @var Cart|null $activeCart */
$activeCart = Cart::query()
->where('tenant_codigo', $purchase->tenant_codigo)
@@ -54,23 +62,6 @@ class SourceCartService
return true;
}
public function syncItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): void {
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null) {
return;
}
$sourceCart->items()
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
->where('variant_id', $purchaseItem->source_variant_id)
->update(['cantidad' => $quantity]);
}
public function finalize(Purchase $purchase): void
{
$sourceCart = $this->findSourceCart($purchase);
@@ -112,7 +103,7 @@ class SourceCartService
->first();
if ($activeItem === null) {
$activeCart->items()->create([
$activeItem = $activeCart->items()->create([
'catalog_item_id' => $sourceItem->catalog_item_id,
'variant_id' => $sourceItem->variant_id,
'cantidad' => $sourceItem->cantidad,
@@ -120,6 +111,8 @@ class SourceCartService
} else {
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
}
$this->reservations->transfer($sourceItem, $activeItem);
}
}
}

View File

@@ -7,6 +7,7 @@ use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
@@ -20,9 +21,9 @@ class StartCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly StockReservationService $reservations,
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly InsufficientStockMessageBuilder $stockMessages,
) {}
@@ -144,9 +145,24 @@ class StartCheckoutService
throw new InsufficientStockException($unavailableItems);
}
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
'guest_token' => null,
'status' => 'checkout',
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$cartItems = collect();
foreach ($resolvedLines as $line) {
$cartItem = $cart->items()->create([
'catalog_item_id' => $line['catalog_item_id'],
'variant_id' => $line['variant_id'],
'cantidad' => $line['quantity'],
]);
try {
$this->inventory->reserve($line['selection'], $line['quantity']);
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
@@ -154,6 +170,10 @@ class StartCheckoutService
$this->unavailableItem($line, $availableQuantity),
]);
}
$cartItem->setRelation('catalogItem', $line['catalog_item']);
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
$cartItems->push($cartItem);
}
$purchase = $this->createPurchase(
@@ -163,19 +183,16 @@ class StartCheckoutService
(float) $resolvedLines->sum(
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
),
null,
$cart->getKey(),
);
$directCartItems = $resolvedLines->map(fn (array $line): CartItem => $this->makeDirectCartItem(
$line['selection'],
$line['catalog_item_id'],
$line['variant_id'],
$line['quantity'],
));
$purchase->items()->createMany(
$this->snapshots->fromCartItems($directCartItems),
);
foreach ($cartItems as $index => $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$resolvedLines->get($index)['selection'],
$purchase,
);
}
return $this->loadPurchase($purchase);
}
@@ -235,7 +252,13 @@ class StartCheckoutService
$cart->getTotalAmount(),
$cart->getKey(),
);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
foreach ($cartItems as $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$cartItem->selectedItem(),
$purchase,
);
}
// The purchase owns the reservation until checkout finishes. The cart is
// retained so it can be restored if the purchase is cancelled or expires.
@@ -336,35 +359,6 @@ class StartCheckoutService
]);
}
private function makeDirectCartItem(
CatalogItem|Variant $selection,
int $catalogItemId,
?int $variantId,
int $quantity,
): CartItem {
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
$catalogItem->loadMissing(['inventory', 'attachments']);
if ($selection instanceof Variant) {
$selection->loadMissing([
'inventory',
'attachments',
'catalogItem',
'definitions.itemAttribute.attribute',
]);
}
$item = new CartItem([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
$item->setRelation('catalogItem', $catalogItem);
$item->setRelation('variant', $selection instanceof Variant ? $selection : null);
return $item;
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
@@ -382,6 +376,15 @@ class StartCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $purchase->load([
'items.imageAttachment',
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
@@ -23,6 +23,7 @@ class CheckoutService
private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser,
private readonly StockReservationService $reservations,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -47,19 +48,6 @@ class CheckoutService
return $this->editor->updateCustomer($purchase, $customerData);
}
public function updateItemQuantity(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
): Purchase {
return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity);
}
public function prepareItemEditing(Purchase $purchase): Purchase
{
return $this->editor->prepareItemEditing($purchase);
}
public function confirmPurchase(Purchase $purchase): void
{
$this->completer->confirm($purchase);
@@ -94,4 +82,9 @@ class CheckoutService
{
return $this->releaser->expireOverdue();
}
public function syncReservationExpiration(Purchase $purchase): void
{
$this->reservations->syncPurchaseExpiration($purchase);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
@@ -52,7 +53,24 @@ class UserPurchaseLimitService
})
->sum('cantidad');
if ($purchasedQuantity + $requestedQuantity > $limit) {
$checkoutQuantity = (int) CartItem::query()
->where('catalog_item_id', $catalogItem->getKey())
->whereHas('cart.purchases', function ($query) use ($userId, $excludedPurchaseId): void {
$query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
])
->whereDoesntHave('items')
->when(
$excludedPurchaseId !== null,
fn ($query) => $query->whereKeyNot($excludedPurchaseId),
);
})
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $requestedQuantity > $limit) {
throw ValidationException::withMessages([
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
]);

View File

@@ -2,12 +2,12 @@
## Propósito
Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma una instantánea de sus ítems, reserva inventario, permite ediciones, inicia el pago y confirma, cancela o vence la operación.
Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un carrito, mantiene sus líneas vivas contra catálogo durante el checkout, inicia el pago y materializa el snapshot definitivo al confirmar, o cancela y vence la operación.
## Modelo
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
- `PurchaseItem`: snapshot del producto o variante, cantidad, precio y total al comprar.
- `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.
@@ -15,13 +15,17 @@ Implementa el ciclo de compra y checkout: crea una compra desde el carrito, toma
`CheckoutService` es la fachada estable. Delega en:
- `StartCheckoutService`: inicia la compra desde el carrito.
- `EditCheckoutService`: modifica cliente o cantidades antes del cierre.
- `CompleteCheckoutService`: completa, envía a revisión o confirma el pago.
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente.
- `SourceCartService`: restaura o finaliza el carrito fuente.
- `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.
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.
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
## Endpoints

View File

@@ -7,8 +7,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
Route::get('compras', [PurchaseController::class, 'index']);
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']);
Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItemQuantity']);
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);