refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared

This commit is contained in:
2026-09-18 10:14:02 -03:00
parent 4659c1049d
commit 1241e1f7e8
425 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
protected CartService $cartService,
) {}
public function show(Request $request, Tenant $tenant): CartResource
{
return CartResource::make($this->cartService->show($tenant, $request));
}
public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse
{
$result = $this->cartService->addItem(
$tenant,
$request,
(int) $request->validated('catalog_item_id'),
$request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null,
(int) $request->validated('cantidad'),
);
$response = CartResource::make($result['cart'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response();
if ($result['guest_token'] !== null) {
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
}
return $response;
}
public function updateItemQuantity(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateItem(
$tenant,
$request,
$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(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
}
}

View File

@@ -0,0 +1,494 @@
<?php
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;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
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;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[Fillable([
'tenant_codigo',
'user_id',
'guest_token',
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'carritos';
public const STATUS_ACTIVE = 'active';
public const STATUS_CHECKOUT = 'checkout';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ABANDONED = 'abandoned';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
protected function casts(): array
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<CartItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(CartItem::class, 'cart_id');
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
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')
? $this->getRelation('items')
: $this->items()->with(['catalogItem', 'variant'])->get();
return (float) $items->reduce(
fn (float $carry, CartItem $item): float => $carry
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0,
);
}
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
$this->assertUserPurchaseLimit(
$selectedItem,
$cartQuantity + $quantity,
heldQuantity: $cartQuantity,
maximumAddableCeiling: $availableQuantity,
);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
/** @var CartItem|null $item */
$item = $this->items()
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $variantId)
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
} else {
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
}
public function updateItem(
int $cartItemId,
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
?int $excludedPurchaseId = null,
): CartItem {
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use (
$cartItemId,
$quantity,
$variantId,
$updateVariant,
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$currentSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
$inventoryService = app(CatalogInventoryService::class);
if ($updateVariant && $variantId !== $item->variant_id) {
$nextSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$variantId,
true,
);
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$nextAvailableQuantity,
);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
$targetItem = $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $variantId)
->whereKeyNot($item->getKey())
->lockForUpdate()
->first();
if ($targetItem !== null) {
$targetItem->cantidad += $quantity;
$targetItem->save();
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
}
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
if ($delta > 0) {
$otherVariantsQuantity = (int) $this->items()
->where('catalog_item_id', $item->catalog_item_id)
->whereKeyNot($item->getKey())
->sum('cantidad');
$this->assertUserPurchaseLimit(
$currentSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$availableQuantity,
);
}
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]);
}
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
}
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$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)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
]);
$this->current_purchase_id = null;
return;
}
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,
bool $lockForUpdate = false,
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $this->tenant_codigo);
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
}
$catalogItem = $catalogItemQuery->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_required'),
]);
}
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
$catalogItem->setRelation('inventory', $inventory);
return $catalogItem;
}
$variantQuery = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id);
if ($lockForUpdate) {
$variantQuery->lockForUpdate();
}
$variant = $variantQuery->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);
return $variant;
}
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
?int $excludedPurchaseId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
): void {
if ($this->user_id === null) {
return;
}
$catalogItem = $selectedItem instanceof Variant
? $selectedItem->catalogItem
: $selectedItem;
app(UserPurchaseLimitService::class)->assertCanPurchase(
$catalogItem,
$this->user_id,
$cartQuantity,
$excludedPurchaseId,
$this->getKey(),
$heldQuantity,
$maximumAddableCeiling,
field: 'cantidad',
);
}
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
{
$query = Inventory::query()->whereKey($inventoryId);
if ($lockForUpdate) {
$query->lockForUpdate();
}
return $query->firstOrFail();
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'cart_id',
'catalog_item_id',
'variant_id',
'cantidad',
])]
class CartItem extends Model
{
use HasFactory;
protected $table = 'carrito_items';
protected function casts(): array
{
return [
'cart_id' => 'integer',
'catalog_item_id' => 'integer',
'variant_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Cart, $this>
*/
public function cart(): BelongsTo
{
return $this->belongsTo(Cart::class, 'cart_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class);
}
public function selectedItem(): CatalogItem|Variant|null
{
return $this->variant ?? $this->catalogItem;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddCartItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
return [
'catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id')->where(
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
),
],
'cantidad' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCartItemQuantityRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['sometimes', 'nullable', 'integer'],
];
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin CartItem
*/
class CartItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$selectedItem = $this->selectedItem();
$imageUrl = null;
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$includeVariants = $tenant instanceof Tenant
&& $tenant->cart_editing_policy->allowsVariantChanges();
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
}
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'nombre' => $selectedItem?->getName(),
'imagen' => $imageUrl,
'variant' => $this->variant === null ? null : $this->variantData($this->variant),
'variants' => $this->when(
$includeVariants,
fn () => $this->catalogItem
->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
),
];
}
/** @return array<string, mixed> */
protected function variantData(Variant $variant): array
{
return [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->precio ?? $this->catalogItem->precio),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectorOptions($this->catalogItem->itemAttributes),
];
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Cart\Resources;
use App\Domains\Cart\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Cart
*/
class CartResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$subtotal = $items->reduce(
fn (float $carry, $item): float => $carry
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0,
);
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'status' => $this->status ?? 'active',
'items' => CartItemResource::collection($items),
'subtotal' => $this->formatMoney($subtotal),
];
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,332 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
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;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function show(Tenant $tenant, Request $request): Cart
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
return $this->makeEmptyCart($tenant);
}
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart, $tenant);
}
/**
* @return array{cart: Cart, guest_token: ?string}
*/
public function addItem(
Tenant $tenant,
Request $request,
int $catalogItemId,
?int $variantId,
int $quantity,
): array {
$resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($catalogItemId, $variantId, $quantity);
return [
'cart' => $this->loadCart($cart, $tenant),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
public function updateItem(
Tenant $tenant,
Request $request,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_change_disabled'),
]);
}
if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
return $this->loadCart($cart, $tenant);
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
if (! $tenant->cart_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'cart_item' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($cartItemId);
return $this->loadCart($cart, $tenant);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
$secure = (bool) config('session.secure');
$sameSite = config('session.same_site');
$partitioned = (bool) config('session.partitioned')
|| ($secure && strtolower((string) $sameSite) === 'none');
return Cookie::create(
name: 'guest_token',
value: $guestToken,
expire: now()->addDays(180),
path: '/',
domain: config('session.domain'),
secure: $secure,
httpOnly: true,
raw: false,
sameSite: $sameSite,
partitioned: $partitioned,
);
}
protected function makeEmptyCart(Tenant $tenant): Cart
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
]);
$cart->setRelation('items', collect());
return $cart;
}
protected function loadCart(Cart $cart, Tenant $tenant): Cart
{
$relations = [
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
];
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
$relations = [
...$relations,
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
];
}
return $cart->fresh()->load($relations);
}
/**
* @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null
*/
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{
$user = $request->user() ?? Auth::guard('sanctum')->user();
if ($user instanceof User) {
return [
'identity' => [
'user_id' => $user->getKey(),
'guest_token' => null,
],
'generated_guest_token' => null,
];
}
$guestToken = $request->cookie('guest_token');
if (is_string($guestToken) && $guestToken !== '') {
return [
'identity' => [
'user_id' => null,
'guest_token' => $guestToken,
],
'generated_guest_token' => null,
];
}
if (! $generateGuestToken) {
return null;
}
$generatedGuestToken = (string) Str::uuid();
return [
'identity' => [
'user_id' => null,
'guest_token' => $generatedGuestToken,
],
'generated_guest_token' => $generatedGuestToken,
];
}
/**
* @return array{user_id: ?int, guest_token: ?string}
*/
protected function requireIdentity(Request $request): array
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $resolvedIdentity['identity'];
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCart(Tenant $tenant, array $identity): ?Cart
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
->first();
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $cart;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
'origin' => Cart::ORIGIN_USER,
];
if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id'];
} else {
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes);
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use Illuminate\Support\Facades\DB;
class GuestCartMergeService
{
public function merge(string $tenantCodigo, User $user, ?string $guestToken): void
{
if ($guestToken === null || $guestToken === '') {
return;
}
DB::transaction(function () use ($tenantCodigo, $user, $guestToken): void {
$guestCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('guest_token', $guestToken)
->where('status', 'active')
->lockForUpdate()
->first();
if ($guestCart === null || ! $guestCart->items()->exists()) {
return;
}
$userCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('user_id', $user->getKey())
->where('status', 'active')
->lockForUpdate()
->first();
if ($userCart !== null) {
$userCart->items()
->orderBy('id')
->pluck('id')
->each(fn (int $itemId) => $userCart->removeItem($itemId));
$userCart->update([
'status' => 'converted',
]);
$userCart->delete();
}
$guestCart->update([
'user_id' => $user->getKey(),
'guest_token' => null,
]);
});
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Domains\Cart\Services;
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\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(
Tenant $tenant,
Collection $eventDateIds,
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
): void {
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
$reason,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -0,0 +1,38 @@
# Dominio Cart
## Propósito
Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios autenticados.
## Modelo
- `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.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
Bajo `/tenants/{tenant:codigo}`:
- `GET /cart`.
- `POST /cart/items`.
- `PATCH /cart/items/{cartItem}`.
- `DELETE /cart/items/{cartItem}`.
## Contratos
`AddCartItemRequest` y `UpdateCartItemQuantityRequest` validan selección y cantidad. `CartResource` y `CartItemResource` estabilizan la respuesta pública. Cada ítem expone `nombre`, `imagen` y `precio_unitario` en la raíz, priorizando la variante seleccionada y usando el catálogo base como respaldo. La variante seleccionada se serializa en `variant`; las variantes alternativas no forman parte de la respuesta del carrito.
## Dependencias y reglas
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
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.
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`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.

View File

@@ -0,0 +1,12 @@
<?php
use App\Domains\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')
->group(function (): void {
Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']);
Route::patch('cart/items/{cartItem}', [CartController::class, 'updateItemQuantity']);
Route::delete('cart/items/{cartItem}', [CartController::class, 'removeItem']);
});

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Catalog\Controllers\AdminApp;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class OnTicketFeaturedGroupController extends Controller
{
public function __construct(
private readonly OnTicketFeaturedGroupService $featuredGroupService,
) {}
public function index(Request $request): AnonymousResourceCollection
{
return OnTicketFeaturedGroupResource::collection(
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
);
}
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
{
$featuredGroup = $this->featuredGroupService->create(
$this->onTicketTenant($request),
$request->validated(),
);
return OnTicketFeaturedGroupResource::make($featuredGroup)
->response()
->setStatusCode(201);
}
public function update(
UpsertOnTicketFeaturedGroupRequest $request,
FeaturedGroup $featuredGroup,
): OnTicketFeaturedGroupResource {
$tenant = $this->onTicketTenant($request);
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
return OnTicketFeaturedGroupResource::make(
$this->featuredGroupService->update(
$tenant,
$featuredGroup,
$request->validated(),
)
);
}
private function onTicketTenant(Request $request): Tenant
{
$tenant = $request->user()->tenant()->firstOrFail();
abort_unless(in_array($tenant->storefront_website_type_code, ['onticket', 'onticket_multi_event'], true), 404);
return $tenant;
}
}

View File

@@ -0,0 +1,187 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Catalog\Requests\CategoryPageRequest;
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Catalog\Resources\CatalogItemResource;
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Catalog\Services\FeaturedGroupService;
use App\Domains\Catalog\Services\VariantSelectionService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
class CatalogController extends Controller
{
public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
{
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->orderBy('group_order')
->get();
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
))->resolve()
));
}
public function search(
SearchCatalogItemsRequest $request,
Tenant $tenant,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
$items = $catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items);
}
public function category(
CategoryPageRequest $request,
Tenant $tenant,
Category $category,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
abort_unless($category->tenant_code === $tenant->codigo, 404);
$items = $catalogService->categoryItems(
$tenant,
$category,
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items)->additional([
'category' => [
'id' => $category->id,
'nombre' => $category->nombre,
'categoria_id' => $category->categoria_id,
],
'layout' => $tenant->search_product_layout->value,
'group_layout' => $tenant->search_group_layout->value,
]);
}
public function featuredGroupItems(
FeaturedGroupPageRequest $request,
Tenant $tenant,
FeaturedGroup $featuredGroup,
FeaturedGroupService $featuredGroupService,
): JsonResponse {
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
$page = (int) $request->validated('page', 1);
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$page,
$this->userId($request),
));
}
public function show(
CatalogItemDetailRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$variantId = $request->validated('variant_id');
$item = $catalogService->getDetail(
$catalogItem,
$variantId === null ? null : (int) $variantId,
);
$allowances->attach(collect([$item]), $this->userId($request));
return CatalogItemDetailResource::make($item);
}
public function variantOptions(
CatalogVariantOptionsRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
VariantSelectionService $variantSelectionService,
CartService $cartService,
): CatalogVariantOptionsResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
$includedVariantId = null;
$cartItemId = $request->validated('cart_item_id');
$selectedValues = $request->validated('selected_values', []);
if ($cartItemId !== null) {
$cartItem = $cartService->show($tenant, $request)
->items
->firstWhere('id', (int) $cartItemId);
abort_unless($cartItem?->catalog_item_id === $catalogItem->id, 404);
$includedVariantId = $cartItem->variant_id;
if ($selectedValues === [] && $cartItem->variant !== null) {
$selectedValues = $cartItem->variant
->selectorOptions($catalogItem->itemAttributes)
->all();
}
}
return CatalogVariantOptionsResource::make(
$variantSelectionService->options(
$catalogItem,
$selectedValues,
$includedVariantId,
)
);
}
public function store(
StoreCatalogItemRequest $request,
Tenant $tenant,
CatalogService $catalogService,
): JsonResponse {
$catalogItem = $catalogService->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CatalogItemResource::make($catalogItem)
->response()
->setStatusCode(201);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();
return $userId === null ? null : (int) $userId;
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogItemType: string
{
case Standard = 'standard';
case Bundle = 'bundle';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Catalog\Enums;
enum FeaturedGroupSource: string
{
case Manual = 'manual';
case Category = 'category';
case All = 'all';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Catalog\Enums;
enum GroupLayout: string
{
case Paginated = 'paginated';
case Simple = 'simple';
case SimpleVertical = 'simple_vertical';
case Carousel = 'carousel';
case Single = 'single';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventorySubject: string
{
case Product = 'product';
case Seat = 'seat';
case Ticket = 'ticket';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Catalog\Enums;
enum ProductLayout: string
{
case Row = 'row';
case ColumnWithImage = 'column_with_image';
case ColumnWithCart = 'column_with_cart';
case TicketSelector = 'ticket_selector';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Catalog\Exceptions;
use RuntimeException;
class StockReservationExpiredException extends RuntimeException
{
public function __construct()
{
parent::__construct(__('api.cart.reservation_expired'));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Event\Models\EventDate;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
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([
'tenant_codigo',
'codigo',
'nombre',
'is_required',
'metadata_schema',
'type',
])]
class Attribute extends Model
{
use HasFactory;
protected $table = 'attribute';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'is_required' => 'boolean',
'metadata_schema' => 'array',
'type' => FieldType::class,
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<AttributeOption, $this>
*/
public function options(): HasMany
{
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
}
/**
* @return HasMany<EventDate, $this>
*/
public function eventDates(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->orderBy('date')
->orderBy('time_start');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'validity_time_id',
'value',
'label',
'sort_order',
'metadata',
])]
class AttributeOption extends Model
{
use HasFactory;
protected $table = 'attribute_options';
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'validity_time_id' => 'integer',
'sort_order' => 'integer',
'metadata' => 'array',
];
}
/**
* @return BelongsTo<Attribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class, 'attribute_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Tenant\Models\Tenant;
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([
'tenant_codigo',
'nombre',
'descripcion',
])]
class Brand extends Model
{
use HasFactory;
protected $table = 'brands';
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_catalog_item_id',
'component_catalog_item_id',
'component_variant_id',
'quantity',
])]
class BundleComponent extends Model
{
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'bundle_catalog_item_id' => 'integer',
'component_catalog_item_id' => 'integer',
'component_variant_id' => 'integer',
'quantity' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function bundle(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id');
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'component_variant_id');
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
#[Fillable([
'tenant_code',
'category_id',
'brand_id',
'inventory_id',
'type',
'slug',
'nombre',
'group_order',
'descripcion',
'precio',
'inventory_policy',
'inventory_subject',
'max_units_per_user',
'has_tickets',
])]
class CatalogItem extends Model
{
use HasFactory, SoftDeletes;
public $timestamps = false;
protected $table = 'catalog_items';
protected $attributes = [
'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false,
'group_order' => 0,
];
protected function casts(): array
{
return [
'category_id' => 'integer',
'brand_id' => 'integer',
'inventory_id' => 'integer',
'type' => CatalogItemType::class,
'group_order' => 'integer',
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
'max_units_per_user' => 'integer',
'has_tickets' => 'boolean',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/** @return BelongsTo<Brand, $this> */
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class);
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return HasMany<BundleComponent, $this> */
public function bundleComponents(): HasMany
{
return $this->hasMany(BundleComponent::class, 'bundle_catalog_item_id');
}
/** @return HasMany<BundleComponent, $this> */
public function bundleComponentUsages(): HasMany
{
return $this->hasMany(BundleComponent::class, 'component_catalog_item_id');
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
}
/** @return BelongsToMany<Attribute, $this> */
public function attributes(): BelongsToMany
{
return $this->belongsToMany(Attribute::class, 'item_attributes')
->withTimestamps();
}
/** @return HasMany<ItemAttribute, $this> */
public function itemAttributes(): HasMany
{
return $this->hasMany(ItemAttribute::class);
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{
return $this->hasMany(FeaturedItem::class);
}
/** @return BelongsToMany<Attachment, $this> */
public function attachments(): BelongsToMany
{
return $this->allAttachments()->wherePivot('is_enabled', true);
}
/** @return BelongsToMany<Attachment, $this> */
public function allAttachments(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'catalog_items_attachments',
'catalog_item_id',
'attachment_id'
)
->withPivot(['orden', 'is_enabled'])
->wherePivotNull('variant_id')
->orderByPivot('orden');
}
public function availableStock(): ?int
{
return app(CatalogInventoryService::class)->availableQuantity($this);
}
public function isAvailable(): bool
{
if ($this->type === CatalogItemType::Bundle) {
$availableStock = $this->availableStock();
return $availableStock === null || $availableStock > 0;
}
if ($this->inventory_policy === InventoryPolicy::Unlimited) {
return true;
}
return ($this->availableStock() ?? 0) > 0;
}
/** @param Builder<CatalogItem> $query */
public function scopeWhereAvailable(Builder $query): Builder
{
return $query->where(function (Builder $query): void {
$query
->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->where(function (Builder $selectionQuery): void {
$selectionQuery
->whereDoesntHave('variants')
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id'));
});
})
->orWhereHas(
'variants',
fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'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> */
public function visibleVariants(?int $includedVariantId = null): Collection
{
return $this->variants
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
&& (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
))))
->values();
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre;
}
public function getSelectionLabel(): string
{
return $this->getName();
}
public function getDescription(): ?string
{
return $this->descripcion;
}
public function isBundle(): bool
{
return $this->type === CatalogItemType::Bundle;
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([
'tenant_code',
'categoria_id',
'nombre',
'is_enabled',
])]
class Category extends Model
{
use HasFactory;
protected $table = 'categorias';
protected $attributes = [
'is_enabled' => true,
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'categoria_id' => 'integer',
'is_enabled' => 'boolean',
];
}
public function isGlobal(): bool
{
return $this->tenant_code === null;
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/**
* @return BelongsTo<Category, $this>
*/
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'categoria_id');
}
/**
* @return HasMany<Category, $this>
*/
public function subCategories(): HasMany
{
return $this->hasMany(self::class, 'categoria_id');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
/** @return BelongsToMany<User, $this> */
public function scanners(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'category_scanners',
'categoria_id',
'user_id',
)->withTimestamps();
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Tenant\Models\Tenant;
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;
use Illuminate\Support\Str;
#[Fillable([
'tenant_code',
'code',
'source_type',
'category_id',
'product_layout',
'group_layout',
'group_name',
'group_order',
])]
class FeaturedGroup extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'featured_groups';
protected $attributes = [
'source_type' => FeaturedGroupSource::Manual->value,
];
protected static function booted(): void
{
static::creating(function (FeaturedGroup $featuredGroup): void {
if (filled($featuredGroup->code)) {
return;
}
$baseCode = Str::slug($featuredGroup->group_name) ?: 'group';
$code = $baseCode;
$suffix = 2;
while (static::query()
->where('tenant_code', $featuredGroup->tenant_code)
->where('code', $code)
->exists()) {
$code = "{$baseCode}-{$suffix}";
$suffix++;
}
$featuredGroup->code = $code;
});
}
protected function casts(): array
{
return [
'source_type' => FeaturedGroupSource::class,
'category_id' => 'integer',
'product_layout' => ProductLayout::class,
'group_layout' => GroupLayout::class,
'group_order' => 'integer',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
/** @return BelongsTo<Category, $this> */
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
/** @return HasMany<FeaturedItem, $this> */
public function featuredItems(): HasMany
{
return $this->hasMany(FeaturedItem::class)->orderBy('order');
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'featured_group_id',
'catalog_item_id',
'order',
])]
class FeaturedItem extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'featured_items';
protected function casts(): array
{
return [
'featured_group_id' => 'integer',
'catalog_item_id' => 'integer',
'order' => 'integer',
];
}
/** @return BelongsTo<FeaturedGroup, $this> */
public function featuredGroup(): BelongsTo
{
return $this->belongsTo(FeaturedGroup::class);
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'sold_units',
'refunded_units',
'reserved_stock',
'real_stock',
])]
class Inventory extends Model
{
use HasFactory;
public $timestamps = false;
protected $table = 'inventories';
protected $attributes = [
'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0,
'real_stock' => 0,
];
protected function casts(): array
{
return [
'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer',
'real_stock' => 'integer',
];
}
/** @return HasOne<CatalogItem, $this> */
public function catalogItem(): HasOne
{
return $this->hasOne(CatalogItem::class);
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
{
return $this->hasMany(Variant::class);
}
/** @return HasMany<StockReservationLine, $this> */
public function stockReservationLines(): HasMany
{
return $this->hasMany(StockReservationLine::class);
}
public function availableStock(): int
{
return max(0, $this->real_stock - $this->reserved_stock);
}
public function reserve(int $amount, bool $tracksInventory): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
}
if ($tracksInventory && $this->availableStock() < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
}
$this->reserved_stock += $amount;
$this->save();
}
public function release(int $amount): void
{
if ($amount < 0 || $this->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
}
$this->reserved_stock -= $amount;
$this->save();
}
public function buy(int $amount, bool $tracksInventory): void
{
if ($amount < 0 || $this->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
}
if ($tracksInventory && $this->real_stock < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
if ($tracksInventory) {
$this->real_stock -= $amount;
}
$this->reserved_stock -= $amount;
$this->sold_units += $amount;
$this->save();
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Catalog\Models;
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([
'catalog_item_id',
'attribute_id',
'allow_multi_select',
'sort_order',
'show_in_selector',
'ticket_label',
])]
class ItemAttribute extends Model
{
use HasFactory;
protected $table = 'item_attributes';
protected $attributes = [
'show_in_selector' => true,
];
protected function casts(): array
{
return [
'catalog_item_id' => 'integer',
'attribute_id' => 'integer',
'allow_multi_select' => 'boolean',
'sort_order' => 'integer',
'show_in_selector' => 'boolean',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Attribute, $this> */
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class);
}
/** @return HasMany<VariantDefinition, $this> */
public function variantDefinitions(): HasMany
{
return $this->hasMany(VariantDefinition::class);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Catalog\Models;
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\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'status',
'expires_at',
'committed_at',
'released_at',
'expired_at',
'release_reason',
])]
class StockReservation extends Model
{
public const STATUS_ACTIVE = 'active';
public const STATUS_COMMITTED = 'committed';
public const STATUS_RELEASED = 'released';
public const STATUS_EXPIRED = 'expired';
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'committed_at' => 'datetime',
'released_at' => 'datetime',
'expired_at' => 'datetime',
];
}
/** @return HasMany<StockReservationLine, $this> */
public function lines(): HasMany
{
return $this->hasMany(StockReservationLine::class);
}
/** @return HasOne<Cart, $this> */
public function currentCart(): HasOne
{
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
}
/** @return HasOne<Purchase, $this> */
public function purchase(): HasOne
{
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

@@ -0,0 +1,345 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Event\Models\EventDate;
use App\Domains\Ticket\Models\Ticket;
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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Str;
#[Fillable([
'catalog_item_id',
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion',
'precio',
])]
class Variant extends Model
{
use HasFactory, SoftDeletes;
public $timestamps = false;
protected $table = 'variantes';
protected function casts(): array
{
return [
'catalog_item_id' => 'integer',
'event_date_id' => 'integer',
'inventory_id' => 'integer',
'replaced_by_variant_id' => 'integer',
'sales_disabled_at' => 'datetime',
'precio' => 'decimal:2',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class)->withTrashed();
}
/** @return BelongsTo<EventDate, $this> */
public function eventDate(): BelongsTo
{
return $this->belongsTo(EventDate::class);
}
/** @return BelongsToMany<EventDate, $this> */
public function eventDates(): BelongsToMany
{
return $this->belongsToMany(
EventDate::class,
'variant_event_dates',
'variant_id',
'event_date_id',
)->orderBy('date')->orderBy('time_start');
}
/** @return HasMany<Ticket, $this> */
public function sourceTickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_variant_id');
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return BelongsTo<Variant, $this> */
public function replacement(): BelongsTo
{
return $this->belongsTo(self::class, 'replaced_by_variant_id');
}
/** @return HasMany<Variant, $this> */
public function replacedVariants(): HasMany
{
return $this->hasMany(self::class, 'replaced_by_variant_id');
}
public function isSellable(): bool
{
return $this->sales_disabled_at === null
&& $this->replaced_by_variant_id === null
&& $this->hasOnlyActiveEventDates();
}
public function hasOnlyActiveEventDates(): bool
{
if (! $this->exists
&& $this->event_date_id === null
&& ! $this->relationLoaded('eventDates')) {
return true;
}
return $this->selectedEventDates()->every(
fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
&& $eventDate->suspended_at === null,
);
}
/** @return HasMany<VariantDefinition, $this> */
public function definitions(): HasMany
{
return $this->hasMany(VariantDefinition::class);
}
/** @return BelongsToMany<Attachment, $this> */
public function attachments(): BelongsToMany
{
return $this->allAttachments()->wherePivot('is_enabled', true);
}
/** @return BelongsToMany<Attachment, $this> */
public function allAttachments(): BelongsToMany
{
$relation = $this->belongsToMany(
Attachment::class,
'catalog_items_attachments',
'variant_id',
'attachment_id'
)
->withPivot(['orden', 'is_enabled'])
->orderByPivot('orden');
if ($this->catalog_item_id !== null) {
$relation->withPivotValue('catalog_item_id', $this->catalog_item_id);
}
return $relation;
}
public function getPrice(): float
{
return (float) ($this->precio ?? $this->catalogItem->precio);
}
public function getDescription(): ?string
{
return $this->descripcion ?? $this->catalogItem->descripcion;
}
public function getName(): string
{
return $this->catalogItem->nombre;
}
public function getSelectionLabel(): string
{
$this->loadMissing([
'catalogItem.itemAttributes.attribute.options',
'definitions.itemAttribute.attribute.options',
'eventDates',
'eventDate',
]);
$itemAttributes = $this->catalogItem->itemAttributes;
$label = $this->selectionOptions($itemAttributes)
->map(function (array $option, string $attributeCode) use ($itemAttributes): ?string {
$itemAttribute = $itemAttributes->first(
fn (ItemAttribute $candidate): bool => $candidate->attribute?->codigo === $attributeCode,
);
$translationKey = "api.catalog.attribute_labels.{$attributeCode}";
$attributeName = Lang::has($translationKey)
? __($translationKey)
: ($itemAttribute?->attribute?->nombre ?? Str::headline($attributeCode));
$selectedOptions = array_is_list($option) ? $option : [$option];
$selectedLabels = collect($selectedOptions)
->pluck('label')
->filter()
->implode(', ');
return $selectedLabels === ''
? null
: "{$attributeName} {$selectedLabels}";
})
->filter()
->implode(' · ');
return $label !== '' ? $label : $this->getName();
}
/** @return Collection<string, string|array<int, string>> */
public function selectionValues(): Collection
{
$values = $this->definitions
->groupBy('item_attribute_id')
->mapWithKeys(function (Collection $definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$attributeCode = $itemAttribute?->attribute?->codigo;
if ($attributeCode === null) {
return [];
}
$definitionValues = $definitions->pluck('value')->values();
return [
$attributeCode => $itemAttribute->allow_multi_select
? $definitionValues->all()
: $definitionValues->first(),
];
});
$eventDateIds = $this->selectedEventDates()
->pluck('id')
->map(fn ($id): string => (string) $id)
->values();
if ($eventDateIds->count() === 1) {
$values->put('event_date', $eventDateIds->first());
} elseif ($eventDateIds->isNotEmpty()) {
$values->put('event_date', $eventDateIds->all());
}
return $values;
}
/**
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
*/
public function selectionOptions(?Collection $itemAttributes = null): Collection
{
$options = $this->definitions
->groupBy('item_attribute_id')
->mapWithKeys(function (Collection $definitions): array {
$itemAttribute = $definitions->first()?->itemAttribute;
$attribute = $itemAttribute?->attribute;
$attributeCode = $attribute?->codigo;
if ($attributeCode === null) {
return [];
}
$values = $definitions
->pluck('value')
->values()
->map(function (string $value) use ($attribute): array {
$attributeOption = $attribute->options->firstWhere('value', $value);
return [
'value' => $value,
'label' => $attributeOption?->label ?? $value,
];
});
return [
$attributeCode => $itemAttribute->allow_multi_select
? $values->all()
: $values->first(),
];
});
$eventDateOptions = $this->selectedEventDates()
->map(fn (EventDate $eventDate): array => [
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'),
])
->values();
if ($eventDateOptions->count() === 1) {
$options->put('event_date', $eventDateOptions->first());
} elseif ($eventDateOptions->isNotEmpty()) {
$options->put('event_date', $eventDateOptions->all());
}
if ($itemAttributes === null) {
$itemAttributes = $this->definitions
->map(fn (VariantDefinition $definition) => $definition->itemAttribute)
->filter()
->unique('id')
->values();
if ($this->catalogItem !== null) {
$itemAttributes = $itemAttributes
->merge($this->catalogItem->itemAttributes)
->unique('id')
->values();
}
}
$ordering = $itemAttributes->mapWithKeys(function (ItemAttribute $itemAttribute): array {
$attribute = $itemAttribute->attribute;
return $attribute === null
? []
: [$attribute->codigo => [$itemAttribute->sort_order, mb_strtolower($attribute->nombre)]];
});
return $options->sortKeysUsing(function (string $left, string $right) use ($ordering): int {
[$leftOrder, $leftLabel] = $ordering->get($left, [0, mb_strtolower($left)]);
[$rightOrder, $rightLabel] = $ordering->get($right, [0, mb_strtolower($right)]);
return $leftOrder <=> $rightOrder
?: $leftLabel <=> $rightLabel
?: $left <=> $right;
});
}
/**
* @param Collection<int, ItemAttribute> $itemAttributes
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
*/
public function selectorOptions(Collection $itemAttributes): Collection
{
$visibleAttributeCodes = $itemAttributes
->filter(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->show_in_selector)
->map(fn (ItemAttribute $itemAttribute): ?string => $itemAttribute->attribute?->codigo)
->filter()
->values();
return $this->selectionOptions($itemAttributes)
->filter(
fn (array $option, string $attributeCode): bool => $visibleAttributeCodes
->contains($attributeCode)
);
}
/** @return Collection<int, EventDate> */
public function selectedEventDates(): Collection
{
$eventDates = $this->eventDates;
if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) {
return collect([$this->eventDate]);
}
return $eventDates;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'variant_id',
'item_attribute_id',
'value',
])]
class VariantDefinition extends Model
{
use HasFactory;
protected $table = 'variant_values';
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class);
}
/** @return BelongsTo<ItemAttribute, $this> */
public function itemAttribute(): BelongsTo
{
return $this->belongsTo(ItemAttribute::class);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Catalog\Requests\AdminApp;
use Illuminate\Foundation\Http\FormRequest;
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'category_name' => ['required', 'string', 'max:255'],
'is_featured' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CatalogItemDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'variant_id' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CatalogVariantOptionsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'selected_values' => ['sometimes', 'array'],
'selected_values.*' => ['nullable'],
'cart_item_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CategoryPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class FeaturedGroupPageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SearchCatalogItemsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['required', 'string', 'min:2', 'max:100'],
'page' => ['sometimes', 'integer', 'min:1'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('q') && is_string($this->input('q'))) {
$this->merge(['q' => trim($this->string('q')->toString())]);
}
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCatalogItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
$type = $this->input('type', CatalogItemType::Standard->value);
$isBundle = $type === CatalogItemType::Bundle->value;
return [
'tenant_code' => ['prohibited'],
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
'category_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('categorias', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'brand_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('catalog_items', 'slug')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'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)],
'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
'inventory_id' => ['prohibited'],
'reserved_stock' => ['prohibited'],
'sold_units' => ['prohibited'],
'attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'multi_select_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'hidden_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'hidden_attribute_codes.*' => [
'required',
'string',
'distinct',
Rule::exists('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $tenantCode)
),
],
'images' => ['sometimes', 'array'],
'images.*' => ['required', new ImageOrBase64Rule],
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.descripcion' => ['sometimes', 'nullable', 'string'],
'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'],
'variants.*.event_date_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'],
'variants.*.event_date_ids.*' => [
'required',
'integer',
'distinct',
Rule::exists('event_dates', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'],
'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable'],
'variants.*.values.*.*' => ['required', 'string'],
'variants.*.images' => ['sometimes', 'array'],
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
'components' => [
Rule::requiredIf($isBundle),
Rule::prohibitedIf(! $isBundle),
'array',
'min:1',
],
'components.*.catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'components.*.variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id'),
],
'components.*.quantity' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Catalog\Resources\AdminApp;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
class OnTicketFeaturedGroupResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'category_id' => $this->category_id,
'category_name' => $this->category->nombre,
'group_name' => $this->group_name,
'is_featured' => $this->product_layout === ProductLayout::Row,
'type' => $this->source_type->value,
'product_layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,
'order' => $this->group_order,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin FeaturedGroup */
class CatalogFeaturedGroupResource extends JsonResource
{
/** @param array<array-key, mixed> $items */
public function __construct($resource, private readonly array $items)
{
parent::__construct($resource);
}
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'title' => $this->group_name,
'layout' => $this->product_layout->value,
'group_layout' => $this->group_layout->value,
'group_order' => $this->group_order,
'items' => $this->items,
];
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogFeaturedItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$catalogItem = $this->resource;
/** @var FeaturedGroup $featuredGroup */
$featuredGroup = $catalogItem->getRelation('featuredGroup');
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
return $this->columnWithImageData($catalogItem);
}
if ($featuredGroup->product_layout === ProductLayout::TicketSelector) {
return $this->ticketSelectorData($catalogItem);
}
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
$availableStock = $catalogItem->availableStock();
$data = [
'id' => $catalogItem->id,
'type' => $catalogItem->type->value,
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'maximum_addable_quantity' => $this->maximumAddable(
$availableStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$availableStock,
$remainingUserQuota,
),
'variants' => $catalogItem->visibleVariants()
->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'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'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(
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$variantStock,
$remainingUserQuota,
),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
})
->values(),
];
return $data;
}
/** @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,
'nombre' => $catalogItem->nombre,
'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->visibleVariants()
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
return $attachment?->getTemporaryUrl(1440);
}
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
{
return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota);
}
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
}
}

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Ticket\Resources\ValidityTimeResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
/** @mixin CatalogItem */
class CatalogItemDetailResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var Variant|null $selectedVariant */
$selectedVariant = $this->resource->getRelation('selectedVariant');
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'category' => $this->category?->nombre,
'brand' => $this->brand?->nombre,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'attributes' => $this->attributesData(),
'maximum_addable_quantity' => $this->when(
$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->variants
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
'selected_variant' => $this->when(
$selectedVariant !== null,
fn (): array => [
...$this->variantData($selectedVariant),
'images' => $this->imageUrls($selectedVariant->attachments),
],
),
'components' => $this->when(
$this->isBundle(),
fn () => $this->bundleComponents
->map(function ($component): array {
$selectedItem = $component->variant ?? $component->catalogItem;
return [
'catalog_item_id' => $component->component_catalog_item_id,
'variant_id' => $component->component_variant_id,
'quantity' => $component->quantity,
'nombre' => $component->catalogItem->nombre,
'item_nombre' => $selectedItem->getName(),
];
})
->values(),
),
];
}
/** @return array<string, mixed> */
private function attributeData(ItemAttribute $itemAttribute): array
{
$attribute = $itemAttribute->attribute;
return [
'id' => $attribute->id,
'codigo' => $attribute->codigo,
'nombre' => $attribute->nombre,
'sort_order' => $itemAttribute->sort_order,
'show_in_selector' => $itemAttribute->show_in_selector,
'ticket_label' => $itemAttribute->ticket_label,
'is_required' => $attribute->is_required,
'allow_multi_select' => $itemAttribute->allow_multi_select,
'metadata_schema' => $attribute->metadata_schema,
'type' => $attribute->type->value,
'options' => $attribute->type === FieldType::EventDate
? $this->eventDateOptions($itemAttribute)
: $this->catalogAttributeOptions($itemAttribute),
];
}
/** @return Collection<int, array<string, mixed>> */
private function eventDateOptions(ItemAttribute $itemAttribute): Collection
{
return $itemAttribute->attribute->eventDates
->map(fn ($eventDate, int $index): array => [
'id' => $eventDate->id,
'value' => (string) $eventDate->id,
'label' => $eventDate->date->format('d/m/Y'),
'sort_order' => $index,
'validity_time_id' => $eventDate->validity_time_id,
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
'metadata' => [
'date' => $eventDate->date->format('Y-m-d'),
'time_start' => $eventDate->time_start,
'time_end' => $eventDate->time_end,
],
]);
}
/** @return Collection<int, array<string, mixed>> */
private function catalogAttributeOptions(ItemAttribute $itemAttribute): Collection
{
$availableValues = $this->variants
->flatMap->definitions
->where('item_attribute_id', $itemAttribute->id)
->pluck('value')
->filter()
->unique();
return $itemAttribute->attribute->options
->whereIn('value', $availableValues)
->map(fn ($option): array => [
'id' => $option->id,
'value' => $option->value,
'label' => $option->label,
'sort_order' => $option->sort_order,
'validity_time_id' => $option->validity_time_id,
'validity_time' => $option->validityTime === null
? null
: ValidityTimeResource::make($option->validityTime),
'metadata' => $option->metadata,
])
->values();
}
/** @return Collection<int, array<string, mixed>> */
private function attributesData(): Collection
{
return $this->itemAttributes
->sort(function (ItemAttribute $left, ItemAttribute $right): int {
return $left->sort_order <=> $right->sort_order
?: mb_strtolower($left->attribute->nombre) <=> mb_strtolower($right->attribute->nombre);
})
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
->values();
}
/** @return array<string, mixed> */
private function variantData(Variant $variant): array
{
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
return [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $eventDates->pluck('id')->values(),
'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($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $values,
];
}
/** @return Collection<int, string> */
private function imageUrls(Collection $attachments): Collection
{
return $attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values();
}
private function variantStock(Variant $variant): ?int
{
return $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock();
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type->value,
'category_id' => $this->category_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()),
'variants' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'id' => $variant->id,
'event_date_id' => $variant->event_date_id,
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'real_stock' => $variant->inventory?->real_stock,
'values' => $variant->selectorOptions($this->itemAttributes),
'images' => $variant->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values(),
])
->values()),
];
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin CatalogItem */
class CatalogSearchItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first()
?? $visibleVariants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
return [
'id' => $this->id,
'type' => $this->type->value,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'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'),
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'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($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
->values(),
];
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CatalogVariantOptionsResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return $this->resource;
}
}

View File

@@ -0,0 +1,254 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Support\Facades\DB;
class CatalogInventoryService
{
/** @return array<int, int> */
public function requirementsFor(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): int => $requirement['quantity'] * $quantity,
$this->inventoryRequirements($selection),
);
}
/**
* @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
&& $selection->type === CatalogItemType::Standard
&& $selection->relationLoaded('inventory')
&& $selection->inventory !== null) {
return $selection->inventory_policy === InventoryPolicy::Unlimited
? null
: $selection->inventory->availableStock();
}
if ($selection instanceof CatalogItem
&& $selection->type === CatalogItemType::Standard
&& $selection->inventory_id === null) {
if ($selection->inventory_policy === InventoryPolicy::Unlimited) {
return null;
}
$selection->loadMissing('variants.inventory');
return $selection->variants
->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory)
: 'id:'.$variant->inventory_id)
->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
}
$requirements = $this->inventoryRequirements($selection);
$trackedRequirements = array_filter(
$requirements,
fn (array $requirement): bool => $requirement['tracks_inventory'],
);
if ($trackedRequirements === []) {
return null;
}
$inventories = Inventory::query()
->whereKey(array_keys($trackedRequirements))
->get()
->keyBy('id');
$available = [];
foreach ($trackedRequirements as $inventoryId => $requirement) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
$available[] = intdiv(
$inventory->availableStock(),
$requirement['quantity'],
);
}
return min($available);
}
public function reserve(CatalogItem|Variant $selection, int $quantity): void
{
$this->mutate($selection, $quantity, 'reserve');
}
public function release(CatalogItem|Variant $selection, int $quantity): void
{
$this->mutate($selection, $quantity, 'release');
}
public function commit(CatalogItem|Variant $selection, int $quantity): void
{
$this->mutate($selection, $quantity, 'commit');
}
private function mutate(
CatalogItem|Variant $selection,
int $quantity,
string $operation,
): void {
if ($quantity <= 0) {
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
}
DB::transaction(function () use ($selection, $quantity, $operation): void {
$requirements = $this->inventoryRequirements($selection);
ksort($requirements);
$inventories = Inventory::query()
->whereKey(array_keys($requirements))
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($requirements as $inventoryId => $requirement) {
$inventory = $inventories->get($inventoryId)
?? throw new \InvalidArgumentException('No se encontro el inventario requerido.');
$requiredQuantity = $requirement['quantity'] * $quantity;
if ($operation === 'reserve'
&& $requirement['tracks_inventory']
&& $inventory->availableStock() < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
}
if (in_array($operation, ['release', 'commit'], true)
&& $inventory->reserved_stock < $requiredQuantity) {
throw new \InvalidArgumentException('La cantidad reservada no alcanza para la operacion.');
}
if ($operation === 'commit'
&& $requirement['tracks_inventory']
&& $inventory->real_stock < $requiredQuantity) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
}
foreach ($requirements as $inventoryId => $requirement) {
/** @var Inventory $inventory */
$inventory = $inventories->get($inventoryId);
$requiredQuantity = $requirement['quantity'] * $quantity;
match ($operation) {
'reserve' => $inventory->reserve($requiredQuantity, $requirement['tracks_inventory']),
'release' => $inventory->release($requiredQuantity),
'commit' => $inventory->buy($requiredQuantity, $requirement['tracks_inventory']),
};
}
});
}
/**
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
private function inventoryRequirements(CatalogItem|Variant $selection): array
{
if ($selection instanceof Variant) {
$selection->loadMissing('catalogItem');
return $this->singleRequirement(
$selection->inventory_id,
$selection->catalogItem->inventory_policy,
);
}
if ($selection->type !== CatalogItemType::Bundle) {
return $this->singleRequirement(
$selection->inventory_id,
$selection->inventory_policy,
);
}
$selection->loadMissing([
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
$requirements = [];
foreach ($selection->bundleComponents as $component) {
$this->addComponentRequirement($requirements, $component);
}
if ($requirements === []) {
throw new \InvalidArgumentException('El bundle no tiene componentes.');
}
return $requirements;
}
/** @return array<int, array{quantity: int, tracks_inventory: bool}> */
private function singleRequirement(
?int $inventoryId,
?InventoryPolicy $policy,
): array {
if ($inventoryId === null || $policy === null) {
throw new \InvalidArgumentException('El item requiere una variante con inventario.');
}
return [
$inventoryId => [
'quantity' => 1,
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
],
];
}
/**
* @param array<int, array{quantity: int, tracks_inventory: bool}> $requirements
*/
private function addComponentRequirement(array &$requirements, BundleComponent $component): void
{
$selectedItem = $component->variant ?? $component->catalogItem;
$inventoryId = $selectedItem->inventory_id;
$policy = $component->catalogItem->inventory_policy;
if ($inventoryId === null || $policy === null) {
throw new \InvalidArgumentException('Un componente del bundle no tiene inventario.');
}
if (isset($requirements[$inventoryId])) {
$requirements[$inventoryId]['quantity'] += $component->quantity;
return;
}
$requirements[$inventoryId] = [
'quantity' => $component->quantity,
'tracks_inventory' => $policy === InventoryPolicy::Tracked,
];
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
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,
) {}
/** @param Collection<int, CatalogItem> $catalogItems */
public function attach(Collection $catalogItems, ?int $userId): void
{
$remaining = $this->purchaseLimits->remainingByCatalogItem($catalogItems, $userId);
foreach ($catalogItems as $catalogItem) {
$catalogItem->setAttribute(
'remaining_user_quota',
$remaining->get($catalogItem->getKey()),
);
}
}
public function maximumAddableQuantity(?int $availableStock, ?int $remainingUserQuota): ?int
{
if ($availableStock === null) {
return $remainingUserQuota;
}
if ($remainingUserQuota === null) {
return $availableStock;
}
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

@@ -0,0 +1,778 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogService
{
public function __construct(protected AttachmentService $attachmentService) {}
/**
* @param array<string, mixed> $data
*/
public function create(array $data): CatalogItem
{
return DB::transaction(function () use ($data): CatalogItem {
$type = CatalogItemType::from(
$data['type'] ?? CatalogItemType::Standard->value,
);
$variants = $data['variants'] ?? [];
$images = $data['images'] ?? [];
$attributeCodes = $data['attribute_codes'] ?? [];
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
$hiddenAttributeCodes = $data['hidden_attribute_codes'] ?? [];
$ticketAttributeLabels = $data['ticket_attribute_labels'] ?? [];
$components = $data['components'] ?? [];
$hasDirectStock = array_key_exists('real_stock', $data);
$realStock = (int) ($data['real_stock'] ?? 0);
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
fn (array $variant): bool => ! empty($variant['event_date_id'])
|| ! empty($variant['event_date_ids'])
);
if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) {
throw ValidationException::withMessages([
'attribute_codes' => [
__('api.catalog.event_date_attribute_required'),
],
]);
}
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) {
throw ValidationException::withMessages([
'multi_select_attribute_codes' => [
__('api.catalog.multi_select_attribute_not_on_item'),
],
]);
}
if (array_diff($hiddenAttributeCodes, $attributeCodes) !== []) {
throw ValidationException::withMessages([
'hidden_attribute_codes' => [
__('api.catalog.hidden_attribute_not_on_item'),
],
]);
}
if (array_diff(array_keys($ticketAttributeLabels), $attributeCodes) !== []) {
throw ValidationException::withMessages([
'ticket_attribute_labels' => [
__('api.catalog.ticket_label_attribute_not_on_item'),
],
]);
}
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
if ($type === CatalogItemType::Bundle) {
$this->validateBundleData($data, $components);
} else {
if (array_key_exists('components', $data)) {
throw ValidationException::withMessages([
'components' => [__('api.catalog.standard_with_components')],
]);
}
$this->validateInventoryStrategy(
$data,
$variants,
$hasVariants,
$hasDirectStock,
);
}
unset(
$data['variants'],
$data['images'],
$data['attribute_codes'],
$data['multi_select_attribute_codes'],
$data['hidden_attribute_codes'],
$data['ticket_attribute_labels'],
$data['components'],
$data['real_stock'],
$data['reserved_stock'],
$data['sold_units'],
$data['inventory_id'],
);
$data['type'] = $type;
if ($type === CatalogItemType::Bundle) {
$data['inventory_id'] = null;
$data['inventory_policy'] = null;
$data['has_tickets'] = false;
} elseif ($hasVariants) {
$data['inventory_id'] = null;
} else {
$data['inventory_id'] = $this->createInventory($realStock)->id;
}
$catalogItem = CatalogItem::query()->create($data);
$itemAttributes = $type === CatalogItemType::Standard
? $this->createItemAttributes(
$catalogItem,
$attributeCodes,
$multiSelectAttributeCodes,
$hiddenAttributeCodes,
$ticketAttributeLabels,
)
: [];
if ($type === CatalogItemType::Bundle) {
$this->createBundleComponents($catalogItem, $components);
}
$createdVariants = [];
foreach ($variants as $index => $variantData) {
$createdVariants[] = [
'variant' => $this->createVariant(
$catalogItem,
$variantData,
$itemAttributes,
$index,
),
'images' => $variantData['images'] ?? [],
'index' => $index,
];
}
$this->attachImages($catalogItem, $images, 'images');
foreach ($createdVariants as $createdVariant) {
$this->attachImages(
$createdVariant['variant'],
$createdVariant['images'],
"variants.{$createdVariant['index']}.images",
);
}
return $catalogItem->load([
'attachments',
'inventory',
'category',
'brand',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
});
}
public function getDetail(CatalogItem $catalogItem, ?int $variantId = null): CatalogItem
{
$catalogItem->load([
'attachments',
'inventory',
'category',
'brand',
'itemAttributes.attribute.options.validityTime',
'itemAttributes.attribute.eventDates.validityTime',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem.inventory',
'bundleComponents.variant.inventory',
'bundleComponents.variant.definitions.itemAttribute.attribute',
]);
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
$catalogItem->setRelation('selectedVariant', $selectedVariant);
return $catalogItem;
}
/** @return LengthAwarePaginator<CatalogItem> */
public function search(
Tenant $tenant,
string $term,
int $perPage,
int $page,
): LengthAwarePaginator {
$normalizedTerm = mb_strtolower($term);
$containsPattern = "%{$normalizedTerm}%";
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
->orWhereRaw('LOWER(descripcion) LIKE ?', [$containsPattern])
->orWhereHas(
'brand',
fn (Builder $brandQuery) => $brandQuery
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
)
->orWhereHas(
'category',
fn (Builder $categoryQuery) => $categoryQuery
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
);
})
->with([
'attachments',
'inventory',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
->orderByRaw(
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
[$normalizedTerm, $startsWithPattern],
)
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
return $paginator->withPath(route('catalog-items.index', [
'tenant' => $tenant->codigo,
]));
}
/** @return LengthAwarePaginator<CatalogItem> */
public function categoryItems(
Tenant $tenant,
Category $category,
int $perPage,
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereAvailable()
->with([
'attachments',
'inventory',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
}
public function delete(CatalogItem $catalogItem): void
{
DB::transaction(function () use ($catalogItem): void {
$catalogItem->variants()->delete();
$catalogItem->delete();
});
}
public function deleteVariant(Variant $variant): void
{
DB::transaction(function () use ($variant): void {
$variant = Variant::query()
->lockForUpdate()
->findOrFail($variant->getKey());
$catalogItem = CatalogItem::query()
->lockForUpdate()
->findOrFail($variant->catalog_item_id);
$variant->delete();
$sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
$this->delete($catalogItem);
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]);
}
});
}
private function createInventory(int $realStock): Inventory
{
return Inventory::query()->create([
'real_stock' => $realStock,
]);
}
/**
* @param array<int, array<string, mixed>> $components
*/
private function createBundleComponents(CatalogItem $bundle, array $components): void
{
$seen = [];
foreach (array_values($components) as $index => $componentData) {
$catalogItemId = (int) $componentData['catalog_item_id'];
$variantId = isset($componentData['variant_id'])
? (int) $componentData['variant_id']
: null;
$key = $catalogItemId.':'.($variantId ?? 'direct');
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"components.{$index}" => [__('api.catalog.duplicate_component')],
]);
}
$seen[$key] = true;
$componentItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $bundle->tenant_code)
->first();
if ($componentItem === null) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
__('api.catalog.component_wrong_tenant'),
],
]);
}
if ($componentItem->is($bundle) || $componentItem->type !== CatalogItemType::Standard) {
throw ValidationException::withMessages([
"components.{$index}.catalog_item_id" => [
__('api.catalog.invalid_component'),
],
]);
}
$hasVariants = $componentItem->variants()->exists();
if ($hasVariants && $variantId === null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_required'),
],
]);
}
if (! $hasVariants && $variantId !== null) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_forbidden'),
],
]);
}
if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'),
],
]);
}
$bundle->bundleComponents()->create([
'component_catalog_item_id' => $componentItem->id,
'component_variant_id' => $variantId,
'quantity' => (int) $componentData['quantity'],
]);
}
}
/**
* @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $components
*/
private function validateBundleData(array $data, array $components): void
{
if ($components === []) {
throw ValidationException::withMessages([
'components' => [__('api.catalog.bundle_component_required')],
]);
}
foreach ([
'real_stock',
'inventory_policy',
'attribute_codes',
'variants',
'has_tickets',
] as $field) {
if (array_key_exists($field, $data)) {
throw ValidationException::withMessages([
$field => [__('api.catalog.bundle_field_forbidden', ['field' => $field])],
]);
}
}
}
/**
* @param array<int, mixed> $images
*/
private function attachImages(
CatalogItem|Variant $owner,
array $images,
string $validationKey,
): void {
foreach (array_values($images) as $order => $image) {
$attachment = $this->resolveAttachment($image, "{$validationKey}.{$order}");
$owner->attachments()->attach($attachment->id, ['orden' => $order]);
}
}
private function resolveAttachment(mixed $image, string $validationKey): Attachment
{
if (is_string($image) && Str::isUuid($image)) {
$attachment = Attachment::query()->where('key', $image)->first();
if ($attachment === null) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.attachment_not_found')],
]);
}
return $attachment;
}
return $this->attachmentService->store($image, 'catalog-items');
}
/**
* @param array<int, string> $attributeCodes
* @param array<int, string> $multiSelectAttributeCodes
* @param array<int, string> $hiddenAttributeCodes
* @param array<string, string> $ticketAttributeLabels
* @return array<string, ItemAttribute>
*/
private function createItemAttributes(
CatalogItem $catalogItem,
array $attributeCodes,
array $multiSelectAttributeCodes = [],
array $hiddenAttributeCodes = [],
array $ticketAttributeLabels = [],
): array {
$itemAttributes = [];
$attributeCodes = array_values(array_unique($attributeCodes));
$attributes = Attribute::query()
->where('tenant_codigo', $catalogItem->tenant_code)
->whereIn('codigo', $attributeCodes)
->get()
->keyBy('codigo');
foreach ($attributeCodes as $attributeCode) {
$attribute = $attributes->get($attributeCode);
if ($attribute === null) {
throw ValidationException::withMessages([
'attribute_codes' => [
__('api.catalog.attribute_not_found', ['attribute' => $attributeCode]),
],
]);
}
$itemAttribute = $catalogItem->itemAttributes()->create([
'attribute_id' => $attribute->id,
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
'show_in_selector' => ! in_array($attributeCode, $hiddenAttributeCodes, true),
'ticket_label' => $ticketAttributeLabels[$attributeCode] ?? null,
]);
$itemAttributes[$attributeCode] = $itemAttribute;
}
return $itemAttributes;
}
/**
* @param array<string, mixed> $data
* @param array<string, ItemAttribute> $itemAttributes
*/
private function createVariant(
CatalogItem $catalogItem,
array $data,
array $itemAttributes,
int $index,
): Variant {
unset($data['images']);
if (
array_key_exists('inventory_id', $data)
|| array_key_exists('reserved_stock', $data)
|| array_key_exists('sold_units', $data)
) {
throw ValidationException::withMessages([
"variants.{$index}.inventory" => [
__('api.catalog.managed_inventory_fields'),
],
]);
}
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$eventDateIds = collect($data['event_date_ids'] ?? [])
->when(
isset($data['event_date_id']),
fn ($ids) => $ids->push($data['event_date_id']),
)
->filter(fn ($id): bool => $id !== null)
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
$eventDateItemAttribute = $itemAttributes['event_date'] ?? null;
if ($eventDateItemAttribute !== null && (
$eventDateIds->isEmpty()
|| (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1)
)) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_ids" => [
$eventDateItemAttribute->allow_multi_select
? __('api.catalog.event_date_selection_required')
: __('api.catalog.single_event_date_required'),
],
]);
}
$validEventDateCount = EventDate::query()
->whereKey($eventDateIds)
->where('tenant_code', $catalogItem->tenant_code)
->count();
if ($validEventDateCount !== $eventDateIds->count()) {
throw ValidationException::withMessages([
"variants.{$index}.event_date_ids" => [
__('api.catalog.event_date_wrong_tenant'),
],
]);
}
$variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id,
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
'descripcion' => $data['descripcion'] ?? null,
'precio' => $data['precio'] ?? null,
]);
$variant->eventDates()->sync($eventDateIds->all());
$variant->setRelation('catalogItem', $catalogItem);
foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
if ($itemAttribute === null) {
throw ValidationException::withMessages([
"variants.{$index}.values.{$attributeCode}" => [
__('api.catalog.attribute_not_on_item'),
],
]);
}
foreach ($this->validatedVariantValues(
$itemAttribute,
$value,
"variants.{$index}.values.{$attributeCode}",
) as $validatedValue) {
$variant->definitions()->create([
'item_attribute_id' => $itemAttribute->id,
'value' => $validatedValue,
]);
}
}
return $variant;
}
/**
* @param array<int, array<string, mixed>> $variants
* @param array<int, string> $attributeCodes
*/
private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void
{
$seen = [];
$attributeCodes = array_values(array_unique($attributeCodes));
sort($attributeCodes);
foreach (array_values($variants) as $index => $variant) {
$eventDateIds = collect($variant['event_date_ids'] ?? [])
->when(
isset($variant['event_date_id']),
fn ($ids) => $ids->push($variant['event_date_id']),
)
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values()
->implode(',');
$combination = [$eventDateIds];
foreach ($attributeCodes as $attributeCode) {
$values = $variant['values'][$attributeCode] ?? '';
$normalizedValues = collect(is_array($values) ? $values : [$values])
->map(fn ($value): string => $this->normalizeVariantValue((string) $value))
->unique()
->sort()
->values()
->implode(',');
$combination[] = $normalizedValues;
}
$key = implode('|', $combination);
if (isset($seen[$key])) {
throw ValidationException::withMessages([
"variants.{$index}" => [__('api.catalog.duplicate_variant_combination')],
]);
}
$seen[$key] = true;
}
}
/** @return array<int, string> */
private function validatedVariantValues(
ItemAttribute $itemAttribute,
mixed $value,
string $validationKey,
): array {
$values = is_array($value) ? array_values($value) : [$value];
if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) {
throw ValidationException::withMessages([
$validationKey => [
$itemAttribute->allow_multi_select
? __('api.catalog.multi_value_required')
: __('api.catalog.single_value_required'),
],
]);
}
if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.selected_values_non_empty')],
]);
}
$normalizedValues = collect($values)
->map(fn (string $item): string => $this->normalizeVariantValue($item));
if ($normalizedValues->unique()->count() !== count($values)) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.selected_values_distinct')],
]);
}
$attribute = $itemAttribute->attribute;
if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) {
$optionsByNormalizedValue = $attribute->options
->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value));
$resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue));
if ($resolvedOptions->contains(null)) {
throw ValidationException::withMessages([
$validationKey => [__('api.catalog.invalid_attribute_options')],
]);
}
return $resolvedOptions->pluck('value')->all();
}
return collect($values)->map(fn (string $item): string => trim($item))->all();
}
private function normalizeVariantValue(string $value): string
{
return Str::ascii(mb_strtolower(trim($value)));
}
/**
* @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $variants
*/
private function validateInventoryStrategy(
array $data,
array $variants,
bool $hasVariants,
bool $hasDirectStock,
): void {
if ($hasVariants && $variants === []) {
throw ValidationException::withMessages([
'variants' => [
__('api.catalog.variants_required'),
],
]);
}
if (! $hasVariants && $variants !== []) {
throw ValidationException::withMessages([
'variants' => [
__('api.catalog.variants_forbidden'),
],
]);
}
if ($hasVariants && $hasDirectStock) {
throw ValidationException::withMessages([
'real_stock' => [
__('api.catalog.direct_inventory_forbidden'),
],
]);
}
if (
array_key_exists('inventory_id', $data)
|| array_key_exists('reserved_stock', $data)
|| array_key_exists('sold_units', $data)
) {
throw ValidationException::withMessages([
'inventory' => [
__('api.catalog.managed_inventory_fields'),
],
]);
}
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class ExpireStockReservationsService
{
private const BATCH_SIZE = 500;
public function __construct(
private readonly ReleaseCheckoutService $purchases,
private readonly StockReservationService $reservations,
) {}
/**
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
*/
public function expireOverdue(): array
{
$summary = [
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 0,
'failed' => 0,
];
$lastReservationId = 0;
do {
$reservationIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('id', '>', $lastReservationId)
->orderBy('id')
->limit(self::BATCH_SIZE)
->pluck('id');
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
try {
$owner = $this->expireReservation($lastReservationId);
if ($owner !== null) {
$summary[$owner]++;
}
} catch (Throwable $exception) {
$summary['failed']++;
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
'command' => 'reservations:expire',
'stock_reservation_id' => $lastReservationId,
'exception' => $exception,
]);
}
}
} while ($reservationIds->count() === self::BATCH_SIZE);
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $summary['purchases'],
'expired_cart_reservations' => $summary['cart_reservations'],
'expired_orphan_reservations' => $summary['orphan_reservations'],
'failed_reservations' => $summary['failed'],
'total_expired' => $summary['purchases']
+ $summary['cart_reservations']
+ $summary['orphan_reservations'],
]);
return $summary;
}
public function expireIfOverdue(int $reservationId): bool
{
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->find($reservationId);
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
return true;
}
if (! $this->isOverdue($reservation)) {
return false;
}
return $this->expireReservation($reservationId) !== null;
}
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
private function expireReservation(int $reservationId): ?string
{
$purchaseId = Purchase::query()
->where('stock_reservation_id', $reservationId)
->value('id');
if ($purchaseId !== null) {
return $this->expirePurchase((int) $purchaseId);
}
$cartId = Cart::query()
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->value('id');
if ($cartId !== null) {
return $this->expireCart((int) $cartId, $reservationId);
}
return $this->expireOrphan($reservationId);
}
/** @return 'purchases'|null */
private function expirePurchase(int $purchaseId): ?string
{
/** @var Purchase|null $purchase */
$purchase = Purchase::query()->find($purchaseId);
if ($purchase === null) {
return null;
}
$purchase = $this->purchases->expire($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED) {
return 'purchases';
}
$reservation = $purchase->stockReservation;
if ($this->isOverdue($reservation)) {
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
}
return null;
}
/** @return 'cart_reservations'|null */
private function expireCart(int $cartId, int $reservationId): ?string
{
return DB::transaction(function () use ($cartId, $reservationId): ?string {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($cartId)
->where('current_stock_reservation_id', $reservationId)
->where('status', Cart::STATUS_ACTIVE)
->lockForUpdate()
->first();
if ($cart === null) {
return null;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
if (! $this->isOverdue($reservation)) {
return null;
}
$this->reservations->expire($reservation);
$cart->update(['status' => Cart::STATUS_EXPIRED]);
return 'cart_reservations';
});
}
/** @return 'orphan_reservations'|null */
private function expireOrphan(int $reservationId): ?string
{
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->find($reservationId);
if (! $this->isOverdue($reservation)) {
return null;
}
$this->reservations->expire($reservation);
return 'orphan_reservations';
}
private function isOverdue(?StockReservation $reservation): bool
{
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
class FeaturedGroupService
{
private const ITEMS_PER_PAGE = 12;
/** @return array<array-key, mixed> */
public function __construct(
private readonly CatalogItemAllowanceService $allowances,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
{
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
$query = $this->itemsQuery($featuredGroup);
if ($featuredGroup->group_layout === GroupLayout::Single) {
$query->limit(1);
}
$items = $query->get();
$this->attachGroup($items, $featuredGroup);
$this->allowances->attach($items, $userId);
return CatalogFeaturedItemResource::collection($items)->resolve();
}
$paginator = $this->paginateItems($featuredGroup, $page);
$this->attachGroup($paginator->getCollection(), $featuredGroup);
$this->allowances->attach($paginator->getCollection(), $userId);
return CatalogFeaturedItemResource::collection($paginator)
->response()
->getData(true);
}
/** @return Builder<CatalogItem> */
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereAvailable()
->where(function (Builder $query): void {
$query
->whereDoesntHave('category')
->orWhereHas(
'category',
fn (Builder $categoryQuery): Builder => $categoryQuery
->where('is_enabled', true),
);
})
->with([
'inventory',
'attachments',
'itemAttributes.attribute',
'variants.inventory',
'variants.attachments',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
return match ($featuredGroup->source_type) {
FeaturedGroupSource::Manual => $query
->select('catalog_items.*')
->join('featured_items', 'featured_items.catalog_item_id', '=', 'catalog_items.id')
->where('featured_items.featured_group_id', $featuredGroup->id)
->orderBy('featured_items.order')
->orderBy('featured_items.id'),
FeaturedGroupSource::Category => $query
->where('catalog_items.category_id', $featuredGroup->category_id)
->orderBy('catalog_items.id'),
FeaturedGroupSource::All => $query
->orderBy('catalog_items.group_order')
->orderBy('catalog_items.id'),
};
}
private function paginateItems(
FeaturedGroup $featuredGroup,
int $page,
): LengthAwarePaginator {
$paginator = $this->itemsQuery($featuredGroup)->paginate(
perPage: self::ITEMS_PER_PAGE,
pageName: 'page',
page: $page,
);
return $paginator->withPath(route('catalog.featured-groups.items.index', [
'tenant' => $featuredGroup->tenant_code,
'featuredGroup' => $featuredGroup->id,
]));
}
private function attachGroup(iterable $items, FeaturedGroup $featuredGroup): void
{
foreach ($items as $item) {
$item->setRelation('featuredGroup', $featuredGroup);
}
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class OnTicketFeaturedGroupService
{
/** @return Collection<int, FeaturedGroup> */
public function forTenant(Tenant $tenant): Collection
{
return FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->where('source_type', FeaturedGroupSource::Category)
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
->with('category')
->orderBy('group_order')
->orderBy('id')
->get();
}
/** @param array{category_name: string, is_featured: bool} $data */
public function create(Tenant $tenant, array $data): FeaturedGroup
{
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
$category = $tenant->categories()->create([
'nombre' => $data['category_name'],
]);
$featuredGroup = FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'source_type' => FeaturedGroupSource::Category,
'category_id' => $category->id,
'product_layout' => $this->productLayout($data['is_featured']),
'group_layout' => GroupLayout::Paginated,
'group_name' => $data['category_name'],
'group_order' => $this->nextOrder($tenant),
]);
return $featuredGroup->setRelation('category', $category);
});
}
/** @param array{category_name: string, is_featured: bool} $data */
public function update(
Tenant $tenant,
FeaturedGroup $featuredGroup,
array $data,
): FeaturedGroup {
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
$featuredGroup = FeaturedGroup::query()
->whereKey($featuredGroup->getKey())
->where('tenant_code', $tenant->codigo)
->where('source_type', FeaturedGroupSource::Category)
->lockForUpdate()
->firstOrFail();
$category = Category::query()
->whereKey($featuredGroup->category_id)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->firstOrFail();
$category->update(['nombre' => $data['category_name']]);
$featuredGroup->update([
'group_name' => $data['category_name'],
'product_layout' => $this->productLayout($data['is_featured']),
'group_layout' => GroupLayout::Paginated,
]);
return $featuredGroup->setRelation('category', $category);
});
}
private function productLayout(bool $isFeatured): ProductLayout
{
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
}
private function nextOrder(Tenant $tenant): int
{
$maximumOrder = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->max('group_order');
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
}
}

View File

@@ -0,0 +1,497 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
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_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
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 syncCart(Cart $cart): ?StockReservation
{
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) {
$this->assertUsableCartReservation($reservation);
}
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 = 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 attachToPurchase(
Cart $cart,
Purchase $purchase,
Carbon $expiresAt,
): StockReservation {
return DB::transaction(function () use ($cart, $purchase, $expiresAt): 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);
$this->assertUsableCartReservation($reservation);
$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' => $expiresAt]);
$purchase->stock_reservation_id = $reservation->getKey();
$cart->current_stock_reservation_id = $reservation->getKey();
return $reservation->fresh('lines');
});
}
public function commit(Purchase $purchase): void
{
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.');
}
/** @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.');
}
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
throw new StockReservationExpiredException;
}
$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,
'expires_at' => null,
'committed_at' => now(),
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
]);
});
}
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 returnToCart(Purchase $purchase, Cart $cart): StockReservation
{
return DB::transaction(function () use ($purchase, $cart): StockReservation {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($purchase->stock_reservation_id === null
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
$this->assertUsableCartReservation($reservation);
$purchase->update(['stock_reservation_id' => null]);
$cart->update(['current_purchase_id' => null]);
$reservation->update(['expires_at' => $this->expiration()]);
return $reservation->fresh('lines');
});
}
public function assertCartReservationUsable(Cart $cart): void
{
DB::transaction(function () use ($cart): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($cart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
});
}
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
{
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 clearExpirationForReview(Purchase $purchase): void
{
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.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
throw new StockReservationExpiredException;
}
$reservation->update(['expires_at' => null]);
});
}
/**
* @param Collection<int, CartItem> $items
* @return array<int, array{quantity: int, tracks_inventory: bool}>
*/
private function requirementsForItems(Collection $items): array
{
$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();
}
/**
* @param Collection<int, StockReservationLine> $lines
* @return Collection<int, Inventory>
*/
private function lockInventories(Collection $lines): Collection
{
return Inventory::query()
->whereKey($lines->pluck('inventory_id'))
->orderBy('id')
->lockForUpdate()
->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,
]);
if ($status === StockReservation::STATUS_RELEASED) {
Cart::query()
->where('current_stock_reservation_id', $reservation->getKey())
->update(['current_stock_reservation_id' => null]);
}
}
private function assertUsableCartReservation(StockReservation $reservation): void
{
if ($reservation->status === StockReservation::STATUS_EXPIRED
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
throw new StockReservationExpiredException;
}
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null) {
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
}
}
private function expiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
);
}
}

View File

@@ -0,0 +1,263 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\BundleComponent;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\EventDate;
use Illuminate\Support\Collection;
class VariantReplacementService
{
/** @return Collection<int, Variant> */
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($source): void {
$query->where('event_date_id', $source->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $source->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
$destinationDateIds = $variant->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id === (int) $source->getKey()
? (int) $destination->getKey()
: (int) $id)
->unique()
->sort()
->values();
$replacement = $this->findEquivalent($variant, $destinationDateIds)
?? $this->cloneWithDates($variant, $destinationDateIds);
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
return $replacement;
})->values();
}
public function disableForSuspension(EventDate $eventDate): void
{
$variants = Variant::query()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->where(function ($query) use ($eventDate): void {
$query->where('event_date_id', $eventDate->getKey())
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
->where('event_dates.id', $eventDate->getKey()));
})
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
->orderBy('id')
->lockForUpdate()
->get();
foreach ($variants as $variant) {
$remainingDateIds = $variant->selectedEventDates()
->filter(fn (EventDate $date): bool => $date->suspended_at === null
&& $date->rescheduled_to_event_date_id === null)
->pluck('id')
->map(fn ($id): int => (int) $id)
->unique()
->sort()
->values();
if ($remainingDateIds->isEmpty()) {
$variant->update(['sales_disabled_at' => now()]);
continue;
}
$replacement = $this->findEquivalent($variant, $remainingDateIds);
if ($replacement === null) {
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
} else {
$this->mergeInventoryInto($variant, $replacement);
}
$variant->update([
'replaced_by_variant_id' => $replacement->getKey(),
'sales_disabled_at' => now(),
]);
BundleComponent::query()
->where('component_variant_id', $variant->getKey())
->update(['component_variant_id' => $replacement->getKey()]);
}
}
/** @param Collection<int, int> $eventDateIds */
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
{
$definitionSignature = $this->definitionSignature($source);
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
return Variant::query()
->where('catalog_item_id', $source->catalog_item_id)
->whereKeyNot($source->getKey())
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->with(['eventDates', 'eventDate', 'definitions'])
->orderBy('id')
->lockForUpdate()
->get()
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
&& $candidate->selectedEventDates()
->pluck('id')
->map(fn ($id): int => (int) $id)
->sort()
->values()
->all() === $dateSignature
);
}
/** @param Collection<int, int> $eventDateIds */
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
{
$replacementInventory = $this->cloneInventory($source);
$replacement = $source->replicate([
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
]);
$replacement->inventory_id = $replacementInventory->getKey();
$replacement->event_date_id = $eventDateIds->count() === 1
? $eventDateIds->first()
: null;
$replacement->save();
$replacement->eventDates()->sync($eventDateIds->all());
$replacement->definitions()->createMany(
$source->definitions
->map(fn ($definition): array => [
'item_attribute_id' => $definition->item_attribute_id,
'value' => $definition->value,
])
->all(),
);
$attachments = $source->allAttachments
->mapWithKeys(fn ($attachment): array => [
$attachment->getKey() => [
'orden' => $attachment->pivot->orden,
'is_enabled' => $attachment->pivot->is_enabled,
],
])
->all();
$replacement->allAttachments()->sync($attachments);
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
}
private function cloneInventory(Variant $source): Inventory
{
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
$sourceInventory = Inventory::query()
->whereKey($source->inventory_id)
->lockForUpdate()
->firstOrFail();
$reservedStock = (int) $activeLines->sum('quantity');
if ($sourceInventory->reserved_stock !== $reservedStock) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$replacementInventory = Inventory::query()->create([
'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $sourceInventory->refunded_units,
'reserved_stock' => $reservedStock,
'real_stock' => $sourceInventory->real_stock,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $replacementInventory->getKey()]);
}
$sourceInventory->update(['reserved_stock' => 0]);
return $replacementInventory;
}
private function mergeInventoryInto(Variant $source, Variant $destination): void
{
if ($source->inventory_id === $destination->inventory_id) {
return;
}
$inventories = Inventory::query()
->whereKey([$source->inventory_id, $destination->inventory_id])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$sourceInventory = $inventories->get($source->inventory_id);
$destinationInventory = $inventories->get($destination->inventory_id);
if ($sourceInventory === null || $destinationInventory === null) {
throw new \LogicException('No se encontró el inventario de una variante.');
}
$activeLines = StockReservationLine::query()
->where('inventory_id', $source->inventory_id)
->whereHas('reservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->orderBy('id')
->lockForUpdate()
->get();
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
}
$destinationInventory->update([
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
]);
if ($activeLines->isNotEmpty()) {
StockReservationLine::query()
->whereKey($activeLines->modelKeys())
->update(['inventory_id' => $destinationInventory->getKey()]);
}
$sourceInventory->update([
'real_stock' => 0,
'reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
}
/** @return list<string> */
private function definitionSignature(Variant $variant): array
{
return $variant->definitions
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
->sort()
->values()
->all();
}
}

View File

@@ -0,0 +1,225 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Support\Collection;
class VariantSelectionService
{
/**
* @param array<string, mixed> $selectedValues
* @return array<string, mixed>
*/
public function options(
CatalogItem $catalogItem,
array $selectedValues,
?int $includedVariantId = null,
): array {
$catalogItem->load([
'itemAttributes.attribute',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.inventory',
'variants.eventDate',
'variants.eventDates',
'variants.definitions' => fn ($query) => $query->orderBy('id'),
'variants.definitions.itemAttribute.attribute.options',
]);
$variants = $catalogItem->visibleVariants($includedVariantId)
->values();
$normalizedSelections = collect($selectedValues)
->mapWithKeys(fn ($value, string $key): array => [$key => $this->normalizeValue($value)])
->filter(fn ($value): bool => $value !== null && $value !== '' && $value !== [])
->all();
$matchingVariants = $variants
->filter(fn (Variant $variant): bool => $this->matches($variant, $normalizedSelections))
->values();
$attributeKeys = $this->attributeKeys($catalogItem, $catalogItem->variants->values());
$isComplete = $attributeKeys->isNotEmpty()
&& $attributeKeys->every(fn (string $key): bool => array_key_exists($key, $normalizedSelections));
$resolvedVariant = $isComplete && $matchingVariants->count() === 1
? $matchingVariants->first()
: null;
return [
'variants' => $variants
->map(fn (Variant $variant): array => $this->variantData($catalogItem, $variant))
->values()
->all(),
'selectors' => $this->selectors(
$catalogItem,
$variants,
$attributeKeys,
$matchingVariants->isEmpty() ? [] : $normalizedSelections,
),
'selected_values' => (object) ($matchingVariants->isEmpty() ? [] : $normalizedSelections),
'resolved_variant' => $resolvedVariant === null
? null
: $this->variantData($catalogItem, $resolvedVariant),
'valid' => $matchingVariants->isNotEmpty(),
'available_variant_count' => $variants->count(),
'matching_variant_count' => $matchingVariants->count(),
'price_range' => $this->priceRange($catalogItem, $variants),
];
}
/** @param array<string, mixed> $selectedValues */
private function matches(Variant $variant, array $selectedValues): bool
{
$variantValues = $variant->selectionValues();
foreach ($selectedValues as $key => $selectedValue) {
if (! $variantValues->has($key)
|| $this->valueKey($variantValues->get($key)) !== $this->valueKey($selectedValue)) {
return false;
}
}
return true;
}
/** @param Collection<int, Variant> $variants */
private function attributeKeys(CatalogItem $catalogItem, Collection $variants): Collection
{
return $variants
->flatMap(fn (Variant $variant): array => $variant
->selectorOptions($catalogItem->itemAttributes)
->keys()
->all())
->unique()
->values();
}
/**
* @param Collection<int, Variant> $variants
* @param Collection<int, string> $attributeKeys
* @param array<string, mixed> $selectedValues
* @return list<array<string, mixed>>
*/
private function selectors(
CatalogItem $catalogItem,
Collection $variants,
Collection $attributeKeys,
array $selectedValues,
): array {
return $attributeKeys
->map(function (string $key, int $index) use (
$catalogItem,
$variants,
$attributeKeys,
$selectedValues,
): array {
$previousKeys = $attributeKeys->take($index);
$previousSelections = collect($selectedValues)
->only($previousKeys->all())
->all();
$compatibleVariants = $variants
->filter(fn (Variant $variant): bool => $this->matches($variant, $previousSelections));
return [
'key' => $key,
'label' => $this->attributeLabel($catalogItem, $key),
'options' => $this->optionsFor($catalogItem, $compatibleVariants, $key),
'enabled' => $index === 0 || $previousKeys->every(
fn (string $previousKey): bool => array_key_exists($previousKey, $selectedValues),
),
];
})
->values()
->all();
}
/**
* @param Collection<int, Variant> $variants
* @return list<mixed>
*/
private function optionsFor(CatalogItem $catalogItem, Collection $variants, string $key): array
{
$options = [];
$seen = [];
foreach ($variants as $variant) {
$option = $variant->selectorOptions($catalogItem->itemAttributes)->get($key);
if ($option === null || $option === '') {
continue;
}
$optionKey = $this->valueKey($option);
if (isset($seen[$optionKey])) {
continue;
}
$seen[$optionKey] = true;
$options[] = $option;
}
return $options;
}
private function attributeLabel(CatalogItem $catalogItem, string $key): string
{
if ($key === 'event_date') {
return 'Fecha';
}
return $catalogItem->itemAttributes
->first(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo === $key)
?->attribute
?->nombre ?? str($key)->headline()->toString();
}
/** @param Collection<int, Variant> $variants */
private function priceRange(CatalogItem $catalogItem, Collection $variants): array
{
$prices = $variants
->map(fn (Variant $variant): float => $variant->getPrice())
->whenEmpty(fn (Collection $prices): Collection => $prices->push($catalogItem->getPrice()));
return [
'minimum' => number_format((float) $prices->min(), 2, '.', ''),
'maximum' => number_format((float) $prices->max(), 2, '.', ''),
];
}
/** @return array<string, mixed> */
private function variantData(CatalogItem $catalogItem, Variant $variant): array
{
return [
'id' => $variant->id,
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
}
private function normalizeValue(mixed $value): mixed
{
if (is_array($value) && array_key_exists('value', $value)) {
return (string) $value['value'];
}
if (is_array($value)) {
return array_map(fn ($item) => $this->normalizeValue($item), $value);
}
return is_scalar($value) ? (string) $value : null;
}
private function valueKey(mixed $value): string
{
$normalized = $this->normalizeValue($value);
if (is_array($normalized)) {
sort($normalized);
}
return json_encode($normalized, JSON_THROW_ON_ERROR);
}
}

View File

@@ -0,0 +1,36 @@
# Dominio Catalog
## Propósito
Modela y publica la oferta comercial del tenant: productos, variantes, categorías, marcas, atributos, inventario, bundles y grupos destacados.
## Modelo
- `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` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. 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.
## Servicios
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
## Endpoints de tienda
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
## Endpoints administrativos
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.

View File

@@ -0,0 +1,15 @@
<?php
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
->name('adminapp.featured-groups.index');
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
->name('adminapp.featured-groups.store');
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
->name('adminapp.featured-groups.update');
});

View File

@@ -0,0 +1,19 @@
<?php
use App\Domains\Catalog\Controllers\CatalogController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('catalog', [CatalogController::class, 'index']);
Route::get('catalog/featured-groups/{featuredGroup}/items', [CatalogController::class, 'featuredGroupItems'])
->name('catalog.featured-groups.items.index');
Route::get('catalog-items', [CatalogController::class, 'search'])
->name('catalog-items.index');
Route::get('categories/{category}', [CatalogController::class, 'category'])
->name('categories.show');
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
Route::post('catalog-items', [CatalogController::class, 'store']);
});
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,285 @@
<?php
namespace App\Domains\Purchase\Controllers;
use App\Domains\Integration\Services\TelepagosIntegrationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Purchase\Requests\StartCheckoutRequest;
use App\Domains\Purchase\Requests\UpdatePurchaseCustomerRequest;
use App\Domains\Purchase\Resources\PurchaseResource;
use App\Domains\Purchase\Services\Checkout\PurchaseResponseLoader;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
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()
->with('stockReservation')
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id)
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
->orderBy('status')
->latest()
->paginateFromRequest()
)->response();
}
public function startCheckout(
StartCheckoutRequest $request,
Tenant $tenant,
CheckoutService $checkoutService,
): JsonResponse {
$data = $request->validated();
$purchase = $checkoutService->startCheckout(
$tenant,
$request->user()->id,
$data
);
return PurchaseResource::make($purchase)->response()->setStatusCode(201);
}
public function show(
Request $request,
Tenant $tenant,
Purchase $compra,
PurchaseResponseLoader $responses,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$compra->loadMissing('items')->loadCount('tickets');
$responses->load($compra);
return PurchaseResource::make($compra);
}
public function updateCustomerData(
UpdatePurchaseCustomerRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->updateCustomerData($compra, $request->validated()),
);
}
public function paymentIntent(
PaymentIntentRequest $request,
Tenant $tenant,
Purchase $compra,
PurchaseStateGuard $purchaseState,
): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
$method = $request->validated('method');
$transferPayerDni = $method === 'transfer'
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
: null;
$updated = DB::transaction(function () use (
$compra,
$method,
$purchaseState,
$transferPayerDni,
): bool {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->whereKey($compra->getKey())
->lockForUpdate()
->first();
if ($purchase === null) {
return false;
}
$purchaseState->assertNotExpired($purchase);
if (
! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)
) {
return false;
}
$purchaseState->lockCurrentCart($purchase);
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
];
if ($transferPayerDni !== null) {
$purchaseUpdate['transfer_payer_dni'] = $transferPayerDni;
}
$purchase->update($purchaseUpdate);
return true;
});
if (! $updated) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_payment'),
]);
}
$compra->refresh();
$totalAmount = (float) $compra->total;
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo);
try {
$accountInfo = $telepagosService->getAccountInfo();
Log::channel('telepagos')->info('Telepagos account information retrieved.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
]);
return response()->json([
'payment_method' => 'transfer',
'transfer_data' => [
'titular' => $accountInfo['holder'] ?? null,
'cvu' => $accountInfo['cvu'] ?? null,
'alias' => $accountInfo['alias'] ?? null,
'cuit' => $accountInfo['cuit'] ?? null,
'entidad' => $accountInfo['entity'] ?? 'Telepagos S.A.',
],
]);
} catch (\Exception $e) {
Log::channel('telepagos')->error('Unable to retrieve TelePagos account information.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
'error' => $e->getMessage(),
]);
return response()->json([
'code' => 'integration.account_info_failed',
'message' => __('api.integration.account_info_failed'),
], 500);
}
}
if ($method === 'qr') {
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forTenant($tenant->codigo);
try {
Log::channel('telepagos')->info('Generating Telepagos QR.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
'amount' => $totalAmount,
]);
$qrResponse = $telepagosService->generateQr(
$totalAmount,
'Compra',
"Compra #{$compra->id}"
);
$telepagosQr = $compra->telepagosQr()->create([
'qr_order_id' => (string) ($qrResponse['qr_order_id'] ?? ''),
'qr_code' => $qrResponse['qr_code'] ?? '',
]);
Log::channel('telepagos')->info('Telepagos QR generated successfully.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
'qr_order_id' => $telepagosQr->qr_order_id,
]);
} catch (\Exception $e) {
Log::channel('telepagos')->error('Unable to generate TelePagos QR code.', [
'tenant_code' => $tenant->codigo,
'purchase_id' => $compra->id,
'error' => $e->getMessage(),
]);
return response()->json([
'code' => 'integration.qr_generation_failed',
'message' => __('api.integration.qr_generation_failed'),
], 500);
}
return response()->json([
'payment_method' => 'qr',
'qr_data' => [
'qr_code' => $telepagosQr->qr_code,
'qr_order_id' => $telepagosQr->qr_order_id,
],
]);
}
return response()->json([
'code' => 'integration.invalid_payment_method',
'message' => __('api.integration.invalid_payment_method'),
], 400);
}
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->completePurchase($compra)
);
}
public function submitForReview(
Request $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
): PurchaseResource {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->submitForReview($compra),
);
}
public function cancel(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
{
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
return PurchaseResource::make(
$checkoutService->cancelPurchase($compra)
);
}
protected function resolveScopedPurchase(Tenant $tenant, int $userId, Purchase $purchase): Purchase
{
if ($purchase->tenant_codigo !== $tenant->codigo || $purchase->user_id !== $userId) {
throw new NotFoundHttpException(__('api.errors.not_found'));
}
return $purchase;
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Purchase\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PurchasePaid
{
use Dispatchable, SerializesModels;
public function __construct(public readonly int $purchaseId) {}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Purchase\Exceptions;
use RuntimeException;
class InsufficientStockException extends RuntimeException
{
/**
* @param array<int, array{
* index: int,
* catalog_item_id: int,
* variant_id: int|null,
* requested_quantity: int,
* available_quantity: int,
* message: string
* }> $unavailableItems
*/
public function __construct(
public readonly array $unavailableItems,
) {
parent::__construct(
collect($unavailableItems)->pluck('message')->unique()->implode(' '),
);
}
/** @return array<string, array<int, string>> */
public function errors(): array
{
return collect($this->unavailableItems)
->mapWithKeys(fn (array $item): array => [
"direct_items.{$item['index']}.cantidad" => [$item['message']],
])
->all();
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Purchase\Exceptions;
use RuntimeException;
class PurchaseExpiredException extends RuntimeException
{
public function __construct()
{
parent::__construct(__('api.purchase.expired'));
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Purchase\Exceptions;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Validation\ValidationException;
class PurchaseLimitExceededException extends ValidationException
{
public readonly int $catalogItemId;
public readonly string $catalogItemName;
public readonly int $maximumAddableQuantity;
public function __construct(CatalogItem $catalogItem, int $maximumAddableQuantity, string $field)
{
$this->catalogItemId = (int) $catalogItem->getKey();
$this->catalogItemName = $catalogItem->nombre;
$this->maximumAddableQuantity = $maximumAddableQuantity;
parent::__construct(validator([], []));
$message = trans_choice('api.purchase_limit.exceeded', $maximumAddableQuantity, [
'max' => $maximumAddableQuantity,
'product' => $this->catalogItemName,
]);
$this->message = $message;
$this->validator->errors()->add($field, $message);
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
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;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'stock_reservation_id',
'tenant_codigo',
'user_id',
'status',
'payment_method',
'total',
'dni',
'transfer_payer_dni',
'telefono',
'nombre_apellido',
'email',
])]
class Purchase extends Model
{
use HasFactory, LogsValueChanges;
public const STATUS_CREATED = 'created';
public const STATUS_PENDING_PAYMENT = 'pending_payment';
public const STATUS_IN_REVIEW = 'in_review';
public const STATUS_PAID = 'paid';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REJECTED = 'rejected';
public const STATUS_EXPIRED = 'expired';
public const STATUS_SUPERSEDED = 'superseded';
public const ADMIN_STATUS_INCOMPLETE = 'incomplete';
public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment';
public const ADMIN_STATUS_CONFIRMED = 'confirmed';
public const ADMIN_STATUS_CANCELLED = 'cancelled';
/** @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,
];
}
/**
* @return array<string, array{name: string, statuses: list<string>}>
*/
public static function adminStatuses(): array
{
return [
self::ADMIN_STATUS_INCOMPLETE => [
'name' => 'Por completar datos',
'statuses' => [self::STATUS_CREATED],
],
self::ADMIN_STATUS_AWAITING_PAYMENT => [
'name' => 'Esperando pago',
'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW],
],
self::ADMIN_STATUS_CONFIRMED => [
'name' => 'Confirmado',
'statuses' => [self::STATUS_PAID],
],
self::ADMIN_STATUS_CANCELLED => [
'name' => 'Anulado',
'statuses' => [
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
self::STATUS_SUPERSEDED,
],
],
];
}
/** @return list<string> */
public static function adminStatusCodes(): array
{
return array_keys(self::adminStatuses());
}
/** @return list<string> */
public static function realStatusesForAdminStatus(string $adminStatus): array
{
return self::adminStatuses()[$adminStatus]['statuses'] ?? [];
}
public static function adminStatusFor(string $realStatus): ?string
{
foreach (self::adminStatuses() as $adminStatus => $definition) {
if (in_array($realStatus, $definition['statuses'], true)) {
return $adminStatus;
}
}
return null;
}
public static function adminStatusNameFor(string $realStatus): ?string
{
$adminStatus = self::adminStatusFor($realStatus);
return $adminStatus === null
? null
: self::adminStatuses()[$adminStatus]['name'];
}
protected $table = 'compras';
/** @var array<int, string> */
protected array $loggedAttributes = [
'status',
];
protected function casts(): array
{
return [
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'total' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return BelongsTo<Cart, $this>
*/
public function cart(): BelongsTo
{
return $this->belongsTo(Cart::class, 'cart_id');
}
/**
* @return HasMany<PurchaseItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(PurchaseItem::class, 'compra_id');
}
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */
public function tickets(): HasManyThrough
{
return $this->hasManyThrough(
Ticket::class,
PurchaseItem::class,
'compra_id',
'source_purchase_item_id',
);
}
/** @return BelongsTo<StockReservation, $this> */
public function stockReservation(): BelongsTo
{
return $this->belongsTo(StockReservation::class);
}
/**
* @return HasOne<TelepagosQr, $this>
*/
public function telepagosQr()
{
return $this->hasOne(TelepagosQr::class, 'compra_id');
}
/**
* @return HasMany<TelepagosPayment, $this>
*/
public function telepagosPayments(): HasMany
{
return $this->hasMany(TelepagosPayment::class, 'compra_id');
}
/** @return HasMany<TelepagosPaymentCandidate, $this> */
public function telepagosPaymentCandidates(): HasMany
{
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
}
public function getTotalAmount(): float
{
if ($this->total !== null) {
return (float) $this->total;
}
return $this->calculateCurrentTotalAmount();
}
public function calculateCurrentTotalAmount(): float
{
if ($this->relationLoaded('items') && $this->getRelation('items')->isNotEmpty()) {
return (float) $this->getRelation('items')->sum('total');
}
return (float) $this->items()->sum('total');
}
protected function valueChangeTenantCode(): string
{
return $this->tenant_codigo;
}
public function markAsPendingPayment(): void
{
$this->update([
'status' => self::STATUS_PENDING_PAYMENT,
]);
}
public function markAsPaid(): void
{
DB::transaction(function (): void {
$currentStatus = self::query()
->whereKey($this->getKey())
->lockForUpdate()
->value('status');
if ($currentStatus === self::STATUS_PAID) {
$this->status = self::STATUS_PAID;
return;
}
$this->update([
'status' => self::STATUS_PAID,
]);
PurchasePaid::dispatch($this->getKey());
});
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Domains\Purchase\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Models\TicketRefund;
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([
'compra_id',
'source_catalog_item_id',
'source_variant_id',
'image_attachment_id',
'nombre',
'descripcion',
'slug',
'item_nombre',
'variant_attributes',
'cantidad',
'precio_unitario',
'discount_total',
'tax_total',
'total',
])]
class PurchaseItem extends Model
{
use HasFactory;
protected $table = 'compra_items';
protected function casts(): array
{
return [
'compra_id' => 'integer',
'source_catalog_item_id' => 'integer',
'source_variant_id' => 'integer',
'image_attachment_id' => 'integer',
'variant_attributes' => 'array',
'cantidad' => 'integer',
'precio_unitario' => 'decimal:2',
'discount_total' => 'decimal:2',
'tax_total' => 'decimal:2',
'total' => 'decimal:2',
];
}
/**
* @return BelongsTo<Purchase, $this>
*/
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
/** @return HasMany<Ticket, $this> */
public function tickets(): HasMany
{
return $this->hasMany(Ticket::class, 'source_purchase_item_id');
}
/** @return HasMany<TicketRefund, $this> */
public function ticketRefunds(): HasMany
{
return $this->hasMany(TicketRefund::class);
}
/** @return BelongsTo<Attachment, $this> */
public function imageAttachment(): BelongsTo
{
return $this->belongsTo(Attachment::class, 'image_attachment_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function sourceCatalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'source_catalog_item_id')->withTrashed();
}
/** @return BelongsTo<Variant, $this> */
public function sourceVariant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'source_variant_id')->withTrashed();
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Purchase\Models;
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([
'compra_id',
'cuit_buyer',
'cvu_buyer',
'amount',
'concept',
'operation',
'operation_id',
'transaction_id',
'qr_order_id',
'link_id',
])]
class TelepagosPayment extends Model
{
use HasFactory;
protected $table = 'telepagos_payments';
protected function casts(): array
{
return [
'compra_id' => 'integer',
'amount' => 'decimal:2',
];
}
/**
* @return BelongsTo<Purchase, $this>
*/
public function compra(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
/** @return HasMany<TelepagosPaymentCandidate, $this> */
public function candidates(): HasMany
{
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'telepagos_payment_id',
'compra_id',
'dni_matches',
'dni_distance',
'payment_dni',
'purchase_dni',
'amount_matches',
'payment_amount',
'purchase_amount',
'amount_difference',
'match_reason',
'confidence',
])]
class TelepagosPaymentCandidate extends Model
{
protected $table = 'telepagos_payment_candidates';
protected function casts(): array
{
return [
'telepagos_payment_id' => 'integer',
'compra_id' => 'integer',
'dni_matches' => 'boolean',
'dni_distance' => 'integer',
'amount_matches' => 'boolean',
'payment_amount' => 'decimal:2',
'purchase_amount' => 'decimal:2',
'amount_difference' => 'decimal:2',
];
}
public function payment(): BelongsTo
{
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
}
public function purchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Purchase\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'compra_id',
'qr_order_id',
'qr_code',
])]
class TelepagosQr extends Model
{
use HasFactory;
protected $table = 'telepagos_qr';
protected function casts(): array
{
return [
'compra_id' => 'integer',
];
}
/**
* @return BelongsTo<Purchase, $this>
*/
public function compra(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'compra_id');
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PaymentIntentRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'method' => ['required', 'string', Rule::in(['qr', 'transfer'])],
'transfer_payer_dni' => [
Rule::requiredIf(fn (): bool => $this->input('method') === 'transfer'),
'string',
'regex:/^\d{7,8}$/',
],
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StartCheckoutRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'cart_id' => [
'required_without:direct_items',
Rule::prohibitedIf(fn (): bool => $this->has('direct_items')),
'integer',
'exists:carritos,id',
],
'direct_items' => [
'required_without:cart_id',
Rule::prohibitedIf(fn (): bool => $this->has('cart_id')),
'array',
'min:1',
],
'direct_items.*' => ['required', 'array'],
'direct_items.*.catalog_item_id' => ['required', 'integer'],
'direct_items.*.variant_id' => ['nullable', 'integer'],
'direct_items.*.cantidad' => ['required', 'integer', 'min:1'],
'dni' => ['prohibited'],
'telefono' => ['prohibited'],
'nombre_apellido' => ['prohibited'],
'email' => ['prohibited'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Purchase\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePurchaseCustomerRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'dni' => ['required', 'string'],
'telefono' => ['required', 'string'],
'nombre_apellido' => ['required', 'string'],
'email' => ['required', 'string', 'email'],
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Purchase\Resources;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin PurchaseItem */
class PurchaseItemResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$imageUrl = $displayImage
? $this->imageAttachment?->getTemporaryUrl(1440)
: null;
return [
'id' => $this->id,
'quantity' => (int) $this->cantidad,
'unit_price' => $this->formatMoney($this->precio_unitario),
'line_total' => $this->formatMoney($this->total),
'source_catalog_item_id' => $this->source_catalog_item_id,
'source_variant_id' => $this->source_variant_id,
'item_details' => [
'nombre' => $this->item_nombre,
'descripcion' => $this->descripcion,
'slug' => $this->slug,
'imagen' => $imageUrl,
'attributes' => $this->variant_attributes ?? [],
],
];
}
private function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Domains\Purchase\Resources;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Purchase
*/
class PurchaseResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
? (int) $this->resource->getAttribute('tickets_count')
: null;
$paymentVerification = $this->resolvePaymentVerification();
$subtotal = $items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemSubtotal($item),
0.0,
)
: (float) ($this->total ?? 0);
$total = $this->status === Purchase::STATUS_PAID && $this->total !== null
? (float) $this->total
: ($items->isNotEmpty()
? $items->reduce(
fn (float $carry, PurchaseItem $item): float => $carry + $this->resolveItemTotal($item),
0.0,
)
: (float) ($this->total ?? 0));
return [
'id' => $this->id,
'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status,
'payment_method' => $this->payment_method,
'expires_at' => $expiresAt,
'expires_in_seconds' => $expiresAt === null
? null
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
'server_time' => $serverTime,
'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono,
'nombre_apellido' => $this->nombre_apellido,
'email' => $this->email,
'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),
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
'subtotal' => $this->formatMoney($subtotal),
'total' => $this->formatMoney($total),
];
}
protected function resolveItemSubtotal(PurchaseItem $item): float
{
return (float) $item->precio_unitario * $item->cantidad;
}
protected function resolveItemTotal(PurchaseItem $item): float
{
return (float) ($item->total ?? 0);
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
/** @return array<string, mixed>|null */
private function resolvePaymentVerification(): ?array
{
if (
$this->status !== Purchase::STATUS_IN_REVIEW
|| $this->payment_method !== 'transfer'
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
) {
return null;
}
$candidates = $this->resource
->getRelation('telepagosPaymentCandidates')
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
->values();
/** @var TelepagosPaymentCandidate|null $primary */
$primary = $candidates->first();
return [
'status' => $primary === null ? 'pending' : 'candidate',
'candidate_count' => $candidates->count(),
'primary' => $primary === null ? null : [
'reason' => $primary->match_reason,
'dni_distance' => $primary->dni_distance,
'payment_amount' => $this->formatMoney($primary->payment_amount),
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
'amount_difference' => $this->formatMoney($primary->amount_difference),
'confidence' => $primary->confidence,
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
],
'reasons' => $candidates
->pluck('match_reason')
->unique()
->values()
->all(),
];
}
private function comparePaymentCandidates(
TelepagosPaymentCandidate $left,
TelepagosPaymentCandidate $right,
): int {
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
<=> $this->paymentCandidateRank($right->match_reason);
if ($reasonComparison !== 0) {
return $reasonComparison;
}
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
if ($differenceComparison !== 0) {
return $differenceComparison;
}
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
}
private function paymentCandidateRank(string $reason): int
{
return match ($reason) {
'ambiguous_exact_match' => 0,
'exact_dni_near_amount' => 1,
'exact_amount_near_dni' => 2,
default => 3,
};
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogSelectionResolver
{
public function resolve(
Tenant $tenant,
int $catalogItemId,
?int $variantId,
string $fieldPrefix = 'direct_items',
): CatalogItem|Variant {
/** @var CatalogItem|null $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItemId)
->where('tenant_code', $tenant->codigo)
->lockForUpdate()
->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => __('api.cart.empty_bundle'),
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.catalog_item_id" => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => __('api.cart.variant_required'),
]);
}
$catalogItem->setRelation(
'inventory',
Inventory::query()->whereKey($catalogItem->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $catalogItem;
}
/** @var Variant|null $variant */
$variant = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id)
->lockForUpdate()
->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
"{$fieldPrefix}.variant_id" => [__('api.cart.variant_unavailable')],
]);
}
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation(
'inventory',
Inventory::query()->whereKey($variant->inventory_id)->lockForUpdate()->firstOrFail(),
);
return $variant;
}
}

View File

@@ -0,0 +1,216 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
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 Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CompleteCheckoutService
{
public function __construct(
private readonly StockReservationService $reservations,
private readonly SourceCartService $sourceCart,
private readonly PurchaseStateGuard $purchaseState,
) {}
public function complete(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->payment_method === null) {
throw ValidationException::withMessages([
'payment_method' => __('api.purchase.payment_method_required'),
]);
}
if ($this->isTerminal($purchase)) {
return $this->loadPurchase($purchase);
}
$this->purchaseState->lockCurrentCart($purchase);
$purchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'total' => $purchase->calculateCurrentTotalAmount(),
]);
return $this->loadPurchase($purchase);
});
}
public function submitForReview(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
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) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
}
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
]);
$this->reservations->clearExpirationForReview($purchase);
return $this->loadPurchase($purchase);
});
}
public function confirm(Purchase $purchase): void
{
DB::transaction(function () use ($purchase): void {
$purchase = $this->lockPurchase($purchase);
$this->purchaseState->assertNotExpired($purchase);
if ($purchase->status === Purchase::STATUS_PAID) {
return;
}
if (in_array($purchase->status, [
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()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$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'),
]);
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$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);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
}
try {
$this->reservations->commit($purchase);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
}
$this->sourceCart->finalize($purchase);
});
}
private function isTerminal(Purchase $purchase): bool
{
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);
}
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function itemKey(int $catalogItemId, ?int $variantId): string
{
return $catalogItemId.':'.($variantId ?? 'none');
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
$cartItems->load([
'catalogItem.inventory',
'catalogItem.attachments',
'variant.inventory',
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
'variant.eventDates',
'variant.eventDate',
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EditCheckoutService
{
public function __construct(
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseStateGuard $purchaseState,
) {}
/** @param array<string, string> $customerData */
public function updateCustomer(Purchase $purchase, array $customerData): Purchase
{
return DB::transaction(function () use ($purchase, $customerData): Purchase {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
$this->purchaseState->assertNotExpired($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->responses->load($purchase);
});
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
class InsufficientStockMessageBuilder
{
public function build(
CatalogItem $catalogItem,
CatalogItem|Variant $selection,
int $availableQuantity,
): string {
return match ($catalogItem->inventory_subject) {
InventorySubject::Seat => __('api.purchase.stock.seat_unavailable', [
'selection' => $selection->getSelectionLabel(),
]),
InventorySubject::Ticket => __('api.purchase.stock.ticket_unavailable', [
'selection' => $selection->getSelectionLabel(),
]),
InventorySubject::Product => __('api.purchase.stock.product_unavailable', [
'selection' => $this->productSelectionLabel($catalogItem, $selection),
'max' => $availableQuantity,
]),
};
}
private function productSelectionLabel(
CatalogItem $catalogItem,
CatalogItem|Variant $selection,
): string {
if ($selection instanceof CatalogItem) {
return $selection->getSelectionLabel();
}
return __('api.purchase.stock.product_selection', [
'product' => $catalogItem->getName(),
'selection' => $selection->getSelectionLabel(),
]);
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\Variant;
use Illuminate\Support\Collection;
class PurchaseItemSnapshotFactory
{
/**
* @param Collection<int, CartItem> $cartItems
* @return array<int, array<string, mixed>>
*/
public function fromCartItems(Collection $cartItems): array
{
return $cartItems
->map(function (CartItem $item): array {
$selectedItem = $item->selectedItem();
$quantity = (int) $item->cantidad;
$unitPrice = $selectedItem?->getPrice() ?? 0;
return [
'source_catalog_item_id' => $item->catalog_item_id,
'source_variant_id' => $item->variant_id,
'image_attachment_id' => $this->firstImageAttachment($item)?->id,
'nombre' => $item->catalogItem->nombre,
'descripcion' => $selectedItem?->getDescription(),
'slug' => $item->catalogItem->slug,
'item_nombre' => $selectedItem->getName(),
'variant_attributes' => $item->variant === null
? []
: $this->snapshotAttributes($item->variant),
'cantidad' => $quantity,
'precio_unitario' => $unitPrice,
'discount_total' => null,
'tax_total' => null,
'total' => $unitPrice * $quantity,
];
})
->all();
}
private function firstImageAttachment(CartItem $item): ?Attachment
{
return $item->variant?->attachments->first()
?? $item->catalogItem?->attachments->first();
}
/** @return array<int, array{name: string, value: mixed}> */
private function snapshotAttributes(Variant $variant): array
{
$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->selectedEventDates();
if ($eventDates->isNotEmpty()) {
$attributes->prepend([
'name' => 'Fecha',
'value' => $eventDates
->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))
->values()
->all(),
]);
}
return $attributes->all();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Purchase\Models\Purchase;
class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $purchase->payment_method === 'transfer'
) {
$relations[] = 'telepagosPaymentCandidates.payment';
}
return $purchase->load($relations);
}
}

View File

@@ -0,0 +1,262 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ReleaseCheckoutService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function cancel(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED);
}
public function cancelFromAdmin(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_CANCELLED, allowInReviewCancellation: true);
}
public function expire(Purchase $purchase): Purchase
{
return $this->release($purchase, Purchase::STATUS_EXPIRED);
}
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 ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus === Purchase::STATUS_CANCELLED) {
return $this->loadPurchase($purchase);
}
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
}
throw ValidationException::withMessages([
'purchase' => __('api.purchase.paid_cannot_cancel'),
]);
}
if (
$purchase->status === Purchase::STATUS_IN_REVIEW
&& $targetStatus === Purchase::STATUS_CANCELLED
&& ! $allowInReviewCancellation
) {
return $this->createNewCartWithoutCancelling($purchase);
}
if ($this->isAlreadyReleased($purchase)) {
return $this->loadPurchase($purchase);
}
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
// Leaving checkout is allowed at the exact instant the reservation
// expires. Finish the expiration while holding the purchase lock so
// the request is idempotent with the scheduled expiration job.
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $this->hasOverdueActiveReservation($purchase)) {
$targetStatus = Purchase::STATUS_EXPIRED;
}
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active'
&& in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
if ($this->hasUnavailableVariants($cart)) {
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$cart->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
} else {
$this->reservations->returnToCart($purchase, $cart);
}
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true) || ! $this->hasOverdueActiveReservation($purchase))
) {
return $this->loadPurchase($purchase);
}
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$purchase->update(['status' => $targetStatus]);
return $this->loadPurchase($purchase);
});
}
private function hasUnavailableVariants(Cart $cart): bool
{
return $cart->items()
->whereNotNull('variant_id')
->with(['variant.eventDate', 'variant.eventDates'])
->lockForUpdate()
->get()
->contains(fn (CartItem $item): bool => $item->variant === null
|| ! $item->variant->isSellable());
}
private function releasePurchaseReservations(
Purchase $purchase,
string $targetStatus,
?Cart $cart,
): void {
try {
$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 ($targetStatus === Purchase::STATUS_EXPIRED
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
->update([
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
]);
return;
}
if ($cart->status === Cart::STATUS_ACTIVE) {
$cartUpdate = [
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
];
Cart::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $purchase->getKey())
->update($cartUpdate);
return;
}
if ($cart->status !== Cart::STATUS_CHECKOUT) {
return;
}
if (! $cart->trashed()) {
$cart->update(['status' => Cart::STATUS_CONVERTED]);
$cart->delete();
}
}
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);
}
private function lockPurchase(Purchase $purchase): Purchase
{
/** @var Purchase */
return Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function hasOverdueActiveReservation(Purchase $purchase): bool
{
/** @var StockReservation|null $reservation */
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Purchase\Models\Purchase;
class SourceCartService
{
public function finalize(Purchase $purchase): void
{
$sourceCart = $this->findSourceCart($purchase);
if ($sourceCart === null || $sourceCart->trashed()) {
return;
}
$sourceCart->update([
'status' => 'converted',
'guest_token' => null,
]);
$sourceCart->delete();
}
private function findSourceCart(Purchase $purchase): ?Cart
{
if ($purchase->cart_id === null) {
return null;
}
/** @var Cart|null */
return Cart::withTrashed()
->whereKey($purchase->cart_id)
->lockForUpdate()
->first();
}
}

View File

@@ -0,0 +1,444 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Cart\Services\CartVariantReplacementService;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class StartCheckoutService
{
public function __construct(
private readonly CatalogInventoryService $inventory,
private readonly StockReservationService $reservations,
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly InsufficientStockMessageBuilder $stockMessages,
private readonly PurchaseResponseLoader $responses,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly CartVariantReplacementService $variantReplacements,
) {}
/** @param array<string, mixed> $purchaseData */
public function start(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
/** @var Tenant $tenant */
$tenant = Tenant::query()
->lockForUpdate()
->findOrFail($tenant->getKey());
$directItems = $purchaseData['direct_items'] ?? null;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_items'], $purchaseData['cart_id']);
if (is_array($directItems)) {
return $this->startDirectItems(
$tenant,
$userId,
$purchaseData,
$directItems,
);
}
if ($cartId === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.source_required'),
]);
}
return $this->startFromCart($tenant, $userId, $purchaseData, $cartId);
});
}
/**
* @param array<string, mixed> $purchaseData
* @param array<int, array<string, mixed>> $directItems
*/
private function startDirectItems(
Tenant $tenant,
int $userId,
array $purchaseData,
array $directItems,
): Purchase {
$lines = collect(array_values($directItems))
->map(function (array $item, int $index): array {
return [
'index' => $index,
'catalog_item_id' => (int) $item['catalog_item_id'],
'variant_id' => isset($item['variant_id']) ? (int) $item['variant_id'] : null,
'quantity' => (int) $item['cantidad'],
'field' => "direct_items.{$index}",
];
})
->groupBy(fn (array $line): string => sprintf(
'%d:%s',
$line['catalog_item_id'],
$line['variant_id'] === null ? 'none' : (string) $line['variant_id'],
))
->map(function (Collection $duplicateLines): array {
$line = $duplicateLines->first();
$line['quantity'] = (int) $duplicateLines->sum('quantity');
return $line;
})
->sortBy(fn (array $line): string => sprintf(
'%020d:%020d',
$line['catalog_item_id'],
$line['variant_id'] ?? 0,
))
->values();
$resolvedLines = $lines->map(function (array $line) use ($tenant): array {
$selection = $this->selections->resolve(
$tenant,
$line['catalog_item_id'],
$line['variant_id'],
$line['field'],
);
return [
...$line,
'selection' => $selection,
'catalog_item' => $selection instanceof Variant
? $selection->catalogItem
: $selection,
];
});
$resolvedLines
->groupBy(fn (array $line): int => $line['catalog_item']->getKey())
->each(function (Collection $catalogLines) use ($userId): void {
/** @var CatalogItem $catalogItem */
$catalogItem = $catalogLines->first()['catalog_item'];
$availableQuantities = $catalogLines
->map(fn (array $line): ?int => $this->inventory->availableQuantity($line['selection']));
$maximumAddableCeiling = $availableQuantities->contains(null)
? null
: (int) $availableQuantities->sum();
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
(int) $catalogLines->sum('quantity'),
maximumAddableCeiling: $maximumAddableCeiling,
field: 'direct_items',
);
});
$unavailableItems = $resolvedLines
->map(function (array $line): ?array {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
if ($availableQuantity === null || $availableQuantity >= $line['quantity']) {
return null;
}
return $this->unavailableItem($line, $availableQuantity);
})
->filter()
->values()
->all();
if ($unavailableItems !== []) {
throw new InsufficientStockException($unavailableItems);
}
$cart = Cart::query()->create([
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
'guest_token' => null,
'status' => 'checkout',
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
]);
$cartItems = collect();
foreach ($resolvedLines as $line) {
$cartItem = $cart->items()->create([
'catalog_item_id' => $line['catalog_item_id'],
'variant_id' => $line['variant_id'],
'cantidad' => $line['quantity'],
]);
$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,
$purchaseData,
(float) $resolvedLines->sum(
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
),
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase);
}
/**
* @param array<string, mixed> $line
* @return array{
* index: int,
* catalog_item_id: int,
* variant_id: int|null,
* requested_quantity: int,
* available_quantity: int,
* message: string
* }
*/
private function unavailableItem(array $line, int $availableQuantity): array
{
return [
'index' => $line['index'],
'catalog_item_id' => $line['catalog_item_id'],
'variant_id' => $line['variant_id'],
'requested_quantity' => $line['quantity'],
'available_quantity' => $availableQuantity,
'message' => $this->stockMessages->build(
$line['catalog_item'],
$line['selection'],
$availableQuantity,
),
];
}
/** @param array<string, mixed> $purchaseData */
private function startFromCart(
Tenant $tenant,
int $userId,
array $purchaseData,
int $cartId,
): Purchase {
$cart = $this->resolveCart($tenant, $userId, $cartId);
$this->variantReplacements->replaceHistoricalVariants($cart);
$cartItems = $cart->items()->lockForUpdate()->get();
if ($cartItems->isEmpty()) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.empty_cart'),
]);
}
$this->loadCartItems($cartItems);
$this->verifyTenantItems($tenant, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
$cart->setRelation('items', $cartItems);
$this->reservations->syncCart($cart);
$purchase = $this->createPurchase(
$tenant,
$userId,
$purchaseData,
$cart->getTotalAmount(),
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$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)) {
$this->reservations->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
]);
}
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.');
}
if ($cart->status === Cart::STATUS_EXPIRED) {
throw new StockReservationExpiredException;
}
if ($cart->status !== Cart::STATUS_ACTIVE) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
}
}
/** @param Collection<int, CartItem> $cartItems */
private function verifyTenantItems(Tenant $tenant, Collection $cartItems): void
{
foreach ($cartItems as $item) {
if ($item->selectedItem() === null) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_missing'),
]);
}
if ($item->catalogItem?->tenant_code !== $tenant->codigo) {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.catalog_item_wrong_tenant'),
]);
}
}
}
/** @param Collection<int, CartItem> $cartItems */
private function assertCartPurchaseLimits(
Tenant $tenant,
int $userId,
Collection $cartItems,
int $cartId,
): void {
$quantities = $cartItems
->groupBy('catalog_item_id')
->map(fn (Collection $items): int => (int) $items->sum('cantidad'))
->sortKeys();
$catalogItems = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereKey($quantities->keys())
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($quantities as $catalogItemId => $quantity) {
/** @var CatalogItem $catalogItem */
$catalogItem = $catalogItems->get($catalogItemId);
$this->purchaseLimits->assertCanPurchase(
$catalogItem,
$userId,
$quantity,
excludedCartId: $cartId,
heldQuantity: $quantity,
field: 'cart_id',
);
}
}
/** @param array<string, mixed> $purchaseData */
private function createPurchase(
Tenant $tenant,
int $userId,
array $purchaseData,
float $total,
?int $cartId,
): Purchase {
return Purchase::query()->create([
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'total' => $total,
]);
}
private function checkoutExpiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
);
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{
$cartItems->load([
'catalogItem.inventory',
'catalogItem.attachments',
'variant.inventory',
'variant.attachments',
'variant.catalogItem',
'variant.definitions.itemAttribute.attribute',
'variant.eventDates',
'variant.eventDate',
]);
}
private function loadPurchase(Purchase $purchase): Purchase
{
return $this->responses->load($purchase);
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use App\Domains\Purchase\Services\Checkout\StartCheckoutService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* Stable checkout API used by controllers, commands and integrations.
*
* Workflow details live in focused services under Services/Checkout.
*/
class CheckoutService
{
public function __construct(
private readonly StartCheckoutService $starter,
private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser,
) {}
/** @param array<string, mixed> $purchaseData */
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
return $this->starter->start($tenant, $userId, $purchaseData);
}
public function completePurchase(Purchase $purchase): Purchase
{
return $this->completer->complete($purchase);
}
public function submitForReview(Purchase $purchase): Purchase
{
return $this->completer->submitForReview($purchase);
}
/** @param array<string, string> $customerData */
public function updateCustomerData(Purchase $purchase, array $customerData): Purchase
{
return $this->editor->updateCustomer($purchase, $customerData);
}
public function confirmPurchase(Purchase $purchase): void
{
$this->completer->confirm($purchase);
}
public function confirmPaidPurchase(Purchase $purchase): Purchase
{
return DB::transaction(function () use ($purchase): Purchase {
$this->completer->confirm($purchase);
$purchase->markAsPaid();
return $purchase->refresh()->load(['items.imageAttachment']);
});
}
public function cancelPurchase(Purchase $purchase): Purchase
{
return $this->releaser->cancel($purchase);
}
public function cancelPurchaseFromAdmin(Purchase $purchase): Purchase
{
return $this->releaser->cancelFromAdmin($purchase);
}
public function expirePurchase(Purchase $purchase): Purchase
{
return $this->releaser->expire($purchase);
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace App\Domains\Purchase\Services;
/**
* Measures likely DNI typing errors using the optimal-string-alignment
* variant of the Damerau-Levenshtein distance.
*
* The returned value is the minimum number of single-character edits needed
* to transform one DNI into the other. Supported edits are insertion,
* deletion, substitution and transposition of two adjacent digits.
*/
class DniDistanceService
{
/**
* Calculate the edit distance between two normalized DNI strings.
*
* Each matrix cell [row][column] stores the minimum edits required to
* transform the first $row digits of $left into the first $column digits
* of $right. The bottom-right cell therefore contains the final distance.
*/
public function distance(string $left, string $right): int
{
$left = $this->normalize($left);
$right = $this->normalize($right);
$leftLength = strlen($left);
$rightLength = strlen($right);
$matrix = [];
// Transforming a prefix into an empty string requires deleting every digit.
for ($row = 0; $row <= $leftLength; $row++) {
$matrix[$row] = [$row];
}
// Transforming an empty string into a prefix requires inserting every digit.
for ($column = 0; $column <= $rightLength; $column++) {
$matrix[0][$column] = $column;
}
for ($row = 1; $row <= $leftLength; $row++) {
for ($column = 1; $column <= $rightLength; $column++) {
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
$deletionDistance = $matrix[$row - 1][$column] + 1;
$insertionDistance = $matrix[$row][$column - 1] + 1;
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
// Keep the cheapest way to align the two prefixes at this position.
$matrix[$row][$column] = min(
$deletionDistance,
$insertionDistance,
$substitutionDistance,
);
// Count two adjacent inverted digits as one edit instead of two substitutions.
if (
$row > 1
&& $column > 1
&& $left[$row - 1] === $right[$column - 2]
&& $left[$row - 2] === $right[$column - 1]
) {
$matrix[$row][$column] = min(
$matrix[$row][$column],
$matrix[$row - 2][$column - 2] + 1,
);
}
}
}
return $matrix[$leftLength][$rightLength];
}
/**
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
* the leading zero that is present when the DNI is extracted from a CUIT.
*/
public function normalize(string $dni): string
{
$digits = preg_replace('/\D+/', '', $dni) ?? '';
return str_pad($digits, 8, '0', STR_PAD_LEFT);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService
{
public function totalForTenant(Tenant $tenant): string
{
$total = TicketRefund::query()
->whereHas(
'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
)
->sum('amount');
return number_format((float) $total, 2, '.', '');
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Validation\ValidationException;
class PurchaseStateGuard
{
public function assertNotExpired(Purchase $purchase): void
{
if ($purchase->status === Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
return;
}
/** @var StockReservation|null $reservation */
$reservation = $purchase->relationLoaded('stockReservation')
? $purchase->getRelation('stockReservation')
: ($purchase->exists
? $purchase->stockReservation()->first()
: null);
if ($reservation !== null && (
$reservation->status === StockReservation::STATUS_EXPIRED
|| (
$reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture()
)
)) {
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

@@ -0,0 +1,160 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class TenantTransactionResetService
{
/** @return array<string, int> */
public function preview(string $tenantCode): array
{
$this->ensureTenantExists($tenantCode);
$scope = $this->scope($tenantCode);
return [
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
'purchases' => $scope['purchase_ids']->count(),
'purchase_items' => DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count(),
'telepagos_payments' => DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count(),
'telepagos_qr' => DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count(),
'carts' => $scope['cart_ids']->count(),
'cart_items' => $scope['cart_item_ids']->count(),
'tickets' => DB::table('tickets')->where('tenant_code', $tenantCode)->count(),
'stock_reservations' => $this->reservationQuery($scope)->count(),
'purchase_changes' => DB::table('value_changes')
->where('tenant_code', $tenantCode)
->where('trackable_type', Purchase::class)
->count(),
'inventories' => $scope['inventory_ids']->count(),
];
}
/** @return array<string, int> */
public function reset(string $tenantCode): array
{
$this->ensureTenantExists($tenantCode);
return DB::transaction(function () use ($tenantCode): array {
$scope = $this->scope($tenantCode);
$purchaseItems = DB::table('compra_items')->whereIn('compra_id', $scope['purchase_ids'])->count();
$cartItems = $scope['cart_item_ids']->count();
$telepagosPayments = DB::table('telepagos_payments')->whereIn('compra_id', $scope['purchase_ids'])->count();
$telepagosQr = DB::table('telepagos_qr')->whereIn('compra_id', $scope['purchase_ids'])->count();
$summary = [
'stock_reservations_deleted' => $this->reservationQuery($scope)->delete(),
'tickets_deleted' => DB::table('tickets')->where('tenant_code', $tenantCode)->delete(),
'purchase_changes_deleted' => DB::table('value_changes')
->where('tenant_code', $tenantCode)
->where('trackable_type', Purchase::class)
->delete(),
'purchases_deleted' => DB::table('compras')->whereIn('id', $scope['purchase_ids'])->delete(),
'purchase_items_deleted' => $purchaseItems,
'telepagos_payments_deleted' => $telepagosPayments,
'telepagos_qr_deleted' => $telepagosQr,
'carts_deleted' => DB::table('carritos')->whereIn('id', $scope['cart_ids'])->delete(),
'cart_items_deleted' => $cartItems,
'inventories_reset' => $scope['inventory_ids']->count(),
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
];
DB::table('inventories')
->whereIn('id', $scope['inventory_ids'])
->update([
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
'reserved_stock' => 0,
'sold_units' => 0,
'refunded_units' => 0,
]);
return $summary;
});
}
private function ensureTenantExists(string $tenantCode): void
{
if (! DB::table('tenants')->where('codigo', $tenantCode)->exists()) {
throw new InvalidArgumentException("El tenant {$tenantCode} no existe.");
}
}
/**
* @return array{
* purchase_ids: Collection<int, int>,
* cart_ids: Collection<int, int>,
* cart_item_ids: Collection<int, int>,
* inventory_ids: Collection<int, int>
* }
*/
private function scope(string $tenantCode): array
{
$catalogItemIds = DB::table('catalog_items')
->where('tenant_code', $tenantCode)
->pluck('id');
$purchaseIds = DB::table('compras')
->where('tenant_codigo', $tenantCode)
->pluck('id');
$cartIds = DB::table('carritos')
->where('tenant_codigo', $tenantCode)
->pluck('id');
$cartItemIds = DB::table('carrito_items')
->whereIn('cart_id', $cartIds)
->pluck('id');
$inventoryIds = DB::table('variantes')
->whereIn('catalog_item_id', $catalogItemIds)
->whereNotNull('inventory_id')
->pluck('inventory_id')
->merge(
DB::table('catalog_items')
->whereIn('id', $catalogItemIds)
->whereNotNull('inventory_id')
->pluck('inventory_id'),
)
->map(fn ($id): int => (int) $id)
->unique()
->values();
return [
'purchase_ids' => $purchaseIds->map(fn ($id): int => (int) $id),
'cart_ids' => $cartIds->map(fn ($id): int => (int) $id),
'cart_item_ids' => $cartItemIds->map(fn ($id): int => (int) $id),
'inventory_ids' => $inventoryIds,
];
}
/**
* @param array{
* purchase_ids: Collection<int, int>,
* cart_ids: Collection<int, int>,
* cart_item_ids: Collection<int, int>,
* inventory_ids: Collection<int, int>
* } $scope
*/
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')
->whereIn('id', $reservationIds);
}
}

View File

@@ -0,0 +1,182 @@
<?php
namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class UserPurchaseLimitService
{
public function assertCanPurchase(
CatalogItem $catalogItem,
int $userId,
int $requestedQuantity,
?int $excludedPurchaseId = null,
?int $excludedCartId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
string $field = 'quantity',
): void {
DB::transaction(function () use (
$catalogItem,
$userId,
$requestedQuantity,
$excludedPurchaseId,
$excludedCartId,
$heldQuantity,
$maximumAddableCeiling,
$field,
): void {
/** @var CatalogItem $catalogItem */
$catalogItem = CatalogItem::query()
->whereKey($catalogItem->getKey())
->lockForUpdate()
->firstOrFail();
$limit = $catalogItem->max_units_per_user;
if ($limit === null) {
return;
}
$purchasedQuantity = (int) PurchaseItem::query()
->where('source_catalog_item_id', $catalogItem->getKey())
->whereHas('purchase', function ($query) use ($userId, $excludedPurchaseId): void {
$query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
])
->when(
$excludedPurchaseId !== null,
fn ($query) => $query->whereKeyNot($excludedPurchaseId),
);
})
->sum('cantidad');
$checkoutQuantity = (int) CartItem::query()
->where('catalog_item_id', $catalogItem->getKey())
->whereHas('cart.purchases', function ($query) use ($userId, $excludedPurchaseId): void {
$query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->whereDoesntHave('items')
->when(
$excludedPurchaseId !== null,
fn ($query) => $query->whereKeyNot($excludedPurchaseId),
);
})
->sum('cantidad');
$reservedCartQuantity = (int) CartItem::query()
->where('catalog_item_id', $catalogItem->getKey())
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active')
->when(
$excludedCartId !== null,
fn ($query) => $query->whereKeyNot($excludedCartId),
))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
$remainingQuota = max(
0,
$limit - $purchasedQuantity - $checkoutQuantity - $reservedCartQuantity,
);
$maximumAddableQuantity = max(0, $remainingQuota - $heldQuantity);
throw new PurchaseLimitExceededException(
$catalogItem,
$maximumAddableCeiling === null
? $maximumAddableQuantity
: min($maximumAddableQuantity, $maximumAddableCeiling),
$field,
);
}
});
}
/**
* @param Collection<int, CatalogItem> $catalogItems
* @return Collection<int, int|null>
*/
public function remainingByCatalogItem(Collection $catalogItems, ?int $userId): Collection
{
$limits = $catalogItems
->unique('id')
->mapWithKeys(fn (CatalogItem $item): array => [$item->getKey() => $item->max_units_per_user]);
if ($userId === null || $limits->filter(fn ($limit) => $limit !== null)->isEmpty()) {
return $limits->map(fn (): ?int => null);
}
$ids = $limits->keys();
$purchased = PurchaseItem::query()
->selectRaw('source_catalog_item_id, SUM(cantidad) AS quantity')
->whereIn('source_catalog_item_id', $ids)
->whereHas('purchase', fn ($query) => $query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
Purchase::STATUS_PAID,
]))
->groupBy('source_catalog_item_id')
->pluck('quantity', 'source_catalog_item_id');
$checkout = CartItem::query()
->selectRaw('catalog_item_id, SUM(cantidad) AS quantity')
->whereIn('catalog_item_id', $ids)
->whereHas('cart.purchases', fn ($query) => $query
->where('user_id', $userId)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->whereDoesntHave('items'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');
$reserved = CartItem::query()
->selectRaw('catalog_item_id, SUM(cantidad) AS quantity')
->whereIn('catalog_item_id', $ids)
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active'))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');
return $limits->map(function (?int $limit, int $catalogItemId) use ($purchased, $checkout, $reserved): ?int {
if ($limit === null) {
return null;
}
$used = (int) ($purchased[$catalogItemId] ?? 0)
+ (int) ($checkout[$catalogItemId] ?? 0)
+ (int) ($reserved[$catalogItemId] ?? 0);
return max(0, $limit - $used);
});
}
}

View File

@@ -0,0 +1,37 @@
# Dominio Purchase
## Propósito
Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un carrito, mantiene sus líneas vivas contra catálogo durante el checkout, inicia el pago y materializa el snapshot definitivo al confirmar, o cancela y vence la operación.
## Modelo
- `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. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
- `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.
## Servicios de checkout
`CheckoutService` es la fachada estable. Delega en:
- `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 o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. 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, devuelve la misma reserva activa al carrito y sincroniza sus líneas con 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.
## Endpoints
Bajo `/tenants/{tenant:codigo}/compras`, con `auth:sanctum`: listado, inicio, detalle, edición de ítems, datos del cliente, intención de pago, finalización, revisión y cancelación.
## Dependencias y reglas
Depende de `Cart`, `Catalog`, `Tenant`, `Auth` e `Integration`; emite eventos consumidos por `Ticket` y `Notification`. Los cambios de estado e inventario deben ser transaccionales y usar los servicios del checkout, no actualizaciones directas del modelo.

View File

@@ -0,0 +1,15 @@
<?php
use App\Domains\Purchase\Controllers\PurchaseController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(function (): void {
Route::get('compras', [PurchaseController::class, 'index']);
Route::post('compras/start-checkout', [PurchaseController::class, 'startCheckout']);
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
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']);
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
});

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
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;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
protected AdminAppSaleExcelService $saleExcelService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated())
)->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
'refunded_total' => $this->saleService->refundedTotal($tenant),
]);
}
public function show(Request $request, int $sale): SaleDetailResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleDetailResource($this->saleService->detail($tenant, $sale));
}
public function tickets(Request $request, int $sale): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale)
);
}
public function confirm(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale));
}
public function cancel(Request $request, int $sale): SaleResource
{
$tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale));
}
public function modifications(
AdminAppSaleModificationIndexRequest $request,
): AnonymousResourceCollection {
return SaleModificationResource::collection(
$this->saleService->modifications(
$request->user()->tenant()->firstOrFail(),
$request->validated(),
)
);
}
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(AdminAppSaleModificationPdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadExcel(AdminAppSalePdfRequest $request): StreamedResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsExcel(
AdminAppSaleModificationPdfRequest $request,
): StreamedResponse {
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppSaleIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())],
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Sale\Requests;
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
$rules = parent::rules();
unset($rules['sort_by'], $rules['sort_direction']);
return $rules;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
...parent::rules(),
'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

@@ -0,0 +1,50 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\PurchaseItem;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Purchase */
class SaleDetailResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'items' => $this->items->map(fn (PurchaseItem $item): array => [
'id' => $item->id,
'product' => $item->item_nombre,
'event_dates' => $this->eventDates($item),
'quantity' => (int) $item->cantidad,
'unit_price' => $this->formatMoney($item->precio_unitario),
'total' => $this->formatMoney($item->total),
])->values(),
'total' => $this->formatMoney($this->total),
];
}
/** @return list<string> */
private function eventDates(PurchaseItem $item): array
{
return collect($item->variant_attributes ?? [])
->filter(fn (mixed $attribute): bool => is_array($attribute)
&& mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha')
->flatMap(function (array $attribute): array {
$value = $attribute['value'] ?? [];
return is_array($value) ? $value : [$value];
})
->filter(fn (mixed $date): bool => is_string($date) && $date !== '')
->values()
->all();
}
private function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

Some files were not shown because too many files have changed in this diff Show More