refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
@@ -1,332 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
<?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')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user