refactor(checkout): materialize purchase items on confirmation
This commit is contained in:
parent
e6c4b40a37
commit
f1649e0e4b
|
|
@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Controllers;
|
||||||
|
|
||||||
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
use App\Domains\Integration\Services\TelepagosIntegrationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
||||||
use App\Domains\Purchase\Requests\StartCheckoutRequest;
|
use App\Domains\Purchase\Requests\StartCheckoutRequest;
|
||||||
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
|
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
|
||||||
|
|
@ -56,7 +55,16 @@ class PurchaseController extends Controller
|
||||||
{
|
{
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$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');
|
$compra->items->load('imageAttachment');
|
||||||
|
|
||||||
return PurchaseResource::make($compra);
|
return PurchaseResource::make($compra);
|
||||||
|
|
@ -79,7 +87,7 @@ class PurchaseController extends Controller
|
||||||
UpdatePurchaseItemQuantityRequest $request,
|
UpdatePurchaseItemQuantityRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Purchase $compra,
|
Purchase $compra,
|
||||||
PurchaseItem $item,
|
int $item,
|
||||||
CheckoutService $checkoutService,
|
CheckoutService $checkoutService,
|
||||||
): PurchaseResource {
|
): PurchaseResource {
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$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);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
$method = $request->validated('method');
|
$method = $request->validated('method');
|
||||||
$totalAmount = $compra->calculateCurrentTotalAmount();
|
$totalAmount = $compra->calculateCurrentTotalAmount();
|
||||||
|
|
@ -148,13 +160,14 @@ class PurchaseController extends Controller
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($updated === 0) {
|
if (! $updated) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'purchase' => __('api.purchase.not_available_for_payment'),
|
'purchase' => __('api.purchase.not_available_for_payment'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$compra->refresh();
|
$compra->refresh();
|
||||||
|
$checkoutService->syncReservationExpiration($compra);
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
|
@ -116,6 +117,12 @@ class Purchase extends Model
|
||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
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>
|
* @return HasOne<TelepagosQr, $this>
|
||||||
*/
|
*/
|
||||||
|
|
@ -143,11 +150,16 @@ class Purchase extends Model
|
||||||
|
|
||||||
public function calculateCurrentTotalAmount(): float
|
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->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
|
protected function valueChangeTenantCode(): string
|
||||||
|
|
|
||||||
|
|
@ -57,22 +57,12 @@ class PurchaseItemResource extends JsonResource
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'unit_price' => $this->formatMoney($unitPrice),
|
'unit_price' => $this->formatMoney($unitPrice),
|
||||||
'line_total' => $this->formatMoney($lineTotal),
|
'line_total' => $this->formatMoney($lineTotal),
|
||||||
'catalog_item_id' => $this->catalog_item_id,
|
'source_catalog_item_id' => $this->catalog_item_id,
|
||||||
'variant_id' => $this->variant_id,
|
'source_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),
|
|
||||||
],
|
|
||||||
'item_details' => $selectedItem === null ? null : [
|
'item_details' => $selectedItem === null ? null : [
|
||||||
'nombre' => $selectedItem->getName(),
|
'nombre' => $selectedItem->getName(),
|
||||||
'descripcion' => $selectedItem->getDescription(),
|
'descripcion' => $selectedItem->getDescription(),
|
||||||
|
'slug' => $catalogItem?->slug,
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
|
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Resources;
|
namespace App\Domains\Purchase\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -17,26 +18,37 @@ class PurchaseResource extends JsonResource
|
||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$items = $this->resource->relationLoaded('items')
|
$purchaseItems = $this->resource->relationLoaded('items')
|
||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: 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())
|
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||||
? (int) $this->resource->getAttribute('tickets_count')
|
? (int) $this->resource->getAttribute('tickets_count')
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$subtotal = $items->isNotEmpty()
|
$subtotal = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $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,
|
0.0,
|
||||||
)
|
)
|
||||||
: (float) ($this->total ?? 0);
|
: (float) ($this->total ?? 0);
|
||||||
|
|
||||||
$total = $items->isNotEmpty()
|
$total = $this->status === Purchase::STATUS_PAID && $this->total !== null
|
||||||
? $items->reduce(
|
? (float) $this->total
|
||||||
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
|
: ($items->isNotEmpty()
|
||||||
0.0,
|
? $items->reduce(
|
||||||
)
|
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
|
||||||
: (float) ($this->total ?? 0);
|
0.0,
|
||||||
|
)
|
||||||
|
: (float) ($this->total ?? 0));
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
|
@ -52,7 +64,7 @@ class PurchaseResource extends JsonResource
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
'nombre_apellido' => $this->nombre_apellido,
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
|
'items_source' => $itemsSource,
|
||||||
'items' => PurchaseItemResource::collection($items),
|
'items' => PurchaseItemResource::collection($items),
|
||||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
'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;
|
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);
|
return (float) ($item->total ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
|
@ -12,8 +15,10 @@ class CompleteCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
private readonly CatalogSelectionResolver $selections,
|
||||||
private readonly SourceCartService $sourceCart,
|
private readonly SourceCartService $sourceCart,
|
||||||
|
private readonly PurchaseItemSnapshotFactory $snapshots,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function complete(Purchase $purchase): Purchase
|
public function complete(Purchase $purchase): Purchase
|
||||||
|
|
@ -59,6 +64,7 @@ class CompleteCheckoutService
|
||||||
}
|
}
|
||||||
|
|
||||||
$purchase->update(['expires_at' => null]);
|
$purchase->update(['expires_at' => null]);
|
||||||
|
$this->reservations->syncPurchaseExpiration($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
|
|
@ -88,6 +94,51 @@ class CompleteCheckoutService
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get();
|
->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) {
|
foreach ($items as $item) {
|
||||||
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
|
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
|
||||||
|
|
||||||
|
|
@ -126,6 +177,30 @@ class CompleteCheckoutService
|
||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
|
|
@ -15,6 +17,7 @@ class EditCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
private readonly CatalogSelectionResolver $selections,
|
||||||
private readonly SourceCartService $sourceCart,
|
private readonly SourceCartService $sourceCart,
|
||||||
|
|
@ -35,10 +38,10 @@ class EditCheckoutService
|
||||||
|
|
||||||
public function updateItemQuantity(
|
public function updateItemQuantity(
|
||||||
Purchase $purchase,
|
Purchase $purchase,
|
||||||
PurchaseItem $purchaseItem,
|
int $itemId,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
): Purchase {
|
): Purchase {
|
||||||
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
|
return DB::transaction(function () use ($purchase, $itemId, $quantity): Purchase {
|
||||||
$purchase = $this->lockPurchase($purchase);
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
|
||||||
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($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;
|
$difference = $quantity - (int) $purchaseItem->cantidad;
|
||||||
|
|
||||||
if ($difference !== 0) {
|
if ($difference !== 0) {
|
||||||
|
|
@ -84,10 +91,66 @@ class EditCheckoutService
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (! $purchase->items()->exists()) {
|
||||||
|
$this->attachCartReservations($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->loadPurchase($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(
|
private function adjustReservation(
|
||||||
Purchase $purchase,
|
Purchase $purchase,
|
||||||
PurchaseItem $purchaseItem,
|
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 */
|
/** @var PurchaseItem|null $lockedItem */
|
||||||
$lockedItem = $purchase->items()
|
$lockedItem = $purchase->items()
|
||||||
->whereKey($item->getKey())
|
->whereKey($itemId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|
@ -142,6 +205,20 @@ class EditCheckoutService
|
||||||
return $lockedItem;
|
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
|
private function assertEditable(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
if (
|
if (
|
||||||
|
|
@ -170,6 +247,15 @@ class EditCheckoutService
|
||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services\Checkout;
|
namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
@ -12,6 +14,7 @@ class ReleaseCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
private readonly CatalogSelectionResolver $selections,
|
||||||
private readonly SourceCartService $sourceCart,
|
private readonly SourceCartService $sourceCart,
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -83,6 +86,13 @@ class ReleaseCheckoutService
|
||||||
->get();
|
->get();
|
||||||
$reservationReturnedToCart = $restoreCart && $this->sourceCart->restore($purchase);
|
$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) {
|
foreach ($items as $item) {
|
||||||
if (! $reservationReturnedToCart) {
|
if (! $reservationReturnedToCart) {
|
||||||
$this->releaseInventory($purchase, $item);
|
$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
|
private function releaseInventory(Purchase $purchase, PurchaseItem $item): void
|
||||||
{
|
{
|
||||||
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
|
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $item);
|
||||||
|
|
@ -129,6 +192,15 @@ class ReleaseCheckoutService
|
||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,16 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
|
||||||
class SourceCartService
|
class SourceCartService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function restore(Purchase $purchase): bool
|
public function restore(Purchase $purchase): bool
|
||||||
{
|
{
|
||||||
$sourceCart = $this->findSourceCart($purchase);
|
$sourceCart = $this->findSourceCart($purchase);
|
||||||
|
|
@ -17,6 +22,10 @@ class SourceCartService
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($sourceCart->origin === Cart::ORIGIN_DIRECT_CHECKOUT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** @var Cart|null $activeCart */
|
/** @var Cart|null $activeCart */
|
||||||
$activeCart = Cart::query()
|
$activeCart = Cart::query()
|
||||||
->where('tenant_codigo', $purchase->tenant_codigo)
|
->where('tenant_codigo', $purchase->tenant_codigo)
|
||||||
|
|
@ -112,7 +121,7 @@ class SourceCartService
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($activeItem === null) {
|
if ($activeItem === null) {
|
||||||
$activeCart->items()->create([
|
$activeItem = $activeCart->items()->create([
|
||||||
'catalog_item_id' => $sourceItem->catalog_item_id,
|
'catalog_item_id' => $sourceItem->catalog_item_id,
|
||||||
'variant_id' => $sourceItem->variant_id,
|
'variant_id' => $sourceItem->variant_id,
|
||||||
'cantidad' => $sourceItem->cantidad,
|
'cantidad' => $sourceItem->cantidad,
|
||||||
|
|
@ -120,6 +129,8 @@ class SourceCartService
|
||||||
} else {
|
} else {
|
||||||
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
|
$activeItem->increment('cantidad', (int) $sourceItem->cantidad);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->reservations->transfer($sourceItem, $activeItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
|
|
@ -20,9 +21,9 @@ class StartCheckoutService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||||
private readonly CatalogSelectionResolver $selections,
|
private readonly CatalogSelectionResolver $selections,
|
||||||
private readonly PurchaseItemSnapshotFactory $snapshots,
|
|
||||||
private readonly InsufficientStockMessageBuilder $stockMessages,
|
private readonly InsufficientStockMessageBuilder $stockMessages,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -144,9 +145,24 @@ class StartCheckoutService
|
||||||
throw new InsufficientStockException($unavailableItems);
|
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) {
|
foreach ($resolvedLines as $line) {
|
||||||
|
$cartItem = $cart->items()->create([
|
||||||
|
'catalog_item_id' => $line['catalog_item_id'],
|
||||||
|
'variant_id' => $line['variant_id'],
|
||||||
|
'cantidad' => $line['quantity'],
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->inventory->reserve($line['selection'], $line['quantity']);
|
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
|
||||||
} catch (\InvalidArgumentException) {
|
} catch (\InvalidArgumentException) {
|
||||||
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
|
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
|
||||||
|
|
||||||
|
|
@ -154,6 +170,10 @@ class StartCheckoutService
|
||||||
$this->unavailableItem($line, $availableQuantity),
|
$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(
|
$purchase = $this->createPurchase(
|
||||||
|
|
@ -163,19 +183,16 @@ class StartCheckoutService
|
||||||
(float) $resolvedLines->sum(
|
(float) $resolvedLines->sum(
|
||||||
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
|
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
|
||||||
),
|
),
|
||||||
null,
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$directCartItems = $resolvedLines->map(fn (array $line): CartItem => $this->makeDirectCartItem(
|
foreach ($cartItems as $index => $cartItem) {
|
||||||
$line['selection'],
|
$this->reservations->attachToPurchase(
|
||||||
$line['catalog_item_id'],
|
$cartItem,
|
||||||
$line['variant_id'],
|
$resolvedLines->get($index)['selection'],
|
||||||
$line['quantity'],
|
$purchase,
|
||||||
));
|
);
|
||||||
|
}
|
||||||
$purchase->items()->createMany(
|
|
||||||
$this->snapshots->fromCartItems($directCartItems),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
@ -235,7 +252,13 @@ class StartCheckoutService
|
||||||
$cart->getTotalAmount(),
|
$cart->getTotalAmount(),
|
||||||
$cart->getKey(),
|
$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
|
// The purchase owns the reservation until checkout finishes. The cart is
|
||||||
// retained so it can be restored if the purchase is cancelled or expires.
|
// 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 */
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
private function loadCartItems(Collection $cartItems): void
|
private function loadCartItems(Collection $cartItems): void
|
||||||
{
|
{
|
||||||
|
|
@ -382,6 +376,15 @@ class StartCheckoutService
|
||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
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',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
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\CompleteCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
|
|
@ -23,6 +23,7 @@ class CheckoutService
|
||||||
private readonly EditCheckoutService $editor,
|
private readonly EditCheckoutService $editor,
|
||||||
private readonly CompleteCheckoutService $completer,
|
private readonly CompleteCheckoutService $completer,
|
||||||
private readonly ReleaseCheckoutService $releaser,
|
private readonly ReleaseCheckoutService $releaser,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param array<string, mixed> $purchaseData */
|
/** @param array<string, mixed> $purchaseData */
|
||||||
|
|
@ -49,10 +50,10 @@ class CheckoutService
|
||||||
|
|
||||||
public function updateItemQuantity(
|
public function updateItemQuantity(
|
||||||
Purchase $purchase,
|
Purchase $purchase,
|
||||||
PurchaseItem $purchaseItem,
|
int $itemId,
|
||||||
int $quantity,
|
int $quantity,
|
||||||
): Purchase {
|
): Purchase {
|
||||||
return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity);
|
return $this->editor->updateItemQuantity($purchase, $itemId, $quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function prepareItemEditing(Purchase $purchase): Purchase
|
public function prepareItemEditing(Purchase $purchase): Purchase
|
||||||
|
|
@ -94,4 +95,9 @@ class CheckoutService
|
||||||
{
|
{
|
||||||
return $this->releaser->expireOverdue();
|
return $this->releaser->expireOverdue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function syncReservationExpiration(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
$this->reservations->syncPurchaseExpiration($purchase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
|
@ -52,7 +53,24 @@ class UserPurchaseLimitService
|
||||||
})
|
})
|
||||||
->sum('cantidad');
|
->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([
|
throw ValidationException::withMessages([
|
||||||
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
|
$field => __('api.purchase_limit.exceeded', ['max' => $limit]),
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@
|
||||||
|
|
||||||
## Propósito
|
## 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
|
## Modelo
|
||||||
|
|
||||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
|
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `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.
|
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
||||||
|
|
||||||
|
|
@ -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:
|
`CheckoutService` es la fachada estable. Delega en:
|
||||||
|
|
||||||
- `StartCheckoutService`: inicia la compra desde el carrito.
|
- `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 antes del cierre.
|
- `EditCheckoutService`: modifica cliente o cantidades del carrito de checkout antes del cierre.
|
||||||
- `CompleteCheckoutService`: completa, envía a revisión o confirma el pago.
|
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
||||||
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
||||||
- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente.
|
- `SourceCartService`: sincroniza, restaura o finaliza el carrito fuente.
|
||||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
||||||
|
|
||||||
|
Durante `created` y `pending_payment`, `PurchaseResource` publica las líneas del carrito con `items_source=cart`; una compra materializada publica `items_source=purchase`. Los datos descriptivos y económicos del checkout se resuelven siempre desde el catálogo vigente.
|
||||||
|
|
||||||
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
|
||||||
|
|
@ -193,12 +193,14 @@ class TelepagosWebhookTest extends TestCase
|
||||||
'sold_units' => 1,
|
'sold_units' => 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', [
|
||||||
'compra_id' => $newerPurchase->id,
|
'compra_id' => $newerPurchase->id,
|
||||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
]);
|
||||||
'source_variant_id' => $variant->id,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cantidad' => 2,
|
'purchase_id' => $newerPurchase->id,
|
||||||
'reservation_status' => 'active',
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertSoftDeleted('carritos', [
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase
|
||||||
Queue::fake();
|
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 = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$tenant->update([
|
$tenant->update([
|
||||||
|
|
@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase
|
||||||
$response->assertJsonPath('data.email', null);
|
$response->assertJsonPath('data.email', null);
|
||||||
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
$response->assertJsonPath('data.tenant_codigo', 'sonder');
|
||||||
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
|
$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->assertJsonCount(1, 'data.items');
|
||||||
$response->assertJsonPath('data.subtotal', '100.00');
|
$response->assertJsonPath('data.subtotal', '100.00');
|
||||||
$response->assertJsonPath('data.total', '100.00');
|
$response->assertJsonPath('data.total', '100.00');
|
||||||
|
|
@ -109,12 +109,12 @@ class StorePurchaseTest extends TestCase
|
||||||
'status' => Purchase::STATUS_CREATED,
|
'status' => Purchase::STATUS_CREATED,
|
||||||
'total' => 100,
|
'total' => 100,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
|
||||||
'compra_id' => $purchaseId,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'inventory_id' => $inventory->id,
|
||||||
'source_variant_id' => $variant->id,
|
'purchase_id' => $purchaseId,
|
||||||
'cantidad' => 2,
|
'quantity' => 2,
|
||||||
'reservation_status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $cartId,
|
'id' => $cartId,
|
||||||
|
|
@ -133,9 +133,22 @@ class StorePurchaseTest extends TestCase
|
||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
'reserved_stock' => 2,
|
'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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
@ -152,18 +165,23 @@ class StorePurchaseTest extends TestCase
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.cart_id', null)
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
|
||||||
->assertJsonPath('data.items.0.quantity', 3)
|
->assertJsonPath('data.items.0.quantity', 3)
|
||||||
->assertJsonPath('data.total', '150.00');
|
->assertJsonPath('data.total', '150.00');
|
||||||
|
|
||||||
$this->assertDatabaseCount('carritos', 0);
|
$this->assertDatabaseHas('carritos', [
|
||||||
$this->assertDatabaseHas('compra_items', [
|
'id' => $response->json('data.cart_id'),
|
||||||
|
'status' => 'checkout',
|
||||||
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseMissing('compra_items', [
|
||||||
'compra_id' => $response->json('data.id'),
|
'compra_id' => $response->json('data.id'),
|
||||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
]);
|
||||||
'source_variant_id' => $variant->id,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cantidad' => 3,
|
'inventory_id' => $variant->inventory_id,
|
||||||
'reservation_status' => 'active',
|
'purchase_id' => $response->json('data.id'),
|
||||||
|
'quantity' => 3,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
|
|
@ -176,9 +194,9 @@ class StorePurchaseTest extends TestCase
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
||||||
|
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'compra_id' => $response->json('data.id'),
|
'purchase_id' => $response->json('data.id'),
|
||||||
'reservation_status' => 'released',
|
'status' => 'released',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
|
|
@ -215,7 +233,7 @@ class StorePurchaseTest extends TestCase
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.cart_id', null)
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(2, 'data.items')
|
->assertJsonCount(2, 'data.items')
|
||||||
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
|
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
|
||||||
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
|
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
|
||||||
|
|
@ -223,18 +241,22 @@ class StorePurchaseTest extends TestCase
|
||||||
|
|
||||||
$purchaseId = $response->json('data.id');
|
$purchaseId = $response->json('data.id');
|
||||||
|
|
||||||
$this->assertDatabaseCount('carritos', 0);
|
$this->assertDatabaseHas('carritos', [
|
||||||
$this->assertDatabaseHas('compra_items', [
|
'id' => $response->json('data.cart_id'),
|
||||||
'compra_id' => $purchaseId,
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
'source_variant_id' => $firstVariant->id,
|
|
||||||
'cantidad' => 1,
|
|
||||||
'reservation_status' => 'active',
|
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
|
||||||
'compra_id' => $purchaseId,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'source_variant_id' => $secondVariant->id,
|
'inventory_id' => $firstVariant->inventory_id,
|
||||||
'cantidad' => 1,
|
'purchase_id' => $purchaseId,
|
||||||
'reservation_status' => 'active',
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'inventory_id' => $secondVariant->inventory_id,
|
||||||
|
'purchase_id' => $purchaseId,
|
||||||
|
'quantity' => 1,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $firstVariant->inventory_id,
|
'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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
@ -479,7 +501,7 @@ class StorePurchaseTest extends TestCase
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
->assertCreated()
|
->assertCreated()
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.quantity', 2)
|
->assertJsonPath('data.items.0.quantity', 2)
|
||||||
->assertJsonPath('data.dni', null)
|
->assertJsonPath('data.dni', null)
|
||||||
|
|
@ -543,7 +565,7 @@ class StorePurchaseTest extends TestCase
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
$itemId = $purchase->items->firstOrFail()->id;
|
$itemId = $purchase->cart->items->firstOrFail()->id;
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
->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 = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$variant->catalogItem->update(['max_units_per_user' => 3]);
|
$variant->catalogItem->update(['max_units_per_user' => 3]);
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
$itemId = $purchase->items->firstOrFail()->id;
|
$itemId = $purchase->cart->items->firstOrFail()->id;
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
|
||||||
|
|
@ -596,7 +618,7 @@ class StorePurchaseTest extends TestCase
|
||||||
->assertUnprocessable()
|
->assertUnprocessable()
|
||||||
->assertJsonValidationErrors('quantity');
|
->assertJsonValidationErrors('quantity');
|
||||||
|
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseHas('carrito_items', [
|
||||||
'id' => $itemId,
|
'id' => $itemId,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
]);
|
]);
|
||||||
|
|
@ -797,9 +819,11 @@ class StorePurchaseTest extends TestCase
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
'status' => Purchase::STATUS_EXPIRED,
|
'status' => Purchase::STATUS_EXPIRED,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('compra_items', [
|
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]);
|
||||||
'compra_id' => $purchase->id,
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'reservation_status' => 'released',
|
'purchase_id' => null,
|
||||||
|
'quantity' => 3,
|
||||||
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
|
|
@ -819,7 +843,7 @@ class StorePurchaseTest extends TestCase
|
||||||
->assertSuccessful();
|
->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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
|
|
@ -832,7 +856,7 @@ class StorePurchaseTest extends TestCase
|
||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.quantity', 2)
|
->assertJsonPath('data.items.0.quantity', 2)
|
||||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||||
|
|
@ -863,7 +887,7 @@ class StorePurchaseTest extends TestCase
|
||||||
'type' => AttachmentType::Image,
|
'type' => AttachmentType::Image,
|
||||||
'mime_type' => 'image/png',
|
'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')
|
$this->actingAs($user, 'sanctum')
|
||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
|
|
@ -871,7 +895,7 @@ class StorePurchaseTest extends TestCase
|
||||||
->assertJsonPath('data.items.0.item_details.imagen', null);
|
->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');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
|
|
@ -890,7 +914,7 @@ class StorePurchaseTest extends TestCase
|
||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
|
||||||
->assertJsonPath('data.items_source', 'purchase')
|
->assertJsonPath('data.items_source', 'cart')
|
||||||
->assertJsonCount(1, 'data.items')
|
->assertJsonCount(1, 'data.items')
|
||||||
->assertJsonPath('data.items.0.quantity', 2)
|
->assertJsonPath('data.items.0.quantity', 2)
|
||||||
->assertJsonPath('data.items.0.unit_price', '50.00')
|
->assertJsonPath('data.items.0.unit_price', '50.00')
|
||||||
|
|
@ -925,6 +949,12 @@ class StorePurchaseTest extends TestCase
|
||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
'sold_units' => 2,
|
'sold_units' => 2,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'purchase_id' => $purchase->id,
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
'status' => 'committed',
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertSoftDeleted('carritos', [
|
||||||
'id' => $purchase->cart_id,
|
'id' => $purchase->cart_id,
|
||||||
|
|
@ -956,7 +986,9 @@ class StorePurchaseTest extends TestCase
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$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')
|
$this->actingAs($user, 'sanctum')
|
||||||
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue