Merge branch 'feature/cart_editing_policy' into dev
This commit is contained in:
@@ -4,6 +4,7 @@ namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
@@ -22,6 +23,8 @@ class CartItemResource extends JsonResource
|
||||
$imageUrl = null;
|
||||
$tenant = $request->route('tenant');
|
||||
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
|
||||
$includeVariants = $tenant instanceof Tenant
|
||||
&& $tenant->cart_editing_policy->allowsVariantChanges();
|
||||
|
||||
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
|
||||
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
||||
@@ -39,14 +42,27 @@ class CartItemResource extends JsonResource
|
||||
'variant_id' => $this->variant_id,
|
||||
'nombre' => $selectedItem?->getName(),
|
||||
'imagen' => $imageUrl,
|
||||
'variant' => $this->variant === null ? null : [
|
||||
'id' => $this->variant->id,
|
||||
'precio' => $this->formatMoney($this->variant->getPrice()),
|
||||
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $this->variant->inventory->availableStock(),
|
||||
'values' => $this->variant->selectorOptions($this->catalogItem->itemAttributes),
|
||||
],
|
||||
'variant' => $this->variant === null ? null : $this->variantData($this->variant),
|
||||
'variants' => $this->when(
|
||||
$includeVariants,
|
||||
fn () => $this->catalogItem
|
||||
->visibleVariants($this->variant_id)
|
||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||
->values(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
protected function variantData(Variant $variant): array
|
||||
{
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'precio' => $this->formatMoney($variant->precio ?? $this->catalogItem->precio),
|
||||
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory->availableStock(),
|
||||
'values' => $variant->selectorOptions($this->catalogItem->itemAttributes),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ class CartService
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
return $this->loadCart($cart);
|
||||
return $this->loadCart($cart, $tenant);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,7 +55,7 @@ class CartService
|
||||
$cart->addItem($catalogItemId, $variantId, $quantity);
|
||||
|
||||
return [
|
||||
'cart' => $this->loadCart($cart),
|
||||
'cart' => $this->loadCart($cart, $tenant),
|
||||
'guest_token' => $resolvedIdentity['generated_guest_token'],
|
||||
];
|
||||
}
|
||||
@@ -68,11 +68,23 @@ class CartService
|
||||
?int $variantId,
|
||||
bool $updateVariant,
|
||||
): Cart {
|
||||
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => __('api.cart.variant_change_disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => __('api.cart.editing_disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
return $this->loadCart($cart, $tenant);
|
||||
}
|
||||
|
||||
public function updateCheckoutItem(
|
||||
@@ -178,11 +190,17 @@ class CartService
|
||||
|
||||
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
|
||||
{
|
||||
if (! $tenant->cart_editing_policy->allowsRemoval()) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_item' => __('api.cart.editing_disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->removeItem($cartItemId);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
return $this->loadCart($cart, $tenant);
|
||||
}
|
||||
|
||||
public function makeGuestTokenCookie(string $guestToken): Cookie
|
||||
@@ -212,9 +230,9 @@ class CartService
|
||||
return $cart;
|
||||
}
|
||||
|
||||
protected function loadCart(Cart $cart): Cart
|
||||
protected function loadCart(Cart $cart, Tenant $tenant): Cart
|
||||
{
|
||||
return $cart->fresh()->load([
|
||||
$relations = [
|
||||
'items.catalogItem.attachments',
|
||||
'items.catalogItem.inventory',
|
||||
'items.catalogItem.itemAttributes.attribute',
|
||||
@@ -223,7 +241,21 @@ class CartService
|
||||
'items.variant.definitions.itemAttribute.attribute.options',
|
||||
'items.variant.eventDates',
|
||||
'items.variant.eventDate',
|
||||
]);
|
||||
];
|
||||
|
||||
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
|
||||
$relations = [
|
||||
...$relations,
|
||||
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
|
||||
'items.catalogItem.variants.inventory',
|
||||
'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
|
||||
'items.catalogItem.variants.eventDates',
|
||||
'items.catalogItem.variants.eventDate',
|
||||
];
|
||||
}
|
||||
|
||||
return $cart->fresh()->load($relations);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,9 @@ use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Requests\PaymentIntentRequest;
|
||||
use App\Domains\Purchase\Requests\StartCheckoutRequest;
|
||||
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
|
||||
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;
|
||||
@@ -50,21 +52,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',
|
||||
'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->loadMissing('items')->loadCount('tickets');
|
||||
$responses->load($compra);
|
||||
|
||||
return PurchaseResource::make($compra);
|
||||
}
|
||||
@@ -82,8 +79,28 @@ class PurchaseController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function paymentIntent(
|
||||
PaymentIntentRequest $request,
|
||||
public function updateItem(
|
||||
UpdatePurchaseItemRequest $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
PurchaseItem $item,
|
||||
CheckoutService $checkoutService,
|
||||
): PurchaseResource {
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
|
||||
return PurchaseResource::make(
|
||||
$checkoutService->updateItem(
|
||||
$compra,
|
||||
$item,
|
||||
$request->exists('quantity') ? (int) $request->validated('quantity') : null,
|
||||
$request->exists('variant_id') ? (int) $request->validated('variant_id') : null,
|
||||
$request->exists('variant_id'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function prepareItemEditing(
|
||||
Request $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
CheckoutService $checkoutService,
|
||||
|
||||
@@ -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;
|
||||
@@ -59,4 +61,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();
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php
Normal file
22
app/Domains/Purchase/Requests/UpdatePurchaseItemRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -132,4 +148,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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EditCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CatalogInventoryService $inventory,
|
||||
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 */
|
||||
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
|
||||
{
|
||||
@@ -21,6 +30,229 @@ class EditCheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
public function updateItem(
|
||||
Purchase $purchase,
|
||||
PurchaseItem $purchaseItem,
|
||||
?int $quantity,
|
||||
?int $variantId,
|
||||
bool $updateVariant,
|
||||
): 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)) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
|
||||
$tenant = $purchase->tenant()->firstOrFail();
|
||||
$finalQuantity = $quantity ?? (int) $purchaseItem->cantidad;
|
||||
|
||||
if ($quantity !== null && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => __('api.cart.editing_disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
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([
|
||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
'payment_method' => null,
|
||||
'transfer_payer_dni' => null,
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||
),
|
||||
]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
private function adjustReservation(
|
||||
Purchase $purchase,
|
||||
PurchaseItem $purchaseItem,
|
||||
int $quantity,
|
||||
int $difference,
|
||||
): void {
|
||||
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem);
|
||||
|
||||
try {
|
||||
if ($difference > 0) {
|
||||
$otherItemQuantity = (int) $purchase->items()
|
||||
->where('source_catalog_item_id', $purchaseItem->source_catalog_item_id)
|
||||
->whereKeyNot($purchaseItem->getKey())
|
||||
->sum('cantidad');
|
||||
$catalogItem = $selection instanceof Variant ? $selection->catalogItem : $selection;
|
||||
|
||||
$this->purchaseLimits->assertCanPurchase(
|
||||
$catalogItem,
|
||||
(int) $purchase->user_id,
|
||||
$otherItemQuantity + $quantity,
|
||||
$purchase->getKey(),
|
||||
);
|
||||
$this->inventory->reserve($selection, $difference);
|
||||
} else {
|
||||
$this->inventory->release($selection, abs($difference));
|
||||
}
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => __('api.purchase.insufficient_stock'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function lockPurchaseItem(Purchase $purchase, PurchaseItem $item): PurchaseItem
|
||||
{
|
||||
/** @var PurchaseItem|null $lockedItem */
|
||||
$lockedItem = $purchase->items()
|
||||
->whereKey($item->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($lockedItem === null) {
|
||||
throw new NotFoundHttpException('Purchase item not found.');
|
||||
}
|
||||
|
||||
if ($lockedItem->reservation_status !== PurchaseItem::RESERVATION_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'item' => __('api.purchase.item_not_editable'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $lockedItem;
|
||||
}
|
||||
|
||||
private function assertEditable(Purchase $purchase): void
|
||||
{
|
||||
if (
|
||||
@@ -49,15 +281,6 @@ class EditCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
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',
|
||||
]);
|
||||
return $this->responses->load($purchase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,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>>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,74 @@ class SourceCartService
|
||||
return true;
|
||||
}
|
||||
|
||||
public function syncItemQuantity(
|
||||
Purchase $purchase,
|
||||
PurchaseItem $purchaseItem,
|
||||
int $quantity,
|
||||
): void {
|
||||
$sourceCart = $this->findSourceCart($purchase);
|
||||
|
||||
if ($sourceCart === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceCart->items()
|
||||
->where('catalog_item_id', $purchaseItem->source_catalog_item_id)
|
||||
->where('variant_id', $purchaseItem->source_variant_id)
|
||||
->update(['cantidad' => $quantity]);
|
||||
}
|
||||
|
||||
public function 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);
|
||||
|
||||
@@ -25,6 +25,7 @@ class StartCheckoutService
|
||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||
private readonly CatalogSelectionResolver $selections,
|
||||
private readonly InsufficientStockMessageBuilder $stockMessages,
|
||||
private readonly PurchaseResponseLoader $responses,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $purchaseData */
|
||||
@@ -376,15 +377,6 @@ class StartCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
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',
|
||||
]);
|
||||
return $this->responses->load($purchase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,27 @@ class CheckoutService
|
||||
return $this->editor->updateCustomer($purchase, $customerData);
|
||||
}
|
||||
|
||||
public function updateItem(
|
||||
Purchase $purchase,
|
||||
PurchaseItem $purchaseItem,
|
||||
?int $quantity,
|
||||
?int $variantId,
|
||||
bool $updateVariant,
|
||||
): Purchase {
|
||||
return $this->editor->updateItem(
|
||||
$purchase,
|
||||
$purchaseItem,
|
||||
$quantity,
|
||||
$variantId,
|
||||
$updateVariant,
|
||||
);
|
||||
}
|
||||
|
||||
public function prepareItemEditing(Purchase $purchase): Purchase
|
||||
{
|
||||
return $this->editor->prepareItemEditing($purchase);
|
||||
}
|
||||
|
||||
public function confirmPurchase(Purchase $purchase): void
|
||||
{
|
||||
$this->completer->confirm($purchase);
|
||||
|
||||
@@ -7,6 +7,8 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
||||
Route::get('compras', [PurchaseController::class, 'index']);
|
||||
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
|
||||
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
||||
Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']);
|
||||
Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, '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']);
|
||||
|
||||
32
app/Domains/Tenant/Enums/CartEditingPolicy.php
Normal file
32
app/Domains/Tenant/Enums/CartEditingPolicy.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Enums;
|
||||
|
||||
enum CartEditingPolicy: string
|
||||
{
|
||||
case Disabled = 'disabled';
|
||||
case QuantityAndRemove = 'quantity_and_remove';
|
||||
case Full = 'full';
|
||||
|
||||
public function allowsQuantityChanges(): bool
|
||||
{
|
||||
return $this !== self::Disabled;
|
||||
}
|
||||
|
||||
public function allowsRemoval(): bool
|
||||
{
|
||||
return $this !== self::Disabled;
|
||||
}
|
||||
|
||||
public function allowsVariantChanges(): bool
|
||||
{
|
||||
return $this === self::Full;
|
||||
}
|
||||
|
||||
public function allowsModification(): bool
|
||||
{
|
||||
return $this->allowsRemoval()
|
||||
|| $this->allowsQuantityChanges()
|
||||
|| $this->allowsVariantChanges();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Menu\Models\TenantMenu;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -44,7 +45,7 @@ use Illuminate\Support\Facades\Schema;
|
||||
'display_categories',
|
||||
'display_seach_bar',
|
||||
'display_cart',
|
||||
'cart_editing_enabled',
|
||||
'cart_editing_policy',
|
||||
'display_cart_item_images',
|
||||
'scanner_category_validation_enabled',
|
||||
'event_title',
|
||||
@@ -63,7 +64,7 @@ class Tenant extends Model
|
||||
'display_categories' => true,
|
||||
'display_seach_bar' => true,
|
||||
'display_cart' => true,
|
||||
'cart_editing_enabled' => true,
|
||||
'cart_editing_policy' => CartEditingPolicy::Full->value,
|
||||
'display_cart_item_images' => true,
|
||||
'scanner_category_validation_enabled' => true,
|
||||
];
|
||||
@@ -114,7 +115,7 @@ class Tenant extends Model
|
||||
'display_categories' => 'boolean',
|
||||
'display_seach_bar' => 'boolean',
|
||||
'display_cart' => 'boolean',
|
||||
'cart_editing_enabled' => 'boolean',
|
||||
'cart_editing_policy' => CartEditingPolicy::class,
|
||||
'display_cart_item_images' => 'boolean',
|
||||
'scanner_category_validation_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Requests;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
@@ -107,7 +108,7 @@ class StoreTenantRequest extends FormRequest
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_enabled' => ['sometimes', 'boolean'],
|
||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
'website_type_code' => [
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Requests;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
@@ -128,7 +129,7 @@ class UpdateTenantRequest extends FormRequest
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_enabled' => ['sometimes', 'boolean'],
|
||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
23
app/Domains/Tenant/Resources/CartEditingPolicyResource.php
Normal file
23
app/Domains/Tenant/Resources/CartEditingPolicyResource.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Resources;
|
||||
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin CartEditingPolicy */
|
||||
class CartEditingPolicyResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, bool|string> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'code' => $this->resource->value,
|
||||
'allow_modify' => $this->resource->allowsModification(),
|
||||
'allow_delete' => $this->resource->allowsRemoval(),
|
||||
'allow_update_quantity' => $this->resource->allowsQuantityChanges(),
|
||||
'allow_update_variant' => $this->resource->allowsVariantChanges(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class TenantResource extends JsonResource
|
||||
'display_categories' => $this->display_categories,
|
||||
'display_seach_bar' => $this->display_seach_bar,
|
||||
'display_cart' => $this->display_cart,
|
||||
'cart_editing_enabled' => $this->cart_editing_enabled,
|
||||
'cart_editing_policy' => CartEditingPolicyResource::make($this->cart_editing_policy),
|
||||
'display_cart_item_images' => $this->display_cart_item_images,
|
||||
'scanner_category_validation_enabled' => $this->scanner_category_validation_enabled,
|
||||
'social_media' => $this->whenLoaded(
|
||||
|
||||
Reference in New Issue
Block a user