refactor(checkout): disable purchase item editing

This commit is contained in:
2026-08-21 11:03:48 -03:00
parent 4e7e42d16e
commit 79e721ae63
18 changed files with 172 additions and 1191 deletions

View File

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

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
@@ -23,13 +22,9 @@ class CartItemResource extends JsonResource
$selectedItem = $this->selectedItem();
$imageUrl = null;
$tenant = $request->route('tenant');
$checkoutCart = $request->route('cart');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$includeVariants = $tenant instanceof Tenant
&& ($checkoutCart instanceof Cart
? $tenant->checkout_editing_policy
: $tenant->cart_editing_policy)
->allowsVariantChanges();
&& $tenant->cart_editing_policy->allowsVariantChanges();
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);

View File

@@ -4,14 +4,9 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
@@ -19,11 +14,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function __construct(
private readonly StockReservationService $reservations,
private readonly PurchaseStateGuard $purchaseState,
) {}
public function show(Tenant $tenant, Request $request): Cart
{
$resolvedIdentity = $this->resolveIdentity($request);
@@ -89,125 +79,6 @@ class CartService
return $this->loadCart($cart, $tenant);
}
public function updateCheckoutItem(
Tenant $tenant,
Request $request,
Cart $cart,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
$user = $request->user() ?? Auth::guard('sanctum')->user();
if (! $user instanceof User) {
throw new NotFoundHttpException('Checkout cart not found.');
}
return DB::transaction(function () use (
$tenant,
$user,
$cart,
$cartItemId,
$quantity,
$variantId,
$updateVariant,
): Cart {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->where('cart_id', $cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->whereDoesntHave('items')
->lockForUpdate()
->first();
if ($purchase === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
$this->purchaseState->assertNotExpired($purchase);
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
throw new NotFoundHttpException('Checkout cart not found.');
}
/** @var Cart|null $checkoutCart */
$checkoutCart = Cart::query()
->whereKey($cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->where('status', 'checkout')
->lockForUpdate()
->first();
if ($checkoutCart === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
/** @var CartItem|null $cartItem */
$cartItem = $checkoutCart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
throw new NotFoundHttpException('Checkout item not found.');
}
$hasChanges = (int) $cartItem->cantidad !== $quantity
|| ($updateVariant && $cartItem->variant_id !== $variantId);
if (! $hasChanges) {
return $this->loadCart($checkoutCart, $tenant, true);
}
if (
(int) $cartItem->cantidad !== $quantity
&& ! $tenant->checkout_editing_policy->allowsQuantityChanges()
) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.editing_disabled'),
]);
}
if (
$updateVariant
&& $cartItem->variant_id !== $variantId
&& ! $tenant->checkout_editing_policy->allowsVariantChanges()
) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_change_disabled'),
]);
}
$checkoutCart->updateItem(
$cartItemId,
$quantity,
$variantId,
$updateVariant,
$purchase->getKey(),
);
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'total' => $checkoutCart->getTotalAmount(),
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadCart($checkoutCart, $tenant, true);
});
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
if (! $tenant->cart_editing_policy->allowsRemoval()) {
@@ -223,77 +94,6 @@ class CartService
return $this->loadCart($cart, $tenant);
}
public function removeCheckoutItem(
Tenant $tenant,
Request $request,
Cart $cart,
int $cartItemId,
): Cart {
if (! $tenant->checkout_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'cart_item' => __('api.cart.editing_disabled'),
]);
}
$user = $request->user() ?? Auth::guard('sanctum')->user();
if (! $user instanceof User) {
throw new NotFoundHttpException('Checkout cart not found.');
}
return DB::transaction(function () use ($tenant, $user, $cart, $cartItemId): Cart {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->where('cart_id', $cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->whereDoesntHave('items')
->lockForUpdate()
->first();
if ($purchase === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
$this->purchaseState->assertNotExpired($purchase);
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
throw new NotFoundHttpException('Checkout cart not found.');
}
/** @var Cart|null $checkoutCart */
$checkoutCart = Cart::query()
->whereKey($cart->getKey())
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $user->getKey())
->where('status', 'checkout')
->lockForUpdate()
->first();
if ($checkoutCart === null) {
throw new NotFoundHttpException('Checkout cart not found.');
}
$checkoutCart->removeItem($cartItemId);
$purchase->telepagosQr()->delete();
$purchase->update([
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'transfer_payer_dni' => null,
'total' => $checkoutCart->getTotalAmount(),
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
]);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadCart($checkoutCart, $tenant, true);
});
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
$secure = (bool) config('session.secure');
@@ -327,7 +127,7 @@ class CartService
return $cart;
}
protected function loadCart(Cart $cart, Tenant $tenant, bool $isCheckout = false): Cart
protected function loadCart(Cart $cart, Tenant $tenant): Cart
{
$relations = [
'items.catalogItem.attachments',
@@ -340,11 +140,7 @@ class CartService
'items.variant.eventDate',
];
$editingPolicy = $isCheckout
? $tenant->checkout_editing_policy
: $tenant->cart_editing_policy;
if ($editingPolicy->allowsVariantChanges()) {
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
$relations = [
...$relations,
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),

View File

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

View File

@@ -4,11 +4,9 @@ namespace App\Domains\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseItemRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService;
@@ -81,53 +79,6 @@ class PurchaseController extends Controller
);
}
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,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->prepareItemEditing($compra),
);
}
public function removeItem(
Request $request,
Tenant $tenant,
Purchase $compra,
PurchaseItem $item,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->removeItem($compra, $item),
);
}
public function paymentIntent(
PaymentIntentRequest $request,
Tenant $tenant,

View File

@@ -1,22 +0,0 @@
<?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

@@ -2,24 +2,14 @@
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EditCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseStateGuard $purchaseState,
) {}
@@ -28,295 +18,22 @@ class EditCheckoutService
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase);
$purchase->update($customerData);
return $this->loadPurchase($purchase);
});
}
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);
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status !== Purchase::STATUS_CREATED || $this->hasExpired($purchase)) {
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
$tenant = $purchase->tenant()->firstOrFail();
$finalQuantity = $quantity ?? (int) $purchaseItem->cantidad;
$purchase->update($customerData);
if ($quantity !== null && ! $tenant->checkout_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'quantity' => __('api.cart.editing_disabled'),
]);
}
if ($updateVariant && ! $tenant->checkout_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);
return $this->responses->load($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->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase);
if (! $purchase->tenant()->firstOrFail()->checkout_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);
});
}
public function removeItem(Purchase $purchase, PurchaseItem $purchaseItem): Purchase
{
return DB::transaction(function () use ($purchase, $purchaseItem): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase);
if (! $purchase->tenant()->firstOrFail()->checkout_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'item' => __('api.cart.editing_disabled'),
]);
}
$purchaseItem = $this->lockPurchaseItem($purchase, $purchaseItem);
$selection = $this->selections->resolvePurchaseItem($purchase->tenant, $purchaseItem);
$this->inventory->release($selection, (int) $purchaseItem->cantidad);
$this->sourceCart->removeItem($purchase, $purchaseItem);
$purchaseItem->delete();
$purchase->update([
'total' => $purchase->calculateCurrentTotalAmount(),
]);
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 (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| $this->hasExpired($purchase)
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
}
private function hasExpired(Purchase $purchase): bool
{
return $purchase->expires_at !== null && $purchase->expires_at->isPast();
}
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $this->responses->load($purchase);
}
}

View File

@@ -9,35 +9,6 @@ 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

@@ -83,88 +83,6 @@ class SourceCartService
}
}
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 removeItem(Purchase $purchase, PurchaseItem $purchaseItem): 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)
->delete();
}
public function finalize(Purchase $purchase): void
{
$sourceCart = $this->findSourceCart($purchase);

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
@@ -49,32 +48,6 @@ 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 removeItem(Purchase $purchase, PurchaseItem $purchaseItem): Purchase
{
return $this->editor->removeItem($purchase, $purchaseItem);
}
public function confirmPurchase(Purchase $purchase): void
{
$this->completer->confirm($purchase);

View File

@@ -7,9 +7,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
Route::get('compras', [PurchaseController::class, 'index']);
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
Route::post('compras/{compra}/edit-items', [PurchaseController::class, 'prepareItemEditing']);
Route::patch('compras/{compra}/items/{item}', [PurchaseController::class, 'updateItem']);
Route::delete('compras/{compra}/items/{item}', [PurchaseController::class, 'removeItem']);
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']);

View File

@@ -111,7 +111,10 @@ class StoreTenantRequest extends FormRequest
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
'website_type_code' => [

View File

@@ -132,7 +132,10 @@ class UpdateTenantRequest extends FormRequest
'display_seach_bar' => ['sometimes', 'boolean'],
'display_cart' => ['sometimes', 'boolean'],
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
'checkout_editing_policy' => [
'sometimes',
Rule::in([CartEditingPolicy::Disabled->value]),
],
'display_cart_item_images' => ['sometimes', 'boolean'],
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
];

View File

@@ -0,0 +1,20 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('tenants')->update([
'checkout_editing_policy' => 'disabled',
]);
}
public function down(): void
{
// Checkout editing stays disabled because previous tenant values cannot
// be reconstructed after normalization.
}
};

View File

@@ -194,8 +194,9 @@ class TelepagosWebhookTest extends TestCase
'sold_units' => 1,
]);
$this->assertDatabaseMissing('compra_items', [
$this->assertDatabaseHas('compra_items', [
'compra_id' => $newerPurchase->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $newerPurchase->id,

View File

@@ -30,7 +30,7 @@ class StorePurchaseTest extends TestCase
Queue::fake();
}
public function test_it_starts_checkout_from_cart_without_materializing_purchase_items(): void
public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update([
@@ -90,7 +90,7 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.email', null);
$response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items_source', 'cart');
$response->assertJsonPath('data.items_source', 'purchase');
$response->assertJsonCount(1, 'data.items');
$response->assertJsonPath('data.subtotal', '100.00');
$response->assertJsonPath('data.total', '100.00');
@@ -109,7 +109,14 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_CREATED,
'total' => 100,
]);
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchaseId,
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => $variant->id,
'cantidad' => 2,
'precio_unitario' => '50.00',
'total' => '100.00',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $inventory->id,
'purchase_id' => $purchaseId,
@@ -142,10 +149,10 @@ class StorePurchaseTest extends TestCase
$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');
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items.0.item_details.nombre', 'Test Product')
->assertJsonPath('data.items.0.unit_price', '50.00')
->assertJsonPath('data.total', '100.00');
}
public function test_it_creates_a_direct_purchase_with_a_technical_checkout_cart(): void
@@ -165,7 +172,7 @@ class StorePurchaseTest extends TestCase
],
])
->assertCreated()
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items.0.quantity', 3)
->assertJsonPath('data.total', '150.00');
@@ -174,8 +181,12 @@ class StorePurchaseTest extends TestCase
'status' => 'checkout',
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$this->assertDatabaseMissing('compra_items', [
$this->assertDatabaseHas('compra_items', [
'compra_id' => $response->json('data.id'),
'source_variant_id' => $variant->id,
'cantidad' => 3,
'precio_unitario' => '50.00',
'total' => '150.00',
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $variant->inventory_id,
@@ -233,7 +244,7 @@ class StorePurchaseTest extends TestCase
],
])
->assertCreated()
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items_source', 'purchase')
->assertJsonCount(2, 'data.items')
->assertJsonPath('data.items.0.source_variant_id', $firstVariant->id)
->assertJsonPath('data.items.1.source_variant_id', $secondVariant->id)
@@ -245,7 +256,7 @@ class StorePurchaseTest extends TestCase
'id' => $response->json('data.cart_id'),
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchaseId]);
$this->assertDatabaseCount('compra_items', 2);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $firstVariant->inventory_id,
'purchase_id' => $purchaseId,
@@ -541,7 +552,7 @@ class StorePurchaseTest extends TestCase
],
])
->assertCreated()
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items_source', 'purchase')
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.dni', null)
@@ -599,373 +610,40 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_updates_a_checkout_cart_item_quantity_and_its_stock_reservation(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'full']);
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 4,
])
->assertOk()
->assertJsonPath('data.items.0.cantidad', 4)
->assertJsonPath('data.subtotal', '200.00');
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 4,
]);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $purchase->cart_id,
'catalog_item_id' => $variant->catalog_item_id,
'variant_id' => $variant->id,
'cantidad' => 4,
]);
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 1,
])
->assertOk()
->assertJsonPath('data.items.0.cantidad', 1)
->assertJsonPath('data.subtotal', '50.00');
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'total' => '50.00',
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 1,
]);
}
public function test_checkout_cart_editing_uses_checkout_policy_independently_from_cart_policy(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update([
'cart_editing_policy' => 'full',
'checkout_editing_policy' => 'disabled',
]);
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 4,
])
->assertUnprocessable()
->assertJsonValidationErrors('cantidad');
$this->assertDatabaseHas('carrito_items', [
'id' => $itemId,
'cantidad' => 2,
]);
}
public function test_it_removes_an_item_from_the_checkout_cart_when_the_policy_allows_it(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'quantity_and_remove']);
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->deleteJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}")
->assertOk()
->assertJsonPath('data.items', [])
->assertJsonPath('data.subtotal', '0.00');
$this->assertDatabaseMissing('carrito_items', ['id' => $itemId]);
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'total' => '0.00',
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 0,
]);
}
public function test_it_changes_a_checkout_item_variant_and_moves_its_reservation(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'full']);
$user = User::factory()->create();
$firstVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $firstVariant->catalogItem->variants()->create([
'inventory_id' => $secondInventory->id,
'precio' => '70.00',
]);
$purchase = $this->createCheckoutPurchase($user, 'sonder', $firstVariant, 2);
$itemId = $purchase->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
'variant_id' => $secondVariant->id,
])
->assertOk()
->assertJsonPath('data.items.0.source_variant_id', $secondVariant->id)
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '70.00')
->assertJsonPath('data.items.0.line_total', '140.00')
->assertJsonPath('data.total', '140.00')
->assertJsonPath('data.items.0.variants.0.id', $firstVariant->id)
->assertJsonPath('data.items.0.variants.1.id', $secondVariant->id);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 2,
]);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $purchase->cart_id,
'catalog_item_id' => $firstVariant->catalog_item_id,
'variant_id' => $secondVariant->id,
'cantidad' => 2,
]);
$this->assertDatabaseMissing('carrito_items', [
'cart_id' => $purchase->cart_id,
'variant_id' => $firstVariant->id,
]);
}
public function test_it_rejects_checkout_variant_changes_when_the_policy_does_not_allow_them(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'quantity_and_remove']);
$user = User::factory()->create();
$firstVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $firstVariant->catalogItem->variants()->create([
'inventory_id' => $secondInventory->id,
]);
$purchase = $this->createCheckoutPurchase($user, 'sonder', $firstVariant, 2);
$itemId = $purchase->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
'variant_id' => $secondVariant->id,
])
->assertUnprocessable()
->assertJsonValidationErrors('variant_id');
$this->assertDatabaseHas('compra_items', [
'id' => $itemId,
'source_variant_id' => $firstVariant->id,
'cantidad' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 0,
]);
}
public function test_checkout_variant_change_rolls_back_when_the_target_has_insufficient_stock(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'full']);
$user = User::factory()->create();
$firstVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$secondInventory = Inventory::query()->create(['real_stock' => 1]);
$secondVariant = $firstVariant->catalogItem->variants()->create([
'inventory_id' => $secondInventory->id,
'precio' => '70.00',
]);
$purchase = $this->createCheckoutPurchase($user, 'sonder', $firstVariant, 2);
$itemId = $purchase->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$itemId}", [
'variant_id' => $secondVariant->id,
])
->assertUnprocessable()
->assertJsonValidationErrors('variant_id');
$this->assertDatabaseHas('compra_items', [
'id' => $itemId,
'source_variant_id' => $firstVariant->id,
'cantidad' => 2,
'precio_unitario' => '50.00',
]);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $purchase->cart_id,
'variant_id' => $firstVariant->id,
'cantidad' => 2,
]);
}
public function test_changing_to_an_existing_checkout_variant_merges_purchase_and_cart_rows(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'full']);
$user = User::factory()->create();
$firstVariant = $this->createVariantForTenant('sonder', 10, '50.00');
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
$secondVariant = $firstVariant->catalogItem->variants()->create([
'inventory_id' => $secondInventory->id,
'precio' => '70.00',
]);
$cart = Cart::query()->create([
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'status' => 'active',
]);
$cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2);
$cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 3);
$purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [
'cart_id' => $cart->id,
]);
$sourceItem = $purchase->items->firstWhere('source_variant_id', $firstVariant->id);
$this->assertNotNull($sourceItem);
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$sourceItem->id}", [
'variant_id' => $secondVariant->id,
])
->assertOk()
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.source_variant_id', $secondVariant->id)
->assertJsonPath('data.items.0.quantity', 5)
->assertJsonPath('data.items.0.line_total', '350.00')
->assertJsonPath('data.total', '350.00');
$this->assertDatabaseCount('compra_items', 1);
$this->assertDatabaseHas('inventories', [
'id' => $firstVariant->inventory_id,
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('inventories', [
'id' => $secondInventory->id,
'reserved_stock' => 5,
]);
$this->assertDatabaseCount('carrito_items', 1);
$this->assertDatabaseHas('carrito_items', [
'cart_id' => $cart->id,
'variant_id' => $secondVariant->id,
'cantidad' => 5,
]);
}
public function test_it_rejects_a_quantity_update_above_the_user_purchase_limit(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'full']);
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$variant->catalogItem->update(['max_units_per_user' => 3]);
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 4,
])
->assertUnprocessable()
->assertJsonValidationErrors('cantidad');
$this->assertDatabaseHas('carrito_items', [
'id' => $itemId,
'cantidad' => 2,
]);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 2,
]);
}
public function test_it_invalidates_a_pending_payment_when_the_checkout_cart_changes(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$tenant->update(['checkout_editing_policy' => 'full']);
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'qr',
]);
$purchase->telepagosQr()->create([
'qr_order_id' => 'stale-order',
'qr_code' => 'stale-qr',
]);
$itemId = $purchase->cart->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 3,
])
->assertOk()
->assertJsonPath('data.items.0.cantidad', 3);
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => '150.00',
]);
$this->assertDatabaseMissing('telepagos_qr', [
'compra_id' => $purchase->id,
'qr_order_id' => 'stale-order',
]);
}
public function test_a_no_op_checkout_cart_update_keeps_the_pending_payment_intact(): void
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'qr',
]);
$purchase->telepagosQr()->create([
'qr_order_id' => 'current-order',
'qr_code' => 'current-qr',
]);
$itemId = $purchase->cart->items->firstOrFail()->id;
$cartItemId = $purchase->cart->items->firstOrFail()->id;
$purchaseItemId = $purchase->items->firstOrFail()->id;
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$itemId}", [
'cantidad' => 2,
->patchJson("/api/tenants/sonder/checkout-carts/{$purchase->cart_id}/items/{$cartItemId}", [
'cantidad' => 3,
])
->assertOk();
->assertNotFound();
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'qr',
$this->patchJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$purchaseItemId}", [
'quantity' => 3,
])->assertNotFound();
$this->deleteJson("/api/tenants/sonder/compras/{$purchase->id}/items/{$purchaseItemId}")
->assertNotFound();
$this->postJson("/api/tenants/sonder/compras/{$purchase->id}/edit-items")
->assertNotFound();
$this->assertDatabaseHas('compra_items', [
'id' => $purchaseItemId,
'cantidad' => 2,
'precio_unitario' => '50.00',
'total' => '100.00',
]);
$this->assertDatabaseHas('telepagos_qr', [
'compra_id' => $purchase->id,
'qr_order_id' => 'current-order',
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 2,
]);
}
@@ -1129,7 +807,10 @@ class StorePurchaseTest extends TestCase
'id' => $purchase->id,
'status' => Purchase::STATUS_EXPIRED,
]);
$this->assertDatabaseMissing('compra_items', ['compra_id' => $purchase->id]);
$this->assertDatabaseHas('compra_items', [
'compra_id' => $purchase->id,
'cantidad' => 3,
]);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $purchase->id,
'quantity' => 0,
@@ -1153,7 +834,7 @@ class StorePurchaseTest extends TestCase
->assertSuccessful();
}
public function test_it_continues_expiring_purchases_after_an_inconsistent_reservation(): void
public function test_it_expires_multiple_purchases_that_already_have_item_snapshots(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -1176,13 +857,13 @@ class StorePurchaseTest extends TestCase
$this->travel(31)->minutes();
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 1')
->expectsOutput('Expired purchases: 2')
->expectsOutput('Expired cart items: 0')
->assertSuccessful();
$this->assertDatabaseHas('compras', [
'id' => $inconsistentPurchase->id,
'status' => Purchase::STATUS_CREATED,
'status' => Purchase::STATUS_EXPIRED,
]);
$this->assertDatabaseHas('compras', [
'id' => $validPurchase->id,
@@ -1190,7 +871,7 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_purchase_detail_uses_cart_items_for_created_purchase(): void
public function test_purchase_detail_uses_purchase_item_snapshot_for_created_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
@@ -1203,7 +884,7 @@ class StorePurchaseTest extends TestCase
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CREATED)
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items_source', 'purchase')
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '50.00')
@@ -1242,7 +923,7 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.items.0.item_details.imagen', null);
}
public function test_purchase_detail_uses_cart_items_for_pending_payment_purchase(): void
public function test_purchase_detail_uses_purchase_item_snapshot_for_pending_payment_purchase(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
@@ -1261,7 +942,7 @@ class StorePurchaseTest extends TestCase
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_PENDING_PAYMENT)
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items_source', 'purchase')
->assertJsonCount(1, 'data.items')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.unit_price', '50.00')
@@ -1272,7 +953,7 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.total', '100.00');
}
public function test_created_and_pending_payment_purchase_details_use_the_current_cart_quantity(): void
public function test_created_and_pending_payment_purchase_details_ignore_later_cart_changes(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create([
@@ -1282,17 +963,6 @@ class StorePurchaseTest extends TestCase
foreach ([Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT] as $status) {
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
$purchase->items()->create([
'source_catalog_item_id' => $variant->catalog_item_id,
'source_variant_id' => $variant->id,
'item_nombre' => $variant->catalogItem->nombre,
'descripcion' => $variant->catalogItem->descripcion,
'slug' => $variant->catalogItem->slug,
'variant_attributes' => [],
'cantidad' => 1,
'precio_unitario' => '50.00',
'total' => '50.00',
]);
$purchase->cart->items()->update(['cantidad' => 3]);
$purchase->update(['status' => $status]);
@@ -1300,11 +970,11 @@ class StorePurchaseTest extends TestCase
->getJson("/api/tenants/sonder/compras/{$purchase->id}")
->assertOk()
->assertJsonPath('data.status', $status)
->assertJsonPath('data.items_source', 'cart')
->assertJsonPath('data.items.0.quantity', 3)
->assertJsonPath('data.items.0.line_total', '150.00')
->assertJsonPath('data.subtotal', '150.00')
->assertJsonPath('data.total', '150.00');
->assertJsonPath('data.items_source', 'purchase')
->assertJsonPath('data.items.0.quantity', 2)
->assertJsonPath('data.items.0.line_total', '100.00')
->assertJsonPath('data.subtotal', '100.00')
->assertJsonPath('data.total', '100.00');
}
}

View File

@@ -7,9 +7,12 @@ use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use App\Domains\Ticket\Models\Ticket;
@@ -31,7 +34,7 @@ class AdminAppSaleControllerTest extends TestCase
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
}
public function test_sales_list_uses_cart_quantity_for_created_and_pending_payment_sales(): void
public function test_sales_list_uses_purchase_item_snapshots_for_every_status(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
@@ -62,9 +65,9 @@ class AdminAppSaleControllerTest extends TestCase
'compra_id' => $createdPurchase->id,
'source_catalog_item_id' => $catalogItem->id,
'item_nombre' => $catalogItem->nombre,
'cantidad' => 1,
'cantidad' => 3,
'precio_unitario' => '10000.00',
'total' => '10000.00',
'total' => '30000.00',
]);
$pendingCart = Cart::query()->create([
@@ -82,6 +85,14 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '40000.00',
]);
PurchaseItem::query()->create([
'compra_id' => $pendingPurchase->id,
'source_catalog_item_id' => $catalogItem->id,
'item_nombre' => $catalogItem->nombre,
'cantidad' => 4,
'precio_unitario' => '10000.00',
'total' => '40000.00',
]);
$paidPurchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
@@ -149,7 +160,7 @@ class AdminAppSaleControllerTest extends TestCase
->assertJsonPath('data.total', '40000.00');
}
public function test_an_adminapp_user_can_read_cart_items_from_an_unconfirmed_sale(): void
public function test_an_adminapp_user_can_read_purchase_item_snapshots_from_an_unconfirmed_sale(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
@@ -176,10 +187,18 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '25000.00',
]);
$purchaseItem = PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'item_nombre' => $catalogItem->nombre,
'cantidad' => 2,
'precio_unitario' => '12500.00',
'total' => '25000.00',
]);
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}")
->assertOk()
->assertJsonPath('data.items.0.id', $cartItem->id)
->assertJsonPath('data.items.0.id', $purchaseItem->id)
->assertJsonPath('data.items.0.product', 'Entrada general')
->assertJsonPath('data.items.0.event_dates', [])
->assertJsonPath('data.items.0.quantity', 2)
@@ -187,7 +206,7 @@ class AdminAppSaleControllerTest extends TestCase
->assertJsonPath('data.items.0.total', '25000.00');
}
public function test_sales_list_uses_cart_quantity_until_purchase_items_exist(): void
public function test_sales_list_quantity_always_comes_from_purchase_items(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
@@ -214,13 +233,7 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '37500.00',
]);
$this->getJson('/api/v1/adminapp/tenant/sales')
->assertOk()
->assertJsonPath('data.0.id', $purchase->id)
->assertJsonPath('data.0.quantity', 3);
PurchaseItem::query()->create([
$purchaseItem = PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'nombre' => 'Entrada general',
@@ -232,7 +245,17 @@ class AdminAppSaleControllerTest extends TestCase
$this->getJson('/api/v1/adminapp/tenant/sales')
->assertOk()
->assertJsonPath('data.0.id', $purchase->id)
->assertJsonPath('data.0.quantity', 2);
$purchaseItem->update([
'cantidad' => 4,
'total' => '50000.00',
]);
$this->getJson('/api/v1/adminapp/tenant/sales')
->assertOk()
->assertJsonPath('data.0.quantity', 4);
}
public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void
@@ -329,11 +352,25 @@ class AdminAppSaleControllerTest extends TestCase
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$purchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
$inventory = Inventory::query()->create(['real_stock' => 10]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'confirmable-item',
'nombre' => 'Confirmable item',
'precio' => '10000.00',
]);
$variant = Variant::query()->create([
'catalog_item_id' => $catalogItem->id,
'inventory_id' => $inventory->id,
]);
$purchase = app(CheckoutService::class)->startCheckout($tenant, $admin->id, [
'direct_items' => [[
'catalog_item_id' => $catalogItem->id,
'variant_id' => $variant->id,
'cantidad' => 1,
]],
]);
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/confirm")
->assertOk()
@@ -385,6 +422,14 @@ class AdminAppSaleControllerTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => '10000.00',
]);
PurchaseItem::query()->create([
'compra_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'item_nombre' => $catalogItem->nombre,
'cantidad' => 2,
'precio_unitario' => '10000.00',
'total' => '20000.00',
]);
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertOk()

View File

@@ -76,7 +76,7 @@ class BootstrapTenantControllerTest extends TestCase
'display_seach_bar' => false,
'display_cart' => false,
'cart_editing_policy' => 'disabled',
'checkout_editing_policy' => 'quantity_and_remove',
'checkout_editing_policy' => 'disabled',
'display_cart_item_images' => false,
]);
@@ -101,10 +101,10 @@ class BootstrapTenantControllerTest extends TestCase
->assertJsonPath('data.cart_editing_policy.allow_delete', false)
->assertJsonPath('data.cart_editing_policy.allow_update_quantity', false)
->assertJsonPath('data.cart_editing_policy.allow_update_variant', false)
->assertJsonPath('data.checkout_editing_policy.code', 'quantity_and_remove')
->assertJsonPath('data.checkout_editing_policy.allow_modify', true)
->assertJsonPath('data.checkout_editing_policy.allow_delete', true)
->assertJsonPath('data.checkout_editing_policy.allow_update_quantity', true)
->assertJsonPath('data.checkout_editing_policy.code', 'disabled')
->assertJsonPath('data.checkout_editing_policy.allow_modify', false)
->assertJsonPath('data.checkout_editing_policy.allow_delete', false)
->assertJsonPath('data.checkout_editing_policy.allow_update_quantity', false)
->assertJsonPath('data.checkout_editing_policy.allow_update_variant', false)
->assertJsonPath('data.display_cart_item_images', false)
->assertJsonPath('data.header_bg_color', '#ffffff')->assertJsonPath('data.footer_bg_color', '#ffffff');