7 Commits

187 changed files with 6997 additions and 4256 deletions

View File

@@ -5,6 +5,9 @@ APP_DEBUG=false
APP_URL=http://localhost
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200
@@ -28,7 +31,6 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=daily
LOG_STACK=single
@@ -39,8 +41,6 @@ TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
DB_CONNECTION=mysql
DB_HOST=127.0.0.1

File diff suppressed because it is too large Load Diff

View File

@@ -22,18 +22,10 @@ class ValidateResetPasswordAttemptController extends Controller
{
$data = $request->validated();
$result = $this->resetPasswordAttemptService->validateCode(
if (! $this->resetPasswordAttemptService->validateCode(
$data['email'],
$data['codigo'],
);
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_expired'),
]);
}
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
)) {
throw ValidationException::withMessages([
'codigo' => __('api.auth.reset_code_invalid'),
]);

View File

@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
#[Hidden(['codigo'])]
class ResetPasswordAttempt extends Model
{
@@ -31,7 +31,6 @@ class ResetPasswordAttempt extends Model
{
return [
'user_id' => 'integer',
'expires_at' => 'datetime',
];
}

View File

@@ -12,12 +12,6 @@ use Throwable;
class ResetPasswordAttemptService
{
public const CODE_VALID = 'valid';
public const CODE_INVALID = 'invalid';
public const CODE_EXPIRED = 'expired';
public function createForEmail(
string $email,
string $tenantCode,
@@ -152,12 +146,12 @@ class ResetPasswordAttemptService
);
}
public function validateCode(string $email, string $code): string
public function validateCode(string $email, string $code): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
$user = User::query()
->where('email', $email)
->lockForUpdate()
@@ -175,27 +169,14 @@ class ResetPasswordAttemptService
'email_fingerprint' => $emailFingerprint,
]);
return self::CODE_INVALID;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset code validation failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return self::CODE_EXPIRED;
return false;
}
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
]);
return self::CODE_VALID;
return true;
});
} catch (Throwable $exception) {
Log::error('Failed to validate password reset code.', [
@@ -233,19 +214,6 @@ class ResetPasswordAttemptService
return false;
}
if ($attempt->expires_at?->isPast()) {
$attempt->update([
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
]);
Log::info('Password reset failed: attempt expired.', [
'email_fingerprint' => $emailFingerprint,
'attempt_id' => $attempt->getKey(),
]);
return false;
}
$user->password = $password;
$user->failed_login_attempts = 0;
$user->last_failed_login_at = null;
@@ -302,7 +270,6 @@ class ResetPasswordAttemptService
'codigo' => $this->generateCode(),
'reason' => $reason,
'status' => ResetPasswordAttempt::STATUS_PENDING,
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
]);
return $attempt->getKey();

View File

@@ -30,7 +30,6 @@ class AdminAppBootstrapResource extends JsonResource
'login_header_footer_color' => $websiteType->login_header_footer_color,
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
];
}
}

View File

@@ -11,7 +11,7 @@ class AdminAppBootstrapService
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo', 'favicon'])
->with(['siteLogo', 'footerLogo'])
->where('dominio', $domain)
->firstOrFail(),
];

View File

@@ -11,7 +11,7 @@ class ScannerBootstrapService
{
return [
'website_type' => WebsiteType::query()
->with(['siteLogo', 'footerLogo', 'favicon'])
->with(['siteLogo', 'footerLogo'])
->where('scanner_domain', $domain)
->firstOrFail(),
];

View File

@@ -5,7 +5,6 @@ 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;
@@ -29,7 +28,6 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
@@ -38,16 +36,6 @@ class Cart extends Model
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';
@@ -57,7 +45,6 @@ class Cart extends Model
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
@@ -97,15 +84,6 @@ class Cart extends Model
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')
@@ -129,7 +107,6 @@ class Cart extends Model
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)
@@ -163,11 +140,12 @@ class Cart extends Model
'cantidad' => $quantity,
]);
} else {
app(StockReservationService::class)->ensure($item, $selectedItem);
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->syncCart($this);
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
return $item->fresh();
});
@@ -194,7 +172,6 @@ class Cart extends Model
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
@@ -228,6 +205,7 @@ class Cart extends Model
$nextAvailableQuantity,
);
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
@@ -244,10 +222,11 @@ class Cart extends Model
->first();
if ($targetItem !== null) {
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
$targetItem->cantidad += $quantity;
$targetItem->save();
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
@@ -255,7 +234,7 @@ class Cart extends Model
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
return $item->fresh();
}
@@ -284,9 +263,16 @@ class Cart extends Model
]);
}
if ($delta > 0) {
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
}
if ($delta < 0) {
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
}
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
@@ -296,7 +282,6 @@ class Cart extends Model
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
@@ -304,8 +289,17 @@ class Cart extends Model
->lockForUpdate()
->firstOrFail();
$selectedItem = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
app(StockReservationService::class)->release(
$item,
$selectedItem,
$item->cantidad,
);
$item->delete();
app(StockReservationService::class)->syncCart($this);
});
}
@@ -347,20 +341,13 @@ class Cart extends Model
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->current_purchase_id = null;
return;
}
app(StockReservationService::class)->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
app(StockReservationService::class)->detachFromPurchase($currentPurchase);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())

View File

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

View File

@@ -4,12 +4,9 @@ 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;
@@ -25,7 +22,7 @@ class CartService
return $this->makeEmptyCart($tenant);
}
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
@@ -122,7 +119,7 @@ class CartService
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
'status' => 'active',
]);
$cart->setRelation('items', collect());
@@ -223,14 +220,12 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->where('status', 'active')
->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();
}
@@ -239,7 +234,7 @@ class CartService
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
$cart = $this->findCart($tenant, $identity);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
@@ -252,73 +247,10 @@ class CartService
* @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,
'status' => 'active',
];
if ($identity['user_id'] !== null) {

View File

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

View File

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

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($tenant->website_type_code === 'onticket', 404);
return $tenant;
}
}

View File

@@ -123,6 +123,10 @@ class CatalogController extends Controller
$variantId === null ? null : (int) $variantId,
);
$allowances->attach(collect([$item]), $this->userId($request));
abort_unless($allowances->availability(
$item->availableStock(),
$item->getAttribute('remaining_user_quota'),
)->isVisible(), 404);
return CatalogItemDetailResource::make($item);
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domains\Catalog\Enums;
enum AvailabilityEffect: string
{
case Hide = 'hide';
case Restrict = 'restrict';
case Notice = 'notice';
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogAction: string
{
case SelectVariant = 'select_variant';
case ChangeQuantity = 'change_quantity';
case AddToCart = 'add_to_cart';
case BuyNow = 'buy_now';
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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

@@ -38,19 +38,13 @@ class CatalogFeaturedItemResource extends JsonResource
'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 {
'availability' => $this->availability($availableStock, $remainingUserQuota),
'variants' => $catalogItem->variants
->map(function (Variant $variant) use ($catalogItem): array {
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock();
$availability = $this->availability($variantStock, null);
return [
'id' => $variant->id,
@@ -60,17 +54,11 @@ class CatalogFeaturedItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$variantStock,
$remainingUserQuota,
),
'availability' => $availability,
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
})
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
->values(),
];
@@ -90,8 +78,7 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
'availability' => $this->availability($availableStock, $remainingUserQuota),
];
}
@@ -107,30 +94,27 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
'availability' => $this->availability($availableStock, $remainingUserQuota),
];
}
private function firstImageUrl(CatalogItem $catalogItem): ?string
{
$attachment = $catalogItem->attachments->first()
?? $catalogItem->visibleVariants()
?? $catalogItem->variants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
return $attachment?->getTemporaryUrl(1440);
}
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
{
/** @return array<string, mixed> */
private function availability(
?int $stock,
?int $remainingUserQuota,
): array {
return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota);
}
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
->availability($stock, $remainingUserQuota)
->toArray();
}
}

View File

@@ -38,20 +38,14 @@ class CatalogItemDetailResource extends JsonResource
'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()),
),
'availability' => $this->availability($this->availableStock()),
'images' => $this->when(
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant))
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
->values(),
'selected_variant' => $this->when(
$selectedVariant !== null,
@@ -164,6 +158,10 @@ class CatalogItemDetailResource extends JsonResource
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
$availability = $this->availability(
$variantStock,
false,
);
return [
'id' => $variant->id,
@@ -173,8 +171,7 @@ class CatalogItemDetailResource extends JsonResource
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'availability' => $availability,
'values' => $values,
];
}
@@ -194,19 +191,14 @@ class CatalogItemDetailResource extends JsonResource
: $variant->inventory->availableStock();
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
/** @return array<string, mixed> */
private function availability(
?int $stock,
bool $includeUserQuota = true,
): array {
return app(CatalogItemAllowanceService::class)->availability(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
)->toArray();
}
}

View File

@@ -16,9 +16,8 @@ class CatalogSearchItemResource extends JsonResource
public function toArray(Request $request): array
{
$availableStock = $this->availableStock();
$visibleVariants = $this->visibleVariants();
$attachment = $this->attachments->first()
?? $visibleVariants
?? $this->variants
->flatMap(fn (Variant $variant) => $variant->attachments)
->first();
@@ -29,13 +28,16 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock),
'variants' => $visibleVariants
'availability' => $this->availability($availableStock),
'variants' => $this->variants
->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock();
$availability = $this->availability(
$variantStock,
false,
);
return [
'id' => $variant->id,
@@ -45,28 +47,23 @@ class CatalogSearchItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'availability' => $availability,
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
->values(),
];
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
/** @return array<string, mixed> */
private function availability(
?int $stock,
bool $includeUserQuota = true,
): array {
return app(CatalogItemAllowanceService::class)->availability(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
)->toArray();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\CatalogAction;
final readonly class AvailabilityDecision
{
/**
* @param list<CatalogAction> $allowedActions
* @param list<array{code: string, message: string}> $reasons
*/
private function __construct(
private bool $visible,
private ?int $maximumQuantity,
private array $allowedActions,
private array $reasons,
) {}
/** @param list<array{code: string, message: string}> $reasons */
public static function hidden(array $reasons): self
{
return new self(false, null, [], $reasons);
}
/**
* @param list<CatalogAction> $allowedActions
* @param list<array{code: string, message: string}> $reasons
*/
public static function visible(
?int $maximumQuantity,
array $allowedActions,
array $reasons,
): self {
return new self(true, $maximumQuantity, $allowedActions, $reasons);
}
public function isVisible(): bool
{
return $this->visible;
}
/** @return array<string, mixed> */
public function toArray(): array
{
if (! $this->visible) {
return [
'state' => 'hidden',
'reasons' => $this->reasons,
];
}
return [
'state' => 'visible',
'maximum_quantity' => $this->maximumQuantity,
'allowed_actions' => array_map(
fn (CatalogAction $action): string => $action->value,
$this->allowedActions,
),
'reasons' => $this->reasons,
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilityEffect;
use App\Domains\Catalog\Enums\CatalogAction;
final class AvailabilityPolicyResolver
{
/** @return array{effect: AvailabilityEffect, denied_actions: list<CatalogAction>} */
public function resolve(string $restrictionCode): array
{
/** @var array{effect?: string, denied_actions?: list<string>} $configured */
$configured = config("catalog.availability.rules.{$restrictionCode}", []);
return [
'effect' => AvailabilityEffect::from(
$configured['effect'] ?? AvailabilityEffect::Notice->value,
),
'denied_actions' => array_map(
fn (string $action): CatalogAction => CatalogAction::from($action),
$configured['denied_actions'] ?? [],
),
];
}
}

View File

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

View File

@@ -2,6 +2,8 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilityEffect;
use App\Domains\Catalog\Enums\CatalogAction;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Collection;
@@ -14,6 +16,7 @@ class CatalogItemAllowanceService
public function __construct(
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly AvailabilityPolicyResolver $policies,
) {}
/** @param Collection<int, CatalogItem> $catalogItems */
@@ -42,16 +45,82 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota);
}
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
{
public function availability(
?int $availableStock,
?int $remainingUserQuota,
): AvailabilityDecision {
$reasons = [];
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
return self::USER_QUOTA_REACHED_MESSAGE;
$reasons[] = [
'code' => 'user_quota_reached',
'message' => self::USER_QUOTA_REACHED_MESSAGE,
];
}
if ($availableStock !== null && $availableStock <= 0) {
return self::OUT_OF_STOCK_MESSAGE;
$reasons[] = [
'code' => 'out_of_stock',
'message' => self::OUT_OF_STOCK_MESSAGE,
];
}
return null;
return $this->decision(
$this->maximumAddableQuantity($availableStock, $remainingUserQuota),
$reasons,
);
}
public function purchaseLimitExceededAvailability(
int $maximumQuantity,
string $message,
): AvailabilityDecision {
$reasons = [];
if ($maximumQuantity <= 0) {
$reasons[] = [
'code' => 'user_quota_reached',
'message' => self::USER_QUOTA_REACHED_MESSAGE,
];
} else {
$reasons[] = [
'code' => 'requested_quantity_exceeds_user_quota',
'message' => $message,
];
}
return $this->decision($maximumQuantity, $reasons);
}
/**
* @param list<array{code: string, message: string}> $reasons
*/
private function decision(?int $maximumQuantity, array $reasons): AvailabilityDecision
{
/** @var list<string> $configuredActions */
$configuredActions = config('catalog.availability.default_actions', []);
$allowedActions = collect($configuredActions)
->map(fn (string $action): CatalogAction => CatalogAction::from($action));
foreach ($reasons as $reason) {
$policy = $this->policies->resolve($reason['code']);
if ($policy['effect'] === AvailabilityEffect::Hide) {
return AvailabilityDecision::hidden($reasons);
}
if ($policy['effect'] === AvailabilityEffect::Restrict) {
$deniedActions = $policy['denied_actions'];
$allowedActions = $allowedActions->reject(
fn (CatalogAction $action): bool => in_array($action, $deniedActions, true),
);
}
}
return AvailabilityDecision::visible(
$maximumQuantity,
$allowedActions->values()->all(),
$reasons,
);
}
}

View File

@@ -22,7 +22,10 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogService
{
public function __construct(protected AttachmentService $attachmentService) {}
public function __construct(
protected AttachmentService $attachmentService,
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
) {}
/**
* @param array<string, mixed> $data
@@ -205,13 +208,6 @@ class CatalogService
]);
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
$catalogItem->setRelation('variants', $visibleVariants);
$selectedVariant = $variantId === null
? $visibleVariants->first()
: $visibleVariants->firstWhere('id', $variantId);
@@ -236,9 +232,8 @@ class CatalogService
$containsPattern = "%{$normalizedTerm}%";
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
$query = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->whereAvailable()
->where(function (Builder $query) use ($containsPattern): void {
$query
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
@@ -265,7 +260,10 @@ class CatalogService
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
]);
$paginator = $this->visibleCatalogItems
->apply($query)
->orderByRaw(
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
[$normalizedTerm, $startsWithPattern],
@@ -285,10 +283,9 @@ class CatalogService
int $perPage,
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
$query = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->whereAvailable()
->with([
'attachments',
'inventory',
@@ -300,7 +297,10 @@ class CatalogService
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
]);
return $this->visibleCatalogItems
->apply($query)
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
}

View File

@@ -2,184 +2,50 @@
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 App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
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,
private readonly CheckoutService $checkout,
private readonly ExpireCartReservationsService $carts,
) {}
/**
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
* @return array{purchases: int, cart_items: int}
*/
public function expireOverdue(): array
{
$summary = [
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 0,
'failed' => 0,
];
$lastReservationId = 0;
$expiredPurchases = null;
$expiredCartItems = null;
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');
try {
$expiredPurchases = $this->checkout->expireOverduePurchases();
$expiredCartItems = $this->carts->expireOverdue();
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_items' => $expiredCartItems,
'total_expired' => $expiredPurchases + $expiredCartItems,
]);
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);
return [
'purchases' => $expiredPurchases,
'cart_items' => $expiredCartItems,
];
} catch (Throwable $exception) {
Log::channel('commands')->error('Stock reservation cleanup failed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_items' => $expiredCartItems,
'exception' => $exception,
]);
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;
throw $exception;
}
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

@@ -17,6 +17,7 @@ class FeaturedGroupService
/** @return array<array-key, mixed> */
public function __construct(
private readonly CatalogItemAllowanceService $allowances,
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
@@ -49,7 +50,6 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->whereAvailable()
->where(function (Builder $query): void {
$query
->whereDoesntHave('category')
@@ -72,6 +72,8 @@ class FeaturedGroupService
'bundleComponents.variant.catalogItem',
]);
$query = $this->visibleCatalogItems->apply($query);
return match ($featuredGroup->source_type) {
FeaturedGroupSource::Manual => $query
->select('catalog_items.*')
@@ -82,9 +84,7 @@ class FeaturedGroupService
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'),
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
};
}

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

@@ -2,486 +2,262 @@
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\CatalogItem;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class StockReservationService
{
public const REASON_CART_EMPTY = 'cart_empty';
public const REASON_CART_CHANGED = 'cart_changed';
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
public const REASON_MANUAL_RELEASE = 'manual_release';
public function __construct(
private readonly CatalogInventoryService $inventory,
) {}
public function syncCart(Cart $cart): ?StockReservation
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
{
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);
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
$this->inventory->reserve($selection, $quantity);
$this->recordIncrease($cartItem, $selection, $quantity);
});
}
$reservation = $lockedCart->current_stock_reservation_id === null
? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
public function release(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus = StockReservation::STATUS_RELEASED,
): void {
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
$this->ensure($cartItem, $selection);
$this->inventory->release($selection, $quantity);
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
});
}
if ($reservation !== null) {
$this->assertUsableCartReservation($reservation);
}
public function commit(
CartItem $cartItem,
CatalogItem|Variant $selection,
Purchase $purchase,
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
$this->inventory->commit($selection, (int) $cartItem->cantidad);
if ($requirements === []) {
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
$this->finalizeLocked(
$reservation,
StockReservation::STATUS_RELEASED,
self::REASON_CART_EMPTY,
);
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if (
$reservation === null
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->purchase_id !== $purchase->getKey()
|| $reservation->quantity !== $quantity
) {
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
}
$lockedCart->update(['current_stock_reservation_id' => null]);
$cart->current_stock_reservation_id = null;
return null;
$reservation->update([
'status' => StockReservation::STATUS_COMMITTED,
'committed_at' => now(),
'expires_at' => null,
]);
}
});
}
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
{
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
foreach ($requirements as $inventoryId => $quantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
$reservation = StockReservation::query()->create([
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
continue;
}
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
$reservation->update([
'quantity' => $quantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
$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,
CartItem $cartItem,
CatalogItem|Variant $selection,
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');
): void {
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
$this->ensure($cartItem, $selection);
StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => $purchase->getKey(),
'expires_at' => $purchase->expires_at,
]);
});
}
public function commit(Purchase $purchase): void
public function detachFromPurchase(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,
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update([
'purchase_id' => null,
'expires_at' => $this->expiration(),
]);
});
}
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
public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void
{
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()
DB::transaction(function () use ($cartItem, $selection): void {
$requirements = $this->inventory->requirementsFor(
$selection,
(int) $cartItem->cantidad,
);
$activeReservations = StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
$this->assertUsableCartReservation($reservation);
->get()
->keyBy('inventory_id');
$purchase->update(['stock_reservation_id' => null]);
$cart->update(['current_purchase_id' => null]);
$reservation->update(['expires_at' => $this->expiration()]);
$hasCompleteReservation = collect($requirements)->every(
fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity,
);
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) {
if ($hasCompleteReservation) {
return;
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
if ($activeReservations->isNotEmpty()) {
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
}
$this->inventory->reserve($selection, (int) $cartItem->cantidad);
$this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad);
});
}
public function syncPurchaseExpiration(Purchase $purchase): void
{
StockReservation::query()
->where('purchase_id', $purchase->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->update(['expires_at' => $purchase->expires_at]);
}
public function transfer(CartItem $source, CartItem $target): void
{
DB::transaction(function () use ($source, $target): void {
$sourceReservations = StockReservation::query()
->where('cart_item_id', $source->getKey())
->where('status', StockReservation::STATUS_ACTIVE)
->orderBy('inventory_id')
->lockForUpdate()
->findOrFail($cart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
});
}
->get();
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;
}
foreach ($sourceReservations as $sourceReservation) {
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
/** @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'];
if ($targetReservation === null) {
$sourceItemQuantity = (int) $source->cantidad;
$targetItemQuantity = (int) $target->fresh()->cantidad;
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
$sourceReservation->update([
'cart_item_id' => $target->getKey(),
'purchase_id' => null,
'quantity' => $perItemQuantity * $targetItemQuantity,
'expires_at' => $this->expiration(),
]);
continue;
}
$requirements[$inventoryId] = $requirement;
$targetReservation->update([
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
$sourceReservation->delete();
}
});
}
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
{
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null) {
StockReservation::query()->create([
'inventory_id' => $inventoryId,
'cart_item_id' => $cartItem->getKey(),
'quantity' => $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
]);
continue;
}
$reservation->update([
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
'status' => StockReservation::STATUS_ACTIVE,
'committed_at' => null,
'released_at' => null,
'expires_at' => $this->expiration(),
]);
}
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,
private function recordDecrease(
CartItem $cartItem,
CatalogItem|Variant $selection,
int $quantity,
string $releasedStatus,
): 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.');
}
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
$reservation = $this->lockReservation($cartItem, $inventoryId);
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
}
$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]);
$remaining = $reservation->quantity - $requiredQuantity;
$reservation->update([
'quantity' => $remaining,
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
'released_at' => $remaining === 0 ? now() : null,
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
]);
}
}
private function assertUsableCartReservation(StockReservation $reservation): void
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
{
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.');
}
return StockReservation::query()
->where('cart_item_id', $cartItem->getKey())
->where('inventory_id', $inventoryId)
->lockForUpdate()
->first();
}
private function expiration(): Carbon

View File

@@ -10,6 +10,10 @@ use Illuminate\Support\Collection;
class VariantSelectionService
{
public function __construct(
private readonly CatalogItemAllowanceService $allowances,
) {}
/**
* @param array<string, mixed> $selectedValues
* @return array<string, mixed>
@@ -188,13 +192,17 @@ class VariantSelectionService
/** @return array<string, mixed> */
private function variantData(CatalogItem $catalogItem, Variant $variant): array
{
$availableStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock();
return [
'id' => $variant->id,
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'availability' => $this->allowances
->availability($availableStock, null)
->toArray(),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilityEffect;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Database\Eloquent\Builder;
final class VisibleCatalogItemsQuery
{
public function __construct(
private readonly AvailabilityPolicyResolver $policies,
) {}
/** @param Builder<CatalogItem> $query */
public function apply(Builder $query): Builder
{
if ($this->policies->resolve('out_of_stock')['effect'] !== AvailabilityEffect::Hide) {
return $query;
}
return $query->where(function (Builder $query): void {
$query
->where(fn (Builder $query) => $this->applyStandardItemAvailability($query))
->orWhere(fn (Builder $query) => $this->applyBundleAvailability($query));
});
}
/** @param Builder<CatalogItem> $query */
private function applyStandardItemAvailability(Builder $query): Builder
{
return $query
->where('catalog_items.type', CatalogItemType::Standard->value)
->where(function (Builder $query): void {
$query
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'inventory',
fn (Builder $query): Builder => $query
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'),
)
->orWhereHas(
'variants.inventory',
fn (Builder $query): Builder => $query
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'),
);
});
}
/** @param Builder<CatalogItem> $query */
private function applyBundleAvailability(Builder $query): Builder
{
return $query
->where('catalog_items.type', CatalogItemType::Bundle->value)
->whereRaw(<<<'SQL'
NOT EXISTS (
SELECT 1
FROM bundle_components AS availability_components
INNER JOIN catalog_items AS availability_items
ON availability_items.id = availability_components.component_catalog_item_id
LEFT JOIN variantes AS availability_variants
ON availability_variants.id = availability_components.component_variant_id
INNER JOIN inventories AS availability_inventories
ON availability_inventories.id = COALESCE(
availability_variants.inventory_id,
availability_items.inventory_id
)
WHERE availability_components.bundle_catalog_item_id = catalog_items.id
AND availability_items.inventory_policy = ?
GROUP BY availability_inventories.id,
availability_inventories.real_stock,
availability_inventories.reserved_stock
HAVING availability_inventories.real_stock
- availability_inventories.reserved_stock
< SUM(availability_components.quantity)
)
SQL, [InventoryPolicy::Tracked->value]);
}
}

View File

@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `StockReservation` 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.
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
@@ -18,14 +18,18 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: 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.
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
- `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

@@ -15,3 +15,5 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
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,47 @@
<?php
namespace App\Domains\Client\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Client\Requests\StoreClientRequest;
use App\Domains\Client\Requests\UpdateClientRequest;
use App\Domains\Client\Resources\ClientResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
class ClientController extends Controller
{
public function index(): JsonResponse
{
return ClientResource::collection(
Client::query()->with('tenants')->latest()->paginateFromRequest()
)->response();
}
public function store(StoreClientRequest $request): JsonResponse
{
return ClientResource::make(
Client::query()->create($request->validated())->load('tenants')
)->response()->setStatusCode(201);
}
public function show(Client $client): ClientResource
{
return ClientResource::make($client->load('tenants'));
}
public function update(UpdateClientRequest $request, Client $client): ClientResource
{
$client->update($request->validated());
return ClientResource::make($client->fresh()->load('tenants'));
}
public function destroy(Client $client): Response
{
$client->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Client\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => ['required', 'string', 'max:255', Rule::unique('clients', 'code')],
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Client\Requests;
use App\Domains\Client\Models\Client;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateClientRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
/** @var Client|null $client */
$client = $this->route('client');
return [
'code' => ['sometimes', 'string', 'max:255', Rule::unique('clients', 'code')->ignore($client?->id)],
'name' => ['sometimes', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Client\Resources;
use App\Domains\Client\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Client */
class ClientResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'code' => $this->code,
'name' => $this->name,
'tenants' => $this->whenLoaded('tenants', fn () => $this->tenants->map(fn ($tenant): array => [
'id' => $tenant->id,
'codigo' => $tenant->codigo,
'nombre' => $tenant->nombre,
'dominio' => $tenant->dominio,
])),
];
}
}

View File

@@ -0,0 +1,6 @@
<?php
use App\Domains\Client\Controllers\ClientController;
use Illuminate\Support\Facades\Route;
Route::apiResource('clients', ClientController::class);

View File

@@ -10,6 +10,7 @@ use App\Domains\Desfile\Services\EntryService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class EntryController extends Controller
{
@@ -47,4 +48,10 @@ class EntryController extends Controller
));
}
public function destroyImage(Request $request): Response
{
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
return response()->noContent();
}
}

View File

@@ -166,6 +166,19 @@ class EntryService
return $this->current($tenant);
}
public function deleteImage(Tenant $tenant): void
{
$attachment = DB::transaction(function () use ($tenant): Attachment {
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
$entry->allAttachments()->detach($attachment->id);
return $attachment;
});
$this->deleteIfUnused($attachment);
}
/** @return Collection<string, ItemAttribute> */
private function itemAttributes(CatalogItem $entry): Collection
{

View File

@@ -144,6 +144,7 @@ class InvitationPurchaseProvisioner
if ($purchaseId !== null) {
DB::table('compras')->where('id', $purchaseId)->update([
'status' => 'paid',
'expires_at' => null,
'total' => 0,
'updated_at' => $now,
]);
@@ -157,6 +158,7 @@ class InvitationPurchaseProvisioner
'cart_id' => null,
'status' => 'paid',
'payment_method' => self::PAYMENT_METHOD,
'expires_at' => null,
'total' => 0,
'dni' => null,
'transfer_payer_dni' => null,
@@ -390,27 +392,15 @@ class InvitationPurchaseProvisioner
'sold_units' => $inventory->sold_units + 1,
]);
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed',
'committed_at' => $now,
'released_at' => null,
'expired_at' => null,
'release_reason' => null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('compras')->where('id', $purchaseId)->update([
'stock_reservation_id' => $reservationId,
]);
}
DB::table('stock_reservation_lines')->insert([
'stock_reservation_id' => $reservationId,
DB::table('stock_reservations')->insert([
'inventory_id' => $inventory->id,
'cart_item_id' => null,
'purchase_id' => $purchaseId,
'quantity' => 1,
'tracks_inventory' => true,
'status' => 'committed',
'expires_at' => null,
'committed_at' => $now,
'released_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);

View File

@@ -14,4 +14,6 @@ Route::prefix('v1/adminapp/tenant/desfile')
->name('adminapp.desfile.entries.image.replace');
Route::patch('entries/image', [EntryController::class, 'updateImage'])
->name('adminapp.desfile.entries.image.update');
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
->name('adminapp.desfile.entries.image.destroy');
});

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\StoreIntegrationRequest;
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
use Illuminate\Routing\Controller;
class IntegrationController extends Controller
{
public function index()
{
return response()->json(Integration::all());
}
public function store(StoreIntegrationRequest $request)
{
$integration = Integration::create($request->validated());
return response()->json($integration, 201);
}
public function show(Integration $integration)
{
return response()->json($integration);
}
public function update(UpdateIntegrationRequest $request, Integration $integration)
{
$integration->update($request->validated());
return response()->json($integration->fresh());
}
public function destroy(Integration $integration)
{
$integration->delete();
return response()->noContent();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
use App\Domains\Integration\Services\TelepagosWebhookService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class TelepagosWebhookController extends Controller
{
/**
* Handle the incoming Telepagos webhook.
*/
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
{
try {
$cashinId = $request->validated('id');
$service->handleWebhook($client, $cashinId);
return response()->json(['status' => 'success']);
} catch (\Exception $e) {
return response()->json([
'status' => 'error',
'code' => 'integration.webhook_failed',
'message' => __('api.integration.webhook_failed'),
], 500);
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class TelepagosWebhookRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
*/
public function rules(): array
{
return [
'id' => ['required', 'string'],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateIntegrationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$integration = $this->route('integration');
return [
'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_client_configuration' => ['sometimes', 'boolean'],
// the code shouldn't ideally be updatable, but if it is:
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
];
}
}

View File

@@ -3,8 +3,6 @@
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Exception;
use Illuminate\Contracts\Mail\Factory as MailFactory;
use Illuminate\Contracts\Mail\Mailer;
@@ -72,35 +70,24 @@ class MailService extends BaseIntegrationService
return [];
}
/**
* @param array<int, array{data: string, name: string, mime: string}> $attachments
*/
public function send(
string|array $recipient,
string $subject,
string $content,
Tenant|WebsiteType|null $brand = null,
array $attachments = [],
): void {
public function send(string|array $recipient, string $subject, string $content): void
{
if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
}
$brand ??= $this->tenant;
$branding = $this->brandingFor($brand);
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$html = Blade::render(
<<<'BLADE'
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
{!! $content !!}
</x-mail.branded-layout>
BLADE,
[
'branding' => $branding,
'headerLogoUrl' => $brand instanceof WebsiteType
? $brand->siteLogo?->getTemporaryUrl(1440)
: $brand->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
'tenant' => $this->tenant,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
'content' => $content,
],
);
@@ -109,14 +96,6 @@ class MailService extends BaseIntegrationService
->subject($subject)
->html($html);
foreach ($attachments as $attachment) {
$mail->attachData(
$attachment['data'],
$attachment['name'],
['mime' => $attachment['mime']],
);
}
$this->mailer->to($recipient)->send($mail);
}
@@ -127,36 +106,6 @@ class MailService extends BaseIntegrationService
: (string) config('mail.default');
}
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
private function brandingFor(Tenant|WebsiteType $brand): array
{
if ($brand instanceof WebsiteType) {
$brand->loadMissing(['siteLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#FF7006',
'body_color' => $brand->body_color ?? '#666666',
'background_color' => $brand->background_color ?? '#f8f8f8',
'surface_color' => $brand->surface_color ?? '#ffffff',
'header_bg_color' => $brand->surface_color ?? '#ffffff',
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
];
}
$brand->loadMissing(['headerLogo', 'footerLogo']);
return [
'name' => $brand->nombre,
'primary_color' => $brand->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
];
}
public function onSetup(): void
{
if (! $this->mailer || ! $this->clientContext) {

View File

@@ -0,0 +1,212 @@
<?php
namespace App\Domains\Integration\Services;
use App\Domains\Client\Models\Client;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Models\TelepagosPayment;
use App\Domains\Purchase\Models\TelepagosQr;
use App\Domains\Purchase\Services\CheckoutService;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TelepagosWebhookService
{
public function __construct(
private readonly CheckoutService $checkoutService,
) {}
/**
* Handle the Telepagos webhook notification.
*
* @throws Exception
*/
public function handleWebhook(Client $client, string $cashinId): void
{
Log::channel('telepagos')->info('Telepagos webhook received.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
]);
$telepagosService = new TelepagosIntegrationService;
$telepagosService->forClient($client);
try {
$details = $telepagosService->getCashinDetails($cashinId);
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
$paymentData = [
'compra_id' => null,
'matched_purchase_ids' => null,
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
'amount' => $amount,
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
'qr_order_id' => $qrOrderId,
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
];
$transferenciaOperationIds = [1, 3, 11];
$qrOperationIds = [31, 37, 47];
$compra = null;
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
if (! $cuit) {
Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
]);
return;
}
$dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
->where('transfer_payer_dni', $dni)
->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
])
->where('payment_method', 'transfer')
->where('total', $amount)
->latest()
->get();
$paymentData['matched_purchase_ids'] = $purchases->pluck('id')->all();
$compra = $purchases->count() === 1 ? $purchases->first() : null;
if (! $compra) {
if ($purchases->count() > 1) {
TelepagosPayment::create($paymentData);
}
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'amount' => $amount,
'matches' => $purchases->count(),
]);
return;
}
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
if (! $qrOrderId) {
Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
]);
return;
}
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
if (! $telepagosQr) {
Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'qr_order_id' => $qrOrderId,
]);
return;
}
$compra = $telepagosQr->compra;
if (! $compra) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'qr_order_id' => $qrOrderId,
]);
return;
}
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
]);
return;
}
if (! in_array($compra->status, [
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
'purchase_status' => $compra->status,
]);
return;
}
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
if ($amount !== $totalAmount) {
Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
'cashin_amount' => $amount,
'purchase_amount' => $totalAmount,
]);
return;
}
} else {
Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'operation_id' => $operationId,
]);
return;
}
$paymentData['compra_id'] = $compra->id;
DB::transaction(function () use ($compra, $paymentData) {
TelepagosPayment::create($paymentData);
$this->checkoutService->confirmPaidPurchase($compra);
});
Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'purchase_id' => $compra->id,
'transaction_id' => $paymentData['transaction_id'],
]);
} catch (Exception $e) {
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
'client_code' => $client->code,
'cashin_id' => $cashinId,
'error' => $e->getMessage(),
]);
throw $e;
}
}
protected function normalizeAmount(mixed $amount): string
{
return number_format((float) $amount, 2, '.', '');
}
}

View File

@@ -16,15 +16,18 @@ Gestiona integraciones externas disponibles y su configuración por cliente. Un
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
- `MailService`: envío de correo usando la integración configurada.
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
## Endpoints
- CRUD global bajo `/integrations`.
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
## Logging de Telepagos
Los eventos de autenticación, QR y consultas de cuenta se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
## Dependencias y reglas
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs.
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.

View File

@@ -1,10 +1,22 @@
<?php
use App\Domains\Integration\Controllers\ClientIntegrationController;
use App\Domains\Integration\Controllers\IntegrationController;
use App\Domains\Integration\Controllers\TelepagosWebhookController;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'integrations'], function () {
Route::get('/', [IntegrationController::class, 'index']);
Route::post('/', [IntegrationController::class, 'store']);
Route::get('/{integration}', [IntegrationController::class, 'show']);
Route::put('/{integration}', [IntegrationController::class, 'update']);
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
});
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
Route::get('/', [ClientIntegrationController::class, 'index']);
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
});
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\MailTest\Controllers;
use App\Domains\MailTest\Requests\SendTestMailRequest;
use App\Domains\MailTest\Services\MailTestService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class MailTestController extends Controller
{
public function __construct(
protected MailTestService $mailTestService,
) {}
public function __invoke(SendTestMailRequest $request, string $tenantCode): JsonResponse
{
$tenant = Tenant::query()
->where('codigo', $tenantCode)
->firstOrFail();
return response()->json(
$this->mailTestService->send(
$tenant,
$request->validated('to'),
$request->validated('subject'),
$request->validated('message'),
)
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\MailTest\Mailables;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class TestMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly string $mailSubject,
public readonly string $mailMessage,
public readonly Tenant $tenant,
) {}
public function envelope(): Envelope
{
return new Envelope(subject: $this->mailSubject);
}
public function content(): Content
{
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
return new Content(
view: 'mail.test',
with: [
'tenant' => $this->tenant,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
],
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\MailTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendTestMailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'to' => ['required', 'string', 'email', 'max:255'],
'subject' => ['nullable', 'string', 'max:255'],
'message' => ['nullable', 'string', 'max:5000'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\MailTest\Services;
use App\Domains\Integration\Services\MailService;
use App\Domains\Tenant\Models\Tenant;
class MailTestService
{
/**
* @return array<string, string>
*/
public function send(Tenant $tenant, string $recipient, ?string $subject = null, ?string $message = null): array
{
$subject ??= 'Prueba de correo de Shopit';
$message ??= 'Este es un correo de prueba enviado desde Shopit.';
$mailService = (new MailService)->forTenant($tenant->codigo);
$mailService->send(
$recipient,
$subject,
'<h1 style="margin: 0 0 20px;">'.e($subject).'</h1>'
.'<p>'.nl2br(e($message)).'</p>',
);
return [
'code' => 'mail.test_sent',
'message' => __('api.mail.test_sent'),
'recipient' => $recipient,
'tenant_code' => $tenant->codigo,
'mailer' => $mailService->mailerName(),
'sent_at' => now()->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,24 @@
# Dominio MailTest
## Propósito
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
## Componentes
- `MailTestController`: endpoint invocable de envío.
- `SendTestMailRequest`: valida destinatario y contenido requerido.
- `MailTestService`: coordina el envío de prueba.
- `TestMail`: mailable utilizado para construir el mensaje.
## Endpoint
- `POST /{tenant_code}/mail-test/send`.
## Dependencias
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
## Consideraciones
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.

View File

@@ -0,0 +1,6 @@
<?php
use App\Domains\MailTest\Controllers\MailTestController;
use Illuminate\Support\Facades\Route;
Route::post('{tenant_code}/mail-test/send', MailTestController::class);

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class MenuController extends Controller
{
public function index(): JsonResponse
{
$menues = Menu::all();
return response()->json($menues);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'code' => 'required|string|unique:menues,code',
'label' => 'required|string|max:255',
'parent_menu_code' => [
'nullable',
'string',
Rule::exists('menues', 'code'),
'different:code',
],
'content_type' => [
'sometimes',
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
],
'static_content_schema' => 'required_if:content_type,static|nullable|array',
'route' => 'required|string',
]);
$menu = Menu::create($validated);
return response()->json($menu, 201);
}
public function show(Menu $menu): JsonResponse
{
return response()->json($menu);
}
public function update(Request $request, Menu $menu): JsonResponse
{
$validated = $request->validate([
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
'label' => 'sometimes|required|string|max:255',
'parent_menu_code' => [
'nullable',
'string',
Rule::exists('menues', 'code'),
Rule::notIn([$menu->code]),
],
'content_type' => [
'sometimes',
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
],
'static_content_schema' => 'required_if:content_type,static|nullable|array',
'route' => 'sometimes|required|string',
]);
$menu->update($validated);
return response()->json($menu);
}
public function destroy(Menu $menu): JsonResponse
{
$menu->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Menu\Controllers;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
use App\Domains\Menu\Services\TenantMenuService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class TenantMenuController extends Controller
{
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
public function store(
StoreTenantMenuRequest $request,
string $tenantCode,
string $menuCode,
): JsonResponse {
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
$tenantMenu = $this->tenantMenuService->configure(
$tenant,
$menu,
$request->validated('static_content'),
);
return response()->json($tenantMenu);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Domains\Menu\Requests;
use App\Domains\Menu\Models\Menu;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class StoreTenantMenuRequest extends FormRequest
{
private ?Menu $menuModel = null;
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$this->menuModel = Menu::query()
->where('code', $this->route('menu_code'))
->first();
if (! $this->menuModel) {
throw ValidationException::withMessages([
'menu_code' => __('api.menu.not_found'),
]);
}
}
public function rules(): array
{
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
return [
'static_content' => ['nullable', 'array'],
];
}
$rules = [
'static_content' => ['required', 'array'],
];
foreach ($this->menuModel->static_content_schema as $field => $rule) {
$rules["static_content.{$field}"] = $rule;
}
return $rules;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Menu\Services;
use App\Domains\Menu\Models\Menu;
use App\Domains\Menu\Models\TenantMenu;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Facades\DB;
class TenantMenuService
{
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
{
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
[
'tenant_code' => $tenant->codigo,
'menu_code' => $menu->code,
],
[
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
? $staticContent
: null,
],
));
}
}

View File

@@ -10,6 +10,15 @@ Define menús disponibles y permite configurar su contenido para cada tenant y r
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
- `MenuRole`: asociación entre menú y rol autorizado.
## Servicios
`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático.
## Endpoints
- Recurso REST `/menues` mediante `MenuController`.
- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant.
## Dependencias y reglas
Depende de `Tenant` y `Authorization`. Los menús se configuran mediante seeders y relaciones internas; no se exponen endpoints públicos de administración.
Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`.

View File

@@ -0,0 +1,12 @@
<?php
use App\Domains\Menu\Controllers\MenuController;
use App\Domains\Menu\Controllers\TenantMenuController;
use Illuminate\Support\Facades\Route;
Route::apiResource('menues', MenuController::class);
Route::post(
'{tenant_code}/menues/{menu_code}',
[TenantMenuController::class, 'store']
);

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Domains\Notification\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TicketsAvailable
{
use Dispatchable, SerializesModels;
/**
* @param array<int, int> $ticketIds
*/
public function __construct(
public readonly Purchase $purchase,
public readonly array $ticketIds,
) {}
}

View File

@@ -6,6 +6,8 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Throwable;
class SendPasswordResetEmail implements ShouldQueueAfterCommit
{
@@ -20,10 +22,21 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
public function handle(PasswordResetRequested $event): void
{
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
try {
app(NotificationMailService::class)->sendPasswordResetCode(
$event->attemptId,
$event->tenantCode,
$event->channel,
);
} catch (Throwable $exception) {
Log::error('Failed to send password reset email.', [
'attempt_id' => $event->attemptId,
'tenant_code' => $event->tenantCode,
'channel' => $event->channel,
'exception' => $exception,
]);
throw $exception;
}
}
}

View File

@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
@@ -20,6 +20,6 @@ class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
public function handle(PurchasePaid $event): void
{
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Notification\Listeners;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
use Illuminate\Queue\InteractsWithQueue;
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
{
use InteractsWithQueue;
public string $queue = 'emails';
public int $tries = 3;
/** @var array<int, int> */
public array $backoff = [30, 120, 300];
public function handle(TicketsAvailable $event): void
{
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds);
}
}

View File

@@ -9,43 +9,28 @@ use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\TicketPdfService;
use App\Domains\Ticket\Services\TicketPresentationResolver;
use Closure;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
class NotificationMailService
{
public function __construct(
private readonly MailService $mailService,
private readonly TicketPdfService $ticketPdfService,
) {}
public function sendWelcome(int $userId, string $tenantCode): void
{
$this->sendLogged('welcome', [
'user_id' => $userId,
'tenant_code' => $tenantCode,
], function () use ($userId, $tenantCode): array {
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$brand = $tenant->websiteType ?? $tenant;
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
$user = User::query()->findOrFail($userId);
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$brand->nombre}",
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
$brand,
);
return [
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
];
});
$this->mailService
->forTenant($tenantCode)
->send(
$user->email,
"Bienvenido a {$tenant->nombre}",
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
);
}
public function sendPasswordResetCode(
@@ -53,161 +38,95 @@ class NotificationMailService
string $tenantCode,
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
): void {
$context = [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'channel' => $channel,
];
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
$tenant = Tenant::query()
->with('websiteType')
->where('codigo', $tenantCode)
->firstOrFail();
$attempt = ResetPasswordAttempt::query()
->with('user')
->findOrFail($attemptId);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
'attempt_id' => $attemptId,
'tenant_code' => $tenantCode,
'attempt_status' => $attempt->status,
]);
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
$this->logSkipped('password_reset', array_merge($context, [
'reason' => 'attempt_not_pending',
'attempt_status' => $attempt->status,
'user_id' => $attempt->user_id,
]));
return;
}
return null;
}
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$recoveryDomain = match ($channel) {
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
default => $tenant->dominio,
};
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
&& $tenant->base_path !== '/'
? $tenant->base_path
: '';
$recoveryQuery = ['email' => $attempt->user->email];
if (
$channel === PasswordResetRequested::CHANNEL_SCANNER
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
) {
$recoveryQuery['code'] = $attempt->codigo;
}
$recoveryUrl = $recoveryDomain === null
? null
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
$brand = $tenant->websiteType ?? $tenant;
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$brand->nombre}",
view('mail.notifications.password-reset', [
'attempt' => $attempt,
'recoveryUrl' => $recoveryUrl,
'brand' => $brand,
])->render(),
$brand,
);
return [
'user_id' => $attempt->user_id,
'recovery_domain_available' => $recoveryDomain !== null,
];
});
$this->mailService
->forTenant($tenantCode)
->send(
$attempt->user->email,
"Código para recuperar tu contraseña - {$tenant->nombre}",
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
);
}
public function sendPurchaseConfirmed(int $purchaseId): void
public function sendPurchasePaid(int $purchaseId): void
{
$context = ['purchase_id' => $purchaseId];
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->findOrFail($purchaseId);
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
$purchase = Purchase::query()
->with(['tenant', 'user', 'items'])
->find($purchaseId);
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Pago confirmado - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
);
}
if ($purchase === null) {
$this->logSkipped('purchase_confirmed', array_merge($context, [
'reason' => 'purchase_not_found',
'missing_model' => Purchase::class,
]));
/** @param array<int, int> $ticketIds */
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
{
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('tenant_code', $purchase->tenant_codigo)
->where('user_id', $purchase->user_id)
->whereKey($ticketIds)
->with(TicketPresentationResolver::RELATIONS)
->get();
return null;
}
if ($tickets->isEmpty()) {
return;
}
/** @var Collection<int, Ticket> $tickets */
$tickets = Ticket::query()
->where('source_purchase_id', $purchase->getKey())
->where('tenant_code', $purchase->tenant_codigo)
->with(TicketPresentationResolver::RELATIONS)
->get();
$attachments = $tickets->isEmpty()
? []
: [[
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
'name' => $this->ticketPdfService->filename($tickets),
'mime' => 'application/pdf',
]];
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
"Compra confirmada - Compra #{$purchase->getKey()}",
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
attachments: $attachments,
);
return [
'tenant_code' => $purchase->tenant_codigo,
'user_id' => $purchase->user_id,
'purchase_status' => $purchase->status,
'purchase_item_count' => $purchase->items->count(),
'ticket_count' => $tickets->count(),
'ticket_ids' => $tickets->modelKeys(),
];
});
$this->mailService
->forTenant($purchase->tenant_codigo)
->send(
$this->recipientFor($purchase),
'Tus tickets ya están disponibles',
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
);
}
private function recipientFor(Purchase $purchase): string
{
return (string) ($purchase->email ?: $purchase->user?->email);
}
/**
* @param array<string, mixed> $context
* @param Closure(): (array<string, mixed>|null) $send
*/
private function sendLogged(string $emailType, array $context, Closure $send): void
{
try {
$resultContext = $send();
if ($resultContext === null) {
return;
}
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
'email_type' => $emailType,
'mailer' => $this->mailService->mailerName(),
]));
} catch (Throwable $exception) {
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
'email_type' => $emailType,
'exception' => $exception,
]));
throw $exception;
}
}
/** @param array<string, mixed> $context */
private function logSkipped(string $emailType, array $context): void
{
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
'email_type' => $emailType,
]));
}
}

View File

@@ -8,11 +8,12 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
- `UserRegistered`: dispara el correo de bienvenida.
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
- `PurchasePaid`: envía la confirmación de pago.
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
## Componentes
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
## API y dependencias
@@ -22,6 +23,4 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
- La recuperación no se envía si el intento dejó de estar pendiente.
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.

View File

@@ -36,7 +36,6 @@ class PurchaseController extends Controller
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))
@@ -93,6 +92,7 @@ class PurchaseController extends Controller
PaymentIntentRequest $request,
Tenant $tenant,
Purchase $compra,
CheckoutService $checkoutService,
PurchaseStateGuard $purchaseState,
): JsonResponse {
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
@@ -101,12 +101,7 @@ class PurchaseController extends Controller
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
: null;
$updated = DB::transaction(function () use (
$compra,
$method,
$purchaseState,
$transferPayerDni,
): bool {
$updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
/** @var Purchase|null $purchase */
$purchase = Purchase::query()
->whereKey($compra->getKey())
@@ -133,6 +128,9 @@ class PurchaseController extends Controller
$purchaseUpdate = [
'payment_method' => $method,
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->addMinutes(
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
),
'total' => $purchase->calculateCurrentTotalAmount(),
];
@@ -152,6 +150,7 @@ class PurchaseController extends Controller
$compra->refresh();
$totalAmount = (float) $compra->total;
$checkoutService->syncReservationExpiration($compra);
if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService;
@@ -243,6 +242,15 @@ class PurchaseController extends Controller
], 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,

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Purchase\Events;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
@@ -9,5 +10,5 @@ class PurchasePaid
{
use Dispatchable, SerializesModels;
public function __construct(public readonly int $purchaseId) {}
public function __construct(public readonly Purchase $purchase) {}
}

View File

@@ -19,11 +19,11 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'stock_reservation_id',
'tenant_codigo',
'user_id',
'status',
'payment_method',
'expires_at',
'total',
'dni',
'transfer_payer_dni',
@@ -77,8 +77,8 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
];
}
@@ -123,10 +123,10 @@ class Purchase extends Model
return $this->hasMany(Ticket::class, 'source_purchase_id');
}
/** @return BelongsTo<StockReservation, $this> */
public function stockReservation(): BelongsTo
/** @return HasMany<StockReservation, $this> */
public function stockReservations(): HasMany
{
return $this->belongsTo(StockReservation::class);
return $this->hasMany(StockReservation::class);
}
/**
@@ -193,7 +193,7 @@ class Purchase extends Model
'status' => self::STATUS_PAID,
]);
PurchasePaid::dispatch($this->getKey());
PurchasePaid::dispatch($this);
});
}
}

View File

@@ -17,8 +17,6 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
@@ -50,11 +48,7 @@ class PurchaseResource extends JsonResource
'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,
'expires_at' => $this->expires_at,
'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono,

View File

@@ -18,6 +18,33 @@ class CompleteCheckoutService
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 {
@@ -34,7 +61,10 @@ class CompleteCheckoutService
$this->purchaseState->lockCurrentCart($purchase);
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
@@ -42,8 +72,9 @@ class CompleteCheckoutService
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null,
]);
$this->reservations->clearExpirationForReview($purchase);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadPurchase($purchase);
});
@@ -130,20 +161,31 @@ class CompleteCheckoutService
]);
}
}
try {
$this->reservations->commit($purchase);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
try {
$this->reservations->commit($cartItem, $selection, $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 */
@@ -152,7 +194,7 @@ class CompleteCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment', 'stockReservation']);
return $purchase->load(['items.imageAttachment']);
}
private function itemKey(int $catalogItemId, ?int $variantId): string

View File

@@ -8,6 +8,6 @@ class PurchaseResponseLoader
{
public function load(Purchase $purchase): Purchase
{
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
return $purchase->load(['tenant', 'items.imageAttachment']);
}
}

View File

@@ -5,10 +5,11 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Throwable;
class ReleaseCheckoutService
{
@@ -31,6 +32,40 @@ class ReleaseCheckoutService
return $this->release($purchase, Purchase::STATUS_EXPIRED);
}
public function expireOverdue(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
try {
$purchase = $this->expire($purchase);
} catch (Throwable $exception) {
Log::channel('commands')->error('Failed to expire overdue purchase.', [
'command' => 'reservations:expire',
'purchase_id' => $purchase->getKey(),
'tenant_codigo' => $purchase->tenant_codigo,
'cart_id' => $purchase->cart_id,
'status' => $purchase->status,
'expires_at' => $purchase->expires_at,
'exception' => $exception,
]);
return;
}
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function release(
Purchase $purchase,
string $targetStatus,
@@ -43,11 +78,6 @@ class ReleaseCheckoutService
): Purchase {
$purchase = $this->lockPurchase($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);
@@ -70,31 +100,14 @@ class ReleaseCheckoutService
return $this->loadPurchase($purchase);
}
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active'
&& in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$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))
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
) {
return $this->loadPurchase($purchase);
}
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$this->releasePurchaseReservations($purchase, $targetStatus);
$purchase->update(['status' => $targetStatus]);
@@ -102,67 +115,60 @@ class ReleaseCheckoutService
});
}
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'),
]);
}
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
{
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($cart === null) {
return;
}
if ($targetStatus === Purchase::STATUS_EXPIRED
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
if ($cart->status === 'active') {
$this->reservations->detachFromPurchase($purchase);
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,
->update(['current_purchase_id' => null]);
return;
}
if ($cart->status !== 'checkout') {
return;
}
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$cartItems->load([
'catalogItem.inventory',
'catalogItem.bundleComponents.catalogItem.inventory',
'catalogItem.bundleComponents.variant.inventory',
'variant.inventory',
'variant.catalogItem',
]);
foreach ($cartItems as $cartItem) {
$selection = $cartItem->selectedItem();
if ($selection === null) {
continue;
}
try {
$this->reservations->release(
$cartItem,
$selection,
(int) $cartItem->cantidad,
$targetStatus === Purchase::STATUS_EXPIRED
? StockReservation::STATUS_EXPIRED
: StockReservation::STATUS_RELEASED,
);
} catch (\InvalidArgumentException) {
throw ValidationException::withMessages([
'items' => __('api.purchase.inconsistent_reservation'),
]);
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->update(['status' => 'converted']);
$cart->delete();
}
}
@@ -212,17 +218,6 @@ class ReleaseCheckoutService
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();
return $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
@@ -13,7 +12,6 @@ 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;
@@ -171,31 +169,21 @@ class StartCheckoutService
'cantidad' => $line['quantity'],
]);
try {
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
throw new InsufficientStockException([
$this->unavailableItem($line, $availableQuantity),
]);
}
$cartItem->setRelation('catalogItem', $line['catalog_item']);
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
$cartItems->push($cartItem);
}
try {
$this->reservations->syncCart($cart);
} catch (\InvalidArgumentException) {
$unavailable = $resolvedLines
->map(function (array $line): ?array {
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
return $availableQuantity !== null && $availableQuantity < $line['quantity']
? $this->unavailableItem($line, $availableQuantity)
: null;
})
->filter()
->values()
->all();
throw new InsufficientStockException($unavailable !== [] ? $unavailable : [
$this->unavailableItem($resolvedLines->first(), 0),
]);
}
$purchase = $this->createPurchase(
$tenant,
$userId,
@@ -206,12 +194,19 @@ class StartCheckoutService
$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));
foreach ($cartItems as $index => $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$resolvedLines->get($index)['selection'],
$purchase,
);
}
return $this->loadPurchase($purchase);
}
@@ -262,7 +257,6 @@ class StartCheckoutService
$this->verifyTenantItems($tenant, $cartItems);
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
$cart->setRelation('items', $cartItems);
$this->reservations->syncCart($cart);
$purchase = $this->createPurchase(
$tenant,
@@ -272,9 +266,16 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
foreach ($cartItems as $cartItem) {
$this->reservations->attachToPurchase(
$cartItem,
$cartItem->selectedItem(),
$purchase,
);
}
return $this->loadPurchase($purchase);
}
@@ -315,9 +316,9 @@ class StartCheckoutService
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
}
@@ -330,11 +331,7 @@ class StartCheckoutService
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status === Cart::STATUS_EXPIRED) {
throw new StockReservationExpiredException;
}
if ($cart->status !== Cart::STATUS_ACTIVE) {
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
@@ -408,17 +405,13 @@ class StartCheckoutService
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'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
{

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
@@ -22,6 +23,7 @@ class CheckoutService
private readonly EditCheckoutService $editor,
private readonly CompleteCheckoutService $completer,
private readonly ReleaseCheckoutService $releaser,
private readonly StockReservationService $reservations,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -30,6 +32,11 @@ class CheckoutService
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);
@@ -70,4 +77,14 @@ class CheckoutService
{
return $this->releaser->expire($purchase);
}
public function expireOverduePurchases(): int
{
return $this->releaser->expireOverdue();
}
public function syncReservationExpiration(Purchase $purchase): void
{
$this->reservations->syncPurchaseExpiration($purchase);
}
}

View File

@@ -3,7 +3,6 @@
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;
@@ -12,32 +11,15 @@ class PurchaseStateGuard
{
public function assertNotExpired(Purchase $purchase): void
{
if ($purchase->status === Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if (! in_array($purchase->status, [
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
$hasExpiredByTime = in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
return;
}
], true)
&& $purchase->expires_at !== null
&& $purchase->expires_at->isPast();
/** @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()
)
)) {
if ($hasExpiredStatus || $hasExpiredByTime) {
throw new PurchaseExpiredException;
}
}

View File

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

View File

@@ -88,9 +88,9 @@ class UserPurchaseLimitService
$excludedCartId !== null,
fn ($query) => $query->whereKeyNot($excludedCartId),
))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->whereHas('stockReservations', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->whereNull('purchase_id'))
->sum('cantidad');
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
@@ -161,9 +161,9 @@ class UserPurchaseLimitService
->whereHas('cart', fn ($query) => $query
->where('user_id', $userId)
->where('status', 'active'))
->whereHas('cart.currentStockReservation', fn ($query) => $query
->whereHas('stockReservations', fn ($query) => $query
->where('status', 'active')
->whereDoesntHave('purchase'))
->whereNull('purchase_id'))
->groupBy('catalog_item_id')
->pluck('quantity', 'catalog_item_id');

View File

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

View File

@@ -9,6 +9,7 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
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

@@ -9,21 +9,18 @@ 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
@@ -97,27 +94,4 @@ class SaleController extends Controller
$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('timezone'),
);
}
}

View File

@@ -1,211 +0,0 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Shared\Date;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AdminAppSaleExcelService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): StreamedResponse
{
$generatedAt = now();
$spreadsheet = $this->spreadsheet($tenant, 'Historial de ventas');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Ventas');
$sheet->fromArray([
'ID',
'Fecha',
'Cliente',
'Cantidad',
'Estado',
'Importe',
'Tickets',
], null, 'A1');
foreach ($sales->values() as $index => $sale) {
$row = $index + 2;
$sheet->setCellValueExplicit("A{$row}", '#'.$sale->id, DataType::TYPE_STRING);
if ($sale->created_at) {
$sheet->setCellValue(
"B{$row}",
Date::dateTimeToExcel($sale->created_at->copy()->timezone($timeZone)),
);
}
$sheet->setCellValueExplicit(
"C{$row}",
$sale->nombre_apellido ?: 'Sin nombre',
DataType::TYPE_STRING,
);
$sheet->setCellValue("D{$row}", (int) ($sale->quantity ?? 0));
$sheet->setCellValue("E{$row}", $this->saleStatus($sale->status));
$sheet->setCellValue("F{$row}", (float) $sale->total);
$sheet->setCellValue("G{$row}", (int) ($sale->tickets_count ?? 0));
}
$lastRow = max(2, $sales->count() + 1);
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
$sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00');
$this->formatSheet($spreadsheet, 'A1:G1', "A1:G{$lastRow}", [
'A' => 13,
'B' => 20,
'C' => 32,
'D' => 12,
'E' => 22,
'F' => 16,
'G' => 12,
]);
return $this->download(
$spreadsheet,
'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(
Tenant $tenant,
Collection $modifications,
string $timeZone,
): StreamedResponse {
$generatedAt = now();
$spreadsheet = $this->spreadsheet($tenant, 'Historial de modificaciones de ventas');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Modificaciones');
$sheet->fromArray([
'Fecha',
'Hora',
'Venta',
'Cliente',
'Campo',
'Valor anterior',
'Valor nuevo',
'Modificado por',
], null, 'A1');
foreach ($modifications->values() as $index => $modification) {
$row = $index + 2;
$changedAt = $modification->changed_at->copy()->timezone($timeZone);
$sale = $modification->trackable;
$sheet->setCellValue("A{$row}", Date::dateTimeToExcel($changedAt));
$sheet->setCellValue("B{$row}", Date::dateTimeToExcel($changedAt));
$sheet->setCellValueExplicit(
"C{$row}",
'#'.$modification->trackable_id,
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"D{$row}",
$sale?->nombre_apellido ?: 'Sin nombre',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"E{$row}",
$modification->attribute,
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"F{$row}",
$modification->old_value ?? '-',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"G{$row}",
$modification->new_value ?? '-',
DataType::TYPE_STRING,
);
$sheet->setCellValueExplicit(
"H{$row}",
$modification->user?->nombre_apellido ?? 'Sistema',
DataType::TYPE_STRING,
);
}
$lastRow = max(2, $modifications->count() + 1);
$sheet->getStyle("A2:A{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy');
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('hh:mm:ss');
$this->formatSheet($spreadsheet, 'A1:H1', "A1:H{$lastRow}", [
'A' => 14,
'B' => 12,
'C' => 13,
'D' => 32,
'E' => 20,
'F' => 24,
'G' => 24,
'H' => 28,
]);
return $this->download(
$spreadsheet,
'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
);
}
private function spreadsheet(Tenant $tenant, string $title): Spreadsheet
{
$spreadsheet = new Spreadsheet;
$spreadsheet->getProperties()
->setCreator('Shopit')
->setTitle($title)
->setSubject($tenant->nombre);
return $spreadsheet;
}
/** @param array<string, int> $widths */
private function formatSheet(
Spreadsheet $spreadsheet,
string $headerRange,
string $filterRange,
array $widths,
): void {
$sheet = $spreadsheet->getActiveSheet();
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '26382E'],
],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($filterRange);
foreach ($widths as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
}
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
{
return response()->streamDownload(function () use ($spreadsheet): void {
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
]);
}
private function saleStatus(string $status): string
{
return match ($status) {
Purchase::STATUS_PAID => 'Confirmado',
Purchase::STATUS_CREATED => 'Por completar datos',
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
default => 'Anulado',
};
}
}

View File

@@ -8,7 +8,6 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
- `SaleController`: entrada HTTP del panel.
@@ -17,8 +16,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
- `GET /sales` y `GET /sales/pdf`.
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
## Dependencias
@@ -26,4 +25,4 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
## Consideraciones
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.

View File

@@ -8,10 +8,8 @@ Route::prefix('v1/adminapp/tenant')
->group(function (): void {
Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/excel', [SaleController::class, 'downloadExcel']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
Route::get('sales/modifications/excel', [SaleController::class, 'downloadModificationsExcel']);
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');

View File

@@ -6,8 +6,5 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('staff', [AdminAppStaffController::class, 'index']);
Route::post('staff', [AdminAppStaffController::class, 'store']);
Route::put('staff/{staff}', [AdminAppStaffController::class, 'update']);
Route::delete('staff/{staff}', [AdminAppStaffController::class, 'destroy']);
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
});

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\StorageTest\Controllers;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
use App\Domains\StorageTest\Services\S3TestService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
class S3TestController extends Controller
{
public function __construct(
protected AttachmentService $attachmentService,
protected S3TestService $s3TestService,
) {
}
public function store(StoreS3TestFileRequest $request): JsonResponse
{
$attachment = $this->attachmentService->store(
$request->file('file') ?? (string) $request->validated('file_base64'),
$request->validated('path'),
);
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
$attachment->path,
(int) $request->validated('expires_in_minutes', 10),
);
return response()->json(
[
'id' => $attachment->id,
'key' => $attachment->key,
'path' => $attachment->path,
'filename' => $attachment->filename,
'type' => $attachment->type->value,
'mime_type' => $attachment->mime_type,
'extension' => $attachment->extension,
'size' => $attachment->size,
'temporary_url' => $temporaryUrl['temporary_url'],
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
],
201,
);
}
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
{
return response()->json(
$this->s3TestService->generateTemporaryUrl(
$request->validated('path'),
(int) $request->validated('expires_in_minutes', 10),
)
);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\StorageTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class GenerateS3TemporaryUrlRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'path' => ['required', 'string', 'max:2048'],
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\StorageTest\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreS3TestFileRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<int, string>>
*/
public function rules(): array
{
return [
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
'file_base64' => ['nullable', 'string', 'required_without:file'],
'path' => ['required', 'string', 'max:2048'],
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
];
}
}

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