Compare commits
19 Commits
bf02d37a33
...
feature/pu
| Author | SHA1 | Date | |
|---|---|---|---|
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 | |||
| e2ad78b1fb | |||
| 8ed8a11b7b | |||
| dc33e5dc09 | |||
| 414df97e9d | |||
| d0424c5589 | |||
| 28d09dedab | |||
| 8220fb5aaf |
@@ -5,9 +5,6 @@ APP_DEBUG=false
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
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
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
@@ -31,6 +28,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
|||||||
AUTH_LOGIN_LOCK_MINUTES=15
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
|
||||||
|
|
||||||
LOG_CHANNEL=daily
|
LOG_CHANNEL=daily
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
@@ -41,6 +39,8 @@ TELEPAGOS_LOG_LEVEL=info
|
|||||||
TELEPAGOS_LOG_DAYS=30
|
TELEPAGOS_LOG_DAYS=30
|
||||||
COMMANDS_LOG_LEVEL=info
|
COMMANDS_LOG_LEVEL=info
|
||||||
COMMANDS_LOG_DAYS=30
|
COMMANDS_LOG_DAYS=30
|
||||||
|
EMAILS_LOG_LEVEL=info
|
||||||
|
EMAILS_LOG_DAYS=30
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
if (! $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
)) {
|
);
|
||||||
|
|
||||||
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_expired'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.reset_code_invalid'),
|
'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\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
|
public const CODE_VALID = 'valid';
|
||||||
|
|
||||||
|
public const CODE_INVALID = 'invalid';
|
||||||
|
|
||||||
|
public const CODE_EXPIRED = 'expired';
|
||||||
|
|
||||||
public function createForEmail(
|
public function createForEmail(
|
||||||
string $email,
|
string $email,
|
||||||
string $tenantCode,
|
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);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('email', $email)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
|
|||||||
'email_fingerprint' => $emailFingerprint,
|
'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([
|
$attempt->update([
|
||||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return self::CODE_VALID;
|
||||||
});
|
});
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Failed to validate password reset code.', [
|
Log::error('Failed to validate password reset code.', [
|
||||||
@@ -214,6 +233,19 @@ class ResetPasswordAttemptService
|
|||||||
return false;
|
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->password = $password;
|
||||||
$user->failed_login_attempts = 0;
|
$user->failed_login_attempts = 0;
|
||||||
$user->last_failed_login_at = null;
|
$user->last_failed_login_at = null;
|
||||||
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
|
|||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $attempt->getKey();
|
return $attempt->getKey();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
|
|||||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('dominio', $domain)
|
->where('dominio', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('scanner_domain', $domain)
|
->where('scanner_domain', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
@@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
'status',
|
'status',
|
||||||
'origin',
|
'origin',
|
||||||
'current_purchase_id',
|
'current_purchase_id',
|
||||||
|
'current_stock_reservation_id',
|
||||||
])]
|
])]
|
||||||
class Cart extends Model
|
class Cart extends Model
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,16 @@ class Cart extends Model
|
|||||||
|
|
||||||
protected $table = 'carritos';
|
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_USER = 'user';
|
||||||
|
|
||||||
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
||||||
@@ -45,6 +57,7 @@ class Cart extends Model
|
|||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'current_purchase_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 $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
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
@@ -107,6 +129,7 @@ class Cart extends Model
|
|||||||
|
|
||||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||||
$cartQuantity = (int) $this->items()
|
$cartQuantity = (int) $this->items()
|
||||||
->where('catalog_item_id', $catalogItemId)
|
->where('catalog_item_id', $catalogItemId)
|
||||||
@@ -140,12 +163,11 @@ class Cart extends Model
|
|||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
app(StockReservationService::class)->ensure($item, $selectedItem);
|
|
||||||
$item->cantidad += $quantity;
|
$item->cantidad += $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -172,6 +194,7 @@ class Cart extends Model
|
|||||||
$excludedPurchaseId,
|
$excludedPurchaseId,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -205,7 +228,6 @@ class Cart extends Model
|
|||||||
$nextAvailableQuantity,
|
$nextAvailableQuantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
|
|
||||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
@@ -222,11 +244,10 @@ class Cart extends Model
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($targetItem !== null) {
|
if ($targetItem !== null) {
|
||||||
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
|
|
||||||
$targetItem->cantidad += $quantity;
|
$targetItem->cantidad += $quantity;
|
||||||
$targetItem->save();
|
$targetItem->save();
|
||||||
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
|
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $targetItem->fresh();
|
return $targetItem->fresh();
|
||||||
}
|
}
|
||||||
@@ -234,7 +255,7 @@ class Cart extends Model
|
|||||||
$item->variant_id = $variantId;
|
$item->variant_id = $variantId;
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
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->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -282,6 +296,7 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($cartItemId): void {
|
DB::transaction(function () use ($cartItemId): void {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -289,17 +304,8 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$selectedItem = $this->resolveScopedItem(
|
|
||||||
$item->catalog_item_id,
|
|
||||||
$item->variant_id,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
app(StockReservationService::class)->release(
|
|
||||||
$item,
|
|
||||||
$selectedItem,
|
|
||||||
$item->cantidad,
|
|
||||||
);
|
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,13 +347,20 @@ class Cart extends Model
|
|||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)) {
|
], true)) {
|
||||||
|
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
|
||||||
$currentPurchase->update([
|
$currentPurchase->update([
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'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()
|
self::query()
|
||||||
->whereKey($cart->getKey())
|
->whereKey($cart->getKey())
|
||||||
->where('current_purchase_id', $currentPurchase->getKey())
|
->where('current_purchase_id', $currentPurchase->getKey())
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
@@ -57,10 +55,4 @@ class CartItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->variant ?? $this->catalogItem;
|
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\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
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 App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
@@ -22,7 +25,7 @@ class CartService
|
|||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
@@ -119,7 +122,7 @@ class CartService
|
|||||||
{
|
{
|
||||||
$cart = new Cart([
|
$cart = new Cart([
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cart->setRelation('items', collect());
|
$cart->setRelation('items', collect());
|
||||||
@@ -220,12 +223,14 @@ class CartService
|
|||||||
{
|
{
|
||||||
return Cart::query()
|
return Cart::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('status', 'active')
|
->where('origin', Cart::ORIGIN_USER)
|
||||||
|
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||||
->when(
|
->when(
|
||||||
$identity['user_id'] !== null,
|
$identity['user_id'] !== null,
|
||||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||||
)
|
)
|
||||||
|
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +239,7 @@ class CartService
|
|||||||
*/
|
*/
|
||||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$cart = $this->findCart($tenant, $identity);
|
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
throw new NotFoundHttpException('Cart not found.');
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
@@ -247,10 +252,73 @@ class CartService
|
|||||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
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 = [
|
$attributes = [
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
'origin' => Cart::ORIGIN_USER,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($identity['user_id'] !== null) {
|
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
|
## 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.
|
- `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
|
## Servicios
|
||||||
|
|
||||||
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
- `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.
|
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||||
|
|
||||||
## Endpoints
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,10 +48,10 @@ class Inventory extends Model
|
|||||||
return $this->hasOne(Variant::class);
|
return $this->hasOne(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
public function stockReservations(): HasMany
|
public function stockReservationLines(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(StockReservation::class);
|
return $this->hasMany(StockReservationLine::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function availableStock(): int
|
public function availableStock(): int
|
||||||
|
|||||||
@@ -2,21 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'inventory_id',
|
|
||||||
'cart_item_id',
|
|
||||||
'purchase_id',
|
|
||||||
'quantity',
|
|
||||||
'status',
|
'status',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'committed_at',
|
'committed_at',
|
||||||
'released_at',
|
'released_at',
|
||||||
|
'expired_at',
|
||||||
|
'release_reason',
|
||||||
])]
|
])]
|
||||||
class StockReservation extends Model
|
class StockReservation extends Model
|
||||||
{
|
{
|
||||||
@@ -31,31 +30,28 @@ class StockReservation extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'inventory_id' => 'integer',
|
|
||||||
'cart_item_id' => 'integer',
|
|
||||||
'purchase_id' => 'integer',
|
|
||||||
'quantity' => 'integer',
|
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'committed_at' => 'datetime',
|
'committed_at' => 'datetime',
|
||||||
'released_at' => 'datetime',
|
'released_at' => 'datetime',
|
||||||
|
'expired_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Inventory, $this> */
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
public function inventory(): BelongsTo
|
public function lines(): HasMany
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Inventory::class);
|
return $this->hasMany(StockReservationLine::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<CartItem, $this> */
|
/** @return HasOne<Cart, $this> */
|
||||||
public function cartItem(): BelongsTo
|
public function currentCart(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(CartItem::class);
|
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Purchase, $this> */
|
/** @return HasOne<Purchase, $this> */
|
||||||
public function purchase(): BelongsTo
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||||
{
|
{
|
||||||
if ($selection instanceof CatalogItem
|
if ($selection instanceof CatalogItem
|
||||||
|
|||||||
@@ -2,50 +2,184 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
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 Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ExpireStockReservationsService
|
class ExpireStockReservationsService
|
||||||
{
|
{
|
||||||
|
private const BATCH_SIZE = 500;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutService $checkout,
|
private readonly ReleaseCheckoutService $purchases,
|
||||||
private readonly ExpireCartReservationsService $carts,
|
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
|
public function expireOverdue(): array
|
||||||
{
|
{
|
||||||
$expiredPurchases = null;
|
$summary = [
|
||||||
$expiredCartItems = null;
|
'purchases' => 0,
|
||||||
|
'cart_reservations' => 0,
|
||||||
|
'orphan_reservations' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
$lastReservationId = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$reservationIds = StockReservation::query()
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->where('id', '>', $lastReservationId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::BATCH_SIZE)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($reservationIds as $reservationId) {
|
||||||
|
$lastReservationId = (int) $reservationId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
$owner = $this->expireReservation($lastReservationId);
|
||||||
$expiredCartItems = $this->carts->expireOverdue();
|
if ($owner !== null) {
|
||||||
|
$summary[$owner]++;
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$summary['failed']++;
|
||||||
|
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||||
|
'command' => 'reservations:expire',
|
||||||
|
'stock_reservation_id' => $lastReservationId,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||||
|
|
||||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||||
'command' => 'reservations:expire',
|
'command' => 'reservations:expire',
|
||||||
'expired_purchases' => $expiredPurchases,
|
'expired_purchases' => $summary['purchases'],
|
||||||
'expired_cart_items' => $expiredCartItems,
|
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||||
|
'failed_reservations' => $summary['failed'],
|
||||||
|
'total_expired' => $summary['purchases']
|
||||||
|
+ $summary['cart_reservations']
|
||||||
|
+ $summary['orphan_reservations'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return $summary;
|
||||||
'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,
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $exception;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,262 +2,486 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
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\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class StockReservationService
|
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(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
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 {
|
return DB::transaction(function () use ($cart): ?StockReservation {
|
||||||
$this->inventory->reserve($selection, $quantity);
|
/** @var Cart $lockedCart */
|
||||||
$this->recordIncrease($cartItem, $selection, $quantity);
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
|
$this->loadSelections($items);
|
||||||
|
$requirements = $this->requirementsForItems($items);
|
||||||
|
|
||||||
|
$reservation = $lockedCart->current_stock_reservation_id === null
|
||||||
|
? null
|
||||||
|
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||||
|
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requirements === []) {
|
||||||
|
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
|
||||||
|
$this->finalizeLocked(
|
||||||
|
$reservation,
|
||||||
|
StockReservation::STATUS_RELEASED,
|
||||||
|
self::REASON_CART_EMPTY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||||
|
$cart->current_stock_reservation_id = null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reservation === null) {
|
||||||
|
$reservation = StockReservation::query()->create([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
|
||||||
|
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentLines = StockReservationLine::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('inventory_id');
|
||||||
|
$inventoryIds = collect(array_keys($requirements))
|
||||||
|
->merge($currentLines->keys())
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values();
|
||||||
|
$inventories = Inventory::query()
|
||||||
|
->whereKey($inventoryIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
|
$inventory = $inventories->get($inventoryId)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
|
||||||
|
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
|
if ($delta > 0
|
||||||
|
&& $requirements[$inventoryId]['tracks_inventory']
|
||||||
|
&& $inventory->availableStock() < $delta) {
|
||||||
|
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
|
/** @var Inventory $inventory */
|
||||||
|
$inventory = $inventories->get($inventoryId);
|
||||||
|
$line = $currentLines->get($inventoryId);
|
||||||
|
$previous = (int) ($line?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
|
if ($delta > 0) {
|
||||||
|
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
|
||||||
|
} elseif ($delta < 0) {
|
||||||
|
$inventory->release(abs($delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($required === 0) {
|
||||||
|
$line?->delete();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
StockReservationLine::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'stock_reservation_id' => $reservation->getKey(),
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quantity' => $required,
|
||||||
|
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function release(
|
public function attachToPurchase(
|
||||||
CartItem $cartItem,
|
Cart $cart,
|
||||||
CatalogItem|Variant $selection,
|
|
||||||
int $quantity,
|
|
||||||
string $releasedStatus = StockReservation::STATUS_RELEASED,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
$this->inventory->release($selection, $quantity);
|
|
||||||
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function commit(
|
|
||||||
CartItem $cartItem,
|
|
||||||
CatalogItem|Variant $selection,
|
|
||||||
Purchase $purchase,
|
Purchase $purchase,
|
||||||
): void {
|
Carbon $expiresAt,
|
||||||
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
): StockReservation {
|
||||||
$this->ensure($cartItem, $selection);
|
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||||
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
/** @var Cart $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
/** @var Purchase $lockedPurchase */
|
||||||
|
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
if ($lockedCart->current_stock_reservation_id === null) {
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
}
|
||||||
if (
|
|
||||||
$reservation === null
|
/** @var StockReservation $reservation */
|
||||||
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||||
|| $reservation->purchase_id !== $purchase->getKey()
|
$this->assertUsableCartReservation($reservation);
|
||||||
|| $reservation->quantity !== $quantity
|
|
||||||
) {
|
$linkedPurchase = Purchase::query()
|
||||||
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
->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([
|
$reservation->update([
|
||||||
'status' => StockReservation::STATUS_COMMITTED,
|
'status' => StockReservation::STATUS_COMMITTED,
|
||||||
'committed_at' => now(),
|
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
'committed_at' => now(),
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
|
|
||||||
{
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
|
||||||
|
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
|
||||||
|
|
||||||
if ($reservation === null) {
|
|
||||||
StockReservation::query()->create([
|
|
||||||
'inventory_id' => $inventoryId,
|
|
||||||
'cart_item_id' => $cartItem->getKey(),
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
|
|
||||||
$reservation->update([
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'committed_at' => null,
|
|
||||||
'released_at' => null,
|
'released_at' => null,
|
||||||
'expires_at' => $this->expiration(),
|
'expired_at' => null,
|
||||||
]);
|
'release_reason' => null,
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
public function releaseForPurchase(
|
||||||
{
|
Purchase $purchase,
|
||||||
StockReservation::query()
|
string $status = StockReservation::STATUS_RELEASED,
|
||||||
->where('purchase_id', $purchase->getKey())
|
?string $reason = null,
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
): void {
|
||||||
->update([
|
DB::transaction(function () use ($purchase, $status, $reason): void {
|
||||||
'purchase_id' => null,
|
/** @var Purchase $purchase */
|
||||||
'expires_at' => $this->expiration(),
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
]);
|
if ($purchase->stock_reservation_id === null) {
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
->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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($activeReservations->isNotEmpty()) {
|
/** @var StockReservation|null $reservation */
|
||||||
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->finalizeLocked($reservation, $status, $reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->inventory->reserve($selection, (int) $cartItem->cantidad);
|
|
||||||
$this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncPurchaseExpiration(Purchase $purchase): void
|
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||||
{
|
{
|
||||||
StockReservation::query()
|
return DB::transaction(function () use ($purchase, $cart): StockReservation {
|
||||||
->where('purchase_id', $purchase->getKey())
|
/** @var Purchase $purchase */
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
->update(['expires_at' => $purchase->expires_at]);
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
|
||||||
|
if ($purchase->stock_reservation_id === null
|
||||||
|
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
|
||||||
|
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function transfer(CartItem $source, CartItem $target): void
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
|
||||||
|
$purchase->update(['stock_reservation_id' => null]);
|
||||||
|
$cart->update(['current_purchase_id' => null]);
|
||||||
|
$reservation->update(['expires_at' => $this->expiration()]);
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertCartReservationUsable(Cart $cart): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($source, $target): void {
|
DB::transaction(function () use ($cart): void {
|
||||||
$sourceReservations = StockReservation::query()
|
/** @var Cart $cart */
|
||||||
->where('cart_item_id', $source->getKey())
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($cart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseCurrentCartReservation(
|
||||||
|
Cart $cart,
|
||||||
|
string $reason = self::REASON_CART_CHANGED,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($cart, $reason): void {
|
||||||
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
|
||||||
|
}
|
||||||
|
$cart->update(['current_stock_reservation_id' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expire(StockReservation $reservation): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($reservation): void {
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null
|
||||||
|
|| $reservation->expires_at->isFuture()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearExpirationForReview(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update(['expires_at' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CartItem> $items
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
private function requirementsForItems(Collection $items): array
|
||||||
|
{
|
||||||
|
$requirements = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$selection = $item->selectedItem();
|
||||||
|
if ($selection === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
|
||||||
|
if (isset($requirements[$inventoryId])) {
|
||||||
|
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] =
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirements[$inventoryId] = $requirement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($requirements);
|
||||||
|
|
||||||
|
return $requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, CartItem> $items */
|
||||||
|
private function loadSelections(Collection $items): void
|
||||||
|
{
|
||||||
|
$items->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.catalogItem',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.catalogItem',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, StockReservationLine> */
|
||||||
|
private function lockLines(StockReservation $reservation): Collection
|
||||||
|
{
|
||||||
|
return StockReservationLine::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
->orderBy('inventory_id')
|
->orderBy('inventory_id')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
foreach ($sourceReservations as $sourceReservation) {
|
|
||||||
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
|
|
||||||
|
|
||||||
if ($targetReservation === null) {
|
|
||||||
$sourceItemQuantity = (int) $source->cantidad;
|
|
||||||
$targetItemQuantity = (int) $target->fresh()->cantidad;
|
|
||||||
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
|
|
||||||
$sourceReservation->update([
|
|
||||||
'cart_item_id' => $target->getKey(),
|
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => $perItemQuantity * $targetItemQuantity,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetReservation->update([
|
/**
|
||||||
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
* @param Collection<int, StockReservationLine> $lines
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
* @return Collection<int, Inventory>
|
||||||
'expires_at' => $this->expiration(),
|
*/
|
||||||
]);
|
private function lockInventories(Collection $lines): Collection
|
||||||
$sourceReservation->delete();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
|
||||||
{
|
{
|
||||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
return Inventory::query()
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
->whereKey($lines->pluck('inventory_id'))
|
||||||
|
->orderBy('id')
|
||||||
if ($reservation === null) {
|
|
||||||
StockReservation::query()->create([
|
|
||||||
'inventory_id' => $inventoryId,
|
|
||||||
'cart_item_id' => $cartItem->getKey(),
|
|
||||||
'quantity' => $requiredQuantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reservation->update([
|
|
||||||
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'committed_at' => null,
|
|
||||||
'released_at' => null,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordDecrease(
|
|
||||||
CartItem $cartItem,
|
|
||||||
CatalogItem|Variant $selection,
|
|
||||||
int $quantity,
|
|
||||||
string $releasedStatus,
|
|
||||||
): void {
|
|
||||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
|
||||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
|
|
||||||
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$remaining = $reservation->quantity - $requiredQuantity;
|
|
||||||
$reservation->update([
|
|
||||||
'quantity' => $remaining,
|
|
||||||
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
|
|
||||||
'released_at' => $remaining === 0 ? now() : null,
|
|
||||||
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
|
|
||||||
{
|
|
||||||
return StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('inventory_id', $inventoryId)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeLocked(
|
||||||
|
StockReservation $reservation,
|
||||||
|
string $status,
|
||||||
|
?string $reason,
|
||||||
|
): void {
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
|
||||||
|
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
$inventory->release((int) $line->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$reservation->update([
|
||||||
|
'status' => $status,
|
||||||
|
'expires_at' => null,
|
||||||
|
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
|
||||||
|
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
|
||||||
|
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === StockReservation::STATUS_RELEASED) {
|
||||||
|
Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservation->getKey())
|
||||||
|
->update(['current_stock_reservation_id' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||||
|
{
|
||||||
|
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||||
|
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function expiration(): Carbon
|
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.
|
- `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.
|
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||||
- `Inventory` administra stock disponible, reservado y comprado.
|
- `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.
|
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||||
- `BundleComponent` representa los componentes de un paquete.
|
- `BundleComponent` representa los componentes de un paquete.
|
||||||
@@ -18,7 +18,8 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
|
|
||||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
- `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.
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner
|
|||||||
if ($purchaseId !== null) {
|
if ($purchaseId !== null) {
|
||||||
DB::table('compras')->where('id', $purchaseId)->update([
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
'status' => 'paid',
|
'status' => 'paid',
|
||||||
'expires_at' => null,
|
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner
|
|||||||
'cart_id' => null,
|
'cart_id' => null,
|
||||||
'status' => 'paid',
|
'status' => 'paid',
|
||||||
'payment_method' => self::PAYMENT_METHOD,
|
'payment_method' => self::PAYMENT_METHOD,
|
||||||
'expires_at' => null,
|
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'dni' => null,
|
'dni' => null,
|
||||||
'transfer_payer_dni' => null,
|
'transfer_payer_dni' => null,
|
||||||
@@ -392,15 +390,27 @@ class InvitationPurchaseProvisioner
|
|||||||
'sold_units' => $inventory->sold_units + 1,
|
'sold_units' => $inventory->sold_units + 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::table('stock_reservations')->insert([
|
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
|
||||||
'inventory_id' => $inventory->id,
|
if ($reservationId === null) {
|
||||||
'cart_item_id' => null,
|
$reservationId = DB::table('stock_reservations')->insertGetId([
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'quantity' => 1,
|
|
||||||
'status' => 'committed',
|
'status' => 'committed',
|
||||||
'expires_at' => null,
|
|
||||||
'committed_at' => $now,
|
'committed_at' => $now,
|
||||||
'released_at' => null,
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('stock_reservation_lines')->insert([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
'tracks_inventory' => true,
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -72,11 +72,15 @@ class MailService extends BaseIntegrationService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array{data: string, name: string, mime: string}> $attachments
|
||||||
|
*/
|
||||||
public function send(
|
public function send(
|
||||||
string|array $recipient,
|
string|array $recipient,
|
||||||
string $subject,
|
string $subject,
|
||||||
string $content,
|
string $content,
|
||||||
Tenant|WebsiteType|null $brand = null,
|
Tenant|WebsiteType|null $brand = null,
|
||||||
|
array $attachments = [],
|
||||||
): void {
|
): void {
|
||||||
if (! $this->mailer || ! $this->tenant) {
|
if (! $this->mailer || ! $this->tenant) {
|
||||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||||
@@ -105,6 +109,14 @@ class MailService extends BaseIntegrationService
|
|||||||
->subject($subject)
|
->subject($subject)
|
||||||
->html($html);
|
->html($html);
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$mail->attachData(
|
||||||
|
$attachment['data'],
|
||||||
|
$attachment['name'],
|
||||||
|
['mime' => $attachment['mime']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->to($recipient)->send($mail);
|
$this->mailer->to($recipient)->send($mail);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 App\Domains\Notification\Services\NotificationMailService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PasswordResetRequested $event): void
|
public function handle(PasswordResetRequested $event): void
|
||||||
{
|
{
|
||||||
try {
|
|
||||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
$event->attemptId,
|
$event->attemptId,
|
||||||
$event->tenantCode,
|
$event->tenantCode,
|
||||||
$event->channel,
|
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
|
|||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
|
||||||
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PurchasePaid $event): void
|
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,18 +9,26 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
|||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class NotificationMailService
|
class NotificationMailService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailService $mailService,
|
private readonly MailService $mailService,
|
||||||
|
private readonly TicketPdfService $ticketPdfService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function sendWelcome(int $userId, string $tenantCode): void
|
public function sendWelcome(int $userId, string $tenantCode): void
|
||||||
{
|
{
|
||||||
|
$this->sendLogged('welcome', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
], function () use ($userId, $tenantCode): array {
|
||||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
@@ -33,6 +41,11 @@ class NotificationMailService
|
|||||||
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
||||||
$brand,
|
$brand,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPasswordResetCode(
|
public function sendPasswordResetCode(
|
||||||
@@ -40,6 +53,13 @@ class NotificationMailService
|
|||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||||
): void {
|
): void {
|
||||||
|
$context = [
|
||||||
|
'attempt_id' => $attemptId,
|
||||||
|
'tenant_code' => $tenantCode,
|
||||||
|
'channel' => $channel,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
|
||||||
$tenant = Tenant::query()
|
$tenant = Tenant::query()
|
||||||
->with('websiteType')
|
->with('websiteType')
|
||||||
->where('codigo', $tenantCode)
|
->where('codigo', $tenantCode)
|
||||||
@@ -49,13 +69,13 @@ class NotificationMailService
|
|||||||
->findOrFail($attemptId);
|
->findOrFail($attemptId);
|
||||||
|
|
||||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||||
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
$this->logSkipped('password_reset', array_merge($context, [
|
||||||
'attempt_id' => $attemptId,
|
'reason' => 'attempt_not_pending',
|
||||||
'tenant_code' => $tenantCode,
|
|
||||||
'attempt_status' => $attempt->status,
|
'attempt_status' => $attempt->status,
|
||||||
]);
|
'user_id' => $attempt->user_id,
|
||||||
|
]));
|
||||||
|
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$recoveryDomain = match ($channel) {
|
$recoveryDomain = match ($channel) {
|
||||||
@@ -91,50 +111,103 @@ class NotificationMailService
|
|||||||
])->render(),
|
])->render(),
|
||||||
$brand,
|
$brand,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'user_id' => $attempt->user_id,
|
||||||
|
'recovery_domain_available' => $recoveryDomain !== null,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPurchasePaid(int $purchaseId): void
|
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||||
{
|
{
|
||||||
|
$context = ['purchase_id' => $purchaseId];
|
||||||
|
|
||||||
|
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
->with(['tenant', 'user', 'items'])
|
->with(['tenant', 'user', 'items'])
|
||||||
->findOrFail($purchaseId);
|
->find($purchaseId);
|
||||||
|
|
||||||
$this->mailService
|
if ($purchase === null) {
|
||||||
->forTenant($purchase->tenant_codigo)
|
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||||
->send(
|
'reason' => 'purchase_not_found',
|
||||||
$this->recipientFor($purchase),
|
'missing_model' => Purchase::class,
|
||||||
"Pago confirmado - Compra #{$purchase->getKey()}",
|
]));
|
||||||
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
|
|
||||||
);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @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 */
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
$tickets = Ticket::query()
|
$tickets = Ticket::query()
|
||||||
|
->where('source_purchase_id', $purchase->getKey())
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
->where('user_id', $purchase->user_id)
|
|
||||||
->whereKey($ticketIds)
|
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
->get();
|
->get();
|
||||||
|
$attachments = $tickets->isEmpty()
|
||||||
if ($tickets->isEmpty()) {
|
? []
|
||||||
return;
|
: [[
|
||||||
}
|
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
||||||
|
'name' => $this->ticketPdfService->filename($tickets),
|
||||||
|
'mime' => 'application/pdf',
|
||||||
|
]];
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($purchase->tenant_codigo)
|
->forTenant($purchase->tenant_codigo)
|
||||||
->send(
|
->send(
|
||||||
$this->recipientFor($purchase),
|
$this->recipientFor($purchase),
|
||||||
'Tus tickets ya están disponibles',
|
"Compra confirmada - Compra #{$purchase->getKey()}",
|
||||||
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
|
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
|
private function recipientFor(Purchase $purchase): string
|
||||||
{
|
{
|
||||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
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.
|
- `UserRegistered`: dispara el correo de bienvenida.
|
||||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||||
- `PurchasePaid`: envía la confirmación de pago.
|
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
|
||||||
|
|
||||||
## Componentes
|
## 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
|
## API y dependencias
|
||||||
|
|
||||||
@@ -24,5 +23,5 @@ 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
- Los correos transaccionales (pago confirmado y tickets disponibles) usan la identidad visual del tenant/evento de la compra.
|
- 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.
|
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PurchaseController extends Controller
|
|||||||
|
|
||||||
return PurchaseResource::collection(
|
return PurchaseResource::collection(
|
||||||
Purchase::query()
|
Purchase::query()
|
||||||
|
->with('stockReservation')
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
||||||
@@ -92,7 +93,6 @@ class PurchaseController extends Controller
|
|||||||
PaymentIntentRequest $request,
|
PaymentIntentRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Purchase $compra,
|
Purchase $compra,
|
||||||
CheckoutService $checkoutService,
|
|
||||||
PurchaseStateGuard $purchaseState,
|
PurchaseStateGuard $purchaseState,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$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'))
|
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
||||||
: null;
|
: 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 */
|
/** @var Purchase|null $purchase */
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
->whereKey($compra->getKey())
|
->whereKey($compra->getKey())
|
||||||
@@ -128,9 +133,6 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate = [
|
$purchaseUpdate = [
|
||||||
'payment_method' => $method,
|
'payment_method' => $method,
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'expires_at' => now()->addMinutes(
|
|
||||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
|
||||||
),
|
|
||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -150,7 +152,6 @@ class PurchaseController extends Controller
|
|||||||
|
|
||||||
$compra->refresh();
|
$compra->refresh();
|
||||||
$totalAmount = (float) $compra->total;
|
$totalAmount = (float) $compra->total;
|
||||||
$checkoutService->syncReservationExpiration($compra);
|
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Events;
|
namespace App\Domains\Purchase\Events;
|
||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
@@ -10,5 +9,5 @@ class PurchasePaid
|
|||||||
{
|
{
|
||||||
use Dispatchable, SerializesModels;
|
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([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
|
'stock_reservation_id',
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
'user_id',
|
'user_id',
|
||||||
'status',
|
'status',
|
||||||
'payment_method',
|
'payment_method',
|
||||||
'expires_at',
|
|
||||||
'total',
|
'total',
|
||||||
'dni',
|
'dni',
|
||||||
'transfer_payer_dni',
|
'transfer_payer_dni',
|
||||||
@@ -77,8 +77,8 @@ class Purchase extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
|
'stock_reservation_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'total' => 'decimal:2',
|
'total' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -123,10 +123,10 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
public function stockReservations(): HasMany
|
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,
|
'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
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$serverTime = now();
|
||||||
|
$expiresAt = $this->stockReservation?->expires_at;
|
||||||
$items = $this->resource->relationLoaded('items')
|
$items = $this->resource->relationLoaded('items')
|
||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: collect();
|
||||||
@@ -48,7 +50,11 @@ class PurchaseResource extends JsonResource
|
|||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'payment_method' => $this->payment_method,
|
'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,
|
'dni' => $this->dni,
|
||||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
$this->purchaseState->lockCurrentCart($purchase);
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
if (
|
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
|
||||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
|
||||||
) {
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'purchase' => __('api.purchase.not_available_for_review'),
|
'purchase' => __('api.purchase.not_available_for_review'),
|
||||||
]);
|
]);
|
||||||
@@ -72,9 +69,8 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
'status' => Purchase::STATUS_IN_REVIEW,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
$this->reservations->syncPurchaseExpiration($purchase);
|
$this->reservations->clearExpirationForReview($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
@@ -161,14 +157,15 @@ class CompleteCheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->reservations->commit($cartItem, $selection, $purchase);
|
$this->reservations->commit($purchase);
|
||||||
} catch (\InvalidArgumentException) {
|
} catch (\InvalidArgumentException) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$this->sourceCart->finalize($purchase);
|
$this->sourceCart->finalize($purchase);
|
||||||
});
|
});
|
||||||
@@ -194,7 +191,7 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
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
|
private function itemKey(int $catalogItemId, ?int $variantId): string
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ class PurchaseResponseLoader
|
|||||||
{
|
{
|
||||||
public function load(Purchase $purchase): Purchase
|
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\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ReleaseCheckoutService
|
class ReleaseCheckoutService
|
||||||
{
|
{
|
||||||
@@ -32,40 +31,6 @@ class ReleaseCheckoutService
|
|||||||
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
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(
|
private function release(
|
||||||
Purchase $purchase,
|
Purchase $purchase,
|
||||||
string $targetStatus,
|
string $targetStatus,
|
||||||
@@ -78,6 +43,11 @@ class ReleaseCheckoutService
|
|||||||
): Purchase {
|
): Purchase {
|
||||||
$purchase = $this->lockPurchase($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 ($purchase->status === Purchase::STATUS_PAID) {
|
||||||
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
@@ -100,14 +70,31 @@ class ReleaseCheckoutService
|
|||||||
return $this->loadPurchase($purchase);
|
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 (
|
if (
|
||||||
$targetStatus === Purchase::STATUS_EXPIRED
|
$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);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->releasePurchaseReservations($purchase, $targetStatus);
|
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
|
||||||
|
|
||||||
$purchase->update(['status' => $targetStatus]);
|
$purchase->update(['status' => $targetStatus]);
|
||||||
|
|
||||||
@@ -115,60 +102,67 @@ class ReleaseCheckoutService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
|
private function releasePurchaseReservations(
|
||||||
{
|
Purchase $purchase,
|
||||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
string $targetStatus,
|
||||||
if ($cart === null) {
|
?Cart $cart,
|
||||||
return;
|
): void {
|
||||||
}
|
|
||||||
|
|
||||||
if ($cart->status === 'active') {
|
|
||||||
$this->reservations->detachFromPurchase($purchase);
|
|
||||||
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 {
|
try {
|
||||||
$this->reservations->release(
|
$this->reservations->releaseForPurchase(
|
||||||
$cartItem,
|
$purchase,
|
||||||
$selection,
|
|
||||||
(int) $cartItem->cantidad,
|
|
||||||
$targetStatus === Purchase::STATUS_EXPIRED
|
$targetStatus === Purchase::STATUS_EXPIRED
|
||||||
? StockReservation::STATUS_EXPIRED
|
? StockReservation::STATUS_EXPIRED
|
||||||
: StockReservation::STATUS_RELEASED,
|
: StockReservation::STATUS_RELEASED,
|
||||||
|
$targetStatus === Purchase::STATUS_CANCELLED
|
||||||
|
? StockReservationService::REASON_PURCHASE_CANCELLED
|
||||||
|
: ($targetStatus === Purchase::STATUS_REJECTED
|
||||||
|
? StockReservationService::REASON_PAYMENT_REJECTED
|
||||||
|
: null),
|
||||||
);
|
);
|
||||||
} catch (\InvalidArgumentException) {
|
} catch (\InvalidArgumentException) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($cart === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($targetStatus === Purchase::STATUS_EXPIRED
|
||||||
|
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
|
||||||
|
Cart::query()
|
||||||
|
->whereKey($cart->getKey())
|
||||||
|
->where('current_purchase_id', $purchase->getKey())
|
||||||
|
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
|
||||||
|
->update([
|
||||||
|
'status' => Cart::STATUS_EXPIRED,
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart->status === Cart::STATUS_ACTIVE) {
|
||||||
|
$cartUpdate = [
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
'current_stock_reservation_id' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
Cart::query()
|
||||||
|
->whereKey($cart->getKey())
|
||||||
|
->where('current_purchase_id', $purchase->getKey())
|
||||||
|
->update($cartUpdate);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart->status !== Cart::STATUS_CHECKOUT) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $cart->trashed()) {
|
if (! $cart->trashed()) {
|
||||||
$cart->update(['status' => 'converted']);
|
$cart->update(['status' => Cart::STATUS_CONVERTED]);
|
||||||
$cart->delete();
|
$cart->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +212,17 @@ class ReleaseCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
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\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
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\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -169,21 +171,31 @@ class StartCheckoutService
|
|||||||
'cantidad' => $line['quantity'],
|
'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('catalogItem', $line['catalog_item']);
|
||||||
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
|
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
|
||||||
$cartItems->push($cartItem);
|
$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(
|
$purchase = $this->createPurchase(
|
||||||
$tenant,
|
$tenant,
|
||||||
$userId,
|
$userId,
|
||||||
@@ -194,19 +206,12 @@ class StartCheckoutService
|
|||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||||
|
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||||
|
|
||||||
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
$this->loadCartItems($cartItems);
|
$this->loadCartItems($cartItems);
|
||||||
$purchase->items()->createMany($this->snapshots->fromCartItems($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);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +262,7 @@ class StartCheckoutService
|
|||||||
$this->verifyTenantItems($tenant, $cartItems);
|
$this->verifyTenantItems($tenant, $cartItems);
|
||||||
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
|
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
|
||||||
$cart->setRelation('items', $cartItems);
|
$cart->setRelation('items', $cartItems);
|
||||||
|
$this->reservations->syncCart($cart);
|
||||||
|
|
||||||
$purchase = $this->createPurchase(
|
$purchase = $this->createPurchase(
|
||||||
$tenant,
|
$tenant,
|
||||||
@@ -266,16 +272,9 @@ class StartCheckoutService
|
|||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||||
|
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||||
|
|
||||||
foreach ($cartItems as $cartItem) {
|
|
||||||
$this->reservations->attachToPurchase(
|
|
||||||
$cartItem,
|
|
||||||
$cartItem->selectedItem(),
|
|
||||||
$purchase,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,9 +315,9 @@ class StartCheckoutService
|
|||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)) {
|
], true)) {
|
||||||
|
$this->reservations->returnToCart($currentPurchase, $cart);
|
||||||
$currentPurchase->update([
|
$currentPurchase->update([
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +330,11 @@ class StartCheckoutService
|
|||||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
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([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => __('api.purchase.inactive_cart'),
|
'cart_id' => __('api.purchase.inactive_cart'),
|
||||||
]);
|
]);
|
||||||
@@ -405,13 +408,17 @@ class StartCheckoutService
|
|||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'status' => Purchase::STATUS_CREATED,
|
'status' => Purchase::STATUS_CREATED,
|
||||||
'payment_method' => null,
|
'payment_method' => null,
|
||||||
'expires_at' => now()->addMinutes(
|
|
||||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
|
||||||
),
|
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checkoutExpiration(): Carbon
|
||||||
|
{
|
||||||
|
return now()->addMinutes(
|
||||||
|
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param Collection<int, CartItem> $cartItems */
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
private function loadCartItems(Collection $cartItems): void
|
private function loadCartItems(Collection $cartItems): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||||
@@ -23,7 +22,6 @@ class CheckoutService
|
|||||||
private readonly EditCheckoutService $editor,
|
private readonly EditCheckoutService $editor,
|
||||||
private readonly CompleteCheckoutService $completer,
|
private readonly CompleteCheckoutService $completer,
|
||||||
private readonly ReleaseCheckoutService $releaser,
|
private readonly ReleaseCheckoutService $releaser,
|
||||||
private readonly StockReservationService $reservations,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param array<string, mixed> $purchaseData */
|
/** @param array<string, mixed> $purchaseData */
|
||||||
@@ -77,14 +75,4 @@ class CheckoutService
|
|||||||
{
|
{
|
||||||
return $this->releaser->expire($purchase);
|
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;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -11,15 +12,32 @@ class PurchaseStateGuard
|
|||||||
{
|
{
|
||||||
public function assertNotExpired(Purchase $purchase): void
|
public function assertNotExpired(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||||
$hasExpiredByTime = in_array($purchase->status, [
|
throw new PurchaseExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($purchase->status, [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)
|
], true)) {
|
||||||
&& $purchase->expires_at !== null
|
return;
|
||||||
&& $purchase->expires_at->isPast();
|
}
|
||||||
|
|
||||||
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;
|
throw new PurchaseExpiredException;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,11 +135,25 @@ class TenantTransactionResetService
|
|||||||
*/
|
*/
|
||||||
private function reservationQuery(array $scope): Builder
|
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')
|
return DB::table('stock_reservations')
|
||||||
->where(function (Builder $query) use ($scope): void {
|
->whereIn('id', $reservationIds);
|
||||||
$query->whereIn('inventory_id', $scope['inventory_ids'])
|
|
||||||
->orWhereIn('purchase_id', $scope['purchase_ids'])
|
|
||||||
->orWhereIn('cart_item_id', $scope['cart_item_ids']);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ class UserPurchaseLimitService
|
|||||||
$excludedCartId !== null,
|
$excludedCartId !== null,
|
||||||
fn ($query) => $query->whereKeyNot($excludedCartId),
|
fn ($query) => $query->whereKeyNot($excludedCartId),
|
||||||
))
|
))
|
||||||
->whereHas('stockReservations', fn ($query) => $query
|
->whereHas('cart.currentStockReservation', fn ($query) => $query
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereNull('purchase_id'))
|
->whereDoesntHave('purchase'))
|
||||||
->sum('cantidad');
|
->sum('cantidad');
|
||||||
|
|
||||||
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
|
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
|
||||||
@@ -161,9 +161,9 @@ class UserPurchaseLimitService
|
|||||||
->whereHas('cart', fn ($query) => $query
|
->whereHas('cart', fn ($query) => $query
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->where('status', 'active'))
|
->where('status', 'active'))
|
||||||
->whereHas('stockReservations', fn ($query) => $query
|
->whereHas('cart.currentStockReservation', fn ($query) => $query
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereNull('purchase_id'))
|
->whereDoesntHave('purchase'))
|
||||||
->groupBy('catalog_item_id')
|
->groupBy('catalog_item_id')
|
||||||
->pluck('quantity', '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
|
## 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.
|
- `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.
|
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
- `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:
|
`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.
|
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
|
||||||
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
- `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.
|
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
||||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
- `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.
|
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class WebsiteTypeService
|
|||||||
&& $previousLogo->id !== $websiteType->site_logo
|
&& $previousLogo->id !== $websiteType->site_logo
|
||||||
&& $previousLogo->id !== $websiteType->footer_logo
|
&& $previousLogo->id !== $websiteType->footer_logo
|
||||||
&& $previousLogo->id !== $websiteType->favicon_id
|
&& $previousLogo->id !== $websiteType->favicon_id
|
||||||
|
&& ! $this->isReferencedByWebsiteType($previousLogo)
|
||||||
) {
|
) {
|
||||||
$this->attachmentService->delete($previousLogo);
|
$this->attachmentService->delete($previousLogo);
|
||||||
}
|
}
|
||||||
@@ -90,4 +91,16 @@ class WebsiteTypeService
|
|||||||
return $websiteType;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Domains\Ticket\Listeners;
|
namespace App\Domains\Ticket\Listeners;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||||
use App\Domains\Ticket\Services\TicketGeneratorService;
|
use App\Domains\Ticket\Services\TicketGeneratorService;
|
||||||
|
|
||||||
@@ -16,13 +16,10 @@ class GenerateTicketsForPaidPurchase
|
|||||||
|
|
||||||
public function handle(PurchasePaid $event): void
|
public function handle(PurchasePaid $event): void
|
||||||
{
|
{
|
||||||
$purchase = $event->purchase
|
$purchase = Purchase::query()
|
||||||
->newQuery()
|
|
||||||
->with(['user', 'items'])
|
->with(['user', 'items'])
|
||||||
->findOrFail($event->purchase->getKey());
|
->findOrFail($event->purchaseId);
|
||||||
$user = $purchase->user;
|
$user = $purchase->user;
|
||||||
$ticketIds = [];
|
|
||||||
|
|
||||||
foreach ($purchase->items as $purchaseItem) {
|
foreach ($purchase->items as $purchaseItem) {
|
||||||
$catalogItem = CatalogItem::query()
|
$catalogItem = CatalogItem::query()
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
@@ -40,19 +37,13 @@ class GenerateTicketsForPaidPurchase
|
|||||||
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$generatedTickets = $this->ticketGenerator->generate(
|
$this->ticketGenerator->generate(
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$user,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
$purchase->getKey(),
|
$purchase->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ticketIds !== []) {
|
|
||||||
TicketsAvailable::dispatch($purchase, $ticketIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Services;
|
|||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||||
use Endroid\QrCode\QrCode;
|
use Endroid\QrCode\QrCode;
|
||||||
use Endroid\QrCode\Writer\PngWriter;
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
@@ -19,12 +20,36 @@ class TicketPdfService
|
|||||||
* @param Collection<int, Ticket> $tickets
|
* @param Collection<int, Ticket> $tickets
|
||||||
*/
|
*/
|
||||||
public function download(Tenant $tenant, Collection $tickets): Response
|
public function download(Tenant $tenant, Collection $tickets): Response
|
||||||
|
{
|
||||||
|
return $this->pdf($tenant, $tickets)->download($this->filename($tickets));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
*/
|
||||||
|
public function contents(Tenant $tenant, Collection $tickets): string
|
||||||
|
{
|
||||||
|
return $this->pdf($tenant, $tickets)->output();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
*/
|
||||||
|
public function filename(Collection $tickets): string
|
||||||
|
{
|
||||||
|
return 'tickets_'.$tickets->pluck('id')->implode('_').'.pdf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
*/
|
||||||
|
private function pdf(Tenant $tenant, Collection $tickets): DomPdf
|
||||||
{
|
{
|
||||||
$tenant->loadMissing('headerLogo');
|
$tenant->loadMissing('headerLogo');
|
||||||
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
||||||
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
||||||
|
|
||||||
$pdf = Pdf::loadView('pdf.tickets', [
|
return Pdf::loadView('pdf.tickets', [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'logoDataUri' => $this->logoDataUri($tenant),
|
'logoDataUri' => $this->logoDataUri($tenant),
|
||||||
@@ -35,10 +60,6 @@ class TicketPdfService
|
|||||||
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
||||||
),
|
),
|
||||||
])->setPaper('a4');
|
])->setPaper('a4');
|
||||||
|
|
||||||
$ticketIds = $tickets->pluck('id')->implode('_');
|
|
||||||
|
|
||||||
return $pdf->download("tickets_{$ticketIds}.pdf");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function qrCodeDataUri(string $value): string
|
private function qrCodeDataUri(string $value): string
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe
|
|||||||
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
|
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
|
||||||
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
||||||
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
||||||
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
|
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
|
||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||||
@@ -33,9 +31,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class);
|
|
||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
||||||
@@ -113,6 +114,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
], 422);
|
], 422);
|
||||||
});
|
});
|
||||||
|
$exceptions->render(function (StockReservationExpiredException $exception, Request $request) {
|
||||||
|
if (! $request->is('api/*')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
], 422);
|
||||||
|
});
|
||||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||||
if (! $request->is('api/*')) {
|
if (! $request->is('api/*')) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ return [
|
|||||||
'users' => [
|
'users' => [
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
'expire' => 60,
|
'expire' => (int) env('AUTH_PASSWORD_RESET_EXPIRATION_MINUTES', 60),
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'replace_placeholders' => true,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'emails' => [
|
||||||
|
'driver' => 'daily',
|
||||||
|
'path' => storage_path('logs/emails/emails.log'),
|
||||||
|
'level' => env('EMAILS_LOG_LEVEL', 'info'),
|
||||||
|
'days' => env('EMAILS_LOG_DAYS', 30),
|
||||||
|
'replace_placeholders' => true,
|
||||||
|
],
|
||||||
|
|
||||||
'slack' => [
|
'slack' => [
|
||||||
'driver' => 'slack',
|
'driver' => 'slack',
|
||||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
|||||||
@@ -2,10 +2,4 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
||||||
|
|
||||||
'payment_expiration_minutes' => [
|
|
||||||
'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15),
|
|
||||||
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
|
|
||||||
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('expires_at')->nullable()->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempts created before this migration did not have an expiration instant.
|
||||||
|
DB::table('reset_password_attempts')
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::rename('stock_reservations', 'stock_reservation_lines');
|
||||||
|
|
||||||
|
Schema::create('stock_reservations', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('status')->default('active');
|
||||||
|
$table->dateTime('expires_at')->nullable();
|
||||||
|
$table->dateTime('committed_at')->nullable();
|
||||||
|
$table->dateTime('released_at')->nullable();
|
||||||
|
$table->dateTime('expired_at')->nullable();
|
||||||
|
$table->string('release_reason')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['status', 'expires_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('stock_reservation_id')->nullable()->after('id');
|
||||||
|
$table->boolean('tracks_inventory')->default(true)->after('quantity');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('stock_reservation_id')->nullable()->after('cart_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
$cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id');
|
||||||
|
$unlimitedInventoryIds = DB::table('catalog_items')
|
||||||
|
->where('inventory_policy', 'unlimited')
|
||||||
|
->whereNotNull('inventory_id')
|
||||||
|
->pluck('inventory_id')
|
||||||
|
->merge(
|
||||||
|
DB::table('variantes')
|
||||||
|
->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id')
|
||||||
|
->where('catalog_items.inventory_policy', 'unlimited')
|
||||||
|
->pluck('variantes.inventory_id'),
|
||||||
|
)
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique();
|
||||||
|
$legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get();
|
||||||
|
$groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string {
|
||||||
|
if ($row->purchase_id !== null) {
|
||||||
|
return 'purchase:'.$row->purchase_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id);
|
||||||
|
|
||||||
|
return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId;
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$statuses = $rows->pluck('status');
|
||||||
|
$status = $statuses->contains('active')
|
||||||
|
? 'active'
|
||||||
|
: ($statuses->contains('committed')
|
||||||
|
? 'committed'
|
||||||
|
: ($statuses->contains('expired') ? 'expired' : 'released'));
|
||||||
|
$first = $rows->first();
|
||||||
|
$reservationId = DB::table('stock_reservations')->insertGetId([
|
||||||
|
'status' => $status,
|
||||||
|
'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null,
|
||||||
|
'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null,
|
||||||
|
'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null,
|
||||||
|
'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null,
|
||||||
|
'release_reason' => null,
|
||||||
|
'created_at' => $first->created_at,
|
||||||
|
'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($rows->groupBy('inventory_id') as $inventoryRows) {
|
||||||
|
$line = $inventoryRows->first();
|
||||||
|
DB::table('stock_reservation_lines')->where('id', $line->id)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'quantity' => $inventoryRows->sum('quantity'),
|
||||||
|
'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id),
|
||||||
|
]);
|
||||||
|
DB::table('stock_reservation_lines')
|
||||||
|
->whereIn('id', $inventoryRows->pluck('id')->skip(1))
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($key, 'purchase:')) {
|
||||||
|
$purchaseId = (int) substr($key, strlen('purchase:'));
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
$cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id');
|
||||||
|
$isCurrent = $cartId !== null
|
||||||
|
&& (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId;
|
||||||
|
|
||||||
|
if ($status === 'active' && $isCurrent) {
|
||||||
|
DB::table('carritos')->where('id', $cartId)->update([
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} elseif (str_starts_with($key, 'cart:') && $status === 'active') {
|
||||||
|
DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign('stock_reservations_cart_item_id_foreign');
|
||||||
|
$table->dropForeign('stock_reservations_purchase_id_foreign');
|
||||||
|
$table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique');
|
||||||
|
$table->dropIndex('stock_reservations_purchase_id_status_index');
|
||||||
|
$table->dropIndex('stock_reservations_status_expires_at_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change();
|
||||||
|
$table->dropColumn([
|
||||||
|
'cart_item_id',
|
||||||
|
'purchase_id',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'committed_at',
|
||||||
|
'released_at',
|
||||||
|
]);
|
||||||
|
$table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk')
|
||||||
|
->references('id')
|
||||||
|
->on('stock_reservations')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unique(
|
||||||
|
['stock_reservation_id', 'inventory_id'],
|
||||||
|
'reservation_lines_reservation_inventory_unique',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk')
|
||||||
|
->references('id')
|
||||||
|
->on('stock_reservations')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk')
|
||||||
|
->references('id')
|
||||||
|
->on('stock_reservations')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->unique('stock_reservation_id', 'purchases_stock_reservation_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('purchases_stock_reservation_unique');
|
||||||
|
$table->dropForeign('purchases_stock_reservation_fk');
|
||||||
|
$table->dropColumn('stock_reservation_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('carts_current_stock_reservation_unique');
|
||||||
|
$table->dropForeign('carts_current_stock_reservation_fk');
|
||||||
|
$table->dropColumn('current_stock_reservation_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::dropIfExists('stock_reservation_lines');
|
||||||
|
Schema::dropIfExists('stock_reservations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('compras')
|
||||||
|
->whereNotNull('stock_reservation_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($purchases): void {
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
DB::table('stock_reservations')
|
||||||
|
->where('id', $purchase->stock_reservation_id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->update(['expires_at' => $purchase->expires_at]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['expires_at']);
|
||||||
|
$table->dropColumn('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('expires_at')->nullable()->index()->after('payment_method');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->whereNotNull('stock_reservation_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($purchases): void {
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
DB::table('compras')
|
||||||
|
->where('id', $purchase->id)
|
||||||
|
->update([
|
||||||
|
'expires_at' => DB::table('stock_reservations')
|
||||||
|
->where('id', $purchase->stock_reservation_id)
|
||||||
|
->value('expires_at'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$expiredReservationIds = fn ($query) => $query
|
||||||
|
->select('id')
|
||||||
|
->from('stock_reservations')
|
||||||
|
->where('status', 'expired');
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->whereIn('status', ['created', 'pending_payment'])
|
||||||
|
->whereIn('stock_reservation_id', $expiredReservationIds)
|
||||||
|
->update(['status' => 'expired']);
|
||||||
|
|
||||||
|
DB::table('carritos')
|
||||||
|
->whereIn('status', ['active', 'checkout'])
|
||||||
|
->whereIn('current_stock_reservation_id', $expiredReservationIds)
|
||||||
|
->update(['status' => 'expired']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Terminal business states cannot be reversed without inventing their prior state.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const FILENAME = 'onticket_favicon.svg';
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private const WEBSITE_TYPE_CODES = ['shopit', 'onticket'];
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$websiteTypes = DB::table('website_type')
|
||||||
|
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||||
|
->get(['codigo', 'favicon_id']);
|
||||||
|
|
||||||
|
if ($websiteTypes->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$faviconIds = $websiteTypes
|
||||||
|
->pluck('favicon_id')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if (
|
||||||
|
$faviconIds->count() === 1
|
||||||
|
&& DB::table('attachments')
|
||||||
|
->where('id', $faviconIds->first())
|
||||||
|
->where('filename', self::FILENAME)
|
||||||
|
->exists()
|
||||||
|
&& $websiteTypes->every(
|
||||||
|
fn (object $websiteType): bool => $websiteType->favicon_id === $faviconIds->first()
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourcePath = public_path('images/website_types/'.self::FILENAME);
|
||||||
|
|
||||||
|
if (! is_file($sourcePath)) {
|
||||||
|
throw new RuntimeException("Favicon not found at path: {$sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = file_get_contents($sourcePath);
|
||||||
|
|
||||||
|
if ($contents === false) {
|
||||||
|
throw new RuntimeException("Could not read favicon at path: {$sourcePath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = (string) Str::uuid();
|
||||||
|
$storedPath = "website-types/{$key}.svg";
|
||||||
|
|
||||||
|
if (! Storage::disk('s3')->put($storedPath, $contents)) {
|
||||||
|
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($contents, $key, $storedPath): void {
|
||||||
|
$attachmentId = DB::table('attachments')->insertGetId([
|
||||||
|
'key' => $key,
|
||||||
|
'path' => $storedPath,
|
||||||
|
'filename' => self::FILENAME,
|
||||||
|
'type' => 'image',
|
||||||
|
'mime_type' => 'image/svg+xml',
|
||||||
|
'extension' => 'svg',
|
||||||
|
'size' => strlen($contents),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('website_type')
|
||||||
|
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||||
|
->update([
|
||||||
|
'favicon_id' => $attachmentId,
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (Throwable $throwable) {
|
||||||
|
Storage::disk('s3')->delete($storedPath);
|
||||||
|
|
||||||
|
throw $throwable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// The shared attachment may be in use outside these website types.
|
||||||
|
// Keep this data migration irreversible to avoid deleting an active asset.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -191,6 +191,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
|
|||||||
|
|
||||||
FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => self::TENANT_CODE,
|
'tenant_code' => self::TENANT_CODE,
|
||||||
|
'code' => 'entradas',
|
||||||
'source_type' => FeaturedGroupSource::All,
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'category_id' => null,
|
'category_id' => null,
|
||||||
'product_layout' => ProductLayout::TicketSelector,
|
'product_layout' => ProductLayout::TicketSelector,
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
|
|
||||||
FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'code' => 'productos',
|
||||||
'source_type' => FeaturedGroupSource::All,
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'category_id' => null,
|
'category_id' => null,
|
||||||
'product_layout' => ProductLayout::Row,
|
'product_layout' => ProductLayout::Row,
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
|
|
||||||
FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'code' => 'productos',
|
||||||
'source_type' => FeaturedGroupSource::All,
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'product_layout' => ProductLayout::ColumnWithImage,
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
'group_layout' => GroupLayout::Paginated,
|
'group_layout' => GroupLayout::Paginated,
|
||||||
@@ -180,6 +181,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
|
|
||||||
$carouselGroup = FeaturedGroup::query()->create([
|
$carouselGroup = FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'code' => 'productos-destacados',
|
||||||
'source_type' => FeaturedGroupSource::Manual,
|
'source_type' => FeaturedGroupSource::Manual,
|
||||||
'product_layout' => ProductLayout::ColumnWithImage,
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
'group_layout' => GroupLayout::Carousel,
|
'group_layout' => GroupLayout::Carousel,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
|
'favicon' => $this->onTicketFavicon(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
|
'favicon' => $shopIt->favicon()->firstOrFail()->key,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,4 +177,21 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function onTicketFavicon(): UploadedFile
|
||||||
|
{
|
||||||
|
$path = public_path('images/website_types/onticket_favicon.svg');
|
||||||
|
|
||||||
|
if (! file_exists($path)) {
|
||||||
|
throw new RuntimeException("OnTicket favicon not found at path: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UploadedFile(
|
||||||
|
$path,
|
||||||
|
'onticket_favicon.svg',
|
||||||
|
'image/svg+xml',
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
||||||
'password_updated' => 'Password updated successfully.',
|
'password_updated' => 'Password updated successfully.',
|
||||||
'reset_code_invalid' => 'The code you entered is invalid.',
|
'reset_code_invalid' => 'The code you entered is invalid.',
|
||||||
|
'reset_code_expired' => 'The password recovery code expired. Request a new one.',
|
||||||
'reset_code_valid' => 'Code validated successfully.',
|
'reset_code_valid' => 'Code validated successfully.',
|
||||||
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
||||||
'request_expired' => 'The authentication request expired. Please try again.',
|
'request_expired' => 'The authentication request expired. Please try again.',
|
||||||
@@ -34,6 +35,7 @@ return [
|
|||||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||||
'empty_bundle' => 'The bundle has no components.',
|
'empty_bundle' => 'The bundle has no components.',
|
||||||
'variant_required' => 'You must select a variant for this item.',
|
'variant_required' => 'You must select a variant for this item.',
|
||||||
|
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
|
||||||
],
|
],
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
'expired' => 'The purchase has expired. Please start a new purchase.',
|
'expired' => 'The purchase has expired. Please start a new purchase.',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||||
'password_updated' => 'Contraseña modificada correctamente.',
|
'password_updated' => 'Contraseña modificada correctamente.',
|
||||||
'reset_code_invalid' => 'El código ingresado es inválido.',
|
'reset_code_invalid' => 'El código ingresado es inválido.',
|
||||||
|
'reset_code_expired' => 'El código de recuperación expiró. Solicitá uno nuevo.',
|
||||||
'reset_code_valid' => 'Código validado correctamente.',
|
'reset_code_valid' => 'Código validado correctamente.',
|
||||||
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
||||||
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
||||||
@@ -34,6 +35,7 @@ return [
|
|||||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
'empty_bundle' => 'El bundle no tiene componentes.',
|
||||||
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
||||||
|
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
|
||||||
],
|
],
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
||||||
|
|||||||
3
public/images/website_types/onticket_favicon.svg
Normal file
3
public/images/website_types/onticket_favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="63" height="36" viewBox="0 0 63 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M44.64 0H17.94C8.05 0 0 8.05 0 17.94C0 27.83 8.05 35.88 17.94 35.88H44.64C54.53 35.88 62.58 27.83 62.58 17.94C62.58 8.05 54.53 0 44.64 0ZM44.62 31.78C36.98 31.78 30.79 25.59 30.79 17.95C30.79 10.31 36.98 4.12 44.62 4.12C52.26 4.12 58.45 10.31 58.45 17.95C58.45 25.59 52.26 31.78 44.62 31.78Z" fill="#FF7006"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 422 B |
@@ -1,4 +1,4 @@
|
|||||||
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
|
<h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
|
||||||
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
@foreach ($purchase->items as $item)
|
@foreach ($purchase->items as $item)
|
||||||
@@ -10,3 +10,6 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
||||||
|
@if ($tickets->isNotEmpty())
|
||||||
|
<p><strong>Tus tickets ya están disponibles</strong></p>
|
||||||
|
@endif
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<h1 style="margin: 0 0 20px;">Tus tickets ya están disponibles</h1>
|
|
||||||
<p>Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra <strong>#{{ $purchase->id }}</strong>.</p>
|
|
||||||
<ul style="padding-left: 20px;">
|
|
||||||
@foreach ($tickets as $ticket)
|
|
||||||
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@@ -15,7 +15,9 @@ Artisan::command('reservations:expire', function (): void {
|
|||||||
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
|
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
|
||||||
|
|
||||||
$this->info("Expired purchases: {$expired['purchases']}");
|
$this->info("Expired purchases: {$expired['purchases']}");
|
||||||
$this->info("Expired cart items: {$expired['cart_items']}");
|
$this->info("Expired cart reservations: {$expired['cart_reservations']}");
|
||||||
|
$this->info("Expired orphan reservations: {$expired['orphan_reservations']}");
|
||||||
|
$this->info("Failed reservations: {$expired['failed']}");
|
||||||
})->purpose('Release expired stock reservations from purchases and abandoned carts');
|
})->purpose('Release expired stock reservations from purchases and abandoned carts');
|
||||||
|
|
||||||
Schedule::command('reservations:expire')
|
Schedule::command('reservations:expire')
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class CreateResetPasswordAttemptControllerTest extends TestCase
|
|||||||
$this->assertTrue($attempt->user->is($user));
|
$this->assertTrue($attempt->user->is($user));
|
||||||
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
|
$this->assertTrue($attempt->expires_at->between(
|
||||||
|
now()->addMinutes(59),
|
||||||
|
now()->addMinutes(60),
|
||||||
|
));
|
||||||
Event::assertDispatched(
|
Event::assertDispatched(
|
||||||
PasswordResetRequested::class,
|
PasswordResetRequested::class,
|
||||||
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ class ResetPasswordAttemptTest extends TestCase
|
|||||||
'id',
|
'id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'codigo',
|
'codigo',
|
||||||
|
'reason',
|
||||||
'status',
|
'status',
|
||||||
|
'expires_at',
|
||||||
], Schema::getColumnListing('reset_password_attempts'));
|
], Schema::getColumnListing('reset_password_attempts'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ class ResetPasswordAttemptTest extends TestCase
|
|||||||
|
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => '123456',
|
'codigo' => '123456',
|
||||||
|
'expires_at' => now()->addHour(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
$this->assertTrue($attempt->user->is($user));
|
$this->assertTrue($attempt->user->is($user));
|
||||||
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
||||||
$this->assertFalse($attempt->usesTimestamps());
|
$this->assertFalse($attempt->usesTimestamps());
|
||||||
|
$this->assertTrue($attempt->expires_at->isFuture());
|
||||||
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,30 @@ class ResetPasswordControllerTest extends TestCase
|
|||||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_an_expired_validated_attempt_cannot_reset_the_password(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'password' => 'OldSecret!123',
|
||||||
|
]);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
'expires_at' => now()->subSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
'password' => 'NewSecret!456',
|
||||||
|
'password_confirmation' => 'NewSecret!456',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $attempt->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_validates_password_confirmation_and_strength(): void
|
public function test_it_validates_password_confirmation_and_strength(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/api/password/reset', [
|
$this->postJson('/api/password/reset', [
|
||||||
|
|||||||
@@ -49,6 +49,27 @@ class ValidateResetPasswordAttemptControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_expires_an_attempt_and_returns_the_expired_code_message(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'expires_at' => now()->subSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo')
|
||||||
|
->assertJsonPath('errors.codigo.0', __('api.auth.reset_code_expired'));
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
$attempt->fresh()->status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ namespace Tests\Feature\Cart;
|
|||||||
use App\Domains\Attachable\Enums\AttachmentType;
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -28,6 +30,81 @@ class CartControllerTest extends TestCase
|
|||||||
]));
|
]));
|
||||||
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
|
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
|
||||||
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
|
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
|
||||||
|
$this->assertTrue(Schema::hasColumns('stock_reservations', [
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'committed_at',
|
||||||
|
'released_at',
|
||||||
|
'expired_at',
|
||||||
|
'release_reason',
|
||||||
|
]));
|
||||||
|
$this->assertFalse(Schema::hasColumn('stock_reservations', 'quantity'));
|
||||||
|
$this->assertTrue(Schema::hasColumns('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id',
|
||||||
|
'inventory_id',
|
||||||
|
'quantity',
|
||||||
|
'tracks_inventory',
|
||||||
|
]));
|
||||||
|
$this->assertTrue(Schema::hasColumn('carritos', 'current_stock_reservation_id'));
|
||||||
|
$this->assertTrue(Schema::hasColumn('compras', 'stock_reservation_id'));
|
||||||
|
$this->assertFalse(Schema::hasColumn('compras', 'expires_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_aggregates_shared_inventory_into_one_cart_reservation_line(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$firstItem = $this->createDirectItem($tenant, 10, '10.00');
|
||||||
|
$secondItem = app(CatalogService::class)->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'type' => 'bundle',
|
||||||
|
'slug' => 'second-shared-item',
|
||||||
|
'nombre' => 'Second shared item',
|
||||||
|
'precio' => '20.00',
|
||||||
|
'components' => [[
|
||||||
|
'catalog_item_id' => $firstItem->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $firstItem->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
|
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
|
||||||
|
|
||||||
|
$this->call(
|
||||||
|
'POST',
|
||||||
|
'/api/tenants/acme/cart/items',
|
||||||
|
[],
|
||||||
|
['guest_token' => $guestToken],
|
||||||
|
[],
|
||||||
|
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||||
|
json_encode([
|
||||||
|
'catalog_item_id' => $secondItem->id,
|
||||||
|
'cantidad' => 3,
|
||||||
|
]),
|
||||||
|
)->assertOk();
|
||||||
|
|
||||||
|
$reservationId = (int) $firstResponse->json('data.id');
|
||||||
|
$reservationId = (int) Cart::query()
|
||||||
|
->findOrFail($reservationId)
|
||||||
|
->current_stock_reservation_id;
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('stock_reservations', 1);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'inventory_id' => $firstItem->inventory_id,
|
||||||
|
'quantity' => 5,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseCount('stock_reservation_lines', 1);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $firstItem->inventory_id,
|
||||||
|
'reserved_stock' => 5,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_adds_a_catalog_item_without_a_variant(): void
|
public function test_it_adds_a_catalog_item_without_a_variant(): void
|
||||||
@@ -74,9 +151,11 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $item->inventory_id,
|
'id' => $item->inventory_id,
|
||||||
'reserved_stock' => 2,
|
'reserved_stock' => 2,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
'inventory_id' => $item->inventory_id,
|
'inventory_id' => $item->inventory_id,
|
||||||
'quantity' => 2,
|
'quantity' => 2,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -96,38 +175,44 @@ class CartControllerTest extends TestCase
|
|||||||
])->assertOk();
|
])->assertOk();
|
||||||
|
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'inventory_id' => $item->inventory_id,
|
|
||||||
'quantity' => 2,
|
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
|
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->travelBack();
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void
|
public function test_it_expires_a_cart_reservation_and_automatically_replaces_the_cart(): void
|
||||||
{
|
{
|
||||||
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
$user = User::factory()->create();
|
||||||
$item = $this->createDirectItem($tenant, 10, '49.90');
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
$response = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
'catalog_item_id' => $item->id,
|
'catalog_item_id' => $item->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
])->assertOk();
|
])->assertOk();
|
||||||
$cartId = $response->json('data.id');
|
$cartId = $response->json('data.id');
|
||||||
$cartItemId = $response->json('data.items.0.id');
|
$cartItemId = $response->json('data.items.0.id');
|
||||||
|
$cart = Cart::query()->findOrFail($cartId);
|
||||||
|
$reservationId = $cart->current_stock_reservation_id;
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->travel(31)->minutes();
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 1')
|
->expectsOutput('Expired cart reservations: 1')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
@@ -135,28 +220,141 @@ class CartControllerTest extends TestCase
|
|||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]);
|
$this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]);
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $cartId,
|
'id' => $cartId,
|
||||||
'status' => 'expired',
|
'status' => Cart::STATUS_EXPIRED,
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'inventory_id' => $item->inventory_id,
|
'id' => $reservationId,
|
||||||
'cart_item_id' => null,
|
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => 'expired',
|
'status' => 'expired',
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentCart = $this->getJson('/api/tenants/acme/cart')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
|
||||||
|
->assertJsonPath('data.items', [])
|
||||||
|
->assertJsonMissingPath('data.stock_reservation')
|
||||||
|
->assertJsonMissingPath('data.current_stock_reservation_id');
|
||||||
|
$newCartId = $currentCart->json('data.id');
|
||||||
|
|
||||||
|
$this->assertNotSame($cartId, $newCartId);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $cartId,
|
||||||
|
'status' => Cart::STATUS_ABANDONED,
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $newCartId,
|
||||||
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
'current_stock_reservation_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', $newCartId)
|
||||||
|
->assertJsonPath('data.items.0.cantidad', 1);
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->travelBack();
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_replaces_an_overdue_cart_before_the_expiration_job_runs(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
|
||||||
|
$original = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
|
$originalCartId = $original->json('data.id');
|
||||||
|
|
||||||
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
|
$replacement = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
|
||||||
|
->assertJsonPath('data.items.0.cantidad', 1);
|
||||||
|
|
||||||
|
$this->assertNotSame($originalCartId, $replacement->json('data.id'));
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $originalCartId,
|
||||||
|
'status' => Cart::STATUS_ABANDONED,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $item->inventory_id,
|
||||||
|
'reserved_stock' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_expired_cart_mutations_return_the_expiration_error_instead_of_not_found(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '49.90');
|
||||||
|
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
|
||||||
|
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
|
||||||
|
|
||||||
|
$cartItemId = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'variant_id' => $firstVariant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->json('data.items.0.id');
|
||||||
|
|
||||||
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
|
$expectedError = [
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => __('api.cart.reservation_expired'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
|
||||||
|
'cantidad' => 3,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson($expectedError);
|
||||||
|
|
||||||
|
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
|
||||||
|
'cantidad' => 2,
|
||||||
|
'variant_id' => $secondVariant->id,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson($expectedError);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/tenants/acme/cart/items/{$cartItemId}")
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson($expectedError);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
@@ -212,10 +410,11 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 5,
|
'reserved_stock' => 5,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
'cart_item_id' => $response->json('data.items.0.id'),
|
|
||||||
'inventory_id' => $variant->inventory_id,
|
'inventory_id' => $variant->inventory_id,
|
||||||
'quantity' => 5,
|
'quantity' => 5,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -229,6 +428,7 @@ class CartControllerTest extends TestCase
|
|||||||
$this->createPurchaseItem($tenant, $user, $item, 1);
|
$this->createPurchaseItem($tenant, $user, $item, 1);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->withHeader('Accept-Language', 'es')
|
||||||
->postJson('/api/tenants/acme/cart/items', [
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
'catalog_item_id' => $item->id,
|
'catalog_item_id' => $item->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
@@ -237,6 +437,7 @@ class CartControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.items.0.cantidad', 2);
|
->assertJsonPath('data.items.0.cantidad', 2);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->withHeader('Accept-Language', 'es')
|
||||||
->postJson('/api/tenants/acme/cart/items', [
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
'catalog_item_id' => $item->id,
|
'catalog_item_id' => $item->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
@@ -381,10 +582,12 @@ class CartControllerTest extends TestCase
|
|||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cart_item_id' => null,
|
|
||||||
'inventory_id' => $variant->inventory_id,
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => 'released',
|
'status' => 'released',
|
||||||
|
'release_reason' => 'cart_empty',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 5,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class CatalogSchemaTest extends TestCase
|
|||||||
$this->assertEqualsCanonicalizing([
|
$this->assertEqualsCanonicalizing([
|
||||||
'id',
|
'id',
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'code',
|
||||||
'source_type',
|
'source_type',
|
||||||
'category_id',
|
'category_id',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
|
|||||||
@@ -60,8 +60,12 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
->assertJsonValidationErrors(['transfer_payer_dni']);
|
->assertJsonValidationErrors(['transfer_payer_dni']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void
|
public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
|
||||||
{
|
{
|
||||||
|
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$this->configureTelepagosIntegration($tenant);
|
$this->configureTelepagosIntegration($tenant);
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -99,6 +103,13 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
'payment_method' => 'transfer',
|
'payment_method' => 'transfer',
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $purchase->stock_reservation_id,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
public function test_transfer_webhook_matches_pending_purchase_by_dni_and_total_amount(): void
|
||||||
@@ -198,11 +209,16 @@ class TelepagosWebhookTest extends TestCase
|
|||||||
'compra_id' => $newerPurchase->id,
|
'compra_id' => $newerPurchase->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
]);
|
]);
|
||||||
|
$newerReservationId = $newerPurchase->fresh()->stock_reservation_id;
|
||||||
|
$this->assertNotNull($newerReservationId);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'purchase_id' => $newerPurchase->id,
|
'id' => $newerReservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id' => $newerReservationId,
|
||||||
'inventory_id' => $variant->inventory_id,
|
'inventory_id' => $variant->inventory_id,
|
||||||
'quantity' => 2,
|
'quantity' => 2,
|
||||||
'status' => 'active',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertSoftDeleted('carritos', [
|
||||||
|
|||||||
61
tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php
Normal file
61
tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SetWebsiteTypeFaviconTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_uploads_one_favicon_and_shares_the_attachment_between_website_types(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
DB::table('website_type')->insert([
|
||||||
|
[
|
||||||
|
'codigo' => 'shopit',
|
||||||
|
'nombre' => 'ShopIt',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$migration = require database_path(
|
||||||
|
'migrations/2026_08_26_000000_set_website_type_favicon.php'
|
||||||
|
);
|
||||||
|
|
||||||
|
$migration->up();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$faviconIds = DB::table('website_type')
|
||||||
|
->whereIn('codigo', ['shopit', 'onticket'])
|
||||||
|
->pluck('favicon_id');
|
||||||
|
|
||||||
|
$this->assertCount(2, $faviconIds);
|
||||||
|
$this->assertNotNull($faviconIds->first());
|
||||||
|
$this->assertSame(1, $faviconIds->unique()->count());
|
||||||
|
|
||||||
|
$attachment = DB::table('attachments')->where('id', $faviconIds->first())->first();
|
||||||
|
|
||||||
|
$this->assertNotNull($attachment);
|
||||||
|
$this->assertSame('onticket_favicon.svg', $attachment->filename);
|
||||||
|
$this->assertSame('image/svg+xml', $attachment->mime_type);
|
||||||
|
$this->assertSame('svg', $attachment->extension);
|
||||||
|
$this->assertSame(1, DB::table('attachments')->where('filename', 'onticket_favicon.svg')->count());
|
||||||
|
Storage::disk('s3')->assertExists($attachment->path);
|
||||||
|
$this->assertSame(
|
||||||
|
file_get_contents(public_path('images/website_types/onticket_favicon.svg')),
|
||||||
|
Storage::disk('s3')->get($attachment->path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
public function test_it_sends_one_purchase_confirmation_with_generated_tickets_attached(): void
|
||||||
{
|
{
|
||||||
$this->useWebsiteTypeBranding();
|
$this->useWebsiteTypeBranding();
|
||||||
$purchase = Purchase::query()->create([
|
$purchase = Purchase::query()->create([
|
||||||
@@ -175,30 +175,48 @@ class NotificationMailServiceTest extends TestCase
|
|||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'tenant_code' => $this->tenant->codigo,
|
'tenant_code' => $this->tenant->codigo,
|
||||||
'ticket' => fake()->uuid(),
|
'ticket' => fake()->uuid(),
|
||||||
|
'source_purchase_id' => $purchase->id,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
'user_id' => $this->user->id,
|
'user_id' => $this->user->id,
|
||||||
]);
|
]);
|
||||||
$service = app(NotificationMailService::class);
|
$service = app(NotificationMailService::class);
|
||||||
|
|
||||||
$service->sendPurchasePaid($purchase->id);
|
$service->sendPurchaseConfirmed($purchase->id);
|
||||||
$service->sendTicketsAvailable($purchase->id, [$ticket->id]);
|
|
||||||
|
|
||||||
Mail::assertSent(Mailable::class, 2);
|
Mail::assertSent(Mailable::class, 1);
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $ticket): bool {
|
||||||
$mail->assertTo('checkout@example.com');
|
$mail->assertTo('checkout@example.com');
|
||||||
|
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
|
||||||
|
|
||||||
return $mail->subject === "Pago confirmado - Compra #{$purchase->id}"
|
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||||
|
&& str_contains($mail->render(), '¡Compra realizada con éxito!')
|
||||||
&& str_contains($mail->render(), 'Total pagado')
|
&& str_contains($mail->render(), 'Total pagado')
|
||||||
|
&& str_contains($mail->render(), 'Tus tickets ya están disponibles')
|
||||||
|
&& $attachment !== null
|
||||||
|
&& $attachment['options'] === ['mime' => 'application/pdf']
|
||||||
|
&& str_starts_with($attachment['data'], '%PDF-')
|
||||||
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
|
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
|
||||||
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
||||||
});
|
});
|
||||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
}
|
||||||
$mail->assertTo('checkout@example.com');
|
|
||||||
|
|
||||||
return $mail->subject === 'Tus tickets ya están disponibles'
|
public function test_purchase_confirmation_omits_ticket_content_and_attachment_without_tickets(): void
|
||||||
&& str_contains($mail->render(), 'Entrada general')
|
{
|
||||||
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
|
$purchase = Purchase::query()->create([
|
||||||
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'status' => Purchase::STATUS_PAID,
|
||||||
|
'payment_method' => 'transfer',
|
||||||
|
'total' => 25,
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
|
||||||
|
|
||||||
|
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
||||||
|
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||||
|
&& str_contains($mail->render(), '¡Compra realizada con éxito!')
|
||||||
|
&& ! str_contains($mail->render(), 'Tus tickets ya están disponibles')
|
||||||
|
&& $mail->rawAttachments === [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Notification;
|
||||||
|
|
||||||
|
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
|
use Mockery;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class QueuedNotificationListenerTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_purchase_confirmed_email_delegates_with_the_purchase_id(): void
|
||||||
|
{
|
||||||
|
$mailService = Mockery::mock(NotificationMailService::class);
|
||||||
|
$mailService->shouldReceive('sendPurchaseConfirmed')
|
||||||
|
->once()
|
||||||
|
->with(123);
|
||||||
|
$this->app->instance(NotificationMailService::class, $mailService);
|
||||||
|
|
||||||
|
(new SendPurchaseConfirmedEmail)->handle(new PurchasePaid(123));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_purchase_paid_event_only_serializes_the_purchase_id(): void
|
||||||
|
{
|
||||||
|
$purchasePaid = unserialize(serialize(new PurchasePaid(123)));
|
||||||
|
|
||||||
|
$this->assertSame(123, $purchasePaid->purchaseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,82 @@ class StorePurchaseTest extends TestCase
|
|||||||
Queue::fake();
|
Queue::fake();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_checkout_keeps_the_cart_reservation_and_refreshes_its_expiration(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 5);
|
||||||
|
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
$cart = Cart::query()->create([
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$cart->addItem($variant->catalog_item_id, $variant->id, 2);
|
||||||
|
$reservationId = $cart->fresh()->current_stock_reservation_id;
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $now->copy()->addMinutes(5)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travel(2)->minutes();
|
||||||
|
$purchase = app(CheckoutService::class)->startCheckout($tenant, $user->id, [
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame($reservationId, $purchase->stock_reservation_id);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $now->copy()->addMinutes(32)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_checkout_cannot_replace_an_overdue_cart_reservation(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
$cart = Cart::query()->create([
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$cart->addItem($variant->catalog_item_id, $variant->id, 2);
|
||||||
|
$reservationId = $cart->fresh()->current_stock_reservation_id;
|
||||||
|
$expiredAt = now()->subMinute()->startOfSecond();
|
||||||
|
$cart->currentStockReservation()->update(['expires_at' => $expiredAt]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||||
|
'cart_id' => $cart->id,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson([
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => __('api.cart.reservation_expired'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('compras', 0);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $expiredAt->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $cart->id,
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void
|
public function test_it_starts_checkout_from_cart_with_purchase_item_snapshots(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
@@ -119,12 +195,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'precio_unitario' => '50.00',
|
'precio_unitario' => '50.00',
|
||||||
'total' => '100.00',
|
'total' => '100.00',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($purchaseId, $inventory->id, 2, 'active');
|
||||||
'inventory_id' => $inventory->id,
|
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'quantity' => 2,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $cartId,
|
'id' => $cartId,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
@@ -168,12 +239,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'id' => $cartId,
|
'id' => $cartId,
|
||||||
'current_purchase_id' => $replacementPurchaseId,
|
'current_purchase_id' => $replacementPurchaseId,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($replacementPurchaseId, $inventory->id, 2, 'active');
|
||||||
'inventory_id' => $inventory->id,
|
|
||||||
'purchase_id' => $replacementPurchaseId,
|
|
||||||
'quantity' => 2,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $inventory->id,
|
'id' => $inventory->id,
|
||||||
'reserved_stock' => 2,
|
'reserved_stock' => 2,
|
||||||
@@ -227,12 +293,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'precio_unitario' => '50.00',
|
'precio_unitario' => '50.00',
|
||||||
'total' => '150.00',
|
'total' => '150.00',
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($response->json('data.id'), $variant->inventory_id, 3, 'active');
|
||||||
'inventory_id' => $variant->inventory_id,
|
|
||||||
'purchase_id' => $response->json('data.id'),
|
|
||||||
'quantity' => 3,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
@@ -244,10 +305,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
||||||
|
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($response->json('data.id'), $variant->inventory_id, 3, 'released');
|
||||||
'purchase_id' => $response->json('data.id'),
|
|
||||||
'status' => 'released',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
@@ -296,18 +354,8 @@ class StorePurchaseTest extends TestCase
|
|||||||
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
'origin' => Cart::ORIGIN_DIRECT_CHECKOUT,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseCount('compra_items', 2);
|
$this->assertDatabaseCount('compra_items', 2);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($purchaseId, $firstVariant->inventory_id, 1, 'active');
|
||||||
'inventory_id' => $firstVariant->inventory_id,
|
$this->assertPurchaseReservation($purchaseId, $secondVariant->inventory_id, 1, 'active');
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'quantity' => 1,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
|
||||||
'inventory_id' => $secondVariant->inventory_id,
|
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'quantity' => 1,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $firstVariant->inventory_id,
|
'id' => $firstVariant->inventory_id,
|
||||||
'reserved_stock' => 1,
|
'reserved_stock' => 1,
|
||||||
@@ -430,7 +478,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$firstVariant = $this->createVariantForTenant('sonder', 20, '50.00');
|
$firstVariant = $this->createVariantForTenant('sonder', 20, '50.00');
|
||||||
$firstVariant->catalogItem->update(['max_units_per_user' => 3]);
|
$firstVariant->catalogItem->update(['max_units_per_user' => 4]);
|
||||||
$secondInventory = Inventory::query()->create(['real_stock' => 20]);
|
$secondInventory = Inventory::query()->create(['real_stock' => 20]);
|
||||||
$secondVariant = Variant::query()->create([
|
$secondVariant = Variant::query()->create([
|
||||||
'catalog_item_id' => $firstVariant->catalog_item_id,
|
'catalog_item_id' => $firstVariant->catalog_item_id,
|
||||||
@@ -443,6 +491,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2);
|
$cart->addItem($firstVariant->catalog_item_id, $firstVariant->id, 2);
|
||||||
$cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 2);
|
$cart->addItem($secondVariant->catalog_item_id, $secondVariant->id, 2);
|
||||||
|
$firstVariant->catalogItem->update(['max_units_per_user' => 3]);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||||
@@ -468,6 +517,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3);
|
||||||
|
$reservationId = $purchase->stock_reservation_id;
|
||||||
$activeCart = Cart::query()
|
$activeCart = Cart::query()
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
@@ -500,27 +550,36 @@ class StorePurchaseTest extends TestCase
|
|||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 3,
|
'reserved_stock' => 3,
|
||||||
]);
|
]);
|
||||||
|
$activeCart->refresh();
|
||||||
|
$this->assertSame($reservationId, $activeCart->current_stock_reservation_id);
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $purchase->id,
|
||||||
|
'stock_reservation_id' => null,
|
||||||
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cart_item_id' => $cartItemId,
|
'id' => $activeCart->current_stock_reservation_id,
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => 3,
|
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id' => $activeCart->current_stock_reservation_id,
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 3,
|
||||||
|
]);
|
||||||
$this->assertSame(1, $activeCart->items()->count());
|
$this->assertSame(1, $activeCart->items()->count());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_reassigns_a_terminal_purchase_reservation_and_rejects_a_late_confirmation(): void
|
public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$previousPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
$previousPurchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 2);
|
||||||
$cart = $previousPurchase->cart;
|
$cart = $previousPurchase->cart;
|
||||||
|
$previousReservationId = $previousPurchase->stock_reservation_id;
|
||||||
|
|
||||||
$previousPurchase->update([
|
$previousPurchase->update([
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'payment_method' => 'transfer',
|
'payment_method' => 'transfer',
|
||||||
'expires_at' => now()->addMinutes(30),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$currentPurchase = app(CheckoutService::class)->startCheckout(
|
$currentPurchase = app(CheckoutService::class)->startCheckout(
|
||||||
@@ -532,17 +591,22 @@ class StorePurchaseTest extends TestCase
|
|||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
'id' => $previousPurchase->id,
|
'id' => $previousPurchase->id,
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $cart->id,
|
'id' => $cart->id,
|
||||||
'current_purchase_id' => $currentPurchase->id,
|
'current_purchase_id' => $currentPurchase->id,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertSame($previousReservationId, $currentPurchase->stock_reservation_id);
|
||||||
'purchase_id' => $currentPurchase->id,
|
$this->assertDatabaseHas('compras', [
|
||||||
'quantity' => 2,
|
'id' => $previousPurchase->id,
|
||||||
'status' => 'active',
|
'stock_reservation_id' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $previousReservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
$this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
app(CheckoutService::class)->confirmPaidPurchase($previousPurchase->fresh());
|
app(CheckoutService::class)->confirmPaidPurchase($previousPurchase->fresh());
|
||||||
@@ -556,11 +620,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'reserved_stock' => 2,
|
'reserved_stock' => 2,
|
||||||
'sold_units' => 0,
|
'sold_units' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active');
|
||||||
'purchase_id' => $currentPurchase->id,
|
|
||||||
'quantity' => 2,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_removing_a_checkout_item_supersedes_the_purchase_and_restores_the_user_quota(): void
|
public function test_removing_a_checkout_item_supersedes_the_purchase_and_restores_the_user_quota(): void
|
||||||
@@ -589,11 +649,12 @@ class StorePurchaseTest extends TestCase
|
|||||||
'current_purchase_id' => null,
|
'current_purchase_id' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'inventory_id' => $variant->inventory_id,
|
|
||||||
'cart_item_id' => null,
|
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => 'released',
|
'status' => 'released',
|
||||||
|
'release_reason' => 'cart_empty',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
@@ -607,7 +668,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
$this->assertSame(3, $remaining);
|
$this->assertSame(3, $remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void
|
public function test_it_expires_the_purchase_and_preserves_the_expired_cart_items(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -618,7 +679,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
$cartItemId = $purchase->cart->items()->firstOrFail()->id;
|
$cartItemId = $purchase->cart->items()->firstOrFail()->id;
|
||||||
$purchase->update(['expires_at' => now()->subMinute()]);
|
$purchase->stockReservation()->update(['expires_at' => now()->subMinute()]);
|
||||||
|
|
||||||
$this->artisan('reservations:expire')->assertSuccessful();
|
$this->artisan('reservations:expire')->assertSuccessful();
|
||||||
|
|
||||||
@@ -628,22 +689,60 @@ class StorePurchaseTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 3,
|
'reserved_stock' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $activeCart->id,
|
'id' => $activeCart->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_EXPIRED,
|
||||||
'current_purchase_id' => null,
|
'current_purchase_id' => null,
|
||||||
|
'current_stock_reservation_id' => $purchase->stock_reservation_id,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired');
|
||||||
|
$this->assertSame(1, $activeCart->items()->count());
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/sonder/compras/start-checkout', [
|
||||||
|
'cart_id' => $activeCart->id,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson([
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => __('api.cart.reservation_expired'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonPath('code', 'purchase.expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_an_overdue_purchase_cannot_be_cancelled_before_the_expiration_job_runs(): void
|
||||||
|
{
|
||||||
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||||
|
$purchase->stockReservation()->update(['expires_at' => now()->subMinute()]);
|
||||||
|
|
||||||
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->postJson("/api/tenants/sonder/compras/{$purchase->id}/cancel")
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson([
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => __('api.cart.reservation_expired'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('compras', [
|
||||||
|
'id' => $purchase->id,
|
||||||
|
'status' => Purchase::STATUS_CREATED,
|
||||||
|
'stock_reservation_id' => $purchase->stock_reservation_id,
|
||||||
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cart_item_id' => $cartItemId,
|
'id' => $purchase->stock_reservation_id,
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => 3,
|
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
$this->assertSame(1, $activeCart->items()->count());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void
|
public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void
|
||||||
@@ -697,12 +796,18 @@ class StorePurchaseTest extends TestCase
|
|||||||
|
|
||||||
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
|
public function test_it_updates_customer_data_for_a_pending_payment_purchase(): void
|
||||||
{
|
{
|
||||||
|
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
|
||||||
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
|
||||||
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
|
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
|
||||||
|
|
||||||
|
$this->travel(10)->minutes();
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
|
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
|
||||||
'dni' => '987654321',
|
'dni' => '987654321',
|
||||||
@@ -719,6 +824,12 @@ class StorePurchaseTest extends TestCase
|
|||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'dni' => '987654321',
|
'dni' => '987654321',
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $purchase->stock_reservation_id,
|
||||||
|
'expires_at' => $now->copy()->addMinutes(30)->toDateTimeString(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void
|
public function test_checkout_items_are_immutable_and_editing_routes_are_unavailable(): void
|
||||||
@@ -848,7 +959,6 @@ class StorePurchaseTest extends TestCase
|
|||||||
$purchase->update([
|
$purchase->update([
|
||||||
'payment_method' => 'transfer',
|
'payment_method' => 'transfer',
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'expires_at' => now()->addMinutes(30),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$url = "/api/tenants/sonder/compras/{$purchase->id}/review";
|
$url = "/api/tenants/sonder/compras/{$purchase->id}/review";
|
||||||
@@ -862,6 +972,10 @@ class StorePurchaseTest extends TestCase
|
|||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
'id' => $purchase->id,
|
'id' => $purchase->id,
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
'status' => Purchase::STATUS_IN_REVIEW,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $purchase->stock_reservation_id,
|
||||||
|
'status' => 'active',
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -912,10 +1026,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'current_purchase_id' => null,
|
'current_purchase_id' => null,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 2, 'active');
|
||||||
'purchase_id' => $purchase->id,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
public function test_it_rejects_review_for_a_purchase_that_is_not_awaiting_payment(): void
|
||||||
@@ -936,7 +1047,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_expires_an_abandoned_purchase_without_mutating_its_active_cart(): void
|
public function test_it_expires_a_purchase_and_its_associated_cart(): void
|
||||||
{
|
{
|
||||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
@@ -947,7 +1058,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$this->assertNotNull($purchase->expires_at);
|
$this->assertNotNull($purchase->stockReservation->expires_at);
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 3,
|
'reserved_stock' => 3,
|
||||||
@@ -957,7 +1068,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 1')
|
->expectsOutput('Expired purchases: 1')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
@@ -968,28 +1079,26 @@ class StorePurchaseTest extends TestCase
|
|||||||
'compra_id' => $purchase->id,
|
'compra_id' => $purchase->id,
|
||||||
'cantidad' => 3,
|
'cantidad' => 3,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 3, 'expired');
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => 3,
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
'reserved_stock' => 3,
|
'reserved_stock' => 0,
|
||||||
'sold_units' => 0,
|
'sold_units' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $activeCart->id,
|
'id' => $activeCart->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_EXPIRED,
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
'current_stock_reservation_id' => $purchase->stock_reservation_id,
|
||||||
'deleted_at' => null,
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertSame(1, $activeCart->items()->count());
|
$this->assertSame(1, $activeCart->items()->count());
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,6 +1115,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'source_catalog_item_id' => $variant->catalog_item_id,
|
'source_catalog_item_id' => $variant->catalog_item_id,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
'nombre' => 'Inconsistent item',
|
'nombre' => 'Inconsistent item',
|
||||||
|
'item_nombre' => 'Inconsistent item',
|
||||||
'slug' => 'inconsistent-item',
|
'slug' => 'inconsistent-item',
|
||||||
'cantidad' => 1,
|
'cantidad' => 1,
|
||||||
'precio_unitario' => '50.00',
|
'precio_unitario' => '50.00',
|
||||||
@@ -1018,7 +1128,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 2')
|
->expectsOutput('Expired purchases: 2')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->assertDatabaseHas('compras', [
|
$this->assertDatabaseHas('compras', [
|
||||||
@@ -1160,12 +1270,7 @@ class StorePurchaseTest extends TestCase
|
|||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
'sold_units' => 2,
|
'sold_units' => 2,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertPurchaseReservation($purchase->id, $variant->inventory_id, 2, 'committed');
|
||||||
'purchase_id' => $purchase->id,
|
|
||||||
'inventory_id' => $variant->inventory_id,
|
|
||||||
'quantity' => 2,
|
|
||||||
'status' => 'committed',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertSoftDeleted('carritos', [
|
||||||
'id' => $purchase->cart_id,
|
'id' => $purchase->cart_id,
|
||||||
@@ -1440,6 +1545,26 @@ class StorePurchaseTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function assertPurchaseReservation(
|
||||||
|
int $purchaseId,
|
||||||
|
int $inventoryId,
|
||||||
|
int $quantity,
|
||||||
|
string $status,
|
||||||
|
): void {
|
||||||
|
$reservationId = Purchase::query()->findOrFail($purchaseId)->stock_reservation_id;
|
||||||
|
|
||||||
|
$this->assertNotNull($reservationId);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservationId,
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
protected function createTenant(string $codigo, string $nombre, string $dominio): Tenant
|
||||||
{
|
{
|
||||||
$hdrKey = (string) Str::uuid();
|
$hdrKey = (string) Str::uuid();
|
||||||
|
|||||||
@@ -520,8 +520,10 @@ class AdminAppSaleControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
->assertJsonPath('data.status', Purchase::STATUS_CANCELLED);
|
||||||
|
|
||||||
$this->assertSoftDeleted('carritos', ['id' => $sourceCartId]);
|
$this->assertSoftDeleted('carritos', ['id' => $sourceCartId]);
|
||||||
|
$reservationId = $purchase->fresh()->stock_reservation_id;
|
||||||
|
$this->assertNotNull($reservationId);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'purchase_id' => $purchase->id,
|
'id' => $reservationId,
|
||||||
'status' => 'released',
|
'status' => 'released',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('featured_groups', [
|
$this->assertDatabaseHas('featured_groups', [
|
||||||
'tenant_code' => 'desfile_pura_tendencia',
|
'tenant_code' => 'desfile_pura_tendencia',
|
||||||
|
'code' => 'entradas',
|
||||||
'source_type' => 'all',
|
'source_type' => 'all',
|
||||||
'product_layout' => 'ticket_selector',
|
'product_layout' => 'ticket_selector',
|
||||||
'group_layout' => 'single',
|
'group_layout' => 'single',
|
||||||
@@ -300,10 +301,13 @@ class DesfilePuraTendenciaSeederTest extends TestCase
|
|||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('source_catalog_item_id', $catalogItemId)
|
->where('source_catalog_item_id', $catalogItemId)
|
||||||
->count());
|
->count());
|
||||||
$this->assertSame(48, DB::table('stock_reservations')
|
$this->assertSame(1, DB::table('stock_reservations')
|
||||||
->where('purchase_id', $purchase->id)
|
->where('id', $purchase->stock_reservation_id)
|
||||||
->where('status', 'committed')
|
->where('status', 'committed')
|
||||||
->count());
|
->count());
|
||||||
|
$this->assertSame(48, DB::table('stock_reservation_lines')
|
||||||
|
->where('stock_reservation_id', $purchase->stock_reservation_id)
|
||||||
|
->count());
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
['sector' => 'A', 'fila' => '1', 'tipo' => 'NORMAL', 'count' => 16],
|
['sector' => 'A', 'fila' => '1', 'tipo' => 'NORMAL', 'count' => 16],
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
|||||||
));
|
));
|
||||||
|
|
||||||
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
|
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
|
||||||
|
$this->assertSame('productos', $featuredGroup->code);
|
||||||
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
|
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
|
||||||
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
|
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
|
||||||
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);
|
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
|||||||
->pluck('id');
|
->pluck('id');
|
||||||
|
|
||||||
$this->assertNotNull($paginatedGroup);
|
$this->assertNotNull($paginatedGroup);
|
||||||
|
$this->assertSame('productos', $paginatedGroup->code);
|
||||||
$this->assertSame(ProductLayout::ColumnWithImage, $paginatedGroup->product_layout);
|
$this->assertSame(ProductLayout::ColumnWithImage, $paginatedGroup->product_layout);
|
||||||
$this->assertSame(GroupLayout::Paginated, $paginatedGroup->group_layout);
|
$this->assertSame(GroupLayout::Paginated, $paginatedGroup->group_layout);
|
||||||
$this->assertSame(FeaturedGroupSource::All, $paginatedGroup->source_type);
|
$this->assertSame(FeaturedGroupSource::All, $paginatedGroup->source_type);
|
||||||
@@ -76,6 +77,7 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
|||||||
$this->assertCount(0, $paginatedGroup->featuredItems);
|
$this->assertCount(0, $paginatedGroup->featuredItems);
|
||||||
|
|
||||||
$this->assertNotNull($carouselGroup);
|
$this->assertNotNull($carouselGroup);
|
||||||
|
$this->assertSame('productos-destacados', $carouselGroup->code);
|
||||||
$this->assertSame(ProductLayout::ColumnWithImage, $carouselGroup->product_layout);
|
$this->assertSame(ProductLayout::ColumnWithImage, $carouselGroup->product_layout);
|
||||||
$this->assertSame(GroupLayout::Carousel, $carouselGroup->group_layout);
|
$this->assertSame(GroupLayout::Carousel, $carouselGroup->group_layout);
|
||||||
$this->assertSame(FeaturedGroupSource::Manual, $carouselGroup->source_type);
|
$this->assertSame(FeaturedGroupSource::Manual, $carouselGroup->source_type);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase
|
|||||||
$this->seed(WebsiteTypeSeeder::class);
|
$this->seed(WebsiteTypeSeeder::class);
|
||||||
|
|
||||||
$this->assertSame(2, WebsiteType::query()->count());
|
$this->assertSame(2, WebsiteType::query()->count());
|
||||||
$this->assertSame(4, Attachment::query()->count());
|
$this->assertSame(5, Attachment::query()->count());
|
||||||
|
|
||||||
$expectedPresentation = [
|
$expectedPresentation = [
|
||||||
'primary_color' => '#FF7006',
|
'primary_color' => '#FF7006',
|
||||||
@@ -49,6 +49,8 @@ class WebsiteTypeSeederTest extends TestCase
|
|||||||
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
|
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
|
||||||
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
|
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
|
||||||
Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
|
Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
|
||||||
|
$this->assertSame('onticket_favicon.svg', $shopIt->favicon->filename);
|
||||||
|
Storage::disk('s3')->assertExists($shopIt->favicon->path);
|
||||||
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
|
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
|
||||||
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
|
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
|
||||||
$this->assertSame([
|
$this->assertSame([
|
||||||
@@ -79,6 +81,8 @@ class WebsiteTypeSeederTest extends TestCase
|
|||||||
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
|
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
|
||||||
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
|
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
|
||||||
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
|
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
|
||||||
|
$this->assertSame($shopIt->favicon_id, $onTicket->favicon_id);
|
||||||
|
$this->assertSame('onticket_favicon.svg', $onTicket->favicon->filename);
|
||||||
$this->assertEqualsCanonicalizing(
|
$this->assertEqualsCanonicalizing(
|
||||||
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
|
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
|
||||||
$onTicket->extras->pluck('codigo')->all(),
|
$onTicket->extras->pluck('codigo')->all(),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class BootstrapAdminAppControllerTest extends TestCase
|
|||||||
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
|
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
|
||||||
{
|
{
|
||||||
$footerLogo = Attachment::factory()->create();
|
$footerLogo = Attachment::factory()->create();
|
||||||
|
$favicon = Attachment::factory()->create();
|
||||||
|
|
||||||
WebsiteType::query()->create([
|
WebsiteType::query()->create([
|
||||||
'codigo' => 'shopit',
|
'codigo' => 'shopit',
|
||||||
@@ -31,6 +32,7 @@ class BootstrapAdminAppControllerTest extends TestCase
|
|||||||
'border_color' => '#eaeaea',
|
'border_color' => '#eaeaea',
|
||||||
'login_header_footer_color' => '#313131',
|
'login_header_footer_color' => '#313131',
|
||||||
'footer_logo' => $footerLogo->id,
|
'footer_logo' => $footerLogo->id,
|
||||||
|
'favicon_id' => $favicon->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
|
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
|
||||||
@@ -41,6 +43,7 @@ class BootstrapAdminAppControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.login_header_footer_color', '#313131')
|
->assertJsonPath('data.login_header_footer_color', '#313131')
|
||||||
->assertJsonPath('data.site_logo', null)
|
->assertJsonPath('data.site_logo', null)
|
||||||
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
|
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
|
||||||
|
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
|
||||||
->assertJsonMissingPath('data.forms')
|
->assertJsonMissingPath('data.forms')
|
||||||
->assertJsonMissingPath('data.codigo')
|
->assertJsonMissingPath('data.codigo')
|
||||||
->assertJsonMissingPath('data.nombre')
|
->assertJsonMissingPath('data.nombre')
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class BootstrapScannerControllerTest extends TestCase
|
|||||||
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
|
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
|
||||||
{
|
{
|
||||||
$siteLogo = Attachment::factory()->create();
|
$siteLogo = Attachment::factory()->create();
|
||||||
|
$favicon = Attachment::factory()->create();
|
||||||
|
|
||||||
WebsiteType::query()->create([
|
WebsiteType::query()->create([
|
||||||
'codigo' => 'shopit',
|
'codigo' => 'shopit',
|
||||||
@@ -32,6 +33,7 @@ class BootstrapScannerControllerTest extends TestCase
|
|||||||
'border_color' => '#eaeaea',
|
'border_color' => '#eaeaea',
|
||||||
'login_header_footer_color' => '#313131',
|
'login_header_footer_color' => '#313131',
|
||||||
'site_logo' => $siteLogo->id,
|
'site_logo' => $siteLogo->id,
|
||||||
|
'favicon_id' => $favicon->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
|
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
|
||||||
@@ -40,6 +42,7 @@ class BootstrapScannerControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.primary_color', '#112233')
|
->assertJsonPath('data.primary_color', '#112233')
|
||||||
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
|
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
|
||||||
->assertJsonPath('data.footer_logo', null)
|
->assertJsonPath('data.footer_logo', null)
|
||||||
|
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
|
||||||
->assertJsonMissingPath('data.codigo')
|
->assertJsonMissingPath('data.codigo')
|
||||||
->assertJsonMissingPath('data.nombre')
|
->assertJsonMissingPath('data.nombre')
|
||||||
->assertJsonMissingPath('data.dominio')
|
->assertJsonMissingPath('data.dominio')
|
||||||
|
|||||||
@@ -69,4 +69,31 @@ class WebsiteTypeServiceTest extends TestCase
|
|||||||
'favicon_id' => $favicon->id,
|
'favicon_id' => $favicon->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_keeps_a_shared_favicon_when_one_website_type_replaces_it(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
|
||||||
|
$service = app(WebsiteTypeService::class);
|
||||||
|
$shopIt = $service->create([
|
||||||
|
'codigo' => 'shopit',
|
||||||
|
'nombre' => 'ShopIt',
|
||||||
|
'favicon' => UploadedFile::fake()->image('shared-favicon.png', 32, 32),
|
||||||
|
]);
|
||||||
|
$sharedFavicon = $shopIt->favicon()->firstOrFail();
|
||||||
|
$onTicket = $service->create([
|
||||||
|
'codigo' => 'onticket',
|
||||||
|
'nombre' => 'OnTicket',
|
||||||
|
'favicon' => $sharedFavicon->key,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service->updateOrCreate(
|
||||||
|
['codigo' => 'shopit'],
|
||||||
|
['favicon' => UploadedFile::fake()->image('shopit-favicon.png', 32, 32)],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame($sharedFavicon->id, $onTicket->fresh()->favicon_id);
|
||||||
|
$this->assertDatabaseHas('attachments', ['id' => $sharedFavicon->id]);
|
||||||
|
Storage::disk('s3')->assertExists($sharedFavicon->path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use App\Domains\Catalog\Models\CatalogItem;
|
|||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Event\Models\EventDate;
|
use App\Domains\Event\Models\EventDate;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -22,7 +21,6 @@ use App\Domains\Ticket\Services\TicketGeneratorService;
|
|||||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Event;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
@@ -220,7 +218,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void
|
public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void
|
||||||
{
|
{
|
||||||
Event::fake([TicketsAvailable::class]);
|
|
||||||
$item = $this->createTicketableItem('paid-ticket');
|
$item = $this->createTicketableItem('paid-ticket');
|
||||||
$purchase = $this->createPurchase($item, 2);
|
$purchase = $this->createPurchase($item, 2);
|
||||||
$purchase->setRelation('items', new EloquentCollection);
|
$purchase->setRelation('items', new EloquentCollection);
|
||||||
@@ -229,7 +226,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||||
$this->assertDatabaseCount('tickets', 2);
|
$this->assertDatabaseCount('tickets', 2);
|
||||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
|
||||||
|
|
||||||
$this->actingAs($this->user, 'sanctum')
|
$this->actingAs($this->user, 'sanctum')
|
||||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||||
@@ -240,7 +236,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
$purchase->markAsPaid();
|
$purchase->markAsPaid();
|
||||||
|
|
||||||
$this->assertDatabaseCount('tickets', 2);
|
$this->assertDatabaseCount('tickets', 2);
|
||||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
|
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
|
||||||
@@ -453,7 +448,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
|
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
|
||||||
{
|
{
|
||||||
Event::fake([TicketsAvailable::class]);
|
|
||||||
$item = $this->createTicketableItem('regular-product');
|
$item = $this->createTicketableItem('regular-product');
|
||||||
$item->update(['has_tickets' => false]);
|
$item->update(['has_tickets' => false]);
|
||||||
$purchase = $this->createPurchase($item->fresh(), 1);
|
$purchase = $this->createPurchase($item->fresh(), 1);
|
||||||
@@ -462,7 +456,6 @@ class TicketGeneratorServiceTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||||
$this->assertDatabaseCount('tickets', 0);
|
$this->assertDatabaseCount('tickets', 0);
|
||||||
Event::assertNotDispatched(TicketsAvailable::class);
|
|
||||||
|
|
||||||
$this->actingAs($this->user, 'sanctum')
|
$this->actingAs($this->user, 'sanctum')
|
||||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ class AdminAppBootstrapResourceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$websiteType->setRelation('siteLogo', null);
|
$websiteType->setRelation('siteLogo', null);
|
||||||
$websiteType->setRelation('footerLogo', null);
|
$websiteType->setRelation('footerLogo', null);
|
||||||
|
$websiteType->setRelation('favicon', null);
|
||||||
$data = AdminAppBootstrapResource::make([
|
$data = AdminAppBootstrapResource::make([
|
||||||
'website_type' => $websiteType,
|
'website_type' => $websiteType,
|
||||||
])->resolve(request());
|
])->resolve(request());
|
||||||
|
|
||||||
$this->assertSame('shopit', $data['website_type_code']);
|
$this->assertSame('shopit', $data['website_type_code']);
|
||||||
|
$this->assertNull($data['favicon']);
|
||||||
$this->assertArrayNotHasKey('forms', $data);
|
$this->assertArrayNotHasKey('forms', $data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,77 +2,102 @@
|
|||||||
|
|
||||||
namespace Tests\Unit\Catalog;
|
namespace Tests\Unit\Catalog;
|
||||||
|
|
||||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
use Illuminate\Support\Facades\Log;
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
use Psr\Log\LoggerInterface;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ExpireStockReservationsServiceTest extends TestCase
|
class ExpireStockReservationsServiceTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_it_expires_purchases_before_abandoned_cart_items(): void
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_it_expires_an_orphan_reservation_and_releases_its_inventory(): void
|
||||||
{
|
{
|
||||||
$checkout = \Mockery::mock(CheckoutService::class);
|
$inventory = Inventory::query()->create([
|
||||||
$checkout->shouldReceive('expireOverduePurchases')
|
'real_stock' => 10,
|
||||||
->once()
|
'reserved_stock' => 2,
|
||||||
->ordered()
|
]);
|
||||||
->andReturn(2);
|
$reservation = StockReservation::query()->create([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
$carts = \Mockery::mock(ExpireCartReservationsService::class);
|
'expires_at' => now()->subMinute(),
|
||||||
$carts->shouldReceive('expireOverdue')
|
]);
|
||||||
->once()
|
StockReservationLine::query()->create([
|
||||||
->ordered()
|
'stock_reservation_id' => $reservation->id,
|
||||||
->andReturn(3);
|
'inventory_id' => $inventory->id,
|
||||||
|
'quantity' => 2,
|
||||||
$logger = \Mockery::mock(LoggerInterface::class);
|
'tracks_inventory' => true,
|
||||||
Log::shouldReceive('channel')
|
|
||||||
->once()
|
|
||||||
->with('commands')
|
|
||||||
->andReturn($logger);
|
|
||||||
$logger->shouldReceive('info')
|
|
||||||
->once()
|
|
||||||
->with('Stock reservation cleanup completed.', [
|
|
||||||
'command' => 'reservations:expire',
|
|
||||||
'expired_purchases' => 2,
|
|
||||||
'expired_cart_items' => 3,
|
|
||||||
'total_expired' => 5,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
|
$result = app(ExpireStockReservationsService::class)->expireOverdue();
|
||||||
|
|
||||||
$this->assertSame([
|
$this->assertSame([
|
||||||
'purchases' => 2,
|
'purchases' => 0,
|
||||||
'cart_items' => 3,
|
'cart_reservations' => 0,
|
||||||
|
'orphan_reservations' => 1,
|
||||||
|
'failed' => 0,
|
||||||
], $result);
|
], $result);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservation->id,
|
||||||
|
'status' => StockReservation::STATUS_EXPIRED,
|
||||||
|
'expires_at' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $inventory->id,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_logs_failed_cleanup_attempts_and_rethrows_the_error(): void
|
public function test_one_failed_reservation_does_not_stop_the_remaining_batch(): void
|
||||||
{
|
{
|
||||||
$exception = new RuntimeException('Unable to clean carts.');
|
$first = StockReservation::query()->create([
|
||||||
$checkout = \Mockery::mock(CheckoutService::class);
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
$checkout->shouldReceive('expireOverduePurchases')->once()->andReturn(2);
|
'expires_at' => now()->subMinutes(2),
|
||||||
|
]);
|
||||||
$carts = \Mockery::mock(ExpireCartReservationsService::class);
|
$second = StockReservation::query()->create([
|
||||||
$carts->shouldReceive('expireOverdue')->once()->andThrow($exception);
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => now()->subMinute(),
|
||||||
$logger = \Mockery::mock(LoggerInterface::class);
|
|
||||||
Log::shouldReceive('channel')
|
|
||||||
->once()
|
|
||||||
->with('commands')
|
|
||||||
->andReturn($logger);
|
|
||||||
$logger->shouldReceive('error')
|
|
||||||
->once()
|
|
||||||
->with('Stock reservation cleanup failed.', [
|
|
||||||
'command' => 'reservations:expire',
|
|
||||||
'expired_purchases' => 2,
|
|
||||||
'expired_cart_items' => null,
|
|
||||||
'exception' => $exception,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectExceptionObject($exception);
|
$reservations = \Mockery::mock(StockReservationService::class);
|
||||||
|
$reservations->shouldReceive('expire')
|
||||||
|
->twice()
|
||||||
|
->andReturnUsing(function (StockReservation $reservation) use ($first): void {
|
||||||
|
if ($reservation->is($first)) {
|
||||||
|
throw new RuntimeException('Broken reservation.');
|
||||||
|
}
|
||||||
|
|
||||||
(new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
|
$reservation->update([
|
||||||
|
'status' => StockReservation::STATUS_EXPIRED,
|
||||||
|
'expires_at' => null,
|
||||||
|
'expired_at' => now(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = new ExpireStockReservationsService(
|
||||||
|
\Mockery::mock(ReleaseCheckoutService::class),
|
||||||
|
$reservations,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $service->expireOverdue();
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'purchases' => 0,
|
||||||
|
'cart_reservations' => 0,
|
||||||
|
'orphan_reservations' => 1,
|
||||||
|
'failed' => 1,
|
||||||
|
], $result);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $first->id,
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $second->id,
|
||||||
|
'status' => StockReservation::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
112
tests/Unit/Notification/NotificationMailServiceLoggingTest.php
Normal file
112
tests/Unit/Notification/NotificationMailServiceLoggingTest.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Notification;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Services\MailService;
|
||||||
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Mockery;
|
||||||
|
use ReflectionMethod;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class NotificationMailServiceLoggingTest extends TestCase
|
||||||
|
{
|
||||||
|
private NotificationMailService $service;
|
||||||
|
|
||||||
|
private MailService $mailService;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->mailService = Mockery::mock(MailService::class);
|
||||||
|
$this->service = new NotificationMailService(
|
||||||
|
$this->mailService,
|
||||||
|
Mockery::mock(TicketPdfService::class),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_logs_and_swallows_a_missing_purchase(): void
|
||||||
|
{
|
||||||
|
$this->createEmptyPurchasesTable();
|
||||||
|
|
||||||
|
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||||
|
Log::shouldReceive('warning')->once()->with(
|
||||||
|
'Notification email skipped.',
|
||||||
|
[
|
||||||
|
'purchase_id' => 123,
|
||||||
|
'reason' => 'purchase_not_found',
|
||||||
|
'missing_model' => Purchase::class,
|
||||||
|
'email_type' => 'purchase_confirmed',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->sendPurchaseConfirmed(123);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_logs_successful_delivery_with_the_mailer(): void
|
||||||
|
{
|
||||||
|
$this->mailService->shouldReceive('mailerName')->once()->andReturn('smtp');
|
||||||
|
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||||
|
Log::shouldReceive('info')->once()->with(
|
||||||
|
'Notification email sent.',
|
||||||
|
[
|
||||||
|
'user_id' => 10,
|
||||||
|
'tenant_code' => 'tenant-test',
|
||||||
|
'email_type' => 'welcome',
|
||||||
|
'mailer' => 'smtp',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->sendLogged(
|
||||||
|
'welcome',
|
||||||
|
['user_id' => 10],
|
||||||
|
fn (): array => ['tenant_code' => 'tenant-test'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_logs_and_rethrows_delivery_failures(): void
|
||||||
|
{
|
||||||
|
$exception = new RuntimeException('SMTP unavailable.');
|
||||||
|
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||||
|
Log::shouldReceive('error')->once()->with(
|
||||||
|
'Notification email delivery failed.',
|
||||||
|
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
|
||||||
|
&& $context['email_type'] === 'purchase_confirmed'
|
||||||
|
&& $context['exception'] === $exception),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
|
$this->sendLogged('purchase_confirmed', ['purchase_id' => 123], function () use ($exception): array {
|
||||||
|
throw $exception;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $context */
|
||||||
|
private function sendLogged(string $emailType, array $context, callable $send): void
|
||||||
|
{
|
||||||
|
(new ReflectionMethod($this->service, 'sendLogged'))->invoke(
|
||||||
|
$this->service,
|
||||||
|
$emailType,
|
||||||
|
$context,
|
||||||
|
$send,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createEmptyPurchasesTable(): void
|
||||||
|
{
|
||||||
|
config([
|
||||||
|
'database.default' => 'sqlite',
|
||||||
|
'database.connections.sqlite.database' => ':memory:',
|
||||||
|
]);
|
||||||
|
Schema::connection('sqlite')->create('compras', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,12 @@ namespace Tests\Unit\Notification;
|
|||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Services\NotificationMailService;
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class SendPasswordResetEmailTest extends TestCase
|
class SendPasswordResetEmailTest extends TestCase
|
||||||
{
|
{
|
||||||
public function test_it_logs_and_rethrows_mail_failures(): void
|
public function test_it_delegates_mail_failures_to_the_notification_service(): void
|
||||||
{
|
{
|
||||||
$exception = new RuntimeException('SMTP unavailable.');
|
$exception = new RuntimeException('SMTP unavailable.');
|
||||||
$mailService = \Mockery::mock(NotificationMailService::class);
|
$mailService = \Mockery::mock(NotificationMailService::class);
|
||||||
@@ -21,16 +20,6 @@ class SendPasswordResetEmailTest extends TestCase
|
|||||||
->andThrow($exception);
|
->andThrow($exception);
|
||||||
$this->app->instance(NotificationMailService::class, $mailService);
|
$this->app->instance(NotificationMailService::class, $mailService);
|
||||||
|
|
||||||
Log::shouldReceive('error')
|
|
||||||
->once()
|
|
||||||
->with(
|
|
||||||
'Failed to send password reset email.',
|
|
||||||
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
|
|
||||||
&& $context['tenant_code'] === 'tenant-test'
|
|
||||||
&& $context['channel'] === PasswordResetRequested::CHANNEL_STOREFRONT
|
|
||||||
&& $context['exception'] === $exception),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectExceptionObject($exception);
|
$this->expectExceptionObject($exception);
|
||||||
|
|
||||||
(new SendPasswordResetEmail)->handle(
|
(new SendPasswordResetEmail)->handle(
|
||||||
|
|||||||
69
tests/Unit/Purchase/PurchaseResourceTest.php
Normal file
69
tests/Unit/Purchase/PurchaseResourceTest.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Purchase;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Resources\PurchaseResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class PurchaseResourceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_exposes_the_remaining_checkout_time_using_the_server_clock(): void
|
||||||
|
{
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
$expiresAt = $now->copy()->addMinutes(12);
|
||||||
|
|
||||||
|
$resource = $this->resourceFor(Purchase::STATUS_PENDING_PAYMENT, $expiresAt);
|
||||||
|
|
||||||
|
$this->assertTrue($expiresAt->equalTo($resource['expires_at']));
|
||||||
|
$this->assertSame(720, $resource['expires_in_seconds']);
|
||||||
|
$this->assertTrue($now->equalTo($resource['server_time']));
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_clamps_an_overdue_checkout_to_zero_seconds(): void
|
||||||
|
{
|
||||||
|
$now = now()->startOfSecond();
|
||||||
|
$this->travelTo($now);
|
||||||
|
|
||||||
|
$resource = $this->resourceFor(
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
$now->copy()->subSecond(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(0, $resource['expires_in_seconds']);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_exposes_null_expiration_after_the_purchase_enters_review(): void
|
||||||
|
{
|
||||||
|
$resource = $this->resourceFor(
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertNull($resource['expires_at']);
|
||||||
|
$this->assertNull($resource['expires_in_seconds']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function resourceFor(string $status, ?Carbon $expiresAt): array
|
||||||
|
{
|
||||||
|
$purchase = (new Purchase)->forceFill([
|
||||||
|
'status' => $status,
|
||||||
|
'total' => '0.00',
|
||||||
|
]);
|
||||||
|
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]));
|
||||||
|
|
||||||
|
return (new PurchaseResource($purchase))->toArray(Request::create('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Unit\Purchase;
|
namespace Tests\Unit\Purchase;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\PurchaseStateGuard;
|
use App\Domains\Purchase\Services\PurchaseStateGuard;
|
||||||
@@ -22,7 +23,6 @@ class PurchaseStateGuardTest extends TestCase
|
|||||||
{
|
{
|
||||||
$purchase = (new Purchase)->forceFill([
|
$purchase = (new Purchase)->forceFill([
|
||||||
'status' => Purchase::STATUS_EXPIRED,
|
'status' => Purchase::STATUS_EXPIRED,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->expectException(PurchaseExpiredException::class);
|
$this->expectException(PurchaseExpiredException::class);
|
||||||
@@ -34,8 +34,11 @@ class PurchaseStateGuardTest extends TestCase
|
|||||||
{
|
{
|
||||||
$purchase = (new Purchase)->forceFill([
|
$purchase = (new Purchase)->forceFill([
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'expires_at' => now()->subMinute(),
|
|
||||||
]);
|
]);
|
||||||
|
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => now()->subMinute(),
|
||||||
|
]));
|
||||||
|
|
||||||
$this->expectException(PurchaseExpiredException::class);
|
$this->expectException(PurchaseExpiredException::class);
|
||||||
|
|
||||||
@@ -46,8 +49,11 @@ class PurchaseStateGuardTest extends TestCase
|
|||||||
{
|
{
|
||||||
$purchase = (new Purchase)->forceFill([
|
$purchase = (new Purchase)->forceFill([
|
||||||
'status' => Purchase::STATUS_PAID,
|
'status' => Purchase::STATUS_PAID,
|
||||||
'expires_at' => now()->subMinute(),
|
|
||||||
]);
|
]);
|
||||||
|
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => now()->subMinute(),
|
||||||
|
]));
|
||||||
|
|
||||||
$this->guard->assertNotExpired($purchase);
|
$this->guard->assertNotExpired($purchase);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user