diff --git a/app/Domains/Purchase/Controllers/PurchaseController.php b/app/Domains/Purchase/Controllers/PurchaseController.php index fbb56f4..440d6a5 100644 --- a/app/Domains/Purchase/Controllers/PurchaseController.php +++ b/app/Domains/Purchase/Controllers/PurchaseController.php @@ -4,7 +4,6 @@ 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; @@ -56,7 +55,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); @@ -79,7 +87,7 @@ class PurchaseController extends Controller UpdatePurchaseItemQuantityRequest $request, Tenant $tenant, Purchase $compra, - PurchaseItem $item, + int $item, CheckoutService $checkoutService, ): PurchaseResource { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); @@ -106,8 +114,12 @@ class PurchaseController extends Controller ); } - public function paymentIntent(PaymentIntentRequest $request, Tenant $tenant, Purchase $compra): JsonResponse - { + public function paymentIntent( + PaymentIntentRequest $request, + Tenant $tenant, + Purchase $compra, + CheckoutService $checkoutService, + ): JsonResponse { $compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra); $method = $request->validated('method'); $totalAmount = $compra->calculateCurrentTotalAmount(); @@ -148,13 +160,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; diff --git a/app/Domains/Purchase/Models/Purchase.php b/app/Domains/Purchase/Models/Purchase.php index a5dcb11..7bb3d94 100644 --- a/app/Domains/Purchase/Models/Purchase.php +++ b/app/Domains/Purchase/Models/Purchase.php @@ -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 */ + public function stockReservations(): HasMany + { + return $this->hasMany(StockReservation::class); + } + /** * @return HasOne */ @@ -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 diff --git a/app/Domains/Purchase/Resources/PurchaseItemResource.php b/app/Domains/Purchase/Resources/PurchaseItemResource.php index 5fb2b90..bd0d700 100644 --- a/app/Domains/Purchase/Resources/PurchaseItemResource.php +++ b/app/Domains/Purchase/Resources/PurchaseItemResource.php @@ -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), ], diff --git a/app/Domains/Purchase/Resources/PurchaseResource.php b/app/Domains/Purchase/Resources/PurchaseResource.php index b383adc..090b52f 100644 --- a/app/Domains/Purchase/Resources/PurchaseResource.php +++ b/app/Domains/Purchase/Resources/PurchaseResource.php @@ -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); } diff --git a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php index d5c2baa..09be4ef 100644 --- a/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/CompleteCheckoutService.php @@ -2,9 +2,12 @@ namespace App\Domains\Purchase\Services\Checkout; +use App\Domains\Cart\Models\CartItem; use App\Domains\Catalog\Services\CatalogInventoryService; +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; @@ -12,8 +15,10 @@ class CompleteCheckoutService { public function __construct( private readonly CatalogInventoryService $inventory, + private readonly StockReservationService $reservations, private readonly CatalogSelectionResolver $selections, private readonly SourceCartService $sourceCart, + private readonly PurchaseItemSnapshotFactory $snapshots, ) {} public function complete(Purchase $purchase): Purchase @@ -59,6 +64,7 @@ class CompleteCheckoutService } $purchase->update(['expires_at' => null]); + $this->reservations->syncPurchaseExpiration($purchase); return $this->loadPurchase($purchase); }); @@ -88,6 +94,51 @@ class CompleteCheckoutService ->lockForUpdate() ->get(); + if ($items->isEmpty() && ! $purchase->items()->exists()) { + $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); + $items = $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'), + ]); + } + + try { + $this->reservations->commit($cartItem, $selection); + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'items' => __('api.purchase.inconsistent_reservation'), + ]); + } + } + + $purchase->items()->update([ + 'reservation_status' => PurchaseItem::RESERVATION_COMMITTED, + ]); + $this->sourceCart->finalize($purchase); + + return; + } + foreach ($items as $item) { $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); @@ -126,6 +177,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 $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', + ]); } } diff --git a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php index 9491b38..6bee44a 100644 --- a/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/EditCheckoutService.php @@ -2,8 +2,10 @@ namespace App\Domains\Purchase\Services\Checkout; +use App\Domains\Cart\Models\CartItem; 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\Models\PurchaseItem; use App\Domains\Purchase\Services\UserPurchaseLimitService; @@ -15,6 +17,7 @@ class EditCheckoutService { public function __construct( private readonly CatalogInventoryService $inventory, + private readonly StockReservationService $reservations, private readonly UserPurchaseLimitService $purchaseLimits, private readonly CatalogSelectionResolver $selections, private readonly SourceCartService $sourceCart, @@ -35,10 +38,10 @@ class EditCheckoutService public function updateItemQuantity( Purchase $purchase, - PurchaseItem $purchaseItem, + int $itemId, int $quantity, ): Purchase { - return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase { + return DB::transaction(function () use ($purchase, $itemId, $quantity): Purchase { $purchase = $this->lockPurchase($purchase); if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) { @@ -47,7 +50,11 @@ class EditCheckoutService ]); } - $purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem); + if (! $purchase->items()->exists()) { + return $this->updateCartItemQuantity($purchase, $itemId, $quantity); + } + + $purchaseItem = $this->lockPurchaseItem($purchase, $itemId); $difference = $quantity - (int) $purchaseItem->cantidad; if ($difference !== 0) { @@ -84,10 +91,66 @@ class EditCheckoutService ), ]); + if (! $purchase->items()->exists()) { + $this->attachCartReservations($purchase); + } + return $this->loadPurchase($purchase); }); } + private function updateCartItemQuantity(Purchase $purchase, int $itemId, int $quantity): Purchase + { + $cart = $purchase->cart()->lockForUpdate()->first(); + if ($cart === null || $cart->status !== 'checkout') { + throw new NotFoundHttpException('Checkout cart not found.'); + } + + /** @var CartItem|null $cartItem */ + $cartItem = $cart->items()->whereKey($itemId)->lockForUpdate()->first(); + if ($cartItem === null) { + throw new NotFoundHttpException('Checkout item not found.'); + } + + $selection = $this->selections->resolve( + $purchase->tenant, + (int) $cartItem->catalog_item_id, + $cartItem->variant_id === null ? null : (int) $cartItem->variant_id, + 'item', + ); + $difference = $quantity - (int) $cartItem->cantidad; + + try { + if ($difference > 0) { + $otherItemQuantity = (int) $cart->items() + ->where('catalog_item_id', $cartItem->catalog_item_id) + ->whereKeyNot($cartItem->getKey()) + ->sum('cantidad'); + $catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection; + $this->purchaseLimits->assertCanPurchase( + $catalogItem, + (int) $purchase->user_id, + $otherItemQuantity + $quantity, + $purchase->getKey(), + ); + $this->reservations->reserve($cartItem, $selection, $difference); + } elseif ($difference < 0) { + $this->reservations->release($cartItem, $selection, abs($difference)); + } + } catch (\InvalidArgumentException) { + throw ValidationException::withMessages([ + 'quantity' => __('api.purchase.insufficient_stock'), + ]); + } + + $cartItem->update(['cantidad' => $quantity]); + $cart->unsetRelation('items'); + $purchase->setRelation('cart', $cart); + $purchase->update(['total' => $cart->getTotalAmount()]); + + return $this->loadPurchase($purchase); + } + private function adjustReservation( Purchase $purchase, PurchaseItem $purchaseItem, @@ -121,11 +184,11 @@ class EditCheckoutService } } - private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem + private function lockPurchaseItem(Purchase $purchase, int $itemId): PurchaseItem { /** @var PurchaseItem|null $lockedItem */ $lockedItem = $purchase->items() - ->whereKey($item->getKey()) + ->whereKey($itemId) ->lockForUpdate() ->first(); @@ -142,6 +205,20 @@ class EditCheckoutService return $lockedItem; } + private function attachCartReservations(Purchase $purchase): void + { + $cartItems = $purchase->cart?->items()->lockForUpdate()->get() ?? collect(); + foreach ($cartItems as $cartItem) { + $selection = $this->selections->resolve( + $purchase->tenant, + (int) $cartItem->catalog_item_id, + $cartItem->variant_id === null ? null : (int) $cartItem->variant_id, + 'item', + ); + $this->reservations->attachToPurchase($cartItem, $selection, $purchase); + } + } + private function assertEditable(Purchase $purchase): void { if ( @@ -170,6 +247,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', + ]); } } diff --git a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php index aa0ce9f..8dc3ab9 100644 --- a/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/ReleaseCheckoutService.php @@ -2,7 +2,9 @@ namespace App\Domains\Purchase\Services\Checkout; +use App\Domains\Catalog\Models\StockReservation; use App\Domains\Catalog\Services\CatalogInventoryService; +use App\Domains\Catalog\Services\StockReservationService; use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\PurchaseItem; use Illuminate\Support\Facades\DB; @@ -12,6 +14,7 @@ class ReleaseCheckoutService { public function __construct( private readonly CatalogInventoryService $inventory, + private readonly StockReservationService $reservations, private readonly CatalogSelectionResolver $selections, private readonly SourceCartService $sourceCart, ) {} @@ -83,6 +86,13 @@ class ReleaseCheckoutService ->get(); $reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase); + if ($items->isEmpty() && ! $purchase->items()->exists()) { + $this->releaseCartReservations($purchase, $reservationReturnedToCart, $targetStatus); + $purchase->update(['status' => $targetStatus]); + + return $this->loadPurchase($purchase); + } + foreach ($items as $item) { if (! $reservationReturnedToCart) { $this->releaseInventory($purchase, $item); @@ -99,6 +109,59 @@ class ReleaseCheckoutService }); } + private function releaseCartReservations( + Purchase $purchase, + bool $reservationReturnedToCart, + string $targetStatus, + ): void { + if ($reservationReturnedToCart) { + $this->reservations->detachFromPurchase($purchase); + + 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(); + } + } + private function releaseInventory(Purchase $purchase, PurchaseItem $item): void { $selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item); @@ -129,6 +192,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', + ]); } } diff --git a/app/Domains/Purchase/Services/Checkout/SourceCartService.php b/app/Domains/Purchase/Services/Checkout/SourceCartService.php index 45624cd..d81a843 100644 --- a/app/Domains/Purchase/Services/Checkout/SourceCartService.php +++ b/app/Domains/Purchase/Services/Checkout/SourceCartService.php @@ -4,11 +4,16 @@ 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 +22,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) @@ -112,7 +121,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 +129,8 @@ class SourceCartService } else { $activeItem->increment('cantidad', (int) $sourceItem->cantidad); } + + $this->reservations->transfer($sourceItem, $activeItem); } } } diff --git a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php index 60a7ccb..f59b812 100644 --- a/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php +++ b/app/Domains/Purchase/Services/Checkout/StartCheckoutService.php @@ -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 $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', + ]); } } diff --git a/app/Domains/Purchase/Services/CheckoutService.php b/app/Domains/Purchase/Services/CheckoutService.php index b575acd..47e5c82 100644 --- a/app/Domains/Purchase/Services/CheckoutService.php +++ b/app/Domains/Purchase/Services/CheckoutService.php @@ -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 $purchaseData */ @@ -49,10 +50,10 @@ class CheckoutService public function updateItemQuantity( Purchase $purchase, - PurchaseItem $purchaseItem, + int $itemId, int $quantity, ): Purchase { - return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity); + return $this->editor->updateItemQuantity($purchase, $itemId, $quantity); } public function prepareItemEditing(Purchase $purchase): Purchase @@ -94,4 +95,9 @@ class CheckoutService { return $this->releaser->expireOverdue(); } + + public function syncReservationExpiration(Purchase $purchase): void + { + $this->reservations->syncPurchaseExpiration($purchase); + } } diff --git a/app/Domains/Purchase/Services/UserPurchaseLimitService.php b/app/Domains/Purchase/Services/UserPurchaseLimitService.php index 8b3cc58..c45d096 100644 --- a/app/Domains/Purchase/Services/UserPurchaseLimitService.php +++ b/app/Domains/Purchase/Services/UserPurchaseLimitService.php @@ -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]), ]); diff --git a/app/Domains/Purchase/documentacion/README.md b/app/Domains/Purchase/documentacion/README.md index 35d53d0..117ffbe 100644 --- a/app/Domains/Purchase/documentacion/README.md +++ b/app/Domains/Purchase/documentacion/README.md @@ -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,15 @@ 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 cliente o cantidades del carrito de checkout 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. - `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. + `UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones. ## Endpoints diff --git a/tests/Feature/Integration/TelepagosWebhookTest.php b/tests/Feature/Integration/TelepagosWebhookTest.php index b0f3f80..c17fdf5 100644 --- a/tests/Feature/Integration/TelepagosWebhookTest.php +++ b/tests/Feature/Integration/TelepagosWebhookTest.php @@ -193,12 +193,14 @@ class TelepagosWebhookTest extends TestCase 'sold_units' => 1, ]); - $this->assertDatabaseHas('compra_items', [ + $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $newerPurchase->id, - 'source_catalog_item_id' => $variant->catalog_item_id, - 'source_variant_id' => $variant->id, - 'cantidad' => 2, - 'reservation_status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $newerPurchase->id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, + 'status' => 'active', ]); $this->assertSoftDeleted('carritos', [ diff --git a/tests/Feature/Purchase/StorePurchaseTest.php b/tests/Feature/Purchase/StorePurchaseTest.php index 601567a..b4fb977 100644 --- a/tests/Feature/Purchase/StorePurchaseTest.php +++ b/tests/Feature/Purchase/StorePurchaseTest.php @@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase Queue::fake(); } - public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void + public function test_it_starts_checkout_from_cart_without_materializing_purchase_items(): void { $tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $tenant->update([ @@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase $response->assertJsonPath('data.email', null); $response->assertJsonPath('data.tenant_codigo', 'sonder'); $response->assertJsonPath('data.status', Purchase::STATUS_CREATED); - $response->assertJsonPath('data.items_source', 'purchase'); + $response->assertJsonPath('data.items_source', 'cart'); $response->assertJsonCount(1, 'data.items'); $response->assertJsonPath('data.subtotal', '100.00'); $response->assertJsonPath('data.total', '100.00'); @@ -109,12 +109,12 @@ class StorePurchaseTest extends TestCase 'status' => Purchase::STATUS_CREATED, 'total' => 100, ]); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchaseId, - 'source_catalog_item_id' => $catalogItem->id, - 'source_variant_id' => $variant->id, - 'cantidad' => 2, - 'reservation_status' => 'active', + $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $inventory->id, + 'purchase_id' => $purchaseId, + 'quantity' => 2, + 'status' => 'active', ]); $this->assertDatabaseHas('carritos', [ 'id' => $cartId, @@ -133,9 +133,22 @@ class StorePurchaseTest extends TestCase 'real_stock' => 10, 'reserved_stock' => 2, ]); + + $catalogItem->update([ + 'nombre' => 'Updated Product', + 'precio' => '75.00', + ]); + + $this->actingAs($user, 'sanctum') + ->getJson("/api/tenants/sonder/compras/{$purchaseId}") + ->assertOk() + ->assertJsonPath('data.items_source', 'cart') + ->assertJsonPath('data.items.0.item_details.nombre', 'Updated Product') + ->assertJsonPath('data.items.0.unit_price', '75.00') + ->assertJsonPath('data.total', '150.00'); } - public function test_it_creates_a_direct_purchase_without_creating_or_changing_a_cart(): void + public function test_it_creates_a_direct_purchase_with_a_technical_checkout_cart(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -152,18 +165,23 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.cart_id', null) - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonPath('data.items.0.quantity', 3) ->assertJsonPath('data.total', '150.00'); - $this->assertDatabaseCount('carritos', 0); - $this->assertDatabaseHas('compra_items', [ + $this->assertDatabaseHas('carritos', [ + 'id' => $response->json('data.cart_id'), + 'status' => 'checkout', + 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, + ]); + $this->assertDatabaseMissing('compra_items', [ 'compra_id' => $response->json('data.id'), - 'source_catalog_item_id' => $variant->catalog_item_id, - 'source_variant_id' => $variant->id, - 'cantidad' => 3, - 'reservation_status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $variant->inventory_id, + 'purchase_id' => $response->json('data.id'), + 'quantity' => 3, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -176,9 +194,9 @@ class StorePurchaseTest extends TestCase ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CANCELLED); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $response->json('data.id'), - 'reservation_status' => 'released', + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $response->json('data.id'), + 'status' => 'released', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -215,7 +233,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.cart_id', null) + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(2, 'data.items') ->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id) ->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id) @@ -223,18 +241,22 @@ class StorePurchaseTest extends TestCase $purchaseId = $response->json('data.id'); - $this->assertDatabaseCount('carritos', 0); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchaseId, - 'source_variant_id' => $firstVariant->id, - 'cantidad' => 1, - 'reservation_status' => 'active', + $this->assertDatabaseHas('carritos', [ + 'id' => $response->json('data.cart_id'), + 'origin' => Cart::ORIGIN_DIRECT_CHECKOUT, ]); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchaseId, - 'source_variant_id' => $secondVariant->id, - 'cantidad' => 1, - 'reservation_status' => 'active', + $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $firstVariant->inventory_id, + 'purchase_id' => $purchaseId, + 'quantity' => 1, + 'status' => 'active', + ]); + $this->assertDatabaseHas('stock_reservations', [ + 'inventory_id' => $secondVariant->inventory_id, + 'purchase_id' => $purchaseId, + 'quantity' => 1, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $firstVariant->inventory_id, @@ -462,7 +484,7 @@ class StorePurchaseTest extends TestCase ]); } - public function test_it_creates_purchase_items_before_checkout_and_updates_customer_data(): void + public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create(); @@ -479,7 +501,7 @@ class StorePurchaseTest extends TestCase ], ]) ->assertCreated() - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.dni', null) @@ -543,7 +565,7 @@ class StorePurchaseTest extends TestCase $user = User::factory()->create(); $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->items->firstOrFail()->id; + $itemId = $purchase->cart->items->firstOrFail()->id; $this->actingAs($user, 'sanctum') ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ @@ -587,7 +609,7 @@ class StorePurchaseTest extends TestCase $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $variant->catalogItem->update(['max_units_per_user' => 3]); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $itemId = $purchase->items->firstOrFail()->id; + $itemId = $purchase->cart->items->firstOrFail()->id; $this->actingAs($user, 'sanctum') ->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [ @@ -596,7 +618,7 @@ class StorePurchaseTest extends TestCase ->assertUnprocessable() ->assertJsonValidationErrors('quantity'); - $this->assertDatabaseHas('compra_items', [ + $this->assertDatabaseHas('carrito_items', [ 'id' => $itemId, 'cantidad' => 2, ]); @@ -797,9 +819,11 @@ class StorePurchaseTest extends TestCase 'id' => $purchase->id, 'status' => Purchase::STATUS_EXPIRED, ]); - $this->assertDatabaseHas('compra_items', [ - 'compra_id' => $purchase->id, - 'reservation_status' => 'released', + $this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => null, + 'quantity' => 3, + 'status' => 'active', ]); $this->assertDatabaseHas('inventories', [ 'id' => $variant->inventory_id, @@ -819,7 +843,7 @@ class StorePurchaseTest extends TestCase ->assertSuccessful(); } - public function test_purchase_detail_uses_purchase_items_for_created_purchase(): void + public function test_purchase_detail_uses_cart_items_for_created_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -832,7 +856,7 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_CREATED) - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') @@ -863,7 +887,7 @@ class StorePurchaseTest extends TestCase 'type' => AttachmentType::Image, 'mime_type' => 'image/png', ]); - $purchase->items()->firstOrFail()->update(['image_attachment_id' => $image->id]); + $variant->catalogItem->attachments()->attach($image->id, ['orden' => 0]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}") @@ -871,7 +895,7 @@ class StorePurchaseTest extends TestCase ->assertJsonPath('data.items.0.item_details.imagen', null); } - public function test_purchase_detail_uses_purchase_items_for_pending_payment_purchase(): void + public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void { $this->createTenant('sonder', 'Sonder', 'sonder.com.ar'); $user = User::factory()->create([ @@ -890,7 +914,7 @@ class StorePurchaseTest extends TestCase ->getJson("/api/tenants/sonder/compras/{$purchase->id}") ->assertOk() ->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT) - ->assertJsonPath('data.items_source', 'purchase') + ->assertJsonPath('data.items_source', 'cart') ->assertJsonCount(1, 'data.items') ->assertJsonPath('data.items.0.quantity', 2) ->assertJsonPath('data.items.0.unit_price', '50.00') @@ -925,6 +949,12 @@ class StorePurchaseTest extends TestCase 'reserved_stock' => 0, 'sold_units' => 2, ]); + $this->assertDatabaseHas('stock_reservations', [ + 'purchase_id' => $purchase->id, + 'inventory_id' => $variant->inventory_id, + 'quantity' => 2, + 'status' => 'committed', + ]); $this->assertSoftDeleted('carritos', [ 'id' => $purchase->cart_id, @@ -956,7 +986,9 @@ class StorePurchaseTest extends TestCase $variant = $this->createVariantForTenant('sonder', 10, '50.00'); $purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2); - $purchase->update(['cart_id' => null]); + $purchase->update(['payment_method' => 'transfer']); + app(CheckoutService::class)->confirmPurchase($purchase); + $purchase->refresh()->update(['cart_id' => null]); $this->actingAs($user, 'sanctum') ->getJson("/api/tenants/sonder/compras/{$purchase->id}")