feat(cart): enhance item update functionality to support variant changes and improve cart editing policies

This commit is contained in:
2026-08-19 10:46:19 -03:00
parent 014b5bb012
commit 7cea8d3495
17 changed files with 514 additions and 52 deletions

View File

@@ -8,8 +8,9 @@ use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseItemQuantityRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseItemRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
@@ -52,12 +53,16 @@ class PurchaseController extends Controller
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
}
public function show(Request $request, Tenant $tenant, Purchase $compra): PurchaseResource
{
public function show(
Request $request,
Tenant $tenant,
Purchase $compra,
PurchaseResponseLoader $responses,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing('items')->loadCount('tickets');
$compra->items->load('imageAttachment');
$responses->load($compra);
return PurchaseResource::make($compra);
}
@@ -75,8 +80,8 @@ class PurchaseController extends Controller
);
}
public function updateItemQuantity(
UpdatePurchaseItemQuantityRequest $request,
public function updateItem(
UpdatePurchaseItemRequest $request,
Tenant $tenant,
Purchase $compra,
PurchaseItem $item,
@@ -85,10 +90,12 @@ class PurchaseController extends Controller
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->updateItemQuantity(
$checkoutService->updateItem(
$compra,
$item,
(int) $request->validated('quantity'),
$request->exists('quantity') ? (int) $request->validated('quantity') : null,
$request->exists('variant_id') ? (int) $request->validated('variant_id') : null,
$request->exists('variant_id'),
),
);
}

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Purchase\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -66,4 +68,16 @@ class PurchaseItem extends Model
{
return $this->belongsTo(Attachment::class, 'image_attachment_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id')->withTrashed();
}
/** @return BelongsTo<Variant, $this> */
public function sourceVariant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed();
}
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\PurchaseItem;
@@ -25,6 +26,14 @@ class PurchaseItemResource extends JsonResource
: null;
$attributes = $this->variant_attributes ?? [];
$catalogItem = $this->relationLoaded('sourceCatalogItem')
? $this->sourceCatalogItem
: null;
$includeVariants = $tenant instanceof Tenant
&& $tenant->cart_editing_policy->allowsVariantChanges()
&& $catalogItem !== null
&& $catalogItem->relationLoaded('variants');
return [
'id' => $this->id,
'quantity' => (int) $this->cantidad,
@@ -39,6 +48,13 @@ class PurchaseItemResource extends JsonResource
'imagen' => $imageUrl,
'attributes' => $attributes,
],
'variants' => $this->when(
$includeVariants,
fn () => $catalogItem
->visibleVariants($this->source_variant_id)
->map(fn (Variant $variant): array => $this->variantData($catalogItem, $variant))
->values(),
),
];
}
@@ -142,4 +158,17 @@ class PurchaseItemResource extends JsonResource
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
/** @return array<string, mixed> */
private function variantData(CatalogItem $catalogItem, Variant $variant): array
{
return [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->precio ?? $catalogItem->precio),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
}
}

View File

@@ -18,6 +18,8 @@ class EditCheckoutService
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly PurchaseResponseLoader $responses,
) {}
/** @param array<string, string> $customerData */
@@ -33,12 +35,20 @@ class EditCheckoutService
});
}
public function updateItemQuantity(
public function updateItem(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
?int $quantity,
?int $variantId,
bool $updateVariant,
): Purchase {
return DB::transaction(function () use ($purchase, $purchaseItem, $quantity): Purchase {
return DB::transaction(function () use (
$purchase,
$purchaseItem,
$quantity,
$variantId,
$updateVariant,
): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) {
@@ -48,16 +58,40 @@ class EditCheckoutService
}
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
$difference = $quantity - (int) $purchaseItem->cantidad;
$tenant = $purchase->tenant()->firstOrFail();
$finalQuantity = $quantity ?? (int) $purchaseItem->cantidad;
if ($difference !== 0) {
$this->adjustReservation($purchase, $purchaseItem, $quantity, $difference);
$purchaseItem->update([
'cantidad' => $quantity,
'total' => (float) $purchaseItem->precio_unitario * $quantity,
if ($quantity !== null && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'quantity' => __('api.cart.editing_disabled'),
]);
$this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $quantity);
}
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
throw ValidationException::withMessages([
'variant_id' => __('api.purchase.variant_change_disabled'),
]);
}
if ($updateVariant && $variantId !== $purchaseItem->source_variant_id) {
$this->changeItemVariant(
$purchase,
$purchaseItem,
(int) $variantId,
$finalQuantity,
);
} else {
$difference = $finalQuantity - (int) $purchaseItem->cantidad;
if ($difference !== 0) {
$this->adjustReservation($purchase, $purchaseItem, $finalQuantity, $difference);
$purchaseItem->update([
'cantidad' => $finalQuantity,
'total' => (float) $purchaseItem->precio_unitario * $finalQuantity,
]);
$this->sourceCart->syncItemQuantity($purchase, $purchaseItem, $finalQuantity);
}
}
$purchase->update([
@@ -68,12 +102,94 @@ class EditCheckoutService
});
}
private function changeItemVariant(
Purchase $purchase,
PurchaseItem $sourceItem,
int $variantId,
int $quantity,
): void {
$tenant = $purchase->tenant()->firstOrFail();
$currentSelection = $this->selections->resolvePurchaseItem($tenant, $sourceItem);
$targetSelection = $this->selections->resolve(
$tenant,
(int) $sourceItem->source_catalog_item_id,
$variantId,
'item',
);
if (! $targetSelection instanceof Variant) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_required'),
]);
}
/** @var PurchaseItem|null $targetItem */
$targetItem = $purchase->items()
->where('source_catalog_item_id', $sourceItem->source_catalog_item_id)
->where('source_variant_id', $targetSelection->id)
->whereKeyNot($sourceItem->getKey())
->lockForUpdate()
->first();
if ($targetItem !== null && $targetItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
throw ValidationException::withMessages([
'variant_id' => __('api.purchase.item_not_editable'),
]);
}
$otherItemQuantity = (int) $purchase->items()
->where('source_catalog_item_id', $sourceItem->source_catalog_item_id)
->whereKeyNot($sourceItem->getKey())
->sum('cantidad');
$this->purchaseLimits->assertCanPurchase(
$targetSelection->catalogItem,
(int) $purchase->user_id,
$otherItemQuantity + $quantity,
$purchase->getKey(),
'variant_id',
);
try {
$this->inventory->release($currentSelection, (int) $sourceItem->cantidad);
$this->inventory->reserve($targetSelection, $quantity);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'variant_id' => __('api.purchase.insufficient_stock'),
]);
}
$previousVariantId = $sourceItem->source_variant_id;
$finalQuantity = $quantity;
if ($targetItem !== null) {
$finalQuantity += (int) $targetItem->cantidad;
$targetItem->update($this->snapshots->fromVariant($targetSelection, $finalQuantity));
$sourceItem->delete();
} else {
$sourceItem->update($this->snapshots->fromVariant($targetSelection, $finalQuantity));
}
$this->sourceCart->syncItemSelection(
$purchase,
(int) $sourceItem->source_catalog_item_id,
$previousVariantId,
$targetSelection->id,
$finalQuantity,
);
}
public function prepareItemEditing(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->assertEditable($purchase);
if (! $purchase->tenant()->firstOrFail()->cart_editing_policy->allowsModification()) {
throw ValidationException::withMessages([
'purchase' => __('api.cart.editing_disabled'),
]);
}
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
@@ -170,6 +286,6 @@ class EditCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $this->responses->load($purchase);
}
}

View File

@@ -10,6 +10,35 @@ use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory
{
/** @return array<string, mixed> */
public function fromVariant(Variant $variant, int $quantity): array
{
$variant->loadMissing([
'attachments',
'catalogItem.attachments',
'definitions.itemAttribute.attribute.options',
'eventDates',
'eventDate',
]);
$unitPrice = $variant->getPrice();
return [
'source_variant_id' => $variant->id,
'image_attachment_id' => $variant->attachments->first()?->id
?? $variant->catalogItem->attachments->first()?->id,
'nombre' => $variant->catalogItem->nombre,
'descripcion' => $variant->getDescription(),
'slug' => $variant->catalogItem->slug,
'item_nombre' => $variant->getName(),
'variant_attributes' => $this->snapshotAttributes($variant),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
];
}
/**
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
$purchase->load(['tenant', 'items.imageAttachment']);
if (! $purchase->tenant->cart_editing_policy->allowsVariantChanges()) {
return $purchase;
}
$purchase->load([
'items.sourceCatalogItem.itemAttributes.attribute',
'items.sourceCatalogItem.variants' => fn ($query) => $query->orderBy('id'),
'items.sourceCatalogItem.variants.inventory',
'items.sourceCatalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'items.sourceCatalogItem.variants.definitions.itemAttribute.attribute.options',
'items.sourceCatalogItem.variants.eventDates',
'items.sourceCatalogItem.variants.eventDate',
]);
return $purchase;
}
}

View File

@@ -71,6 +71,57 @@ class SourceCartService
->update(['cantidad' => $quantity]);
}
public function syncItemSelection(
Purchase $purchase,
int $catalogItemId,
?int $previousVariantId,
int $newVariantId,
int $finalQuantity,
): void {
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null) {
return;
}
/** @var CartItem|null $previousItem */
$previousItem = $sourceCart->items()
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $previousVariantId)
->lockForUpdate()
->first();
/** @var CartItem|null $targetItem */
$targetItem = $sourceCart->items()
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $newVariantId)
->when($previousItem !== null, fn ($query) => $query->whereKeyNot($previousItem->getKey()))
->lockForUpdate()
->first();
if ($targetItem !== null) {
$targetItem->update(['cantidad' => $finalQuantity]);
$previousItem?->delete();
return;
}
if ($previousItem !== null) {
$previousItem->update([
'variant_id' => $newVariantId,
'cantidad' => $finalQuantity,
]);
return;
}
$sourceCart->items()->create([
'catalog_item_id' => $catalogItemId,
'variant_id' => $newVariantId,
'cantidad' => $finalQuantity,
]);
}
public function finalize(Purchase $purchase): void
{
$sourceCart = $this->findSourceCart($purchase);

View File

@@ -24,6 +24,7 @@ class StartCheckoutService
private readonly CatalogSelectionResolver $selections,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -382,6 +383,6 @@ class StartCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment']);
return $this->responses->load($purchase);
}
}

View File

@@ -47,12 +47,20 @@ class CheckoutService
return $this->editor->updateCustomer($purchase, $customerData);
}
public function updateItemQuantity(
public function updateItem(
Purchase $purchase,
PurchaseItem $purchaseItem,
int $quantity,
?int $quantity,
?int $variantId,
bool $updateVariant,
): Purchase {
return $this->editor->updateItemQuantity($purchase, $purchaseItem, $quantity);
return $this->editor->updateItem(
$purchase,
$purchaseItem,
$quantity,
$variantId,
$updateVariant,
);
}
public function prepareItemEditing(Purchase $purchase): Purchase

View File

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