Compare commits
64 Commits
feature/al
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d123cd403 | |||
| 3164a00e6c | |||
| 36cbc8da29 | |||
| 58e027cad5 | |||
| 971c5f6cd9 | |||
| 2524f00dfd | |||
| 045fcfab72 | |||
| cfe29b41a9 | |||
| bd600649b6 | |||
| 7685089540 | |||
| 889e188deb | |||
| 4a166e3cbf | |||
| d8b3a354d9 | |||
| 44f75185a1 | |||
| 3adef9341e | |||
| 3b1698ffd1 | |||
| c436302d7a | |||
| 899bf12457 | |||
| a460743ae0 | |||
| a4d7b1b789 | |||
| 10655b8c07 | |||
| adc7b21ab8 | |||
| ba1355448a | |||
| 1c76e56f2d | |||
| daa74845b7 | |||
| 05944cec9c | |||
| c820741e5f | |||
| adc4cc9595 | |||
| c67dfcc1a1 | |||
| f61b7e4dee | |||
| e53fa949cf | |||
| c51e24311f | |||
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 | |||
| e2ad78b1fb | |||
| 8ed8a11b7b | |||
| dc33e5dc09 | |||
| 414df97e9d | |||
| d0424c5589 | |||
| 28d09dedab | |||
| 8220fb5aaf | |||
| bf02d37a33 | |||
| 78ce263849 | |||
| 0c8419a5eb | |||
| 843659583e | |||
| c2921166bd | |||
| 0f67e66b6b | |||
| 725abed06a | |||
| 743939fc63 | |||
| eb50e65fef | |||
| 1cf13501d8 | |||
| 2eeef8d392 | |||
| 87a4fa6288 | |||
| 07d2129410 |
@@ -5,9 +5,6 @@ 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
|
||||
|
||||
@@ -31,6 +28,7 @@ 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
|
||||
@@ -41,6 +39,8 @@ 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
@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if (! $this->resetPasswordAttemptService->validateCode(
|
||||
$result = $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'),
|
||||
]);
|
||||
|
||||
@@ -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'])]
|
||||
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||
#[Hidden(['codigo'])]
|
||||
class ResetPasswordAttempt extends Model
|
||||
{
|
||||
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ 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,
|
||||
@@ -146,12 +152,12 @@ class ResetPasswordAttemptService
|
||||
);
|
||||
}
|
||||
|
||||
public function validateCode(string $email, string $code): bool
|
||||
public function validateCode(string $email, string $code): string
|
||||
{
|
||||
$emailFingerprint = $this->emailFingerprint($email);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->lockForUpdate()
|
||||
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
|
||||
'email_fingerprint' => $emailFingerprint,
|
||||
]);
|
||||
|
||||
return false;
|
||||
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;
|
||||
}
|
||||
|
||||
$attempt->update([
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
|
||||
return true;
|
||||
return self::CODE_VALID;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Failed to validate password reset code.', [
|
||||
@@ -214,6 +233,19 @@ 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;
|
||||
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
|
||||
'codigo' => $this->generateCode(),
|
||||
'reason' => $reason,
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||
]);
|
||||
|
||||
return $attempt->getKey();
|
||||
|
||||
@@ -30,6 +30,7 @@ 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||
->where('dominio', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||
->where('scanner_domain', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
@@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
'status',
|
||||
'origin',
|
||||
'current_purchase_id',
|
||||
'current_stock_reservation_id',
|
||||
])]
|
||||
class Cart extends Model
|
||||
{
|
||||
@@ -36,6 +38,16 @@ 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';
|
||||
@@ -45,6 +57,7 @@ class Cart extends Model
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'current_purchase_id' => 'integer',
|
||||
'current_stock_reservation_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,6 +97,15 @@ 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')
|
||||
@@ -107,6 +129,7 @@ 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)
|
||||
@@ -140,12 +163,11 @@ class Cart extends Model
|
||||
'cantidad' => $quantity,
|
||||
]);
|
||||
} else {
|
||||
app(StockReservationService::class)->ensure($item, $selectedItem);
|
||||
$item->cantidad += $quantity;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
@@ -172,6 +194,7 @@ class Cart extends Model
|
||||
$excludedPurchaseId,
|
||||
): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -205,7 +228,6 @@ class Cart extends Model
|
||||
$nextAvailableQuantity,
|
||||
);
|
||||
|
||||
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
|
||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
@@ -222,11 +244,10 @@ class Cart extends Model
|
||||
->first();
|
||||
|
||||
if ($targetItem !== null) {
|
||||
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
|
||||
$targetItem->cantidad += $quantity;
|
||||
$targetItem->save();
|
||||
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
|
||||
$item->delete();
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $targetItem->fresh();
|
||||
}
|
||||
@@ -234,7 +255,7 @@ class Cart extends Model
|
||||
$item->variant_id = $variantId;
|
||||
$item->cantidad = $quantity;
|
||||
$item->save();
|
||||
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $item->fresh();
|
||||
}
|
||||
@@ -263,16 +284,9 @@ class Cart extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
if ($delta > 0) {
|
||||
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
|
||||
}
|
||||
|
||||
if ($delta < 0) {
|
||||
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
|
||||
}
|
||||
|
||||
$item->cantidad = $quantity;
|
||||
$item->save();
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
@@ -282,6 +296,7 @@ class Cart extends Model
|
||||
{
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -289,17 +304,8 @@ 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -341,13 +347,20 @@ 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)->detachFromPurchase($currentPurchase);
|
||||
app(StockReservationService::class)->releaseForPurchase(
|
||||
$currentPurchase,
|
||||
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
|
||||
);
|
||||
self::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $currentPurchase->getKey())
|
||||
|
||||
@@ -3,13 +3,11 @@
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'cart_id',
|
||||
@@ -57,10 +55,4 @@ class CartItem extends Model
|
||||
{
|
||||
return $this->variant ?? $this->catalogItem;
|
||||
}
|
||||
|
||||
/** @return HasMany<StockReservation, $this> */
|
||||
public function stockReservations(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockReservation::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ 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;
|
||||
@@ -22,7 +25,7 @@ class CartService
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
||||
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
@@ -119,7 +122,7 @@ class CartService
|
||||
{
|
||||
$cart = new Cart([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$cart->setRelation('items', collect());
|
||||
@@ -220,12 +223,14 @@ class CartService
|
||||
{
|
||||
return Cart::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('status', 'active')
|
||||
->where('origin', Cart::ORIGIN_USER)
|
||||
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||
->when(
|
||||
$identity['user_id'] !== null,
|
||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||
)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -234,7 +239,7 @@ class CartService
|
||||
*/
|
||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||
|
||||
if ($cart === null) {
|
||||
throw new NotFoundHttpException('Cart not found.');
|
||||
@@ -247,10 +252,73 @@ 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' => 'active',
|
||||
'status' => Cart::STATUS_ACTIVE,
|
||||
'origin' => Cart::ORIGIN_USER,
|
||||
];
|
||||
|
||||
if ($identity['user_id'] !== null) {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Models\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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
|
||||
|
||||
## Modelo
|
||||
|
||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
|
||||
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
||||
|
||||
## Servicios
|
||||
|
||||
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
||||
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
|
||||
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||
|
||||
## Endpoints
|
||||
@@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
|
||||
|
||||
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
||||
|
||||
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.
|
||||
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, 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.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class StockReservationExpiredException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('api.cart.reservation_expired'));
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
|
||||
'type',
|
||||
'slug',
|
||||
'nombre',
|
||||
'group_order',
|
||||
'descripcion',
|
||||
'precio',
|
||||
'inventory_policy',
|
||||
@@ -47,6 +48,7 @@ class CatalogItem extends Model
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'inventory_subject' => InventorySubject::Product->value,
|
||||
'has_tickets' => false,
|
||||
'group_order' => 0,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -56,6 +58,7 @@ class CatalogItem extends Model
|
||||
'brand_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'type' => CatalogItemType::class,
|
||||
'group_order' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
'inventory_subject' => InventorySubject::class,
|
||||
@@ -172,17 +175,29 @@ class CatalogItem extends Model
|
||||
}
|
||||
|
||||
/** @param Builder<CatalogItem> $query */
|
||||
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
||||
public function scopeWhereAvailable(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereDoesntHave('variants')
|
||||
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||
->orWhereHas(
|
||||
'variants.inventory',
|
||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||
);
|
||||
)
|
||||
->orWhere(function (Builder $directItemQuery): void {
|
||||
$directItemQuery
|
||||
->whereDoesntHave('variants')
|
||||
->where(function (Builder $inventoryQuery): void {
|
||||
$inventoryQuery
|
||||
->whereNull('catalog_items.inventory_id')
|
||||
->orWhereHas(
|
||||
'inventory',
|
||||
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,10 +48,10 @@ class Inventory extends Model
|
||||
return $this->hasOne(Variant::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<StockReservation, $this> */
|
||||
public function stockReservations(): HasMany
|
||||
/** @return HasMany<StockReservationLine, $this> */
|
||||
public function stockReservationLines(): HasMany
|
||||
{
|
||||
return $this->hasMany(StockReservation::class);
|
||||
return $this->hasMany(StockReservationLine::class);
|
||||
}
|
||||
|
||||
public function availableStock(): int
|
||||
|
||||
@@ -2,21 +2,20 @@
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable([
|
||||
'inventory_id',
|
||||
'cart_item_id',
|
||||
'purchase_id',
|
||||
'quantity',
|
||||
'status',
|
||||
'expires_at',
|
||||
'committed_at',
|
||||
'released_at',
|
||||
'expired_at',
|
||||
'release_reason',
|
||||
])]
|
||||
class StockReservation extends Model
|
||||
{
|
||||
@@ -31,31 +30,28 @@ class StockReservation extends Model
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'inventory_id' => 'integer',
|
||||
'cart_item_id' => 'integer',
|
||||
'purchase_id' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'committed_at' => 'datetime',
|
||||
'released_at' => 'datetime',
|
||||
'expired_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
/** @return HasMany<StockReservationLine, $this> */
|
||||
public function lines(): HasMany
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
return $this->hasMany(StockReservationLine::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<CartItem, $this> */
|
||||
public function cartItem(): BelongsTo
|
||||
/** @return HasOne<Cart, $this> */
|
||||
public function currentCart(): HasOne
|
||||
{
|
||||
return $this->belongsTo(CartItem::class);
|
||||
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function purchase(): BelongsTo
|
||||
/** @return HasOne<Purchase, $this> */
|
||||
public function purchase(): HasOne
|
||||
{
|
||||
return $this->belongsTo(Purchase::class);
|
||||
return $this->hasOne(Purchase::class);
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'stock_reservation_id',
|
||||
'inventory_id',
|
||||
'quantity',
|
||||
'tracks_inventory',
|
||||
])]
|
||||
class StockReservationLine extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'stock_reservation_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'quantity' => 'integer',
|
||||
'tracks_inventory' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<StockReservation, $this> */
|
||||
public function reservation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
|
||||
),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'group_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource
|
||||
$availableStock,
|
||||
$remainingUserQuota,
|
||||
),
|
||||
'variants' => $catalogItem->variants
|
||||
'variants' => $catalogItem->visibleVariants()
|
||||
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
||||
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
@@ -115,7 +115,7 @@ class CatalogFeaturedItemResource extends JsonResource
|
||||
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
||||
{
|
||||
$attachment = $catalogItem->attachments->first()
|
||||
?? $catalogItem->variants
|
||||
?? $catalogItem->visibleVariants()
|
||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||
->first();
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$availableStock = $this->availableStock();
|
||||
$visibleVariants = $this->visibleVariants();
|
||||
$attachment = $this->attachments->first()
|
||||
?? $this->variants
|
||||
?? $visibleVariants
|
||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||
->first();
|
||||
|
||||
@@ -30,7 +31,7 @@ class CatalogSearchItemResource extends JsonResource
|
||||
'image' => $attachment?->getTemporaryUrl(1440),
|
||||
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
|
||||
'unavailable_message' => $this->unavailableMessage($availableStock),
|
||||
'variants' => $this->variants
|
||||
'variants' => $visibleVariants
|
||||
->map(function (Variant $variant): array {
|
||||
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
|
||||
@@ -25,6 +25,24 @@ class CatalogInventoryService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (array $requirement): array => [
|
||||
...$requirement,
|
||||
'quantity' => $requirement['quantity'] * $quantity,
|
||||
],
|
||||
$this->inventoryRequirements($selection),
|
||||
);
|
||||
}
|
||||
|
||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||
{
|
||||
if ($selection instanceof CatalogItem
|
||||
|
||||
@@ -205,6 +205,13 @@ class CatalogService
|
||||
]);
|
||||
|
||||
$visibleVariants = $catalogItem->visibleVariants();
|
||||
if ($catalogItem->type === CatalogItemType::Standard
|
||||
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
||||
&& ! $catalogItem->isAvailable()) {
|
||||
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||
}
|
||||
|
||||
$catalogItem->setRelation('variants', $visibleVariants);
|
||||
$selectedVariant = $variantId === null
|
||||
? $visibleVariants->first()
|
||||
: $visibleVariants->firstWhere('id', $variantId);
|
||||
@@ -231,6 +238,7 @@ class CatalogService
|
||||
|
||||
$paginator = CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereAvailable()
|
||||
->where(function (Builder $query) use ($containsPattern): void {
|
||||
$query
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
@@ -280,6 +288,7 @@ class CatalogService
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->whereAvailable()
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
|
||||
@@ -2,50 +2,184 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ExpireStockReservationsService
|
||||
{
|
||||
private const BATCH_SIZE = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkout,
|
||||
private readonly ExpireCartReservationsService $carts,
|
||||
private readonly ReleaseCheckoutService $purchases,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{purchases: int, cart_items: int}
|
||||
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||
*/
|
||||
public function expireOverdue(): array
|
||||
{
|
||||
$expiredPurchases = null;
|
||||
$expiredCartItems = null;
|
||||
$summary = [
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$lastReservationId = 0;
|
||||
|
||||
try {
|
||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
||||
$expiredCartItems = $this->carts->expireOverdue();
|
||||
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');
|
||||
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $expiredPurchases,
|
||||
'expired_cart_items' => $expiredCartItems,
|
||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
||||
]);
|
||||
foreach ($reservationIds as $reservationId) {
|
||||
$lastReservationId = (int) $reservationId;
|
||||
|
||||
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,
|
||||
]);
|
||||
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);
|
||||
|
||||
throw $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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ class FeaturedGroupService
|
||||
{
|
||||
$query = CatalogItem::query()
|
||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||
->whereAvailable()
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereDoesntHave('category')
|
||||
@@ -81,7 +82,9 @@ class FeaturedGroupService
|
||||
FeaturedGroupSource::Category => $query
|
||||
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||
->orderBy('catalog_items.id'),
|
||||
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
||||
FeaturedGroupSource::All => $query
|
||||
->orderBy('catalog_items.group_order')
|
||||
->orderBy('catalog_items.id'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -2,262 +2,486 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Models\StockReservationLine;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class StockReservationService
|
||||
{
|
||||
public const REASON_CART_EMPTY = 'cart_empty';
|
||||
|
||||
public const REASON_CART_CHANGED = 'cart_changed';
|
||||
|
||||
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
|
||||
|
||||
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
|
||||
|
||||
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
|
||||
|
||||
public const REASON_MANUAL_RELEASE = 'manual_release';
|
||||
|
||||
public function __construct(
|
||||
private readonly CatalogInventoryService $inventory,
|
||||
) {}
|
||||
|
||||
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
||||
public function syncCart(Cart $cart): ?StockReservation
|
||||
{
|
||||
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
|
||||
$this->inventory->reserve($selection, $quantity);
|
||||
$this->recordIncrease($cartItem, $selection, $quantity);
|
||||
});
|
||||
}
|
||||
return DB::transaction(function () use ($cart): ?StockReservation {
|
||||
/** @var Cart $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
|
||||
$this->loadSelections($items);
|
||||
$requirements = $this->requirementsForItems($items);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
$reservation = $lockedCart->current_stock_reservation_id === null
|
||||
? null
|
||||
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||
|
||||
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 ($reservation !== null) {
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
}
|
||||
|
||||
$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.');
|
||||
if ($requirements === []) {
|
||||
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
|
||||
$this->finalizeLocked(
|
||||
$reservation,
|
||||
StockReservation::STATUS_RELEASED,
|
||||
self::REASON_CART_EMPTY,
|
||||
);
|
||||
}
|
||||
|
||||
$reservation->update([
|
||||
'status' => StockReservation::STATUS_COMMITTED,
|
||||
'committed_at' => now(),
|
||||
'expires_at' => null,
|
||||
]);
|
||||
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||
$cart->current_stock_reservation_id = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
|
||||
{
|
||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
||||
|
||||
foreach ($requirements as $inventoryId => $quantity) {
|
||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||
|
||||
if ($reservation === null) {
|
||||
StockReservation::query()->create([
|
||||
'inventory_id' => $inventoryId,
|
||||
'cart_item_id' => $cartItem->getKey(),
|
||||
'quantity' => $quantity,
|
||||
$reservation = StockReservation::query()->create([
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
|
||||
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function attachToPurchase(
|
||||
CartItem $cartItem,
|
||||
CatalogItem|Variant $selection,
|
||||
Purchase $purchase,
|
||||
): void {
|
||||
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
||||
$this->ensure($cartItem, $selection);
|
||||
StockReservation::query()
|
||||
->where('cart_item_id', $cartItem->getKey())
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->update([
|
||||
'purchase_id' => $purchase->getKey(),
|
||||
'expires_at' => $purchase->expires_at,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function detachFromPurchase(Purchase $purchase): void
|
||||
{
|
||||
StockReservation::query()
|
||||
->where('purchase_id', $purchase->getKey())
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->update([
|
||||
'purchase_id' => null,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void
|
||||
{
|
||||
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)
|
||||
$currentLines = StockReservationLine::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
->orderBy('inventory_id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('inventory_id');
|
||||
|
||||
$hasCompleteReservation = collect($requirements)->every(
|
||||
fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity,
|
||||
);
|
||||
|
||||
if ($hasCompleteReservation) {
|
||||
return;
|
||||
}
|
||||
|
||||
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')
|
||||
$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();
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($sourceReservations as $sourceReservation) {
|
||||
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_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 ($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(),
|
||||
]);
|
||||
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;
|
||||
}
|
||||
|
||||
$targetReservation->update([
|
||||
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
$sourceReservation->delete();
|
||||
StockReservationLine::query()->updateOrCreate(
|
||||
[
|
||||
'stock_reservation_id' => $reservation->getKey(),
|
||||
'inventory_id' => $inventoryId,
|
||||
],
|
||||
[
|
||||
'quantity' => $required,
|
||||
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$reservation->update([
|
||||
'expires_at' => $this->expiration(),
|
||||
'release_reason' => null,
|
||||
]);
|
||||
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
public function attachToPurchase(
|
||||
Cart $cart,
|
||||
Purchase $purchase,
|
||||
Carbon $expiresAt,
|
||||
): StockReservation {
|
||||
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||
/** @var Cart $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
/** @var Purchase $lockedPurchase */
|
||||
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
|
||||
if ($lockedCart->current_stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$linkedPurchase = Purchase::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
->whereKeyNot($lockedPurchase->getKey())
|
||||
->exists();
|
||||
if ($linkedPurchase) {
|
||||
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
|
||||
}
|
||||
|
||||
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||
$reservation->update(['expires_at' => $expiresAt]);
|
||||
$purchase->stock_reservation_id = $reservation->getKey();
|
||||
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
public function commit(Purchase $purchase): void
|
||||
{
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
|
||||
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
|
||||
return;
|
||||
}
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
$lines = $this->lockLines($reservation);
|
||||
if ($lines->isEmpty()) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
|
||||
}
|
||||
|
||||
$inventories = $this->lockInventories($lines);
|
||||
foreach ($lines as $line) {
|
||||
$inventory = $inventories->get($line->inventory_id)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||
if ($inventory->reserved_stock < $line->quantity
|
||||
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$inventories->get($line->inventory_id)->buy(
|
||||
(int) $line->quantity,
|
||||
(bool) $line->tracks_inventory,
|
||||
);
|
||||
}
|
||||
|
||||
$reservation->update([
|
||||
'status' => StockReservation::STATUS_COMMITTED,
|
||||
'expires_at' => null,
|
||||
'committed_at' => now(),
|
||||
'released_at' => null,
|
||||
'expired_at' => null,
|
||||
'release_reason' => null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseForPurchase(
|
||||
Purchase $purchase,
|
||||
string $status = StockReservation::STATUS_RELEASED,
|
||||
?string $reason = null,
|
||||
): void {
|
||||
DB::transaction(function () use ($purchase, $status, $reason): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
|
||||
if ($reservation !== null) {
|
||||
$this->finalizeLocked($reservation, $status, $reason);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
||||
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||
{
|
||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
||||
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 ($reservation === null) {
|
||||
StockReservation::query()->create([
|
||||
'inventory_id' => $inventoryId,
|
||||
'cart_item_id' => $cartItem->getKey(),
|
||||
'quantity' => $requiredQuantity,
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
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.');
|
||||
}
|
||||
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$purchase->update(['stock_reservation_id' => null]);
|
||||
$cart->update(['current_purchase_id' => null]);
|
||||
$reservation->update(['expires_at' => $this->expiration()]);
|
||||
|
||||
return $reservation->fresh('lines');
|
||||
});
|
||||
}
|
||||
|
||||
private function recordDecrease(
|
||||
CartItem $cartItem,
|
||||
CatalogItem|Variant $selection,
|
||||
int $quantity,
|
||||
string $releasedStatus,
|
||||
public function assertCartReservationUsable(Cart $cart): void
|
||||
{
|
||||
DB::transaction(function () use ($cart): void {
|
||||
/** @var Cart $cart */
|
||||
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
if ($cart->current_stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($cart->current_stock_reservation_id);
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseCurrentCartReservation(
|
||||
Cart $cart,
|
||||
string $reason = self::REASON_CART_CHANGED,
|
||||
): void {
|
||||
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.');
|
||||
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;
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
|
||||
if ($reservation !== null) {
|
||||
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
|
||||
}
|
||||
$cart->update(['current_stock_reservation_id' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
public function expire(StockReservation $reservation): void
|
||||
{
|
||||
DB::transaction(function () use ($reservation): void {
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||
|| $reservation->expires_at === null
|
||||
|| $reservation->expires_at->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||
});
|
||||
}
|
||||
|
||||
public function clearExpirationForReview(Purchase $purchase): void
|
||||
{
|
||||
DB::transaction(function () use ($purchase): void {
|
||||
/** @var Purchase $purchase */
|
||||
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||
}
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($purchase->stock_reservation_id);
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
$reservation->update(['expires_at' => null]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CartItem> $items
|
||||
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||
*/
|
||||
private function requirementsForItems(Collection $items): array
|
||||
{
|
||||
$requirements = [];
|
||||
foreach ($items as $item) {
|
||||
$selection = $item->selectedItem();
|
||||
if ($selection === null) {
|
||||
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
|
||||
}
|
||||
|
||||
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
|
||||
if (isset($requirements[$inventoryId])) {
|
||||
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
|
||||
$requirements[$inventoryId]['tracks_inventory'] =
|
||||
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$requirements[$inventoryId] = $requirement;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($requirements);
|
||||
|
||||
return $requirements;
|
||||
}
|
||||
|
||||
/** @param Collection<int, CartItem> $items */
|
||||
private function loadSelections(Collection $items): void
|
||||
{
|
||||
$items->load([
|
||||
'catalogItem.inventory',
|
||||
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||
'catalogItem.bundleComponents.variant.inventory',
|
||||
'catalogItem.bundleComponents.variant.catalogItem',
|
||||
'variant.inventory',
|
||||
'variant.catalogItem',
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return Collection<int, StockReservationLine> */
|
||||
private function lockLines(StockReservation $reservation): Collection
|
||||
{
|
||||
return StockReservationLine::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
->orderBy('inventory_id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, StockReservationLine> $lines
|
||||
* @return Collection<int, Inventory>
|
||||
*/
|
||||
private function lockInventories(Collection $lines): Collection
|
||||
{
|
||||
return Inventory::query()
|
||||
->whereKey($lines->pluck('inventory_id'))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
}
|
||||
|
||||
private function finalizeLocked(
|
||||
StockReservation $reservation,
|
||||
string $status,
|
||||
?string $reason,
|
||||
): void {
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
|
||||
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
|
||||
}
|
||||
|
||||
$lines = $this->lockLines($reservation);
|
||||
$inventories = $this->lockInventories($lines);
|
||||
foreach ($lines as $line) {
|
||||
$inventory = $inventories->get($line->inventory_id)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||
$inventory->release((int) $line->quantity);
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$reservation->update([
|
||||
'status' => $status,
|
||||
'expires_at' => null,
|
||||
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
|
||||
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
|
||||
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||
]);
|
||||
|
||||
if ($status === StockReservation::STATUS_RELEASED) {
|
||||
Cart::query()
|
||||
->where('current_stock_reservation_id', $reservation->getKey())
|
||||
->update(['current_stock_reservation_id' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
|
||||
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||
{
|
||||
return StockReservation::query()
|
||||
->where('cart_item_id', $cartItem->getKey())
|
||||
->where('inventory_id', $inventoryId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||
|| $reservation->expires_at === null) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||
}
|
||||
}
|
||||
|
||||
private function expiration(): Carbon
|
||||
|
||||
@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
||||
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||
- `Inventory` administra stock disponible, reservado y comprado.
|
||||
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
|
||||
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
|
||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||
- `BundleComponent` representa los componentes de un paquete.
|
||||
@@ -18,18 +18,14 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
||||
|
||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
|
||||
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||
|
||||
## Endpoints de tienda
|
||||
|
||||
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
||||
|
||||
## Endpoints administrativos
|
||||
|
||||
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
||||
|
||||
## Dependencias y reglas
|
||||
|
||||
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?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');
|
||||
});
|
||||
@@ -15,5 +15,3 @@ 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';
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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,
|
||||
])),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Client\Controllers\ClientController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::apiResource('clients', ClientController::class);
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
{
|
||||
@@ -48,10 +47,4 @@ class EntryController extends Controller
|
||||
));
|
||||
}
|
||||
|
||||
public function destroyImage(Request $request): Response
|
||||
{
|
||||
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,19 +166,6 @@ 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
|
||||
{
|
||||
|
||||
@@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner
|
||||
if ($purchaseId !== null) {
|
||||
DB::table('compras')->where('id', $purchaseId)->update([
|
||||
'status' => 'paid',
|
||||
'expires_at' => null,
|
||||
'total' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner
|
||||
'cart_id' => null,
|
||||
'status' => 'paid',
|
||||
'payment_method' => self::PAYMENT_METHOD,
|
||||
'expires_at' => null,
|
||||
'total' => 0,
|
||||
'dni' => null,
|
||||
'transfer_payer_dni' => null,
|
||||
@@ -392,15 +390,27 @@ class InvitationPurchaseProvisioner
|
||||
'sold_units' => $inventory->sold_units + 1,
|
||||
]);
|
||||
|
||||
DB::table('stock_reservations')->insert([
|
||||
$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,
|
||||
'inventory_id' => $inventory->id,
|
||||
'cart_item_id' => null,
|
||||
'purchase_id' => $purchaseId,
|
||||
'quantity' => 1,
|
||||
'status' => 'committed',
|
||||
'expires_at' => null,
|
||||
'committed_at' => $now,
|
||||
'released_at' => null,
|
||||
'tracks_inventory' => true,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
@@ -14,6 +14,4 @@ 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');
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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 ?? '')],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
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;
|
||||
@@ -70,24 +72,35 @@ class MailService extends BaseIntegrationService
|
||||
return [];
|
||||
}
|
||||
|
||||
public function send(string|array $recipient, string $subject, string $content): void
|
||||
{
|
||||
/**
|
||||
* @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 {
|
||||
if (! $this->mailer || ! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
$brand ??= $this->tenant;
|
||||
$branding = $this->brandingFor($brand);
|
||||
|
||||
$html = Blade::render(
|
||||
<<<'BLADE'
|
||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
{!! $content !!}
|
||||
</x-mail.branded-layout>
|
||||
BLADE,
|
||||
[
|
||||
'tenant' => $this->tenant,
|
||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||
'branding' => $branding,
|
||||
'headerLogoUrl' => $brand instanceof WebsiteType
|
||||
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||
'content' => $content,
|
||||
],
|
||||
);
|
||||
@@ -96,6 +109,14 @@ 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);
|
||||
}
|
||||
|
||||
@@ -106,6 +127,36 @@ 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) {
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
<?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, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -16,18 +16,15 @@ 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, 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.
|
||||
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.
|
||||
|
||||
## 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; los webhooks deben validar su contrato antes de alterar una compra.
|
||||
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.
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
<?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']);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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'),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,6 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\MailTest\Controllers\MailTestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::post('{tenant_code}/mail-test/send', MailTestController::class);
|
||||
@@ -1,79 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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,
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -10,15 +10,6 @@ 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 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`.
|
||||
Depende de `Tenant` y `Authorization`. Los menús se configuran mediante seeders y relaciones internas; no se exponen endpoints públicos de administración.
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?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']
|
||||
);
|
||||
@@ -1,20 +0,0 @@
|
||||
<?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,
|
||||
) {}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ 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
|
||||
{
|
||||
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||
|
||||
public function handle(PasswordResetRequested $event): void
|
||||
{
|
||||
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;
|
||||
}
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$event->attemptId,
|
||||
$event->tenantCode,
|
||||
$event->channel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
||||
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
|
||||
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
||||
|
||||
public function handle(PurchasePaid $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
|
||||
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -9,28 +9,43 @@ 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
|
||||
{
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||
$user = User::query()->findOrFail($userId);
|
||||
$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;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$user->email,
|
||||
"Bienvenido a {$tenant->nombre}",
|
||||
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
|
||||
);
|
||||
$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',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function sendPasswordResetCode(
|
||||
@@ -38,95 +53,161 @@ class NotificationMailService
|
||||
string $tenantCode,
|
||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||
): void {
|
||||
$tenant = Tenant::query()
|
||||
->with('websiteType')
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
$attempt = ResetPasswordAttempt::query()
|
||||
->with('user')
|
||||
->findOrFail($attemptId);
|
||||
$context = [
|
||||
'attempt_id' => $attemptId,
|
||||
'tenant_code' => $tenantCode,
|
||||
'channel' => $channel,
|
||||
];
|
||||
|
||||
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,
|
||||
]);
|
||||
$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);
|
||||
|
||||
return;
|
||||
}
|
||||
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,
|
||||
]));
|
||||
|
||||
$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);
|
||||
return 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(),
|
||||
);
|
||||
$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,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function sendPurchasePaid(int $purchaseId): void
|
||||
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||
{
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->findOrFail($purchaseId);
|
||||
$context = ['purchase_id' => $purchaseId];
|
||||
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
"Pago confirmado - Compra #{$purchase->getKey()}",
|
||||
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
|
||||
);
|
||||
}
|
||||
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->find($purchaseId);
|
||||
|
||||
/** @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();
|
||||
if ($purchase === null) {
|
||||
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||
'reason' => 'purchase_not_found',
|
||||
'missing_model' => Purchase::class,
|
||||
]));
|
||||
|
||||
if ($tickets->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$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(),
|
||||
);
|
||||
/** @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(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,11 @@ 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 pago.
|
||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
||||
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||
|
||||
## Componentes
|
||||
|
||||
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`.
|
||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||
|
||||
## API y dependencias
|
||||
|
||||
@@ -23,4 +22,6 @@ 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.
|
||||
|
||||
@@ -36,6 +36,7 @@ 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))
|
||||
@@ -92,7 +93,6 @@ class PurchaseController extends Controller
|
||||
PaymentIntentRequest $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
CheckoutService $checkoutService,
|
||||
PurchaseStateGuard $purchaseState,
|
||||
): JsonResponse {
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
@@ -101,7 +101,12 @@ 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())
|
||||
@@ -128,9 +133,6 @@ 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(),
|
||||
];
|
||||
|
||||
@@ -150,7 +152,6 @@ class PurchaseController extends Controller
|
||||
|
||||
$compra->refresh();
|
||||
$totalAmount = (float) $compra->total;
|
||||
$checkoutService->syncReservationExpiration($compra);
|
||||
|
||||
if ($method === 'transfer') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
@@ -242,15 +243,6 @@ 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,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Purchase\Events;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
@@ -10,5 +9,5 @@ class PurchasePaid
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly Purchase $purchase) {}
|
||||
public function __construct(public readonly int $purchaseId) {}
|
||||
}
|
||||
|
||||
@@ -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 HasMany<StockReservation, $this> */
|
||||
public function stockReservations(): HasMany
|
||||
/** @return BelongsTo<StockReservation, $this> */
|
||||
public function stockReservation(): BelongsTo
|
||||
{
|
||||
return $this->hasMany(StockReservation::class);
|
||||
return $this->belongsTo(StockReservation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +193,7 @@ class Purchase extends Model
|
||||
'status' => self::STATUS_PAID,
|
||||
]);
|
||||
|
||||
PurchasePaid::dispatch($this);
|
||||
PurchasePaid::dispatch($this->getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ 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();
|
||||
@@ -48,7 +50,11 @@ class PurchaseResource extends JsonResource
|
||||
'created_at' => $this->created_at,
|
||||
'status' => $this->status,
|
||||
'payment_method' => $this->payment_method,
|
||||
'expires_at' => $this->expires_at,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_in_seconds' => $expiresAt === null
|
||||
? null
|
||||
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
|
||||
'server_time' => $serverTime,
|
||||
'dni' => $this->dni,
|
||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||
'telefono' => $this->telefono,
|
||||
|
||||
@@ -18,33 +18,6 @@ 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 {
|
||||
@@ -61,10 +34,7 @@ class CompleteCheckoutService
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
@@ -72,9 +42,8 @@ class CompleteCheckoutService
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
$this->reservations->syncPurchaseExpiration($purchase);
|
||||
$this->reservations->clearExpirationForReview($purchase);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
@@ -161,31 +130,20 @@ class CompleteCheckoutService
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->reservations->commit($cartItem, $selection, $purchase);
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->reservations->commit($purchase);
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->sourceCart->finalize($purchase);
|
||||
});
|
||||
}
|
||||
|
||||
private function isTerminal(Purchase $purchase): bool
|
||||
{
|
||||
return in_array($purchase->status, [
|
||||
Purchase::STATUS_PAID,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
Purchase::STATUS_CANCELLED,
|
||||
Purchase::STATUS_REJECTED,
|
||||
Purchase::STATUS_EXPIRED,
|
||||
Purchase::STATUS_SUPERSEDED,
|
||||
], true);
|
||||
}
|
||||
|
||||
private function lockPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
/** @var Purchase */
|
||||
@@ -194,7 +152,7 @@ class CompleteCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['items.imageAttachment']);
|
||||
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
|
||||
private function itemKey(int $catalogItemId, ?int $variantId): string
|
||||
|
||||
@@ -8,6 +8,6 @@ class PurchaseResponseLoader
|
||||
{
|
||||
public function load(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['tenant', 'items.imageAttachment']);
|
||||
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\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
|
||||
{
|
||||
@@ -32,40 +31,6 @@ 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,
|
||||
@@ -78,6 +43,11 @@ 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);
|
||||
@@ -100,14 +70,31 @@ 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
|
||||
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
|
||||
&& (! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true) || ! $this->hasOverdueActiveReservation($purchase))
|
||||
) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$this->releasePurchaseReservations($purchase, $targetStatus);
|
||||
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
|
||||
|
||||
$purchase->update(['status' => $targetStatus]);
|
||||
|
||||
@@ -115,60 +102,67 @@ class ReleaseCheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
|
||||
{
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
private function releasePurchaseReservations(
|
||||
Purchase $purchase,
|
||||
string $targetStatus,
|
||||
?Cart $cart,
|
||||
): void {
|
||||
try {
|
||||
$this->reservations->releaseForPurchase(
|
||||
$purchase,
|
||||
$targetStatus === Purchase::STATUS_EXPIRED
|
||||
? StockReservation::STATUS_EXPIRED
|
||||
: StockReservation::STATUS_RELEASED,
|
||||
$targetStatus === Purchase::STATUS_CANCELLED
|
||||
? StockReservationService::REASON_PURCHASE_CANCELLED
|
||||
: ($targetStatus === Purchase::STATUS_REJECTED
|
||||
? StockReservationService::REASON_PAYMENT_REJECTED
|
||||
: null),
|
||||
);
|
||||
} catch (\InvalidArgumentException) {
|
||||
throw ValidationException::withMessages([
|
||||
'items' => __('api.purchase.inconsistent_reservation'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($cart === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status === 'active') {
|
||||
$this->reservations->detachFromPurchase($purchase);
|
||||
if ($targetStatus === Purchase::STATUS_EXPIRED
|
||||
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->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'),
|
||||
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
|
||||
->update([
|
||||
'status' => Cart::STATUS_EXPIRED,
|
||||
'current_purchase_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status === Cart::STATUS_ACTIVE) {
|
||||
$cartUpdate = [
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
];
|
||||
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->update($cartUpdate);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status !== Cart::STATUS_CHECKOUT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $cart->trashed()) {
|
||||
$cart->update(['status' => 'converted']);
|
||||
$cart->update(['status' => Cart::STATUS_CONVERTED]);
|
||||
$cart->delete();
|
||||
}
|
||||
}
|
||||
@@ -218,6 +212,17 @@ class ReleaseCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['items.imageAttachment']);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -12,6 +13,7 @@ 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;
|
||||
@@ -169,21 +171,31 @@ class StartCheckoutService
|
||||
'cantidad' => $line['quantity'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
|
||||
|
||||
throw new InsufficientStockException([
|
||||
$this->unavailableItem($line, $availableQuantity),
|
||||
]);
|
||||
}
|
||||
|
||||
$cartItem->setRelation('catalogItem', $line['catalog_item']);
|
||||
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
|
||||
$cartItems->push($cartItem);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->reservations->syncCart($cart);
|
||||
} catch (\InvalidArgumentException) {
|
||||
$unavailable = $resolvedLines
|
||||
->map(function (array $line): ?array {
|
||||
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
|
||||
|
||||
return $availableQuantity !== null && $availableQuantity < $line['quantity']
|
||||
? $this->unavailableItem($line, $availableQuantity)
|
||||
: null;
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
throw new InsufficientStockException($unavailable !== [] ? $unavailable : [
|
||||
$this->unavailableItem($resolvedLines->first(), 0),
|
||||
]);
|
||||
}
|
||||
|
||||
$purchase = $this->createPurchase(
|
||||
$tenant,
|
||||
$userId,
|
||||
@@ -194,19 +206,12 @@ 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);
|
||||
}
|
||||
|
||||
@@ -257,6 +262,7 @@ class StartCheckoutService
|
||||
$this->verifyTenantItems($tenant, $cartItems);
|
||||
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
|
||||
$cart->setRelation('items', $cartItems);
|
||||
$this->reservations->syncCart($cart);
|
||||
|
||||
$purchase = $this->createPurchase(
|
||||
$tenant,
|
||||
@@ -266,16 +272,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -316,9 +315,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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -331,7 +330,11 @@ class StartCheckoutService
|
||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
||||
}
|
||||
|
||||
if ($cart->status !== 'active') {
|
||||
if ($cart->status === Cart::STATUS_EXPIRED) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
if ($cart->status !== Cart::STATUS_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
@@ -405,13 +408,17 @@ 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
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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;
|
||||
@@ -23,7 +22,6 @@ class CheckoutService
|
||||
private readonly EditCheckoutService $editor,
|
||||
private readonly CompleteCheckoutService $completer,
|
||||
private readonly ReleaseCheckoutService $releaser,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $purchaseData */
|
||||
@@ -32,11 +30,6 @@ 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);
|
||||
@@ -77,14 +70,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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;
|
||||
@@ -11,15 +12,32 @@ class PurchaseStateGuard
|
||||
{
|
||||
public function assertNotExpired(Purchase $purchase): void
|
||||
{
|
||||
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
|
||||
$hasExpiredByTime = in_array($purchase->status, [
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
|
||||
if (! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)
|
||||
&& $purchase->expires_at !== null
|
||||
&& $purchase->expires_at->isPast();
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($hasExpiredStatus || $hasExpiredByTime) {
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = $purchase->relationLoaded('stockReservation')
|
||||
? $purchase->getRelation('stockReservation')
|
||||
: ($purchase->exists
|
||||
? $purchase->stockReservation()->first()
|
||||
: null);
|
||||
|
||||
if ($reservation !== null && (
|
||||
$reservation->status === StockReservation::STATUS_EXPIRED
|
||||
|| (
|
||||
$reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture()
|
||||
)
|
||||
)) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +135,25 @@ class TenantTransactionResetService
|
||||
*/
|
||||
private function reservationQuery(array $scope): Builder
|
||||
{
|
||||
$reservationIds = DB::table('carritos')
|
||||
->whereIn('id', $scope['cart_ids'])
|
||||
->whereNotNull('current_stock_reservation_id')
|
||||
->pluck('current_stock_reservation_id')
|
||||
->merge(
|
||||
DB::table('compras')
|
||||
->whereIn('id', $scope['purchase_ids'])
|
||||
->whereNotNull('stock_reservation_id')
|
||||
->pluck('stock_reservation_id'),
|
||||
)
|
||||
->merge(
|
||||
DB::table('stock_reservation_lines')
|
||||
->whereIn('inventory_id', $scope['inventory_ids'])
|
||||
->pluck('stock_reservation_id'),
|
||||
)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return DB::table('stock_reservations')
|
||||
->where(function (Builder $query) use ($scope): void {
|
||||
$query->whereIn('inventory_id', $scope['inventory_ids'])
|
||||
->orWhereIn('purchase_id', $scope['purchase_ids'])
|
||||
->orWhereIn('cart_item_id', $scope['cart_item_ids']);
|
||||
});
|
||||
->whereIn('id', $reservationIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ class UserPurchaseLimitService
|
||||
$excludedCartId !== null,
|
||||
fn ($query) => $query->whereKeyNot($excludedCartId),
|
||||
))
|
||||
->whereHas('stockReservations', fn ($query) => $query
|
||||
->whereHas('cart.currentStockReservation', fn ($query) => $query
|
||||
->where('status', 'active')
|
||||
->whereNull('purchase_id'))
|
||||
->whereDoesntHave('purchase'))
|
||||
->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('stockReservations', fn ($query) => $query
|
||||
->whereHas('cart.currentStockReservation', fn ($query) => $query
|
||||
->where('status', 'active')
|
||||
->whereNull('purchase_id'))
|
||||
->whereDoesntHave('purchase'))
|
||||
->groupBy('catalog_item_id')
|
||||
->pluck('quantity', 'catalog_item_id');
|
||||
|
||||
|
||||
@@ -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`.
|
||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
|
||||
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
|
||||
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
||||
@@ -15,16 +15,16 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
|
||||
|
||||
`CheckoutService` es la fachada estable. Delega en:
|
||||
|
||||
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
|
||||
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `PurchaseItem`.
|
||||
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
|
||||
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
||||
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
||||
- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
|
||||
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
||||
|
||||
Al 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.
|
||||
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
||||
|
||||
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||
|
||||
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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']);
|
||||
});
|
||||
|
||||
@@ -9,18 +9,21 @@ 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
|
||||
@@ -94,4 +97,27 @@ 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'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
211
app/Domains/Sale/Services/AdminAppSaleExcelService.php
Normal file
211
app/Domains/Sale/Services/AdminAppSaleExcelService.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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.
|
||||
@@ -16,8 +17,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
||||
|
||||
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
|
||||
|
||||
- `GET /sales` y `GET /sales/pdf`.
|
||||
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
|
||||
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
|
||||
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
|
||||
|
||||
## Dependencias
|
||||
|
||||
@@ -25,4 +26,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 y PDF.
|
||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||
|
||||
@@ -8,8 +8,10 @@ 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');
|
||||
|
||||
@@ -6,5 +6,8 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<?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),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\StorageTest\Services;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class S3TestService
|
||||
{
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function storeTestFile(
|
||||
UploadedFile $file,
|
||||
?string $directory = null,
|
||||
int $expiresInMinutes = 10,
|
||||
): array {
|
||||
$directory = $this->normalizeDirectory($directory);
|
||||
$disk = Storage::disk('s3');
|
||||
$path = $disk->putFile($directory, $file);
|
||||
|
||||
if (! is_string($path) || $path === '') {
|
||||
Log::error('S3 upload returned an empty path.', [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'size' => $file->getSize(),
|
||||
]);
|
||||
|
||||
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
||||
}
|
||||
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'directory' => $directory,
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'filename' => basename($path),
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'mime_type' => $file->getClientMimeType(),
|
||||
'extension' => $file->extension(),
|
||||
'size' => $file->getSize(),
|
||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
||||
{
|
||||
return [
|
||||
'disk' => 's3',
|
||||
'key' => $path,
|
||||
'path' => $path,
|
||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||
}
|
||||
|
||||
protected function normalizeDirectory(?string $directory): string
|
||||
{
|
||||
$directory = trim((string) $directory, '/');
|
||||
|
||||
if ($directory !== '') {
|
||||
return $directory;
|
||||
}
|
||||
|
||||
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
# Dominio StorageTest
|
||||
|
||||
## Propósito
|
||||
|
||||
Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales.
|
||||
|
||||
## Componentes
|
||||
|
||||
- `S3TestController`: recibe solicitudes de carga y URL temporal.
|
||||
- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado.
|
||||
- `StoreS3TestFileRequest`: valida la carga.
|
||||
- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Bajo `/storage-test/s3`:
|
||||
|
||||
- `POST /upload`.
|
||||
- `GET /temporary-url`.
|
||||
|
||||
## Consideraciones
|
||||
|
||||
Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`.
|
||||
@@ -1,9 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\StorageTest\Controllers\S3TestController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('storage-test/s3')->group(function (): void {
|
||||
Route::post('upload', [S3TestController::class, 'store']);
|
||||
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
||||
});
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Tenant\Controllers\AdminApp;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
|
||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtraResource;
|
||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
@@ -26,20 +25,6 @@ class WebsiteExtraController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function showExtra(Request $request, string $websiteExtraCode): WebsiteExtraResource
|
||||
{
|
||||
$tenant = $this->loadTenant($request->user());
|
||||
$definition = $this->websiteExtraService->definitionForTenant($tenant, $websiteExtraCode);
|
||||
$websiteExtra = $tenant->websiteExtras
|
||||
->firstWhere('website_type_extra_id', $definition->id);
|
||||
|
||||
if (! $websiteExtra) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return WebsiteExtraResource::make($websiteExtra);
|
||||
}
|
||||
|
||||
public function update(
|
||||
UpdateWebsiteExtraRequest $request,
|
||||
string $websiteExtraCode
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Controllers;
|
||||
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||
use App\Domains\Tenant\Resources\TenantResource;
|
||||
use App\Domains\Tenant\Services\TenantInformationService;
|
||||
use App\Domains\Tenant\Services\TenantService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected TenantService $tenantService,
|
||||
protected TenantInformationService $tenantInformationService,
|
||||
) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$tenants = Tenant::query()
|
||||
->latest()
|
||||
->paginateFromRequest();
|
||||
|
||||
$this->tenantInformationService->loadMany($tenants->getCollection());
|
||||
|
||||
return TenantResource::collection($tenants)->response();
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = $this->tenantService->create($request->validated());
|
||||
|
||||
return TenantResource::make(
|
||||
$this->tenantInformationService->load($tenant)
|
||||
)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant): TenantResource
|
||||
{
|
||||
return TenantResource::make(
|
||||
$this->tenantInformationService->load($tenant)
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||
{
|
||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||
|
||||
return TenantResource::make(
|
||||
$this->tenantInformationService->load($tenant)
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): Response
|
||||
{
|
||||
$tenant->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
protected bool $hasInvalidDomain = false;
|
||||
|
||||
protected bool $hasInvalidBasePath = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$rawDomain = $this->input('dominio');
|
||||
$hasExplicitBasePath = $this->has('base_path');
|
||||
$rawBasePath = $hasExplicitBasePath
|
||||
? $this->input('base_path')
|
||||
: TenantDomainNormalizer::pathFromDomain($rawDomain);
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& $normalizedDomain === null;
|
||||
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|
||||
|| $normalizedBasePath === null;
|
||||
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
'base_path' => $normalizedBasePath,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$logoRule = ['required', new ImageOrBase64Rule];
|
||||
|
||||
return array_merge([
|
||||
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
|
||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidDomain) {
|
||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||
}
|
||||
},
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'dominio')
|
||||
->where('base_path', $this->input('base_path')),
|
||||
],
|
||||
'base_path' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidBasePath) {
|
||||
$fail("The {$attribute} field must contain a valid URL path.");
|
||||
}
|
||||
},
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'base_path')
|
||||
->where('dominio', $this->input('dominio')),
|
||||
],
|
||||
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'social_media' => ['sometimes', 'array'],
|
||||
'social_media.*.code' => [
|
||||
'required',
|
||||
'string',
|
||||
'distinct',
|
||||
Rule::exists('social_media', 'code'),
|
||||
],
|
||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
||||
'checkout_editing_policy' => [
|
||||
'sometimes',
|
||||
Rule::in([CartEditingPolicy::Disabled->value]),
|
||||
],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
'website_type_code' => [
|
||||
'required_with:extras',
|
||||
'sometimes',
|
||||
'string',
|
||||
Rule::exists('website_type', 'codigo'),
|
||||
],
|
||||
], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code')));
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||
use Closure;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
protected bool $hasInvalidDomain = false;
|
||||
|
||||
protected bool $hasInvalidBasePath = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('dominio')) {
|
||||
$rawDomain = $this->input('dominio');
|
||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
||||
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
|
||||
|
||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||
&& ($normalizedDomain === null || $embeddedBasePath === null);
|
||||
|
||||
$this->merge([
|
||||
'dominio' => $normalizedDomain,
|
||||
]);
|
||||
|
||||
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
|
||||
$this->merge(['base_path' => $embeddedBasePath]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->has('base_path')) {
|
||||
$rawBasePath = $this->input('base_path');
|
||||
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
|
||||
|
||||
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|
||||
|| $normalizedBasePath === null;
|
||||
|
||||
$this->merge(['base_path' => $normalizedBasePath]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $this->route('tenant');
|
||||
$domain = $this->input('dominio', $tenant?->dominio);
|
||||
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
|
||||
|
||||
$logoRule = ['nullable', new ImageOrBase64Rule];
|
||||
|
||||
return [
|
||||
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
|
||||
'codigo' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
||||
],
|
||||
'nombre' => ['nullable', 'string', 'max:255'],
|
||||
'dominio' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidDomain) {
|
||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
||||
}
|
||||
},
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'dominio')
|
||||
->where('base_path', $basePath)
|
||||
->ignore($tenant?->id),
|
||||
],
|
||||
'base_path' => [
|
||||
'bail',
|
||||
function (string $attribute, mixed $value, Closure $fail): void {
|
||||
if ($this->hasInvalidBasePath) {
|
||||
$fail("The {$attribute} field must contain a valid URL path.");
|
||||
}
|
||||
},
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('tenants', 'base_path')
|
||||
->where('dominio', $domain)
|
||||
->ignore($tenant?->id),
|
||||
],
|
||||
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
||||
'header_logo' => $logoRule,
|
||||
'footer_logo' => $logoRule,
|
||||
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
||||
'social_media' => ['sometimes', 'array'],
|
||||
'social_media.*.code' => [
|
||||
'required',
|
||||
'string',
|
||||
'distinct',
|
||||
Rule::exists('social_media', 'code'),
|
||||
],
|
||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
||||
'display_categories' => ['sometimes', 'boolean'],
|
||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
||||
'display_cart' => ['sometimes', 'boolean'],
|
||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
||||
'checkout_editing_policy' => [
|
||||
'sometimes',
|
||||
Rule::in([CartEditingPolicy::Disabled->value]),
|
||||
],
|
||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Tenant\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Models\AttachmentCrop;
|
||||
use App\Domains\Tenant\Models\WebsiteExtra;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin WebsiteExtra
|
||||
*/
|
||||
class WebsiteExtraResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'codigo' => $this->websiteTypeExtra->codigo,
|
||||
'nombre' => $this->websiteTypeExtra->nombre,
|
||||
'descripcion' => $this->websiteTypeExtra->descripcion,
|
||||
'is_required' => $this->websiteTypeExtra->is_required,
|
||||
'is_enabled' => $this->is_enabled,
|
||||
'request_rules' => $this->websiteTypeExtra->config_schema['request_rules'] ?? [],
|
||||
'config' => $this->formatConfig(
|
||||
$this->resolvedConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->key
|
||||
),
|
||||
'resolved_config' => $this->formatConfig(
|
||||
$this->resolvedAdminConfig(),
|
||||
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvedAdminConfig(): mixed
|
||||
{
|
||||
$config = $this->resolvedConfig();
|
||||
|
||||
if (
|
||||
$this->websiteTypeExtra->codigo !== 'heroConfig'
|
||||
|| ! is_array($config)
|
||||
|| ! ($config['background_image_id'] ?? null) instanceof Attachment
|
||||
) {
|
||||
return $config;
|
||||
}
|
||||
|
||||
$attachment = $config['background_image_id'];
|
||||
$fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0];
|
||||
$crops = $attachment->cropVariants->keyBy('variant');
|
||||
$config['background_image_id'] = [
|
||||
'url' => $attachment->getTemporaryUrl(1440),
|
||||
'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys(
|
||||
function (string $variant) use ($crops, $fullRange): array {
|
||||
$crop = $crops->get($variant);
|
||||
|
||||
return [$variant => [
|
||||
'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange,
|
||||
'crop_vertical' => $crop?->crop_vertical ?? $fullRange,
|
||||
]];
|
||||
}
|
||||
)->all(),
|
||||
];
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
private function formatConfig(mixed $value, callable $formatAttachment): mixed
|
||||
{
|
||||
if ($value instanceof Attachment) {
|
||||
return $formatAttachment($value);
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
|
||||
$value
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ class WebsiteTypeService
|
||||
&& $previousLogo->id !== $websiteType->site_logo
|
||||
&& $previousLogo->id !== $websiteType->footer_logo
|
||||
&& $previousLogo->id !== $websiteType->favicon_id
|
||||
&& ! $this->isReferencedByWebsiteType($previousLogo)
|
||||
) {
|
||||
$this->attachmentService->delete($previousLogo);
|
||||
}
|
||||
@@ -90,4 +91,16 @@ class WebsiteTypeService
|
||||
return $websiteType;
|
||||
});
|
||||
}
|
||||
|
||||
private function isReferencedByWebsiteType(Attachment $attachment): bool
|
||||
{
|
||||
return WebsiteType::query()
|
||||
->where(function ($query) use ($attachment): void {
|
||||
$query
|
||||
->where('site_logo', $attachment->id)
|
||||
->orWhere('footer_logo', $attachment->id)
|
||||
->orWhere('favicon_id', $attachment->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user