Compare commits

..

51 Commits

Author SHA1 Message Date
6db99e775a docs(stock): document centralized reservation flow 2026-08-25 15:05:34 -03:00
e5f7ba3615 test(stock): cover aggregate reservation lifecycle 2026-08-25 15:05:29 -03:00
24bfef431b refactor(stock): centralize reservation aggregate 2026-08-25 15:05:23 -03:00
843659583e Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-25 10:49:07 -03:00
c2921166bd feat(catalog): exclude out-of-stock items and update variant visibility logic 2026-08-25 10:49:05 -03:00
0f67e66b6b feat(category-visibility): add success messages for category visibility updates 2026-08-25 10:47:53 -03:00
725abed06a feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-25 10:47:53 -03:00
743939fc63 feat(category-visibility): add success messages for category visibility updates 2026-08-25 10:47:53 -03:00
eb50e65fef feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-25 10:47:53 -03:00
1cf13501d8 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-25 10:47:53 -03:00
2eeef8d392 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-25 10:47:53 -03:00
87a4fa6288 feat(catalog): add group_order field to catalog items and update related logic 2026-08-25 10:45:12 -03:00
07d2129410 feat(catalog): exclude out-of-stock items and update variant visibility logic 2026-08-25 09:32:56 -03:00
ceb1d1f1af Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 17:01:55 -03:00
885ab2a6e3 feat(category-visibility): add success messages for category visibility updates 2026-08-24 17:01:46 -03:00
47196a572a feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 17:01:46 -03:00
45bba9516d feat(category-visibility): add success messages for category visibility updates 2026-08-24 17:00:48 -03:00
c6da71894e feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 17:00:48 -03:00
75d3c819d9 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 17:00:48 -03:00
bd6d672df2 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 17:00:48 -03:00
6b06028771 fix(sales): render PDFs in client timezone 2026-08-24 16:42:38 -03:00
0aa423e90e feat(sales): validate PDF timezone parameter 2026-08-24 16:42:38 -03:00
e7bf2f887a Merge branch 'hotfix/change_history_time' 2026-08-24 16:13:19 -03:00
80a02fdc52 feat(sale): add changed_at serialization to SaleModificationResource 2026-08-24 16:12:55 -03:00
4162e2c1cc Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 16:05:15 -03:00
8d01620fa8 feat(sale-modification): add changed_at field serialization to ISO 8601 format 2026-08-24 16:05:10 -03:00
f8b8bab474 feat(category-visibility): add success messages for category visibility updates 2026-08-24 16:05:10 -03:00
53d1415b18 feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 16:05:10 -03:00
6ff9119086 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 16:05:10 -03:00
24e000b423 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 16:05:10 -03:00
c69ebe5f7c feat(sale-modification): add changed_at field serialization to ISO 8601 format 2026-08-24 15:59:00 -03:00
c3a2da607d Merge branch 'dev' into homo 2026-08-24 12:45:51 -03:00
d1c4acc7ae feat(category-visibility): add success messages for category visibility updates 2026-08-24 12:44:37 -03:00
f8c4cc7428 Merge branch 'dev' into homo 2026-08-24 12:36:57 -03:00
21cfbe2a46 feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 12:35:20 -03:00
abc060ab25 Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 11:32:09 -03:00
80adbd9ca9 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 11:31:26 -03:00
12c10bbe06 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 10:49:14 -03:00
36eb41a18a feat(invitation): update seat allocation structure and enhance provisioning logic 2026-08-22 14:16:57 +00:00
1dd2366957 feat(catalog): expose product unavailability messages 2026-08-21 16:47:15 -03:00
e8bcaa2026 feat(sales): handle in-review purchases as pending 2026-08-21 16:15:59 -03:00
5548fe8511 feat(purchase): restore in-review transfer lifecycle 2026-08-21 16:15:55 -03:00
dc0fbe06b8 fix(cart): invalidate checkout on mutation 2026-08-21 14:06:11 -03:00
afd69b8393 feat(checkout): track current cart purchase 2026-08-21 14:05:33 -03:00
a34d5cba1a test(sale): require purchase item snapshots 2026-08-21 13:42:30 -03:00
1650176aa0 refactor(checkout): keep source cart active 2026-08-21 13:42:30 -03:00
79e721ae63 refactor(checkout): disable purchase item editing 2026-08-21 11:03:48 -03:00
4e7e42d16e refactor(checkout): materialize purchase item snapshots at start 2026-08-21 11:03:39 -03:00
3a2dc8b7e0 feat(cart): implement restoration and reservation of source cart on expired checkout modification 2026-08-21 10:10:28 -03:00
0510c1aa12 test(checkout): cover source cart restoration 2026-08-21 09:29:23 -03:00
9d870be294 feat(checkout): restore source cart on cancellation 2026-08-21 09:29:23 -03:00
97 changed files with 2822 additions and 2184 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

@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
@@ -27,6 +28,8 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'guest_token',
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
@@ -43,6 +46,8 @@ class Cart extends Model
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
@@ -76,6 +81,21 @@ class Cart extends Model
return $this->hasMany(Purchase::class, 'cart_id');
}
/** @return BelongsTo<Purchase, $this> */
public function currentPurchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
/** @return BelongsTo<StockReservation, $this> */
public function currentStockReservation(): BelongsTo
{
return $this->belongsTo(
StockReservation::class,
'current_stock_reservation_id',
);
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
@@ -98,7 +118,7 @@ class Cart extends Model
}
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
$this->invalidateCurrentCheckout();
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
@@ -132,12 +152,11 @@ class Cart extends Model
'cantidad' => $quantity,
]);
} else {
app(StockReservationService::class)->ensure($item, $selectedItem);
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -163,6 +182,8 @@ class Cart extends Model
$updateVariant,
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
@@ -195,7 +216,6 @@ class Cart extends Model
$nextAvailableQuantity,
);
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
@@ -212,11 +232,10 @@ class Cart extends Model
->first();
if ($targetItem !== null) {
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
$targetItem->cantidad += $quantity;
$targetItem->save();
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
@@ -224,7 +243,7 @@ class Cart extends Model
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
}
@@ -253,16 +272,9 @@ class Cart extends Model
]);
}
if ($delta > 0) {
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
}
if ($delta < 0) {
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
}
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -271,26 +283,74 @@ class Cart extends Model
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(StockReservationService::class)->release(
$item,
$selectedItem,
$item->cantidad,
);
$item->delete();
app(StockReservationService::class)->syncCart($this);
});
}
private function invalidateCurrentCheckout(): void
{
$candidatePurchaseId = self::query()
->whereKey($this->getKey())
->value('current_purchase_id');
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var self $cart */
$cart = self::query()->lockForUpdate()->findOrFail($this->getKey());
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.checkout_in_progress'),
]);
}
$this->current_purchase_id = null;
return;
}
if ($currentPurchase === null) {
return;
}
if ($currentPurchase->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart' => __('api.cart.editing_disabled'),
]);
}
if (in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
}
app(StockReservationService::class)->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())
->update(['current_purchase_id' => null]);
$this->current_purchase_id = null;
}
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,

View File

@@ -3,13 +3,11 @@
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'cart_id',
@@ -57,10 +55,4 @@ class CartItem extends Model
{
return $this->variant ?? $this->catalogItem;
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->hasMany(StockReservation::class);
}
}

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

@@ -3,119 +3,82 @@
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
class ExpireCartReservationsService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function expireOverdue(): int
{
$expiredItems = 0;
$lastCartItemId = 0;
$expired = 0;
$lastReservationId = 0;
do {
$cartItemIds = StockReservation::query()
$reservationIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNull('purchase_id')
->whereNotNull('cart_item_id')
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('cart_item_id', '>', $lastCartItemId)
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
->select('cart_item_id')
->distinct()
->orderBy('cart_item_id')
->where('id', '>', $lastReservationId)
->whereHas('currentCart', fn ($query) => $query->where('status', 'active'))
->whereDoesntHave('purchase', fn ($query) => $query->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
]))
->orderBy('id')
->limit(500)
->pluck('cart_item_id');
->pluck('id');
foreach ($cartItemIds as $cartItemId) {
$lastCartItemId = (int) $cartItemId;
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
if ($this->expireCartItem($lastCartItemId)) {
$expiredItems++;
if ($this->expireReservation($lastReservationId)) {
$expired++;
}
}
} while ($cartItemIds->count() === 500);
} while ($reservationIds->count() === 500);
return $expiredItems;
return $expired;
}
private function expireCartItem(int $cartItemId): bool
private function expireReservation(int $reservationId): bool
{
/** @var CartItem|null $candidate */
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
if ($candidate === null) {
$cartId = Cart::query()
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->value('id');
if ($cartId === null) {
return false;
}
return DB::transaction(function () use ($candidate, $cartItemId): bool {
return DB::transaction(function () use ($cartId, $reservationId): bool {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($candidate->cart_id)
->whereKey($cartId)
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($cart === null) {
return false;
}
/** @var CartItem|null $cartItem */
$cartItem = $cart->items()
->whereKey($cartItemId)
->lockForUpdate()
->first();
if ($cartItem === null) {
return false;
}
$reservations = StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('inventory_id')
->lockForUpdate()
->get();
if (
$reservations->isEmpty()
|| $reservations->contains(
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
if ($reservation === null
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture(),
)
) {
|| $reservation->expires_at->isFuture()) {
return false;
}
$inventories = Inventory::query()
->whereKey($reservations->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($reservations as $reservation) {
$inventory = $inventories->get($reservation->inventory_id)
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
$inventory->release((int) $reservation->quantity);
$reservation->update([
'quantity' => 0,
'status' => StockReservation::STATUS_EXPIRED,
'expires_at' => null,
'released_at' => now(),
]);
}
$cartItem->delete();
if (! $cart->items()->exists()) {
$cart->update(['status' => 'expired']);
$cart->delete();
}
$this->reservations->expire($reservation);
return true;
});

View File

@@ -6,13 +6,13 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
## Modelo
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
- `ExpireCartReservationsService`: libera como conjunto las reservas vencidas de carritos activos sin eliminar el contenido del carrito.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
@@ -34,4 +34,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, su reserva se libera con el motivo correspondiente y se crea otra para el carrito actualizado.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas de carrito cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la reserva y sus líneas como historial, libera el stock como conjunto y deja intactos el carrito y sus ítems.

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

@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
'type',
'slug',
'nombre',
'group_order',
'descripcion',
'precio',
'inventory_policy',
@@ -47,6 +48,7 @@ class CatalogItem extends Model
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false,
'group_order' => 0,
];
protected function casts(): array
@@ -56,6 +58,7 @@ class CatalogItem extends Model
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
@@ -172,18 +175,30 @@ class CatalogItem extends Model
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereVariantsAvailable(Builder $query): Builder
public function scopeWhereAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->whereDoesntHave('variants')
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
->whereDoesntHave('variants')
->where(function (Builder $inventoryQuery): void {
$inventoryQuery
->whereNull('catalog_items.inventory_id')
->orWhereHas(
'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
);
});
});
});
}
/** @return Collection<int, Variant> */

View File

@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'tenant_code',
'categoria_id',
'nombre',
'is_enabled',
])]
class Category extends Model
{
@@ -22,6 +23,10 @@ class Category extends Model
protected $table = 'categorias';
protected $attributes = [
'is_enabled' => true,
];
/**
* @return array<string, string>
*/
@@ -29,6 +34,7 @@ class Category extends Model
{
return [
'categoria_id' => 'integer',
'is_enabled' => 'boolean',
];
}

View File

@@ -48,10 +48,10 @@ class Inventory extends Model
return $this->hasOne(Variant::class);
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
/** @return HasMany<StockReservationLine, $this> */
public function stockReservationLines(): HasMany
{
return $this->hasMany(StockReservation::class);
return $this->hasMany(StockReservationLine::class);
}
public function availableStock(): int

View File

@@ -2,21 +2,20 @@
namespace App\Domains\Catalog\Models;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'inventory_id',
'cart_item_id',
'purchase_id',
'quantity',
'status',
'expires_at',
'committed_at',
'released_at',
'expired_at',
'release_reason',
])]
class StockReservation extends Model
{
@@ -31,31 +30,28 @@ class StockReservation extends Model
protected function casts(): array
{
return [
'inventory_id' => 'integer',
'cart_item_id' => 'integer',
'purchase_id' => 'integer',
'quantity' => 'integer',
'expires_at' => 'datetime',
'committed_at' => 'datetime',
'released_at' => 'datetime',
'expired_at' => 'datetime',
];
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
/** @return HasMany<StockReservationLine, $this> */
public function lines(): HasMany
{
return $this->belongsTo(Inventory::class);
return $this->hasMany(StockReservationLine::class);
}
/** @return BelongsTo<CartItem, $this> */
public function cartItem(): BelongsTo
/** @return HasOne<Cart, $this> */
public function currentCart(): HasOne
{
return $this->belongsTo(CartItem::class);
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
}
/** @return BelongsTo<Purchase, $this> */
public function purchase(): BelongsTo
/** @return HasOne<Purchase, $this> */
public function purchase(): HasOne
{
return $this->belongsTo(Purchase::class);
return $this->hasOne(Purchase::class);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'stock_reservation_id',
'inventory_id',
'quantity',
'tracks_inventory',
])]
class StockReservationLine extends Model
{
protected function casts(): array
{
return [
'stock_reservation_id' => 'integer',
'inventory_id' => 'integer',
'quantity' => 'integer',
'tracks_inventory' => 'boolean',
];
}
/** @return BelongsTo<StockReservation, $this> */
public function reservation(): BelongsTo
{
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
}

View File

@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
),
],
'nombre' => ['required', 'string', 'max:255'],
'group_order' => ['sometimes', 'integer', 'min:0'],
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],

View File

@@ -20,7 +20,6 @@ class CatalogFeaturedItemResource extends JsonResource
$catalogItem = $this->resource;
/** @var FeaturedGroup $featuredGroup */
$featuredGroup = $catalogItem->getRelation('featuredGroup');
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
return $this->columnWithImageData($catalogItem);
@@ -30,6 +29,9 @@ class CatalogFeaturedItemResource extends JsonResource
return $this->ticketSelectorData($catalogItem);
}
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
$availableStock = $catalogItem->availableStock();
$data = [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
@@ -37,11 +39,20 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'maximum_addable_quantity' => $this->maximumAddable(
$catalogItem->availableStock(),
$availableStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$availableStock,
$remainingUserQuota,
),
'variants' => $catalogItem->visibleVariants()
->map(fn (Variant $variant): array => [
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock();
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
@@ -50,13 +61,16 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$variantStock,
$remainingUserQuota,
),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
])
];
})
->values(),
];
@@ -66,6 +80,9 @@ class CatalogFeaturedItemResource extends JsonResource
/** @return array<string, mixed> */
private function ticketSelectorData(CatalogItem $catalogItem): array
{
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
@@ -73,25 +90,32 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
];
}
/** @return array<string, mixed> */
private function columnWithImageData(CatalogItem $catalogItem): array
{
$availableStock = $catalogItem->availableStock();
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
return [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
];
}
private function firstImageUrl(CatalogItem $catalogItem): ?string
{
$attachment = $catalogItem->attachments->first()
?? $catalogItem->variants
?? $catalogItem->visibleVariants()
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
@@ -103,4 +127,10 @@ class CatalogFeaturedItemResource extends JsonResource
return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota);
}
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
}
}

View File

@@ -42,11 +42,15 @@ class CatalogItemDetailResource extends JsonResource
$selectedVariant === null,
fn () => $this->maximumAddable($this->availableStock()),
),
'unavailable_message' => $this->when(
$selectedVariant === null,
fn () => $this->unavailableMessage($this->availableStock()),
),
'images' => $this->when(
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->visibleVariants()
'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
'selected_variant' => $this->when(
@@ -159,6 +163,7 @@ class CatalogItemDetailResource extends JsonResource
{
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
return [
'id' => $variant->id,
@@ -168,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($this->variantStock($variant)),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $values,
];
}
@@ -195,4 +201,12 @@ class CatalogItemDetailResource extends JsonResource
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
}

View File

@@ -15,8 +15,10 @@ class CatalogSearchItemResource extends JsonResource
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first()
?? $this->variants
?? $visibleVariants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
@@ -27,9 +29,15 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($this->availableStock()),
'variants' => $this->visibleVariants()
->map(fn (Variant $variant): array => [
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock),
'variants' => $visibleVariants
->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock();
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
@@ -37,13 +45,11 @@ class CatalogSearchItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $variant->selectorOptions($this->itemAttributes),
])
];
})
->values(),
];
}
@@ -55,4 +61,12 @@ class CatalogSearchItemResource extends JsonResource
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
}

View File

@@ -25,6 +25,24 @@ class CatalogInventoryService
);
}
/**
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
{
if ($quantity <= 0) {
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
}
return array_map(
fn (array $requirement): array => [
...$requirement,
'quantity' => $requirement['quantity'] * $quantity,
],
$this->inventoryRequirements($selection),
);
}
public function availableQuantity(CatalogItem|Variant $selection): ?int
{
if ($selection instanceof CatalogItem

View File

@@ -8,6 +8,10 @@ use Illuminate\Support\Collection;
class CatalogItemAllowanceService
{
private const USER_QUOTA_REACHED_MESSAGE = 'Alcanzaste el cupo máximo permitido para este producto.';
private const OUT_OF_STOCK_MESSAGE = 'Este producto no tiene stock disponible.';
public function __construct(
private readonly UserPurchaseLimitService $purchaseLimits,
) {}
@@ -37,4 +41,17 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota);
}
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
{
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
return self::USER_QUOTA_REACHED_MESSAGE;
}
if ($availableStock !== null && $availableStock <= 0) {
return self::OUT_OF_STOCK_MESSAGE;
}
return null;
}
}

View File

@@ -205,6 +205,13 @@ class CatalogService
]);
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
@@ -231,7 +238,7 @@ class CatalogService
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereVariantsAvailable()
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -281,7 +288,7 @@ class CatalogService
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereVariantsAvailable()
->whereAvailable()
->with([
'attachments',
'inventory',

View File

@@ -15,7 +15,7 @@ class ExpireStockReservationsService
) {}
/**
* @return array{purchases: int, cart_items: int}
* @return array{purchases: int, cart_reservations: int}
*/
public function expireOverdue(): array
{
@@ -29,19 +29,19 @@ class ExpireStockReservationsService
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_items' => $expiredCartItems,
'expired_cart_reservations' => $expiredCartItems,
'total_expired' => $expiredPurchases + $expiredCartItems,
]);
return [
'purchases' => $expiredPurchases,
'cart_items' => $expiredCartItems,
'cart_reservations' => $expiredCartItems,
];
} catch (Throwable $exception) {
Log::channel('commands')->error('Stock reservation cleanup failed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_items' => $expiredCartItems,
'expired_cart_reservations' => $expiredCartItems,
'exception' => $exception,
]);

View File

@@ -49,7 +49,16 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereVariantsAvailable()
->whereAvailable()
->where(function (Builder $query): void {
$query
->whereDoesntHave('category')
->orWhereHas(
'category',
fn (Builder $categoryQuery): Builder => $categoryQuery
->where('is_enabled', true),
);
})
->with([
'inventory',
'attachments',
@@ -73,7 +82,9 @@ class FeaturedGroupService
FeaturedGroupSource::Category => $query
->where('catalog_items.category_id', $featuredGroup->category_id)
->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query
->orderBy('catalog_items.group_order')
->orderBy('catalog_items.id'),
};
}

View File

@@ -2,223 +2,415 @@
namespace App\Domains\Catalog\Services;
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\StockReservation;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockReservationService
{
public const REASON_CART_EMPTY = 'cart_empty';
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
public const REASON_MANUAL_RELEASE = 'manual_release';
public function __construct(
private readonly CatalogInventoryService $inventory,
) {}
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
public function syncCart(Cart $cart): ?StockReservation
{
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
$this->inventory->reserve($selection, $quantity);
$this->recordIncrease($cartItem, $selection, $quantity);
return DB::transaction(function () use ($cart): ?StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadSelections($items);
$requirements = $this->requirementsForItems($items);
$reservation = $lockedCart->current_stock_reservation_id === null
? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
if ($reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& $reservation->expires_at->isPast()) {
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
$lockedCart->update(['current_stock_reservation_id' => null]);
$reservation = null;
}
if ($requirements === []) {
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
$this->finalizeLocked(
$reservation,
StockReservation::STATUS_RELEASED,
self::REASON_CART_EMPTY,
);
}
$lockedCart->update(['current_stock_reservation_id' => null]);
$cart->current_stock_reservation_id = null;
return null;
}
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) {
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
}
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
}
$currentLines = StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get()
->keyBy('inventory_id');
$inventoryIds = collect(array_keys($requirements))
->merge($currentLines->keys())
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$inventories = Inventory::query()
->whereKey($inventoryIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($inventoryIds as $inventoryId) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
$delta = $required - $previous;
if ($delta > 0
&& $requirements[$inventoryId]['tracks_inventory']
&& $inventory->availableStock() < $delta) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
}
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
}
}
foreach ($inventoryIds as $inventoryId) {
/** @var Inventory $inventory */
$inventory = $inventories->get($inventoryId);
$line = $currentLines->get($inventoryId);
$previous = (int) ($line?->quantity ?? 0);
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
$delta = $required - $previous;
if ($delta > 0) {
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
} elseif ($delta < 0) {
$inventory->release(abs($delta));
}
if ($required === 0) {
$line?->delete();
continue;
}
StockReservationLine::query()->updateOrCreate(
[
'stock_reservation_id' => $reservation->getKey(),
'inventory_id' => $inventoryId,
],
[
'quantity' => $required,
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
],
);
}
$reservation->update([
'expires_at' => $this->expiration(),
'release_reason' => null,
]);
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function release(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus = StockReservation::STATUS_RELEASED,
): void {
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
$this->ensure($cartItem, $selection);
$this->inventory->release($selection, $quantity);
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation
{
return DB::transaction(function () use ($cart, $purchase): StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
/** @var Purchase $lockedPurchase */
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($lockedCart->current_stock_reservation_id === null) {
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
$linkedPurchase = Purchase::query()
->where('stock_reservation_id', $reservation->getKey())
->whereKeyNot($lockedPurchase->getKey())
->exists();
if ($linkedPurchase) {
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
}
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
$reservation->update(['expires_at' => $lockedPurchase->expires_at]);
$purchase->stock_reservation_id = $reservation->getKey();
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function commit(CartItem $cartItem, CatalogItem|Variant $selection): void
public function commit(Purchase $purchase): void
{
DB::transaction(function () use ($cartItem, $selection): void {
$this->ensure($cartItem, $selection);
$this->inventory->commit($selection, (int) $cartItem->cantidad);
DB::transaction(function () use ($purchase): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
}
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
return;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
$lines = $this->lockLines($reservation);
if ($lines->isEmpty()) {
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
}
$inventories = $this->lockInventories($lines);
foreach ($lines as $line) {
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
}
}
foreach ($lines as $line) {
$inventories->get($line->inventory_id)->buy(
(int) $line->quantity,
(bool) $line->tracks_inventory,
);
}
$reservation->update([
'status' => StockReservation::STATUS_COMMITTED,
'committed_at' => now(),
'expires_at' => null,
]);
}
});
}
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
{
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
$reservation->update([
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'committed_at' => now(),
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
}
}
public function attachToPurchase(
CartItem $cartItem,
CatalogItem|Variant $selection,
Purchase $purchase,
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => $purchase->getKey(),
'expires_at' => $purchase->expires_at,
'expired_at' => null,
'release_reason' => null,
]);
});
}
public function detachFromPurchase(Purchase $purchase): void
public function releaseForPurchase(
Purchase $purchase,
string $status = StockReservation::STATUS_RELEASED,
?string $reason = null,
): void {
DB::transaction(function () use ($purchase, $status, $reason): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
if ($reservation !== null) {
$this->finalizeLocked($reservation, $status, $reason);
}
});
}
public function releaseCurrentCartReservation(
Cart $cart,
string $reason = self::REASON_CART_CHANGED,
): void {
DB::transaction(function () use ($cart, $reason): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
if ($reservation !== null) {
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
}
$cart->update(['current_stock_reservation_id' => null]);
});
}
public function expire(StockReservation $reservation): void
{
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => null,
'expires_at' => $this->expiration(),
]);
DB::transaction(function () use ($reservation): void {
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture()) {
return;
}
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
});
}
public function syncPurchaseExpiration(Purchase $purchase): void
{
if ($purchase->stock_reservation_id === null) {
return;
}
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->whereKey($purchase->stock_reservation_id)
->where('status', StockReservation::STATUS_ACTIVE)
->update(['expires_at' => $purchase->expires_at]);
}
public function transfer(CartItem $source, CartItem $target): void
/**
* @param Collection<int, CartItem> $items
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
private function requirementsForItems(Collection $items): array
{
DB::transaction(function () use ($source, $target): void {
$sourceReservations = StockReservation::query()
->where('cart_item_id', $source->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
$requirements = [];
foreach ($items as $item) {
$selection = $item->selectedItem();
if ($selection === null) {
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
}
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
if (isset($requirements[$inventoryId])) {
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
$requirements[$inventoryId]['tracks_inventory'] =
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
continue;
}
$requirements[$inventoryId] = $requirement;
}
}
ksort($requirements);
return $requirements;
}
/** @param Collection<int, CartItem> $items */
private function loadSelections(Collection $items): void
{
$items->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'catalogItem.bundleComponents.variant.catalogItem',
'variant.inventory',
'variant.catalogItem',
]);
}
/** @return Collection<int, StockReservationLine> */
private function lockLines(StockReservation $reservation): Collection
{
return StockReservationLine::query()
->where('stock_reservation_id', $reservation->getKey())
->orderBy('inventory_id')
->lockForUpdate()
->get();
foreach ($sourceReservations as $sourceReservation) {
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
if ($targetReservation === null) {
$sourceItemQuantity = (int) $source->cantidad;
$targetItemQuantity = (int) $target->fresh()->cantidad;
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
$sourceReservation->update([
'cart_item_id' => $target->getKey(),
'purchase_id' => null,
'quantity' => $perItemQuantity * $targetItemQuantity,
'expires_at' => $this->expiration(),
]);
continue;
}
$targetReservation->update([
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$sourceReservation->delete();
}
});
}
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
/**
* @param Collection<int, StockReservationLine> $lines
* @return Collection<int, Inventory>
*/
private function lockInventories(Collection $lines): Collection
{
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
}
$reservation->update([
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
}
private function recordDecrease(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus,
): void {
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
}
$remaining = $reservation->quantity - $requiredQuantity;
$reservation->update([
'quantity' => $remaining,
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
'released_at' => $remaining === 0 ? now() : null,
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
]);
}
}
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
{
return StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('inventory_id', $inventoryId)
return Inventory::query()
->whereKey($lines->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->first();
->get()
->keyBy('id');
}
private function finalizeLocked(
StockReservation $reservation,
string $status,
?string $reason,
): void {
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
return;
}
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
}
$lines = $this->lockLines($reservation);
$inventories = $this->lockInventories($lines);
foreach ($lines as $line) {
$inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
$inventory->release((int) $line->quantity);
}
$now = now();
$reservation->update([
'status' => $status,
'expires_at' => null,
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
]);
Cart::query()
->where('current_stock_reservation_id', $reservation->getKey())
->update(['current_stock_reservation_id' => null]);
}
private function expiration(): Carbon

View File

@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
@@ -18,7 +18,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.

View File

@@ -17,21 +17,25 @@ class InvitationPurchaseProvisioner
private const PAYMENT_METHOD = 'invitation';
/**
* @var list<array{sector: string, row: int, seats: int, type: string}>
* @var list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>
*/
private const ALLOCATIONS = [
['sector' => 'A', 'row' => 1, 'seats' => 16, 'type' => 'NORMAL'],
['sector' => 'A', 'row' => 3, 'seats' => 14, 'type' => 'NORMAL'],
['sector' => 'C', 'row' => 1, 'seats' => 16, 'type' => 'VIP + LUNCH'],
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
];
public function provision(): void
/**
* @param list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>|null $allocations
*/
public function provision(?array $allocations = null): void
{
if (! $this->prerequisitesExist()) {
return;
}
DB::transaction(function (): void {
DB::transaction(function () use ($allocations): void {
$now = now();
$userId = $this->userId($now);
$purchaseId = $this->purchaseId($userId, $now);
@@ -44,8 +48,8 @@ class InvitationPurchaseProvisioner
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
}
foreach (self::ALLOCATIONS as $allocation) {
foreach (range(1, $allocation['seats']) as $seat) {
foreach ($allocations ?? self::ALLOCATIONS as $allocation) {
foreach (range($allocation['first_seat'], $allocation['last_seat']) as $seat) {
$variant = $this->variant(
(int) $catalogItem->id,
$allocation['sector'],
@@ -388,15 +392,28 @@ class InvitationPurchaseProvisioner
'sold_units' => $inventory->sold_units + 1,
]);
DB::table('stock_reservations')->insert([
'inventory_id' => $inventory->id,
'cart_item_id' => null,
'purchase_id' => $purchaseId,
'quantity' => 1,
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed',
'expires_at' => null,
'committed_at' => $now,
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
}
DB::table('stock_reservation_lines')->insert([
'stock_reservation_id' => $reservationId,
'inventory_id' => $inventory->id,
'quantity' => 1,
'tracks_inventory' => true,
'created_at' => $now,
'updated_at' => $now,
]);

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateCategoryVisibilityRequest;
use App\Domains\FiestaFutbolInfantil\Services\CategoryVisibilityService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategoryVisibilityController extends Controller
{
public function __construct(private readonly CategoryVisibilityService $visibilityService) {}
public function show(Request $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
return response()->json([
'data' => [
'is_enabled' => $this->visibilityService->isEnabled($tenant, $category),
],
]);
}
public function update(UpdateCategoryVisibilityRequest $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
$model = $this->visibilityService->update(
$tenant,
$category,
$request->boolean('is_enabled'),
);
return response()->json([
'data' => [
'is_enabled' => $model->is_enabled,
],
'message' => $model->is_enabled
? 'Mostrar en sitio web se activo correctamente'
: 'Mostrar en sitio web se desactivo correctamente',
]);
}
private function category(Request $request): FiestaCategory
{
return FiestaCategory::from((string) $request->route('category'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Enums;
enum FiestaCategory: string
{
case Entries = 'entries';
case Foods = 'foods';
case Accommodations = 'accommodations';
case Merchandise = 'merchandise';
public function categoryName(): string
{
return match ($this) {
self::Entries => 'Entradas',
self::Foods => 'Comidas',
self::Accommodations => 'Alojamientos',
self::Merchandise => 'Merchandising',
};
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCategoryVisibilityRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'is_enabled' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Models\Category;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\Tenant\Models\Tenant;
class CategoryVisibilityService
{
public function isEnabled(Tenant $tenant, FiestaCategory $category): bool
{
return Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', $category->categoryName())
->value('is_enabled') ?? true;
}
public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category
{
$model = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => $category->categoryName(),
]);
$model->update(['is_enabled' => $isEnabled]);
return $model;
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
use App\Domains\FiestaFutbolInfantil\Controllers\CategoryVisibilityController;
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
@@ -9,6 +10,20 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
$visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void {
Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
};
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index');

View File

@@ -12,10 +12,12 @@ class SaleFormService
$names = [
Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_IN_REVIEW => 'En revisión',
Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
];
return [

View File

@@ -78,6 +78,7 @@ class TelepagosWebhookService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer')
->where('total', $amount)

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;
@@ -26,13 +24,22 @@ class PurchaseController extends Controller
{
public function index(Request $request, Tenant $tenant): JsonResponse
{
$statusParam = $request->query('status');
$statuses = is_string($statusParam)
? collect(explode(',', $statusParam))
->map(fn (string $status): string => trim($status))
->filter()
->unique()
->values()
->all()
: [];
return PurchaseResource::collection(
Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id)
->when($request->query('status'), function ($query, $status) {
$query->where('status', $status);
})
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
->orderBy('status')
->latest()
->paginateFromRequest()
)->response();
@@ -81,53 +88,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,
@@ -163,6 +123,8 @@ class PurchaseController extends Controller
return false;
}
$purchaseState->lockCurrentCart($purchase);
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,

View File

@@ -19,6 +19,7 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'stock_reservation_id',
'tenant_codigo',
'user_id',
'status',
@@ -39,6 +40,8 @@ class Purchase extends Model
public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
@@ -47,16 +50,20 @@ class Purchase extends Model
public const STATUS_EXPIRED = 'expired';
public const STATUS_SUPERSEDED = 'superseded';
/** @return list<string> */
public static function statuses(): array
{
return [
self::STATUS_CREATED,
self::STATUS_PENDING_PAYMENT,
self::STATUS_IN_REVIEW,
self::STATUS_PAID,
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
self::STATUS_SUPERSEDED,
];
}
@@ -71,6 +78,7 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -117,10 +125,10 @@ class Purchase extends Model
return $this->hasMany(Ticket::class, 'source_purchase_id');
}
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
/** @return BelongsTo<StockReservation, $this> */
public function stockReservation(): BelongsTo
{
return $this->hasMany(StockReservation::class);
return $this->belongsTo(StockReservation::class);
}
/**
@@ -154,12 +162,7 @@ class Purchase extends Model
return (float) $this->getRelation('items')->sum('total');
}
$itemsTotal = (float) $this->items()->sum('total');
if ($itemsTotal > 0 || $this->items()->exists()) {
return $itemsTotal;
}
return (float) ($this->cart?->getTotalAmount() ?? $this->total ?? 0);
return (float) $this->items()->sum('total');
}
protected function valueChangeTenantCode(): string

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,16 +2,12 @@
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;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin PurchaseItem|CartItem */
/** @mixin PurchaseItem */
class PurchaseItemResource extends JsonResource
{
/** @return array<string, mixed> */
@@ -20,19 +16,9 @@ class PurchaseItemResource extends JsonResource
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
if ($this->resource instanceof PurchaseItem) {
$imageUrl = $displayImage
? $this->imageAttachment?->getTemporaryUrl(1440)
: null;
$attributes = $this->variant_attributes ?? [];
$catalogItem = $this->relationLoaded('sourceCatalogItem')
? $this->sourceCatalogItem
: null;
$includeVariants = $tenant instanceof Tenant
&& $tenant->checkout_editing_policy->allowsVariantChanges()
&& $catalogItem !== null
&& $catalogItem->relationLoaded('variants');
return [
'id' => $this->id,
@@ -46,132 +32,13 @@ class PurchaseItemResource extends JsonResource
'descripcion' => $this->descripcion,
'slug' => $this->slug,
'imagen' => $imageUrl,
'attributes' => $attributes,
'attributes' => $this->variant_attributes ?? [],
],
'variants' => $this->when(
$includeVariants,
fn () => $catalogItem
->visibleVariants($this->source_variant_id)
->map(fn (Variant $variant): array => $this->variantData($catalogItem, $variant))
->values(),
),
];
}
$selectedItem = $this->selectedItem();
$catalogItem = $this->catalogItem;
$variant = $this->variant;
$quantity = (int) ($this->cantidad ?? 0);
$unitPrice = $this->resolveUnitPrice($selectedItem);
$lineTotal = $unitPrice * $quantity;
$imageUrl = $displayImage
? $this->resolveImageUrl($selectedItem, $catalogItem)
: null;
$includeVariants = $tenant instanceof Tenant
&& $tenant->checkout_editing_policy->allowsVariantChanges()
&& $catalogItem?->relationLoaded('variants');
return [
'id' => $this->id,
'quantity' => $quantity,
'unit_price' => $this->formatMoney($unitPrice),
'line_total' => $this->formatMoney($lineTotal),
'source_catalog_item_id' => $this->catalog_item_id,
'source_variant_id' => $this->variant_id,
'item_details' => $selectedItem === null ? null : [
'nombre' => $selectedItem->getName(),
'descripcion' => $selectedItem->getDescription(),
'slug' => $catalogItem?->slug,
'imagen' => $imageUrl,
'attributes' => $variant === null ? [] : $this->resolveAttributes($variant),
],
'variants' => $this->when(
$includeVariants,
fn () => $catalogItem
->visibleVariants($this->variant_id)
->map(fn (Variant $availableVariant): array => $this->variantData(
$catalogItem,
$availableVariant,
))
->values(),
),
];
}
private function resolveUnitPrice(CatalogItem|Variant|null $selectedItem): float
{
return (float) ($selectedItem?->getPrice() ?? 0);
}
private function resolveImageUrl(
CatalogItem|Variant|null $selectedItem,
?CatalogItem $catalogItem,
): ?string {
$attachment = $selectedItem?->relationLoaded('attachments')
? $selectedItem->attachments->first()
: null;
if ($attachment === null && $catalogItem?->relationLoaded('attachments')) {
$attachment = $catalogItem->attachments->first();
}
return $attachment?->getTemporaryUrl(1440);
}
/** @return array<int, array{name: string, value: mixed}> */
private function resolveAttributes(Variant $variant): array
{
if (! $variant->relationLoaded('definitions')) {
return [];
}
$attributes = $variant->definitions
->groupBy('item_attribute_id')
->map(function ($definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$values = $definitions->pluck('value')->values();
return [
'name' => (string) ($itemAttribute?->attribute?->nombre ?? ''),
'value' => $itemAttribute?->allow_multi_select
? $values->all()
: $values->first(),
];
})
->filter(fn (array $attribute): bool => $attribute['name'] !== '' || $attribute['value'] !== null)
->values();
$eventDates = $variant->relationLoaded('eventDates')
? $variant->selectedEventDates()
: collect();
if ($eventDates->isNotEmpty()) {
$attributes->prepend([
'name' => 'Fecha',
'value' => $eventDates
->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))
->values()
->all(),
]);
}
return $attributes->all();
}
private function formatMoney(float|int|string|null $amount): string
{
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

@@ -2,7 +2,6 @@
namespace App\Domains\Purchase\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
@@ -18,28 +17,16 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
$purchaseItems = $this->resource->relationLoaded('items')
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$cartItems = $this->resource->relationLoaded('cart')
&& $this->resource->getRelation('cart')?->relationLoaded('items')
? $this->resource->getRelation('cart')->getRelation('items')
: null;
$usesCartItems = in_array($this->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true) && $cartItems !== null;
$items = $usesCartItems ? $cartItems : $purchaseItems;
$itemsSource = $usesCartItems
? 'cart'
: ($purchaseItems->isNotEmpty() ? 'purchase' : null);
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$subtotal = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemSubtotal($item),
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0,
)
: (float) ($this->total ?? 0);
@@ -48,7 +35,7 @@ class PurchaseResource extends JsonResource
? (float) $this->total
: ($items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem|CartItem $item): float => $carry + $this->resolveItemTotal($item),
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0));
@@ -67,7 +54,7 @@ class PurchaseResource extends JsonResource
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'items_source' => $itemsSource,
'items_source' => $items->isNotEmpty() ? 'purchase' : null,
'items' => PurchaseItemResource::collection($items),
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
@@ -76,21 +63,13 @@ class PurchaseResource extends JsonResource
];
}
protected function resolveItemSubtotal(PurchaseItem|CartItem $item): float
protected function resolveItemSubtotal(PurchaseItem $item): float
{
if ($item instanceof CartItem) {
return (float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
}
return (float) $item->precio_unitario * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem|CartItem $item): float
protected function resolveItemTotal(PurchaseItem $item): float
{
if ($item instanceof CartItem) {
return $this->resolveItemSubtotal($item);
}
return (float) ($item->total ?? 0);
}

View File

@@ -15,7 +15,6 @@ class CompleteCheckoutService
public function __construct(
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly PurchaseStateGuard $purchaseState,
) {}
@@ -35,6 +34,8 @@ class CompleteCheckoutService
return $this->loadPurchase($purchase);
}
$this->purchaseState->lockCurrentCart($purchase);
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
@@ -54,6 +55,12 @@ class CompleteCheckoutService
return $this->loadPurchase($purchase);
}
if ($purchase->status === Purchase::STATUS_IN_REVIEW) {
return $this->loadPurchase($purchase);
}
$this->purchaseState->lockCurrentCart($purchase);
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
@@ -63,7 +70,10 @@ class CompleteCheckoutService
]);
}
$purchase->update(['expires_at' => null]);
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null,
]);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadPurchase($purchase);
@@ -84,25 +94,26 @@ class CompleteCheckoutService
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
Purchase::STATUS_SUPERSEDED,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.cannot_confirm'),
]);
}
if ($purchase->items()->exists()) {
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart?->status === 'converted' && $cart->trashed()) {
return;
}
if (! $purchase->items()->exists()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$cart = $purchase->cart()->lockForUpdate()->first();
if ($cart === null || $cart->status !== 'checkout') {
$cart = $this->purchaseState->lockCurrentCart($purchase);
if ($cart->status === 'converted' && $cart->trashed()) {
return;
}
if (! in_array($cart->status, ['active', 'checkout'], true)) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
@@ -115,10 +126,32 @@ class CompleteCheckoutService
]);
}
$snapshotQuantities = $purchase->items()
->lockForUpdate()
->get(['source_catalog_item_id', 'source_variant_id', 'cantidad'])
->groupBy(fn ($item): string => $this->itemKey(
(int) $item->source_catalog_item_id,
$item->source_variant_id === null ? null : (int) $item->source_variant_id,
))
->map(fn (Collection $items): int => (int) $items->sum('cantidad'))
->sortKeys()
->all();
$cartQuantities = $cartItems
->groupBy(fn (CartItem $item): string => $this->itemKey(
(int) $item->catalog_item_id,
$item->variant_id === null ? null : (int) $item->variant_id,
))
->map(fn (Collection $items): int => (int) $items->sum('cantidad'))
->sortKeys()
->all();
if ($snapshotQuantities !== $cartQuantities) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->loadCartItems($cartItems);
$purchase->items()->createMany(
$this->snapshots->fromCartItems($cartItems),
);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
@@ -128,14 +161,15 @@ class CompleteCheckoutService
]);
}
}
try {
$this->reservations->commit($cartItem, $selection);
$this->reservations->commit($purchase);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
$this->sourceCart->finalize($purchase);
});
@@ -145,9 +179,11 @@ class CompleteCheckoutService
{
return in_array($purchase->status, [
Purchase::STATUS_PAID,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
Purchase::STATUS_SUPERSEDED,
], true);
}
@@ -159,16 +195,12 @@ class CompleteCheckoutService
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 $purchase->load(['items.imageAttachment']);
}
private function itemKey(int $catalogItemId, ?int $variantId): string
{
return $catalogItemId.':'.($variantId ?? 'none');
}
/** @param Collection<int, CartItem> $cartItems */

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,24 @@ class EditCheckoutService
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
$purchase = $this->lockPurchase($purchase);
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
$this->purchaseState->assertNotExpired($purchase);
$this->assertEditable($purchase);
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_editable'),
]);
}
$this->purchaseState->lockCurrentCart($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);
$this->purchaseState->assertNotExpired($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->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);
});
}
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

@@ -8,51 +8,6 @@ class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
$purchase->load(['tenant', 'items.imageAttachment']);
if (
$purchase->cart_id !== null
&& (
in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
|| $purchase->items->isEmpty()
)
) {
$purchase->load([
'cart.items.catalogItem.inventory',
'cart.items.catalogItem.attachments',
'cart.items.variant.inventory',
'cart.items.variant.attachments',
'cart.items.variant.catalogItem',
'cart.items.variant.definitions.itemAttribute.attribute',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
]);
}
if (! $purchase->tenant->checkout_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',
'cart.items.catalogItem.itemAttributes.attribute',
'cart.items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
'cart.items.catalogItem.variants.inventory',
'cart.items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'cart.items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'cart.items.catalogItem.variants.eventDates',
'cart.items.catalogItem.variants.eventDate',
]);
return $purchase;
return $purchase->load(['tenant', 'items.imageAttachment']);
}
}

View File

@@ -2,10 +2,10 @@
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
@@ -15,8 +15,6 @@ class ReleaseCheckoutService
{
public function __construct(
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
private readonly PurchaseStateGuard $purchaseState,
) {}
public function cancel(Purchase $purchase): Purchase
@@ -24,9 +22,9 @@ class ReleaseCheckoutService
return $this->release($purchase, Purchase::STATUS_CANCELLED);
}
public function cancelWithoutRestoringCart(Purchase $purchase): Purchase
public function cancelFromAdmin(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED);
return $this->release($purchase, Purchase::STATUS_CANCELLED, allowInReviewCancellation: true);
}
public function expire(Purchase $purchase): Purchase
@@ -68,15 +66,18 @@ class ReleaseCheckoutService
return $expiredCount;
}
private function release(Purchase $purchase, string $targetStatus): Purchase
{
return DB::transaction(function () use ($purchase, $targetStatus): Purchase {
private function release(
Purchase $purchase,
string $targetStatus,
bool $allowInReviewCancellation = false,
): Purchase {
return DB::transaction(function () use (
$purchase,
$targetStatus,
$allowInReviewCancellation,
): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($targetStatus !== Purchase::STATUS_EXPIRED) {
$this->purchaseState->assertNotExpired($purchase);
}
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
@@ -87,6 +88,14 @@ class ReleaseCheckoutService
]);
}
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $targetStatus === Purchase::STATUS_CANCELLED
&& ! $allowInReviewCancellation
) {
return $this->createNewCartWithoutCancelling($purchase);
}
if ($this->isAlreadyReleased($purchase)) {
return $this->loadPurchase($purchase);
}
@@ -98,13 +107,7 @@ class ReleaseCheckoutService
return $this->loadPurchase($purchase);
}
if ($purchase->items()->exists()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->releaseCartReservations($purchase, $targetStatus);
$this->releasePurchaseReservations($purchase, $targetStatus);
$purchase->update(['status' => $targetStatus]);
@@ -112,44 +115,49 @@ class ReleaseCheckoutService
});
}
private function releaseCartReservations(
Purchase $purchase,
string $targetStatus,
): void {
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
{
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart === null) {
return;
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$cartItems->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'variant.inventory',
'variant.catalogItem',
]);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
continue;
}
try {
$this->reservations->release(
$cartItem,
$selection,
(int) $cartItem->cantidad,
$this->reservations->releaseForPurchase(
$purchase,
$targetStatus === Purchase::STATUS_EXPIRED
? StockReservation::STATUS_EXPIRED
: StockReservation::STATUS_RELEASED,
$targetStatus === Purchase::STATUS_CANCELLED
? StockReservationService::REASON_PURCHASE_CANCELLED
: ($targetStatus === Purchase::STATUS_REJECTED
? StockReservationService::REASON_PAYMENT_REJECTED
: null),
);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
if ($cart === null) {
return;
}
if ($cart->status === 'active') {
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->update([
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
if ($targetStatus === Purchase::STATUS_CANCELLED) {
$this->reservations->syncCart($cart);
}
return;
}
if ($cart->status !== 'checkout') {
return;
}
if (! $cart->trashed()) {
@@ -158,12 +166,40 @@ class ReleaseCheckoutService
}
}
private function createNewCartWithoutCancelling(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status !== Purchase::STATUS_IN_REVIEW || $purchase->user_id === null) {
return $this->loadPurchase($purchase);
}
/** @var Cart|null $sourceCart */
$sourceCart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($sourceCart !== null && ! $sourceCart->trashed() && $sourceCart->status === 'active') {
$sourceCart->update(['status' => 'checkout']);
}
Cart::query()->firstOrCreate([
'tenant_codigo' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'guest_token' => null,
'status' => 'active',
'origin' => Cart::ORIGIN_USER,
]);
return $this->loadPurchase($purchase);
});
}
private function isAlreadyReleased(Purchase $purchase): bool
{
return in_array($purchase->status, [
Purchase::STATUS_CANCELLED,
Purchase::STATUS_REJECTED,
Purchase::STATUS_EXPIRED,
Purchase::STATUS_SUPERSEDED,
], true);
}
@@ -175,15 +211,6 @@ class ReleaseCheckoutService
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 $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -7,88 +7,6 @@ use App\Domains\Purchase\Models\Purchase;
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

@@ -26,6 +26,7 @@ class StartCheckoutService
private readonly CatalogSelectionResolver $selections,
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -168,21 +169,31 @@ class StartCheckoutService
'cantidad' => $line['quantity'],
]);
try {
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
throw new InsufficientStockException([
$this->unavailableItem($line, $availableQuantity),
]);
}
$cartItem->setRelation('catalogItem', $line['catalog_item']);
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
$cartItems->push($cartItem);
}
try {
$this->reservations->syncCart($cart);
} catch (\InvalidArgumentException) {
$unavailable = $resolvedLines
->map(function (array $line): ?array {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
return $availableQuantity !== null && $availableQuantity < $line['quantity']
? $this->unavailableItem($line, $availableQuantity)
: null;
})
->filter()
->values()
->all();
throw new InsufficientStockException($unavailable !== [] ? $unavailable : [
$this->unavailableItem($resolvedLines->first(), 0),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
@@ -192,14 +203,12 @@ class StartCheckoutService
),
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase);
foreach ($cartItems as $index => $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$resolvedLines->get($index)['selection'],
$purchase,
);
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase);
}
@@ -251,6 +260,7 @@ class StartCheckoutService
$this->verifyTenantItems($tenant, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
$cart->setRelation('items', $cartItems);
$this->reservations->syncCart($cart);
$purchase = $this->createPurchase(
$tenant,
@@ -259,29 +269,69 @@ class StartCheckoutService
$cart->getTotalAmount(),
$cart->getKey(),
);
foreach ($cartItems as $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$cartItem->selectedItem(),
$purchase,
);
}
// The purchase owns the reservation until checkout finishes. The cart is
// retained so it can be restored if the purchase is cancelled or expires.
$cart->update([
'status' => 'checkout',
'guest_token' => null,
]);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase);
}
private function resolveCart(Tenant $tenant, int $userId, int $cartId): Cart
{
/** @var Cart|null $candidate */
$candidate = Cart::query()->find($cartId);
$this->assertCartCanCheckout($candidate, $tenant, $userId);
$candidatePurchaseId = $candidate->current_purchase_id;
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var Cart|null $cart */
$cart = Cart::query()->lockForUpdate()->find($cartId);
$this->assertCartCanCheckout($cart, $tenant, $userId);
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.checkout_in_progress'),
]);
}
$currentPurchase = null;
}
if ($currentPurchase?->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.checkout_in_progress'),
]);
}
if ($currentPurchase !== null && in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->reservations->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
$cart->update([
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
}
return $cart;
}
private function assertCartCanCheckout(?Cart $cart, Tenant $tenant, int $userId): void
{
if ($cart === null || $cart->tenant_codigo !== $tenant->codigo || $cart->user_id !== $userId) {
throw new NotFoundHttpException('Cart not found for tenant.');
}
@@ -291,8 +341,6 @@ class StartCheckoutService
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
return $cart;
}
/** @param Collection<int, CartItem> $cartItems */

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);
@@ -95,9 +68,9 @@ class CheckoutService
return $this->releaser->cancel($purchase);
}
public function cancelPurchaseWithoutRestoringCart(Purchase $purchase): Purchase
public function cancelPurchaseFromAdmin(Purchase $purchase): Purchase
{
return $this->releaser->cancelWithoutRestoringCart($purchase);
return $this->releaser->cancelFromAdmin($purchase);
}
public function expirePurchase(Purchase $purchase): Purchase

View File

@@ -2,8 +2,10 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Validation\ValidationException;
class PurchaseStateGuard
{
@@ -21,4 +23,18 @@ class PurchaseStateGuard
throw new PurchaseExpiredException;
}
}
public function lockCurrentCart(Purchase $purchase): Cart
{
/** @var Cart|null $cart */
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart === null || $cart->current_purchase_id !== $purchase->getKey()) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_current'),
]);
}
return $cart;
}
}

View File

@@ -135,11 +135,25 @@ class TenantTransactionResetService
*/
private function reservationQuery(array $scope): Builder
{
$reservationIds = DB::table('carritos')
->whereIn('id', $scope['cart_ids'])
->whereNotNull('current_stock_reservation_id')
->pluck('current_stock_reservation_id')
->merge(
DB::table('compras')
->whereIn('id', $scope['purchase_ids'])
->whereNotNull('stock_reservation_id')
->pluck('stock_reservation_id'),
)
->merge(
DB::table('stock_reservation_lines')
->whereIn('inventory_id', $scope['inventory_ids'])
->pluck('stock_reservation_id'),
)
->unique()
->values();
return DB::table('stock_reservations')
->where(function (Builder $query) use ($scope): void {
$query->whereIn('inventory_id', $scope['inventory_ids'])
->orWhereIn('purchase_id', $scope['purchase_ids'])
->orWhereIn('cart_item_id', $scope['cart_item_ids']);
});
->whereIn('id', $reservationIds);
}
}

View File

@@ -51,6 +51,7 @@ class UserPurchaseLimitService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
])
->when(
@@ -68,6 +69,7 @@ class UserPurchaseLimitService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->whereDoesntHave('items')
->when(
@@ -86,7 +88,9 @@ class UserPurchaseLimitService
$excludedCartId !== null,
fn ($query) => $query->whereKeyNot($excludedCartId),
))
->whereHas('stockReservations', fn ($query) => $query->where('status', 'active'))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
@@ -131,6 +135,7 @@ class UserPurchaseLimitService
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
]))
->groupBy('source_catalog_item_id')
@@ -141,7 +146,11 @@ class UserPurchaseLimitService
->whereIn('catalog_item_id', $ids)
->whereHas('cart.purchases', fn ($query) => $query
->where('user_id', $userId)
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->whereDoesntHave('items'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');
@@ -152,7 +161,9 @@ class UserPurchaseLimitService
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active'))
->whereHas('stockReservations', fn ($query) => $query->where('status', 'active'))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');

View File

@@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
## Modelo
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `paid`, `cancelled`, `rejected` y `expired`.
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout.
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
@@ -15,16 +15,16 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
`CheckoutService` es la fachada estable. Delega en:
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `PurchaseItem`.
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
Durante `created` y `pending_payment`, `PurchaseResource` publica las líneas del carrito con `items_source=cart`; una compra materializada publica `items_source=purchase`. Los datos descriptivos y económicos del checkout se resuelven siempre desde el catálogo vigente.
Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación.
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, libera su reserva completa y crea otra para el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.

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

@@ -3,6 +3,8 @@
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
@@ -71,23 +73,25 @@ class SaleController extends Controller
);
}
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
public function downloadPdf(AdminAppSalePdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsPdf(Request $request): Response
public function downloadModificationsPdf(AdminAppSaleModificationPdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$request->validated('timezone'),
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
use Illuminate\Foundation\Http\FormRequest;
class AdminAppSaleModificationPdfRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppSalePdfRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -2,12 +2,10 @@
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** @mixin Purchase */
class SaleDetailResource extends JsonResource
@@ -15,44 +13,23 @@ class SaleDetailResource extends JsonResource
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$items = $this->saleItems();
return [
'id' => $this->id,
'items' => $items->map(fn (PurchaseItem|CartItem $item): array => [
'items' => $this->items->map(fn (PurchaseItem $item): array => [
'id' => $item->id,
'product' => $item instanceof PurchaseItem
? $item->item_nombre
: $item->selectedItem()?->getName(),
'product' => $item->item_nombre,
'event_dates' => $this->eventDates($item),
'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($this->unitPrice($item)),
'total' => $this->formatMoney($this->lineTotal($item)),
'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total),
])->values(),
'total' => $this->formatMoney($this->total),
];
}
/** @return Collection<int, PurchaseItem|CartItem> */
private function saleItems(): Collection
{
if ($this->items->isNotEmpty()) {
return $this->items;
}
return $this->cart?->items ?? collect();
}
/** @return list<string> */
private function eventDates(PurchaseItem|CartItem $item): array
private function eventDates(PurchaseItem $item): array
{
if ($item instanceof CartItem) {
return $item->variant?->selectedEventDates()
->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))
->values()
->all() ?? [];
}
return collect($item->variant_attributes ?? [])
->filter(fn (mixed $attribute): bool => is_array($attribute)
&& mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha')
@@ -66,20 +43,6 @@ class SaleDetailResource extends JsonResource
->all();
}
private function unitPrice(PurchaseItem|CartItem $item): float|int|string|null
{
return $item instanceof PurchaseItem
? $item->precio_unitario
: $item->selectedItem()?->getPrice();
}
private function lineTotal(PurchaseItem|CartItem $item): float|int|string|null
{
return $item instanceof PurchaseItem
? $item->total
: ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad;
}
private function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');

View File

@@ -23,6 +23,7 @@ class SaleModificationResource extends JsonResource
'attribute' => $this->attribute,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'changed_at' => $this->changed_at->utc()->toIso8601String(),
'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value,

View File

@@ -13,12 +13,14 @@ use Illuminate\Support\Collection;
class AdminAppSalePdfService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales): Response
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): Response
{
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant,
'sales' => $sales,
'generatedAt' => now(),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2,
@@ -30,23 +32,30 @@ class AdminAppSalePdfService
$this->addPageNumbers($pdf);
return $pdf->download(
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
{
public function downloadModifications(
Tenant $tenant,
Collection $modifications,
string $timeZone,
): Response {
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant,
'modifications' => $modifications,
'generatedAt' => now(),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}

View File

@@ -51,14 +51,7 @@ class AdminAppSaleService
{
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->with([
'items',
'cart' => fn ($query) => $query->withTrashed(),
'cart.items.catalogItem',
'cart.items.variant.catalogItem',
'cart.items.variant.eventDates',
'cart.items.variant.eventDate',
])
->with('items')
->findOrFail($saleId);
}
@@ -86,7 +79,7 @@ class AdminAppSaleService
$sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse(
$this->checkoutService->cancelPurchaseWithoutRestoringCart($sale)
$this->checkoutService->cancelPurchaseFromAdmin($sale)
);
}
@@ -133,6 +126,7 @@ class AdminAppSaleService
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search);
@@ -150,19 +144,18 @@ class AdminAppSaleService
)
->when(
$filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query->where('status', $status)
fn (Builder $query, string $status): Builder => $status === Purchase::STATUS_PENDING_PAYMENT
? $query->whereIn('status', [
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
: $query->where('status', $status)
)
->select('compras.*')
->selectRaw(
'CASE WHEN compras.status IN (?, ?) '
.'THEN (SELECT COALESCE(SUM(cart_items.cantidad), 0) FROM carrito_items AS cart_items '
.'WHERE cart_items.cart_id = compras.cart_id) '
.'ELSE (SELECT COALESCE(SUM(purchase_items.cantidad), 0) FROM compra_items AS purchase_items '
.'WHERE purchase_items.compra_id = compras.id) END AS quantity',
[
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
],
'(SELECT COALESCE(SUM(purchase_items.cantidad), 0) '
.'FROM compra_items AS purchase_items '
.'WHERE purchase_items.compra_id = compras.id) AS quantity',
)
->withCount('tickets')
->orderBy($sortColumns[$sortBy], $sortDirection)
@@ -199,16 +192,11 @@ class AdminAppSaleService
protected function quantityExpression(): string
{
// El fallback se resuelve en SQL para poder ordenar por cantidad antes de paginar;
// los ítems consolidados de la compra tienen prioridad sobre los del carrito de origen.
return <<<'SQL'
COALESCE(
(SELECT SUM(compra_items.cantidad)
FROM compra_items
WHERE compra_items.compra_id = compras.id),
(SELECT SUM(carrito_items.cantidad)
FROM carrito_items
WHERE carrito_items.cart_id = compras.cart_id),
0
) AS quantity
SQL;

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use DateTimeZone;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidTimezone implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
try {
new DateTimeZone($value);
} catch (Exception) {
$fail(__('validation.timezone'));
}
}
}

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

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('carritos', function (Blueprint $table): void {
$table->foreignId('current_purchase_id')
->nullable()
->after('origin')
->constrained('compras')
->nullOnDelete();
$table->unique('current_purchase_id', 'carts_current_purchase_unique');
});
DB::table('carritos')
->select('id')
->orderBy('id')
->each(function (object $cart): void {
$purchaseId = DB::table('compras')
->where('cart_id', $cart->id)
->whereIn('status', ['created', 'pending_payment'])
->latest('id')
->value('id');
if ($purchaseId !== null) {
DB::table('carritos')
->where('id', $cart->id)
->update(['current_purchase_id' => $purchaseId]);
}
});
}
public function down(): void
{
Schema::table('carritos', function (Blueprint $table): void {
$table->dropUnique('carts_current_purchase_unique');
$table->dropConstrainedForeignId('current_purchase_id');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use App\Domains\Desfile\Services\InvitationPurchaseProvisioner;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
app(InvitationPurchaseProvisioner::class)->provision([
[
'sector' => 'C',
'row' => 3,
'first_seat' => 6,
'last_seat' => 7,
'type' => 'NORMAL',
],
]);
}
public function down(): void
{
// Intentionally irreversible: issued invitation tickets may already be used.
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('categorias', function (Blueprint $table): void {
$table->boolean('is_enabled')->default(true)->after('nombre');
});
}
public function down(): void
{
Schema::table('categorias', function (Blueprint $table): void {
$table->dropColumn('is_enabled');
});
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->unsignedInteger('group_order')->default(0)->after('nombre');
});
$footballOrder = [
1 => [
'slugs' => ['camiseta', 'camiseta-oficial-fnfi'],
'names' => ['Camiseta', 'CAMISETA OFICIAL FNFI'],
],
2 => [
'slugs' => ['alojamiento', 'camping'],
'names' => ['Alojamiento', 'CAMPING'],
],
3 => [
'slugs' => ['abono'],
'names' => ['Abono', 'ABONO'],
],
4 => [
'slugs' => ['comida'],
'names' => ['Comida', 'COMIDA'],
],
];
foreach ($footballOrder as $order => $identifiers) {
DB::table('catalog_items')
->where('tenant_code', 'fiesta_futbol_infantil')
->where(function ($query) use ($identifiers): void {
$query
->whereIn('slug', $identifiers['slugs'])
->orWhereIn('nombre', $identifiers['names']);
})
->update(['group_order' => $order]);
}
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('group_order');
});
}
};

View File

@@ -0,0 +1,180 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::rename('stock_reservations', 'stock_reservation_lines');
Schema::create('stock_reservations', function (Blueprint $table): void {
$table->id();
$table->string('status')->default('active');
$table->dateTime('expires_at')->nullable();
$table->dateTime('committed_at')->nullable();
$table->dateTime('released_at')->nullable();
$table->dateTime('expired_at')->nullable();
$table->string('release_reason')->nullable();
$table->timestamps();
$table->index(['status', 'expires_at']);
});
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->foreignId('stock_reservation_id')->nullable()->after('id');
$table->boolean('tracks_inventory')->default(true)->after('quantity');
});
Schema::table('carritos', function (Blueprint $table): void {
$table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id');
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('stock_reservation_id')->nullable()->after('cart_id');
});
$cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id');
$unlimitedInventoryIds = DB::table('catalog_items')
->where('inventory_policy', 'unlimited')
->whereNotNull('inventory_id')
->pluck('inventory_id')
->merge(
DB::table('variantes')
->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id')
->where('catalog_items.inventory_policy', 'unlimited')
->pluck('variantes.inventory_id'),
)
->map(fn ($id): int => (int) $id)
->unique();
$legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get();
$groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string {
if ($row->purchase_id !== null) {
return 'purchase:'.$row->purchase_id;
}
$cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id);
return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId;
});
foreach ($groups as $key => $rows) {
$statuses = $rows->pluck('status');
$status = $statuses->contains('active')
? 'active'
: ($statuses->contains('committed')
? 'committed'
: ($statuses->contains('expired') ? 'expired' : 'released'));
$first = $rows->first();
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => $status,
'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null,
'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null,
'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null,
'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null,
'release_reason' => null,
'created_at' => $first->created_at,
'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at,
]);
foreach ($rows->groupBy('inventory_id') as $inventoryRows) {
$line = $inventoryRows->first();
DB::table('stock_reservation_lines')->where('id', $line->id)->update([
'stock_reservation_id' => $reservationId,
'quantity' => $inventoryRows->sum('quantity'),
'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id),
]);
DB::table('stock_reservation_lines')
->whereIn('id', $inventoryRows->pluck('id')->skip(1))
->delete();
}
if (str_starts_with($key, 'purchase:')) {
$purchaseId = (int) substr($key, strlen('purchase:'));
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
$cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id');
$isCurrent = $cartId !== null
&& (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId;
if ($status === 'active' && $isCurrent) {
DB::table('carritos')->where('id', $cartId)->update([
'current_stock_reservation_id' => $reservationId,
]);
}
} elseif (str_starts_with($key, 'cart:') && $status === 'active') {
DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([
'current_stock_reservation_id' => $reservationId,
]);
}
}
if (DB::getDriverName() !== 'sqlite') {
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->dropForeign('stock_reservations_cart_item_id_foreign');
$table->dropForeign('stock_reservations_purchase_id_foreign');
$table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique');
$table->dropIndex('stock_reservations_purchase_id_status_index');
$table->dropIndex('stock_reservations_status_expires_at_index');
});
}
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
$table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change();
$table->dropColumn([
'cart_item_id',
'purchase_id',
'status',
'expires_at',
'committed_at',
'released_at',
]);
$table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk')
->references('id')
->on('stock_reservations')
->cascadeOnDelete();
$table->unique(
['stock_reservation_id', 'inventory_id'],
'reservation_lines_reservation_inventory_unique',
);
});
Schema::table('carritos', function (Blueprint $table): void {
$table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk')
->references('id')
->on('stock_reservations')
->nullOnDelete();
$table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique');
});
Schema::table('compras', function (Blueprint $table): void {
$table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk')
->references('id')
->on('stock_reservations')
->nullOnDelete();
$table->unique('stock_reservation_id', 'purchases_stock_reservation_unique');
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropUnique('purchases_stock_reservation_unique');
$table->dropForeign('purchases_stock_reservation_fk');
$table->dropColumn('stock_reservation_id');
});
Schema::table('carritos', function (Blueprint $table): void {
$table->dropUnique('carts_current_stock_reservation_unique');
$table->dropForeign('carts_current_stock_reservation_fk');
$table->dropColumn('current_stock_reservation_id');
});
Schema::dropIfExists('stock_reservation_lines');
Schema::dropIfExists('stock_reservations');
}
};

View File

@@ -78,6 +78,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'camiseta',
'nombre' => 'Camiseta',
'group_order' => 1,
'category_id' => $categories['merchandising']->id,
'precio' => 18000,
'attribute_codes' => ['color', 'talle'],
@@ -92,6 +93,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'alojamiento',
'nombre' => 'Alojamiento',
'group_order' => 2,
'category_id' => $categories['alojamientos']->id,
'precio' => 35000,
'attribute_codes' => ['tipo_alojamiento'],
@@ -104,6 +106,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'comida',
'nombre' => 'Comida',
'group_order' => 4,
'category_id' => $categories['comidas']->id,
'precio' => 4000,
'attribute_codes' => ['event_date', 'horario', 'servicio'],
@@ -126,6 +129,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
$this->createProduct($tenant, [
'slug' => 'abono',
'nombre' => 'Abono',
'group_order' => 3,
'category_id' => $categories['entradas']->id,
'precio' => 40000,
'has_tickets' => true,

View File

@@ -42,8 +42,9 @@ return [
'payment_method_required' => 'The purchase payment method must be selected before finalizing.',
'not_editable' => 'The purchase is no longer editable.',
'insufficient_stock' => 'There is not enough stock available.',
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
'cannot_confirm' => 'A cancelled, rejected, expired, or superseded purchase cannot be confirmed.',
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
'not_current' => 'The purchase is no longer the cart\'s current checkout.',
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
'stock' => [
'seat_unavailable' => 'Seat :selection is no longer available.',
@@ -55,6 +56,7 @@ return [
'catalog_item_missing' => 'One or more catalog items could not be loaded.',
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
'inactive_cart' => 'The selected cart is no longer active.',
'checkout_in_progress' => 'The cart already has a purchase in progress.',
'not_available_for_payment' => 'The purchase is no longer available for payment.',
'not_available_for_review' => 'The purchase is no longer available for review.',
],

View File

@@ -37,6 +37,7 @@ return [
'required_with' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'string' => 'The :attribute must be a string.',
'timezone' => 'The timezone sent by the browser is invalid.',
'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.',
@@ -52,6 +53,7 @@ return [
'password_confirmation' => 'password confirmation',
'telefono' => 'phone number',
'tenant_codigo' => 'tenant',
'timezone' => 'timezone',
'variant_id' => 'variant',
],
];

View File

@@ -42,8 +42,9 @@ return [
'payment_method_required' => 'Debes seleccionar el método de pago antes de finalizar la compra.',
'not_editable' => 'La compra ya no se puede modificar.',
'insufficient_stock' => 'No hay suficiente stock disponible.',
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
'cannot_confirm' => 'Una compra cancelada, rechazada, vencida o reemplazada no se puede confirmar.',
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
'not_current' => 'La compra ya no es el checkout actual del carrito.',
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
'stock' => [
'seat_unavailable' => 'El asiento :selection ya no está disponible.',
@@ -55,6 +56,7 @@ return [
'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.',
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
'inactive_cart' => 'El carrito seleccionado ya no está activo.',
'checkout_in_progress' => 'El carrito ya tiene una compra en curso.',
'not_available_for_payment' => 'La compra ya no está disponible para el pago.',
'not_available_for_review' => "La compra ya no est\u{00E1} disponible para revisi\u{00F3}n.",
],

View File

@@ -37,6 +37,7 @@ return [
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'string' => ':Attribute debe ser texto.',
'timezone' => 'La zona horaria enviada por el navegador no es válida.',
'unique' => 'El :attribute ya está en uso.',
'url' => ':Attribute debe ser una URL válida.',
'uuid' => ':Attribute debe ser un UUID válido.',
@@ -52,6 +53,7 @@ return [
'password_confirmation' => 'confirmación de contraseña',
'telefono' => 'teléfono',
'tenant_codigo' => 'tenant',
'timezone' => 'zona horaria',
'variant_id' => 'variante',
],
];

View File

@@ -25,7 +25,7 @@
</head>
<body>
<h1>Historial de modificaciones de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<table>
<thead>
@@ -44,8 +44,8 @@
@forelse ($modifications as $modification)
@php($sale = $modification->trackable)
<tr>
<td>{{ $modification->changed_at->format('d/m/Y') }}</td>
<td>{{ $modification->changed_at->format('H:i:s') }}</td>
<td>{{ $modification->changed_at->copy()->timezone($timeZone)->format('d/m/Y') }}</td>
<td>{{ $modification->changed_at->copy()->timezone($timeZone)->format('H:i:s') }}</td>
<td>#{{ $modification->trackable_id }}</td>
<td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td>
<td>{{ $modification->attribute }}</td>

View File

@@ -22,7 +22,7 @@
</head>
<body>
<h1>Historial de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<div class="summary">
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
@@ -44,7 +44,7 @@
@forelse ($sales as $sale)
<tr>
<td>#{{ $sale->id }}</td>
<td>{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $sale->created_at?->copy()->timezone($timeZone)->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
<td>{{ match ($sale->status) {

View File

@@ -15,7 +15,7 @@ Artisan::command('reservations:expire', function (): void {
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
$this->info("Expired purchases: {$expired['purchases']}");
$this->info("Expired cart items: {$expired['cart_items']}");
$this->info("Expired cart reservations: {$expired['cart_reservations']}");
})->purpose('Release expired stock reservations from purchases and abandoned carts');
Schedule::command('reservations:expire')

View File

@@ -5,10 +5,12 @@ namespace Tests\Feature\Cart;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
@@ -28,6 +30,80 @@ class CartControllerTest extends TestCase
]));
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
$this->assertTrue(Schema::hasColumns('stock_reservations', [
'status',
'expires_at',
'committed_at',
'released_at',
'expired_at',
'release_reason',
]));
$this->assertFalse(Schema::hasColumn('stock_reservations', 'quantity'));
$this->assertTrue(Schema::hasColumns('stock_reservation_lines', [
'stock_reservation_id',
'inventory_id',
'quantity',
'tracks_inventory',
]));
$this->assertTrue(Schema::hasColumn('carritos', 'current_stock_reservation_id'));
$this->assertTrue(Schema::hasColumn('compras', 'stock_reservation_id'));
}
public function test_it_aggregates_shared_inventory_into_one_cart_reservation_line(): void
{
$tenant = $this->createTenant('acme');
$firstItem = $this->createDirectItem($tenant, 10, '10.00');
$secondItem = app(CatalogService::class)->create([
'tenant_code' => $tenant->codigo,
'type' => 'bundle',
'slug' => 'second-shared-item',
'nombre' => 'Second shared item',
'precio' => '20.00',
'components' => [[
'catalog_item_id' => $firstItem->id,
'quantity' => 1,
]],
]);
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $firstItem->id,
'cantidad' => 2,
])->assertOk();
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
$this->call(
'POST',
'/api/tenants/acme/cart/items',
[],
['guest_token' => $guestToken],
[],
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
json_encode([
'catalog_item_id' => $secondItem->id,
'cantidad' => 3,
]),
)->assertOk();
$reservationId = (int) $firstResponse->json('data.id');
$reservationId = (int) Cart::query()
->findOrFail($reservationId)
->current_stock_reservation_id;
$this->assertDatabaseCount('stock_reservations', 1);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'stock_reservation_id' => $reservationId,
'inventory_id' => $firstItem->inventory_id,
'quantity' => 5,
]);
$this->assertDatabaseCount('stock_reservation_lines', 1);
$this->assertDatabaseHas('inventories', [
'id' => $firstItem->inventory_id,
'reserved_stock' => 5,
]);
}
public function test_it_adds_a_catalog_item_without_a_variant(): void
@@ -74,9 +150,11 @@ class CartControllerTest extends TestCase
'id' => $item->inventory_id,
'reserved_stock' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
]);
$this->assertDatabaseHas('stock_reservations', [
'status' => 'active',
]);
}
@@ -96,16 +174,18 @@ class CartControllerTest extends TestCase
])->assertOk();
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
'status' => 'active',
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
]);
$this->travelBack();
}
public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void
public function test_it_expires_abandoned_cart_reservations_without_deleting_the_cart(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
@@ -120,14 +200,14 @@ class CartControllerTest extends TestCase
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->travel(31)->minutes();
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 1')
->expectsOutput('Expired cart reservations: 1')
->assertSuccessful();
$this->assertDatabaseHas('inventories', [
@@ -135,23 +215,25 @@ class CartControllerTest extends TestCase
'real_stock' => 10,
'reserved_stock' => 0,
]);
$this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]);
$this->assertSoftDeleted('carritos', [
$this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]);
$this->assertDatabaseHas('carritos', [
'id' => $cartId,
'status' => 'expired',
'status' => 'active',
'current_stock_reservation_id' => null,
'deleted_at' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'inventory_id' => $item->inventory_id,
'cart_item_id' => null,
'purchase_id' => null,
'quantity' => 0,
'status' => 'expired',
'expires_at' => null,
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $item->inventory_id,
'quantity' => 2,
]);
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart items: 0')
->expectsOutput('Expired cart reservations: 0')
->assertSuccessful();
$this->travelBack();
@@ -212,10 +294,11 @@ class CartControllerTest extends TestCase
'id' => $variant->inventory_id,
'reserved_stock' => 5,
]);
$this->assertDatabaseHas('stock_reservations', [
'cart_item_id' => $response->json('data.items.0.id'),
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $variant->inventory_id,
'quantity' => 5,
]);
$this->assertDatabaseHas('stock_reservations', [
'status' => 'active',
]);
}
@@ -229,6 +312,7 @@ class CartControllerTest extends TestCase
$this->createPurchaseItem($tenant, $user, $item, 1);
$this->actingAs($user, 'sanctum')
->withHeader('Accept-Language', 'es')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
@@ -237,6 +321,7 @@ class CartControllerTest extends TestCase
->assertJsonPath('data.items.0.cantidad', 2);
$this->actingAs($user, 'sanctum')
->withHeader('Accept-Language', 'es')
->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
@@ -381,10 +466,12 @@ class CartControllerTest extends TestCase
'reserved_stock' => 0,
]);
$this->assertDatabaseHas('stock_reservations', [
'cart_item_id' => null,
'inventory_id' => $variant->inventory_id,
'quantity' => 0,
'status' => 'released',
'release_reason' => 'cart_empty',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $variant->inventory_id,
'quantity' => 5,
]);
}

View File

@@ -76,11 +76,15 @@ class CatalogControllerTest extends TestCase
->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3)
->assertJsonMissing(['id' => $unavailableVariant->id])
->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
->assertJsonMissingPath('1.items.data.0.stock_tecnico')
->assertJsonCount(0, '1.items.data.0.variants');
$this->assertNotContains(
$unavailableVariant->id,
collect($response->json('0.items.0.variants'))->pluck('id')->all(),
);
}
public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void
@@ -94,7 +98,7 @@ class CatalogControllerTest extends TestCase
);
$user = User::factory()->create();
$item = $this->createItem($tenant, 'Limited variants');
$item->update(['max_units_per_user' => 5]);
$item->update(['max_units_per_user' => 3]);
$firstVariant = $item->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id,
]);
@@ -114,13 +118,21 @@ class CatalogControllerTest extends TestCase
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 2)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 2)
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 0)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 0)
->assertJsonPath(
'0.items.0.variants.0.unavailable_message',
'Alcanzaste el cupo máximo permitido para este producto.',
)
->assertJsonPath(
'0.items.0.variants.1.unavailable_message',
'Alcanzaste el cupo máximo permitido para este producto.',
)
->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico')
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
}
public function test_it_excludes_items_when_all_of_their_variants_are_out_of_stock(): void
public function test_it_excludes_out_of_stock_items(): void
{
$tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup(
@@ -150,6 +162,7 @@ class CatalogControllerTest extends TestCase
->assertOk()
->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonPath('0.items.0.unavailable_message', null)
->assertJsonMissing(['nombre' => 'Unavailable']);
}
@@ -335,8 +348,10 @@ class CatalogControllerTest extends TestCase
]);
$food = $this->createItem($tenant, 'Hamburger');
$food->update(['group_order' => 2]);
$food->category()->associate($category)->save();
$this->createItem($tenant, 'Parking');
$parking = $this->createItem($tenant, 'Parking');
$parking->update(['group_order' => 1]);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
@@ -345,9 +360,59 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('0.items.0.nombre', 'Hamburger')
->assertJsonPath('1.title', 'All products')
->assertJsonCount(2, '1.items.data')
->assertJsonPath('1.items.data.0.nombre', 'Parking')
->assertJsonPath('1.items.data.1.nombre', 'Hamburger')
->assertJsonMissingPath('1.items.data.0.group_order')
->assertJsonPath('1.items.meta.total', 2);
}
public function test_index_excludes_items_from_disabled_categories(): void
{
$tenant = $this->createTenant('catalog-disabled-category');
$group = $this->createGroup(
$tenant,
ProductLayout::Row,
'All products',
groupLayout: GroupLayout::Simple,
);
$enabledCategory = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Enabled',
]);
$disabledCategory = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Disabled',
'is_enabled' => false,
]);
$enabledItem = $this->createItem($tenant, 'Enabled item');
$enabledItem->category()->associate($enabledCategory)->save();
$disabledItem = $this->createItem($tenant, 'Disabled item');
$disabledItem->category()->associate($disabledCategory)->save();
$uncategorizedItem = $this->createItem($tenant, 'Uncategorized item');
foreach ([$enabledItem, $disabledItem, $uncategorizedItem] as $order => $item) {
$group->featuredItems()->create([
'catalog_item_id' => $item->id,
'order' => $order,
]);
}
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(2, '0.items')
->assertJsonPath('0.items.0.nombre', 'Enabled item')
->assertJsonPath('0.items.1.nombre', 'Uncategorized item')
->assertJsonMissing(['nombre' => 'Disabled item']);
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items"
)
->assertOk()
->assertJsonCount(2)
->assertJsonMissing(['nombre' => 'Disabled item']);
}
private function createGroup(
Tenant $tenant,
ProductLayout $layout,

View File

@@ -31,6 +31,7 @@ class CatalogItemControllerTest extends TestCase
$response = $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
'slug' => 'shirt',
'nombre' => 'Shirt',
'group_order' => 7,
'precio' => 100,
'max_units_per_user' => 4,
'attribute_codes' => [$attribute->codigo],
@@ -47,6 +48,7 @@ class CatalogItemControllerTest extends TestCase
$response
->assertCreated()
->assertJsonPath('data.nombre', 'Shirt')
->assertJsonMissingPath('data.group_order')
->assertJsonPath('data.max_units_per_user', 4)
->assertJsonCount(2, 'data.images')
->assertJsonCount(1, 'data.variants')
@@ -57,6 +59,7 @@ class CatalogItemControllerTest extends TestCase
$this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all());
$this->assertSame(4, $item->max_units_per_user);
$this->assertSame(7, $item->group_order);
$this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all());
$this->assertDatabaseHas('catalog_items_attachments', [
'catalog_item_id' => $item->id,

View File

@@ -48,7 +48,7 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
}
public function test_it_filters_unavailable_variants_and_selects_the_first_available_one(): void
public function test_it_omits_unavailable_variants_and_selects_the_first_available_one(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-default');
@@ -78,6 +78,10 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonMissingPath('data.images');
$this->assertNotContains(
$firstVariant->id,
collect($response->json('data.variants'))->pluck('id')->all(),
);
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
@@ -87,6 +91,19 @@ class CatalogItemDetailControllerTest extends TestCase
)->assertNotFound();
}
public function test_it_does_not_return_an_out_of_stock_item(): void
{
$tenant = $this->createTenant('detail-out-of-stock');
$inventory = Inventory::query()->create([
'real_stock' => 5,
'reserved_stock' => 5,
]);
$item = $this->createItem($tenant, 'Sold out item', $inventory);
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertNotFound();
}
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
{
Storage::fake('s3');

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -15,6 +16,21 @@ class CatalogSchemaTest extends TestCase
{
use RefreshDatabase;
public function test_categories_are_enabled_by_default(): void
{
$this->assertTrue(Schema::hasColumn('categorias', 'is_enabled'));
$category = Category::query()->create([
'nombre' => 'Enabled category',
]);
$this->assertTrue($category->is_enabled);
$this->assertDatabaseHas('categorias', [
'id' => $category->id,
'is_enabled' => true,
]);
}
public function test_legacy_product_tables_are_replaced_by_catalog_tables(): void
{
$this->assertFalse(Schema::hasTable('productos'));

View File

@@ -62,6 +62,24 @@ class CatalogSearchTest extends TestCase
$this->createCatalogItem($tenant, "Running {$number}");
}
$exactMatch = $this->createCatalogItem($tenant, 'Running');
$outOfStock = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'running-sold-out',
'nombre' => 'Running sold out',
'descripcion' => 'Running sold out description',
'precio' => 100,
]);
$outOfStock->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
]);
CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => Inventory::query()->create(['real_stock' => 2, 'reserved_stock' => 2])->id,
'slug' => 'running-direct-sold-out',
'nombre' => 'Running direct sold out',
'descripcion' => 'Running direct sold out description',
'precio' => 100,
]);
$this->createCatalogItem($tenant, 'Unrelated');
$this->createCatalogItem($otherTenant, 'Running foreign');
@@ -76,6 +94,8 @@ class CatalogSearchTest extends TestCase
->assertJsonPath('meta.total', 6)
->assertJsonCount(4, 'data')
->assertJsonPath('data.0.id', $exactMatch->id)
->assertJsonMissing(['nombre' => 'Running sold out'])
->assertJsonMissing(['nombre' => 'Running direct sold out'])
->assertJsonMissing(['nombre' => 'Running foreign'])
->assertJsonMissing(['nombre' => 'Unrelated']);
}

View File

@@ -29,6 +29,17 @@ class CategoryDetailTest extends TestCase
$this->createCatalogItem($tenant, $category, 'Remera C');
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
$outOfStock = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'category_id' => $category->id,
'slug' => 'remera-agotada',
'nombre' => 'Remera agotada',
'descripcion' => 'Sin stock',
'precio' => 100,
]);
$outOfStock->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
]);
$this->createCatalogItem($tenant, $otherCategory, 'Pantalón');
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
@@ -44,6 +55,7 @@ class CategoryDetailTest extends TestCase
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $firstItem->id)
->assertJsonPath('data.1.id', $secondItem->id)
->assertJsonMissing(['nombre' => 'Remera agotada'])
->assertJsonMissing(['nombre' => 'Pantalón']);
}

View File

@@ -38,6 +38,59 @@ class EntryControllerTest extends TestCase
->assertUnauthorized();
}
public function test_category_visibility_can_be_read_and_updated_for_every_fiesta_menu(): void
{
$tenant = $this->createFiestaTenant();
$categories = [
'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'],
'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'],
'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'],
'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'],
];
foreach ($categories as [$label, $menuCode]) {
$menu = Menu::query()->firstOrCreate(
['code' => $menuCode],
['label' => $label, 'route' => '/admin'],
);
$tenant->menues()->syncWithoutDetaching([$menu->code]);
}
Sanctum::actingAs($this->createAdminAppUser($tenant));
foreach ($categories as $endpoint => [$categoryName]) {
$this->getJson("/api/v1/adminapp/tenant/{$endpoint}/visibility")
->assertOk()
->assertJsonPath('data.is_enabled', true);
$this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [
'is_enabled' => false,
])
->assertOk()
->assertJsonPath('data.is_enabled', false)
->assertJsonPath(
'message',
'Mostrar en sitio web se desactivo correctamente',
);
$this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [
'is_enabled' => true,
])
->assertOk()
->assertJsonPath('data.is_enabled', true)
->assertJsonPath(
'message',
'Mostrar en sitio web se activo correctamente',
);
$this->assertDatabaseHas('categorias', [
'tenant_code' => $tenant->codigo,
'nombre' => $categoryName,
'is_enabled' => true,
]);
}
}
public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void
{
$tenant = $this->createFiestaTenant();

View File

@@ -51,8 +51,9 @@ class AdminAppSaleFormControllerTest extends TestCase
->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED)
->assertJsonPath('data.statuses.0.name', 'Creada')
->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT)
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID)
->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED);
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_IN_REVIEW)
->assertJsonPath('data.statuses.3.code', Purchase::STATUS_PAID)
->assertJsonPath('data.statuses.6.code', Purchase::STATUS_EXPIRED);
}
public function test_a_customer_cannot_get_the_sale_form(): void

View File

@@ -194,14 +194,20 @@ class TelepagosWebhookTest extends TestCase
'sold_units' => 1,
]);
$this->assertDatabaseMissing('compra_items', [
$this->assertDatabaseHas('compra_items', [
'compra_id' => $newerPurchase->id,
'cantidad' => 2,
]);
$newerReservationId = $newerPurchase->fresh()->stock_reservation_id;
$this->assertNotNull($newerReservationId);
$this->assertDatabaseHas('stock_reservations', [
'purchase_id' => $newerPurchase->id,
'id' => $newerReservationId,
'status' => 'active',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'stock_reservation_id' => $newerReservationId,
'inventory_id' => $variant->inventory_id,
'quantity' => 2,
'status' => 'active',
]);
$this->assertSoftDeleted('carritos', [

File diff suppressed because it is too large Load Diff

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,
@@ -97,6 +108,20 @@ class AdminAppSaleControllerTest extends TestCase
'total' => '20000.00',
]);
$supersededPurchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_SUPERSEDED,
'total' => '50000.00',
]);
PurchaseItem::query()->create([
'compra_id' => $supersededPurchase->id,
'source_catalog_item_id' => $catalogItem->id,
'item_nombre' => $catalogItem->nombre,
'cantidad' => 5,
'precio_unitario' => '10000.00',
'total' => '50000.00',
]);
$this->getJson('/api/v1/adminapp/tenant/sales?sort_by=id&sort_direction=asc')
->assertOk()
->assertJsonCount(3, 'data')
@@ -106,6 +131,10 @@ class AdminAppSaleControllerTest extends TestCase
->assertJsonPath('data.1.quantity', 4)
->assertJsonPath('data.2.id', $paidPurchase->id)
->assertJsonPath('data.2.quantity', 2);
$this->getJson('/api/v1/adminapp/tenant/sales?status='.Purchase::STATUS_SUPERSEDED)
->assertOk()
->assertJsonCount(0, 'data');
}
public function test_authentication_is_required_to_read_a_sale_detail(): void
@@ -113,6 +142,31 @@ class AdminAppSaleControllerTest extends TestCase
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized();
}
public function test_pending_payment_filter_also_returns_purchases_in_review(): void
{
$tenant = $this->createTenant('acme');
Sanctum::actingAs($this->createAdminAppUser($tenant));
$pendingPurchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PENDING_PAYMENT,
]);
$reviewPurchase = Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_IN_REVIEW,
]);
Purchase::query()->create([
'tenant_codigo' => $tenant->codigo,
'status' => Purchase::STATUS_PAID,
]);
$this->getJson('/api/v1/adminapp/tenant/sales?status=pending_payment&sort_by=id&sort_direction=asc')
->assertOk()
->assertJsonCount(2, 'data')
->assertJsonPath('data.0.id', $pendingPurchase->id)
->assertJsonPath('data.1.id', $reviewPurchase->id);
}
public function test_an_adminapp_user_can_read_a_sale_detail_from_its_tenant(): void
{
$tenant = $this->createTenant('acme');
@@ -149,7 +203,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 +230,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 +249,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 +276,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 +288,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 +395,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 +465,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()
@@ -401,6 +489,45 @@ class AdminAppSaleControllerTest extends TestCase
->assertJsonPath('data.items.0.quantity', 2);
}
public function test_adminapp_can_cancel_a_purchase_in_review(): void
{
$tenant = $this->createTenant('acme');
$admin = $this->createAdminAppUser($tenant);
Sanctum::actingAs($admin);
$inventory = Inventory::query()->create(['real_stock' => 10]);
$catalogItem = CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'slug' => 'review-item',
'nombre' => 'Review 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' => 2,
]],
]);
$purchase->update(['status' => Purchase::STATUS_IN_REVIEW]);
$sourceCartId = $purchase->cart_id;
$this->postJson("/api/v1/adminapp/tenant/sales/{$purchase->id}/cancel")
->assertOk()
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
$this->assertSoftDeleted('carritos', ['id' => $sourceCartId]);
$reservationId = $purchase->fresh()->stock_reservation_id;
$this->assertNotNull($reservationId);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'released',
]);
}
public function test_an_adminapp_user_cannot_change_a_sale_from_another_tenant(): void
{
$tenant = $this->createTenant('acme');

View File

@@ -288,27 +288,31 @@ class DesfilePuraTendenciaSeederTest extends TestCase
$items = DB::table('compra_items')->where('compra_id', $purchase->id);
$this->assertSame(46, (clone $items)->count());
$this->assertSame(46, (clone $items)
$this->assertSame(48, (clone $items)->count());
$this->assertSame(48, (clone $items)
->where('precio_unitario', 0)
->where('discount_total', 0)
->where('tax_total', 0)
->where('total', 0)
->count());
$this->assertSame(46, DB::table('tickets')
$this->assertSame(48, DB::table('tickets')
->where('source_purchase_id', $purchase->id)
->where('user_id', $user->id)
->where('source_catalog_item_id', $catalogItemId)
->count());
$this->assertSame(46, DB::table('stock_reservations')
->where('purchase_id', $purchase->id)
$this->assertSame(1, DB::table('stock_reservations')
->where('id', $purchase->stock_reservation_id)
->where('status', 'committed')
->count());
$this->assertSame(48, DB::table('stock_reservation_lines')
->where('stock_reservation_id', $purchase->stock_reservation_id)
->count());
foreach ([
['sector' => 'A', 'fila' => '1', 'tipo' => 'NORMAL', 'count' => 16],
['sector' => 'A', 'fila' => '3', 'tipo' => 'NORMAL', 'count' => 14],
['sector' => 'C', 'fila' => '1', 'tipo' => 'VIP + LUNCH', 'count' => 16],
['sector' => 'C', 'fila' => '3', 'tipo' => 'NORMAL', 'count' => 2, 'seats' => ['6', '7']],
] as $allocation) {
$allocatedVariants = DB::table('variantes')
->where('catalog_item_id', $catalogItemId)
@@ -326,6 +330,20 @@ class DesfilePuraTendenciaSeederTest extends TestCase
}
$this->assertSame($allocation['count'], $allocatedVariants->count());
if (isset($allocation['seats'])) {
$seatValues = DB::table('variant_values')
->join('item_attributes', 'item_attributes.id', '=', 'variant_values.item_attribute_id')
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
->whereIn('variant_values.variant_id', (clone $allocatedVariants)->pluck('id'))
->where('attribute.codigo', 'asiento')
->pluck('variant_values.value')
->sort()
->values()
->all();
$this->assertSame($allocation['seats'], $seatValues);
}
}
}

View File

@@ -70,6 +70,14 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
['abono', 'alojamiento', 'camiseta', 'comida'],
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(),
);
$this->assertSame(
['camiseta', 'alojamiento', 'abono', 'comida'],
CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->orderBy('group_order')
->pluck('slug')
->all(),
);
$this->assertTrue(
CatalogItem::query()
->where('tenant_code', $tenant->codigo)

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');

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Unit\Catalog;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use ReflectionClass;
use Tests\TestCase;
class CatalogItemAllowanceServiceTest extends TestCase
{
private CatalogItemAllowanceService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = (new ReflectionClass(CatalogItemAllowanceService::class))
->newInstanceWithoutConstructor();
}
public function test_it_returns_the_user_quota_message_with_priority_over_stock(): void
{
$this->assertSame(
'Alcanzaste el cupo máximo permitido para este producto.',
$this->service->unavailableMessage(0, 0),
);
}
public function test_it_returns_the_out_of_stock_message(): void
{
$this->assertSame(
'Este producto no tiene stock disponible.',
$this->service->unavailableMessage(0, null),
);
}
public function test_it_returns_no_message_when_the_item_is_available(): void
{
$this->assertNull($this->service->unavailableMessage(1, null));
$this->assertNull($this->service->unavailableMessage(null, 1));
$this->assertNull($this->service->unavailableMessage(null, null));
}
}

View File

@@ -83,11 +83,13 @@ class CatalogModelsTest extends TestCase
public function test_catalog_item_is_the_catalog_root(): void
{
$item = new CatalogItem;
$this->assertSame(0, $item->group_order);
$item->setRawAttributes([
'category_id' => '10',
'brand_id' => '20',
'inventory_id' => '30',
'type' => CatalogItemType::Standard->value,
'group_order' => '4',
'precio' => '12.50',
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Seat->value,
@@ -100,6 +102,7 @@ class CatalogModelsTest extends TestCase
$this->assertSame(20, $item->brand_id);
$this->assertSame(30, $item->inventory_id);
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame(4, $item->group_order);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
$this->assertSame(InventorySubject::Seat, $item->inventory_subject);

View File

@@ -12,7 +12,7 @@ use Tests\TestCase;
class ExpireStockReservationsServiceTest extends TestCase
{
public function test_it_expires_purchases_before_abandoned_cart_items(): void
public function test_it_expires_purchases_before_abandoned_cart_reservations(): void
{
$checkout = \Mockery::mock(CheckoutService::class);
$checkout->shouldReceive('expireOverduePurchases')
@@ -36,7 +36,7 @@ class ExpireStockReservationsServiceTest extends TestCase
->with('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => 2,
'expired_cart_items' => 3,
'expired_cart_reservations' => 3,
'total_expired' => 5,
]);
@@ -44,7 +44,7 @@ class ExpireStockReservationsServiceTest extends TestCase
$this->assertSame([
'purchases' => 2,
'cart_items' => 3,
'cart_reservations' => 3,
], $result);
}
@@ -67,7 +67,7 @@ class ExpireStockReservationsServiceTest extends TestCase
->with('Stock reservation cleanup failed.', [
'command' => 'reservations:expire',
'expired_purchases' => 2,
'expired_cart_items' => null,
'expired_cart_reservations' => null,
'exception' => $exception,
]);

View File

@@ -16,10 +16,12 @@ class SaleFormServiceTest extends TestCase
$this->assertSame([
'Creada',
'Esperando pago',
'En revisión',
'Confirmada',
'Cancelada',
'Rechazada',
'Vencida',
'Reemplazada',
], array_column($form['statuses'], 'name'));
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
class AdminAppSalePdfRequestTest extends TestCase
{
public function test_pdf_requests_accept_php_timezone_identifiers_including_browser_aliases(): void
{
$requests = [
new AdminAppSalePdfRequest,
new AdminAppSaleModificationPdfRequest,
];
foreach ($requests as $request) {
$this->assertTrue(Validator::make([
'timezone' => 'America/Argentina/Buenos_Aires',
], $request->rules())->passes());
$this->assertTrue(Validator::make([
'timezone' => 'America/Buenos_Aires',
], $request->rules())->passes());
$this->assertFalse(Validator::make([
'timezone' => 'Invalid/Timezone',
], $request->rules())->passes());
$this->assertFalse(Validator::make([], $request->rules())->passes());
}
}
public function test_invalid_timezone_message_uses_the_application_locale(): void
{
$request = new AdminAppSalePdfRequest;
App::setLocale('es');
$spanishValidator = Validator::make(['timezone' => 'Invalid/Timezone'], $request->rules());
$this->assertSame(
'La zona horaria enviada por el navegador no es válida.',
$spanishValidator->errors()->first('timezone'),
);
App::setLocale('en');
$englishValidator = Validator::make(['timezone' => 'Invalid/Timezone'], $request->rules());
$this->assertSame(
'The timezone sent by the browser is invalid.',
$englishValidator->errors()->first('timezone'),
);
}
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\ServiceProvider;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class AdminAppSalePdfServiceTest extends TestCase
@@ -17,6 +18,14 @@ class AdminAppSalePdfServiceTest extends TestCase
parent::setUp();
$this->app->register(ServiceProvider::class);
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_it_downloads_the_sales_report_as_a_pdf(): void
@@ -24,11 +33,12 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadSales(
$this->tenant(),
collect([$this->sale()]),
'America/La_Paz',
);
$this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString(
'attachment; filename=ventas_acme_',
'attachment; filename=ventas_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'),
);
$this->assertStringStartsWith('%PDF', $response->getContent());
@@ -57,16 +67,52 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadModifications(
$this->tenant(),
collect([$modification]),
'America/La_Paz',
);
$this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString(
'attachment; filename=historial_modificaciones_acme_',
'attachment; filename=historial_modificaciones_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'),
);
$this->assertStringStartsWith('%PDF', $response->getContent());
}
public function test_it_renders_pdf_dates_in_the_requested_timezone(): void
{
$sale = $this->sale();
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => $sale->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'system',
]);
$modification->setRelation('trackable', $sale);
$modification->setRelation('user', null);
$salesHtml = view('pdf.adminapp.sales', [
'tenant' => $this->tenant(),
'sales' => collect([$sale]),
'generatedAt' => now(),
'timeZone' => 'America/La_Paz',
'confirmedSalesTotal' => '25000.00',
])->render();
$modificationsHtml = view('pdf.adminapp.sale-modifications', [
'tenant' => $this->tenant(),
'modifications' => collect([$modification]),
'generatedAt' => now(),
'timeZone' => 'America/La_Paz',
])->render();
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $salesHtml);
$this->assertStringContainsString('24/08/2026 13:53', $salesHtml);
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $modificationsHtml);
$this->assertStringContainsString('13:53:00', $modificationsHtml);
}
private function tenant(): Tenant
{
return (new Tenant)->forceFill([

View File

@@ -2,9 +2,6 @@
namespace Tests\Unit\Sale;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
@@ -42,38 +39,16 @@ class SaleDetailResourceTest extends TestCase
$this->assertSame('30000.00', $data['total']);
}
public function test_it_uses_the_associated_cart_items_when_the_purchase_has_no_items(): void
public function test_it_does_not_fall_back_to_cart_items_when_the_purchase_has_no_items(): void
{
$catalogItem = (new CatalogItem)->forceFill([
'id' => 21,
'nombre' => 'Entrada general',
'precio' => '12500.00',
]);
$cartItem = (new CartItem)->forceFill([
'id' => 34,
'catalog_item_id' => $catalogItem->id,
'cantidad' => 2,
]);
$cartItem->setRelation('catalogItem', $catalogItem);
$cartItem->setRelation('variant', null);
$cart = new Cart;
$cart->setRelation('items', collect([$cartItem]));
$purchase = (new Purchase)->forceFill([
'id' => 16,
'total' => '25000.00',
]);
$purchase->setRelation('items', collect());
$purchase->setRelation('cart', $cart);
$data = (new SaleDetailResource($purchase))->resolve(Request::create('/'));
$this->assertSame(34, $data['items'][0]['id']);
$this->assertSame('Entrada general', $data['items'][0]['product']);
$this->assertSame([], $data['items'][0]['event_dates']);
$this->assertSame(2, $data['items'][0]['quantity']);
$this->assertSame('12500.00', $data['items'][0]['unit_price']);
$this->assertSame('25000.00', $data['items'][0]['total']);
$this->assertCount(0, $data['items']);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Tests\TestCase;
class SaleModificationResourceTest extends TestCase
{
public function test_it_serializes_the_change_instant_as_utc_iso_8601(): void
{
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => 33,
'attribute' => 'status',
'old_value' => 'pending_payment',
'new_value' => 'cancelled',
'changed_at' => CarbonImmutable::parse('2026-08-24 17:53:00', 'UTC'),
'actor_type' => 'system',
]);
$modification->setRelation('trackable', null);
$modification->setRelation('user', null);
$data = (new SaleModificationResource($modification))->resolve(Request::create('/'));
$this->assertSame('2026-08-24T17:53:00+00:00', $data['changed_at']);
$this->assertSame('2026-08-24', $data['date']);
$this->assertSame('17:53:00', $data['time']);
}
}