Compare commits

..

3 Commits

94 changed files with 613 additions and 2665 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,16 +38,6 @@ class Cart extends Model
protected $table = 'carritos';
public const STATUS_ACTIVE = 'active';
public const STATUS_CHECKOUT = 'checkout';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ABANDONED = 'abandoned';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
@@ -129,7 +119,6 @@ class Cart extends Model
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
@@ -194,7 +183,6 @@ class Cart extends Model
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
@@ -296,7 +284,6 @@ class Cart extends Model
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
@@ -347,14 +334,10 @@ class Cart extends Model
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->current_purchase_id = null;
return;
}
app(StockReservationService::class)->releaseForPurchase(

View File

@@ -4,12 +4,9 @@ namespace App\Domains\Cart\Services;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
@@ -25,7 +22,7 @@ class CartService
return $this->makeEmptyCart($tenant);
}
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
@@ -122,7 +119,7 @@ class CartService
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
'status' => 'active',
]);
$cart->setRelation('items', collect());
@@ -223,14 +220,12 @@ class CartService
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->where('status', 'active')
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
->first();
}
@@ -239,7 +234,7 @@ class CartService
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
$cart = $this->findCart($tenant, $identity);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
@@ -252,73 +247,10 @@ class CartService
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
'origin' => Cart::ORIGIN_USER,
'status' => 'active',
];
if ($identity['user_id'] !== null) {

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
class ExpireCartReservationsService
{
public function __construct(
private readonly StockReservationService $reservations,
) {}
public function expireOverdue(): int
{
$expired = 0;
$lastReservationId = 0;
do {
$reservationIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('id', '>', $lastReservationId)
->whereHas('currentCart', fn ($query) => $query->where('status', 'active'))
->whereDoesntHave('purchase', fn ($query) => $query->whereIn('status', [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
Purchase::STATUS_IN_REVIEW,
]))
->orderBy('id')
->limit(500)
->pluck('id');
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
if ($this->expireReservation($lastReservationId)) {
$expired++;
}
}
} while ($reservationIds->count() === 500);
return $expired;
}
private function expireReservation(int $reservationId): bool
{
$cartId = Cart::query()
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->value('id');
if ($cartId === null) {
return false;
}
return DB::transaction(function () use ($cartId, $reservationId): bool {
/** @var Cart|null $cart */
$cart = Cart::query()
->whereKey($cartId)
->where('current_stock_reservation_id', $reservationId)
->where('status', 'active')
->lockForUpdate()
->first();
if ($cart === null) {
return false;
}
/** @var StockReservation|null $reservation */
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
if ($reservation === null
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|| $reservation->expires_at === null
|| $reservation->expires_at->isFuture()) {
return false;
}
$this->reservations->expire($reservation);
return true;
});
}
}

View File

@@ -12,6 +12,7 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
## Servicios
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
- `ExpireCartReservationsService`: libera como conjunto las reservas vencidas de carritos activos sin eliminar el contenido del carrito.
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
## Endpoints
@@ -33,6 +34,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.
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.
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`, su reserva se libera con el motivo correspondiente y se crea otra para el carrito actualizado.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas de carrito cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la reserva y sus líneas como historial, libera el stock como conjunto y deja intactos el carrito y sus ítems.

View File

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

View File

@@ -2,184 +2,50 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Support\Facades\DB;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Purchase\Services\CheckoutService;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Throwable;
class ExpireStockReservationsService
{
private const BATCH_SIZE = 500;
public function __construct(
private readonly ReleaseCheckoutService $purchases,
private readonly StockReservationService $reservations,
private readonly CheckoutService $checkout,
private readonly ExpireCartReservationsService $carts,
) {}
/**
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
* @return array{purchases: int, cart_reservations: int}
*/
public function expireOverdue(): array
{
$summary = [
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 0,
'failed' => 0,
];
$lastReservationId = 0;
do {
$reservationIds = StockReservation::query()
->where('status', StockReservation::STATUS_ACTIVE)
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->where('id', '>', $lastReservationId)
->orderBy('id')
->limit(self::BATCH_SIZE)
->pluck('id');
foreach ($reservationIds as $reservationId) {
$lastReservationId = (int) $reservationId;
$expiredPurchases = null;
$expiredCartItems = null;
try {
$owner = $this->expireReservation($lastReservationId);
if ($owner !== null) {
$summary[$owner]++;
}
} catch (Throwable $exception) {
$summary['failed']++;
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
'command' => 'reservations:expire',
'stock_reservation_id' => $lastReservationId,
'exception' => $exception,
]);
}
}
} while ($reservationIds->count() === self::BATCH_SIZE);
$expiredPurchases = $this->checkout->expireOverduePurchases();
$expiredCartItems = $this->carts->expireOverdue();
Log::channel('commands')->info('Stock reservation cleanup completed.', [
'command' => 'reservations:expire',
'expired_purchases' => $summary['purchases'],
'expired_cart_reservations' => $summary['cart_reservations'],
'expired_orphan_reservations' => $summary['orphan_reservations'],
'failed_reservations' => $summary['failed'],
'total_expired' => $summary['purchases']
+ $summary['cart_reservations']
+ $summary['orphan_reservations'],
'expired_purchases' => $expiredPurchases,
'expired_cart_reservations' => $expiredCartItems,
'total_expired' => $expiredPurchases + $expiredCartItems,
]);
return $summary;
}
return [
'purchases' => $expiredPurchases,
'cart_reservations' => $expiredCartItems,
];
} catch (Throwable $exception) {
Log::channel('commands')->error('Stock reservation cleanup failed.', [
'command' => 'reservations:expire',
'expired_purchases' => $expiredPurchases,
'expired_cart_reservations' => $expiredCartItems,
'exception' => $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();
throw $exception;
}
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Catalog\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
@@ -44,8 +43,13 @@ class StockReservationService
? null
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
if ($reservation !== null) {
$this->assertUsableCartReservation($reservation);
if ($reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& $reservation->expires_at->isPast()) {
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
$lockedCart->update(['current_stock_reservation_id' => null]);
$reservation = null;
}
if ($requirements === []) {
@@ -63,7 +67,7 @@ class StockReservationService
return null;
}
if ($reservation === null) {
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) {
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => $this->expiration(),
@@ -154,12 +158,9 @@ class StockReservationService
});
}
public function attachToPurchase(
Cart $cart,
Purchase $purchase,
Carbon $expiresAt,
): StockReservation {
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation
{
return DB::transaction(function () use ($cart, $purchase): StockReservation {
/** @var Cart $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
/** @var Purchase $lockedPurchase */
@@ -171,7 +172,9 @@ class StockReservationService
/** @var StockReservation $reservation */
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
throw new \InvalidArgumentException('La reserva de stock no está activa.');
}
$linkedPurchase = Purchase::query()
->where('stock_reservation_id', $reservation->getKey())
@@ -182,7 +185,7 @@ class StockReservationService
}
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
$reservation->update(['expires_at' => $expiresAt]);
$reservation->update(['expires_at' => $lockedPurchase->expires_at]);
$purchase->stock_reservation_id = $reservation->getKey();
$cart->current_stock_reservation_id = $reservation->getKey();
@@ -207,9 +210,6 @@ class StockReservationService
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()) {
@@ -264,50 +264,6 @@ class StockReservationService
});
}
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
{
return DB::transaction(function () use ($purchase, $cart): StockReservation {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($purchase->stock_reservation_id === null
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($purchase->stock_reservation_id);
$this->assertUsableCartReservation($reservation);
$purchase->update(['stock_reservation_id' => null]);
$cart->update(['current_purchase_id' => null]);
$reservation->update(['expires_at' => $this->expiration()]);
return $reservation->fresh('lines');
});
}
public function assertCartReservationUsable(Cart $cart): void
{
DB::transaction(function () use ($cart): void {
/** @var Cart $cart */
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
if ($cart->current_stock_reservation_id === null) {
return;
}
/** @var StockReservation $reservation */
$reservation = StockReservation::query()
->lockForUpdate()
->findOrFail($cart->current_stock_reservation_id);
$this->assertUsableCartReservation($reservation);
});
}
public function releaseCurrentCartReservation(
Cart $cart,
string $reason = self::REASON_CART_CHANGED,
@@ -343,28 +299,16 @@ class StockReservationService
});
}
public function clearExpirationForReview(Purchase $purchase): void
public function syncPurchaseExpiration(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.');
return;
}
/** @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]);
});
StockReservation::query()
->whereKey($purchase->stock_reservation_id)
->where('status', StockReservation::STATUS_ACTIVE)
->update(['expires_at' => $purchase->expires_at]);
}
/**
@@ -464,25 +408,10 @@ class StockReservationService
'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
{

View File

@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
- `Inventory` administra stock disponible, reservado y comprado.
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
- `Category` soporta jerarquía y categorías globales o propias del tenant.
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
- `BundleComponent` representa los componentes de un paquete.
@@ -19,7 +19,6 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.

View File

@@ -144,6 +144,7 @@ class InvitationPurchaseProvisioner
if ($purchaseId !== null) {
DB::table('compras')->where('id', $purchaseId)->update([
'status' => 'paid',
'expires_at' => null,
'total' => 0,
'updated_at' => $now,
]);
@@ -157,6 +158,7 @@ class InvitationPurchaseProvisioner
'cart_id' => null,
'status' => 'paid',
'payment_method' => self::PAYMENT_METHOD,
'expires_at' => null,
'total' => 0,
'dni' => null,
'transfer_payer_dni' => null,
@@ -394,6 +396,7 @@ class InvitationPurchaseProvisioner
if ($reservationId === null) {
$reservationId = DB::table('stock_reservations')->insertGetId([
'status' => 'committed',
'expires_at' => null,
'committed_at' => $now,
'released_at' => null,
'expired_at' => null,

View File

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

View File

@@ -28,21 +28,10 @@ class TestMail extends Mailable
{
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
$branding = [
'name' => $this->tenant->nombre,
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
'body_color' => '#334155',
'background_color' => '#f1f5f9',
'surface_color' => '#ffffff',
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
];
return new Content(
view: 'mail.test',
with: [
'tenant' => $this->tenant,
'branding' => $branding,
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ use Illuminate\Support\Facades\DB;
'user_id',
'status',
'payment_method',
'expires_at',
'total',
'dni',
'transfer_payer_dni',
@@ -79,6 +80,7 @@ class Purchase extends Model
'cart_id' => 'integer',
'stock_reservation_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
];
}
@@ -193,7 +195,7 @@ class Purchase extends Model
'status' => self::STATUS_PAID,
]);
PurchasePaid::dispatch($this->getKey());
PurchasePaid::dispatch($this);
});
}
}

View File

@@ -17,8 +17,6 @@ class PurchaseResource extends JsonResource
*/
public function toArray(Request $request): array
{
$serverTime = now();
$expiresAt = $this->stockReservation?->expires_at;
$items = $this->resource->relationLoaded('items')
? $this->resource->getRelation('items')
: collect();
@@ -50,11 +48,7 @@ class PurchaseResource extends JsonResource
'created_at' => $this->created_at,
'status' => $this->status,
'payment_method' => $this->payment_method,
'expires_at' => $expiresAt,
'expires_in_seconds' => $expiresAt === null
? null
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
'server_time' => $serverTime,
'expires_at' => $this->expires_at,
'dni' => $this->dni,
'transfer_payer_dni' => $this->transfer_payer_dni,
'telefono' => $this->telefono,

View File

@@ -61,7 +61,10 @@ class CompleteCheckoutService
$this->purchaseState->lockCurrentCart($purchase);
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
if (
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
) {
throw ValidationException::withMessages([
'purchase' => __('api.purchase.not_available_for_review'),
]);
@@ -69,8 +72,9 @@ class CompleteCheckoutService
$purchase->update([
'status' => Purchase::STATUS_IN_REVIEW,
'expires_at' => null,
]);
$this->reservations->clearExpirationForReview($purchase);
$this->reservations->syncPurchaseExpiration($purchase);
return $this->loadPurchase($purchase);
});
@@ -191,7 +195,7 @@ class CompleteCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment', 'stockReservation']);
return $purchase->load(['items.imageAttachment']);
}
private function itemKey(int $catalogItemId, ?int $variantId): string

View File

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

View File

@@ -5,10 +5,11 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Throwable;
class ReleaseCheckoutService
{
@@ -31,6 +32,40 @@ class ReleaseCheckoutService
return $this->release($purchase, Purchase::STATUS_EXPIRED);
}
public function expireOverdue(): int
{
$expiredCount = 0;
Purchase::query()
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
->whereNotNull('expires_at')
->where('expires_at', '<=', now())
->orderBy('id')
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
try {
$purchase = $this->expire($purchase);
} catch (Throwable $exception) {
Log::channel('commands')->error('Failed to expire overdue purchase.', [
'command' => 'reservations:expire',
'purchase_id' => $purchase->getKey(),
'tenant_codigo' => $purchase->tenant_codigo,
'cart_id' => $purchase->cart_id,
'status' => $purchase->status,
'expires_at' => $purchase->expires_at,
'exception' => $exception,
]);
return;
}
if ($purchase->status === Purchase::STATUS_EXPIRED) {
$expiredCount++;
}
});
return $expiredCount;
}
private function release(
Purchase $purchase,
string $targetStatus,
@@ -43,11 +78,6 @@ class ReleaseCheckoutService
): Purchase {
$purchase = $this->lockPurchase($purchase);
if ($purchase->status === Purchase::STATUS_EXPIRED
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if ($purchase->status === Purchase::STATUS_PAID) {
if ($targetStatus === Purchase::STATUS_EXPIRED) {
return $this->loadPurchase($purchase);
@@ -70,31 +100,14 @@ class ReleaseCheckoutService
return $this->loadPurchase($purchase);
}
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
if ($targetStatus === Purchase::STATUS_CANCELLED
&& $cart?->status === 'active'
&& in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($purchase, $cart);
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
return $this->loadPurchase($purchase);
}
if (
$targetStatus === Purchase::STATUS_EXPIRED
&& (! in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true) || ! $this->hasOverdueActiveReservation($purchase))
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
) {
return $this->loadPurchase($purchase);
}
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
$this->releasePurchaseReservations($purchase, $targetStatus);
$purchase->update(['status' => $targetStatus]);
@@ -102,11 +115,9 @@ class ReleaseCheckoutService
});
}
private function releasePurchaseReservations(
Purchase $purchase,
string $targetStatus,
?Cart $cart,
): void {
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
{
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
try {
$this->reservations->releaseForPurchase(
$purchase,
@@ -129,40 +140,28 @@ class ReleaseCheckoutService
return;
}
if ($targetStatus === Purchase::STATUS_EXPIRED
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
if ($cart->status === 'active') {
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);
if ($targetStatus === Purchase::STATUS_CANCELLED) {
$this->reservations->syncCart($cart);
}
return;
}
if ($cart->status !== Cart::STATUS_CHECKOUT) {
if ($cart->status !== 'checkout') {
return;
}
if (! $cart->trashed()) {
$cart->update(['status' => Cart::STATUS_CONVERTED]);
$cart->update(['status' => 'converted']);
$cart->delete();
}
}
@@ -212,17 +211,6 @@ class ReleaseCheckoutService
private function loadPurchase(Purchase $purchase): Purchase
{
return $purchase->load(['items.imageAttachment', 'stockReservation']);
}
private function hasOverdueActiveReservation(Purchase $purchase): bool
{
/** @var StockReservation|null $reservation */
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
return $reservation !== null
&& $reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture();
return $purchase->load(['items.imageAttachment']);
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Cart\Models\Cart;
use App\Domains\Cart\Models\CartItem;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Services\CatalogInventoryService;
@@ -13,7 +12,6 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@@ -206,7 +204,7 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$this->reservations->attachToPurchase($cart, $purchase);
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
$this->loadCartItems($cartItems);
@@ -272,7 +270,7 @@ class StartCheckoutService
$cart->getKey(),
);
$cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
$this->reservations->attachToPurchase($cart, $purchase);
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
return $this->loadPurchase($purchase);
@@ -315,9 +313,17 @@ class StartCheckoutService
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
$this->reservations->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->reservations->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
$cart->update([
'current_purchase_id' => null,
'current_stock_reservation_id' => null,
]);
}
@@ -330,11 +336,7 @@ class StartCheckoutService
throw new NotFoundHttpException('Cart not found for tenant.');
}
if ($cart->status === Cart::STATUS_EXPIRED) {
throw new StockReservationExpiredException;
}
if ($cart->status !== Cart::STATUS_ACTIVE) {
if ($cart->status !== 'active') {
throw ValidationException::withMessages([
'cart_id' => __('api.purchase.inactive_cart'),
]);
@@ -408,17 +410,13 @@ class StartCheckoutService
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,
'expires_at' => now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
),
'total' => $total,
]);
}
private function checkoutExpiration(): Carbon
{
return now()->addMinutes(
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
);
}
/** @param Collection<int, CartItem> $cartItems */
private function loadCartItems(Collection $cartItems): void
{

View File

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

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Purchase\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Validation\ValidationException;
@@ -12,32 +11,15 @@ class PurchaseStateGuard
{
public function assertNotExpired(Purchase $purchase): void
{
if ($purchase->status === Purchase::STATUS_EXPIRED) {
throw new PurchaseExpiredException;
}
if (! in_array($purchase->status, [
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
$hasExpiredByTime = in_array($purchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
return;
}
], true)
&& $purchase->expires_at !== null
&& $purchase->expires_at->isPast();
/** @var StockReservation|null $reservation */
$reservation = $purchase->relationLoaded('stockReservation')
? $purchase->getRelation('stockReservation')
: ($purchase->exists
? $purchase->stockReservation()->first()
: null);
if ($reservation !== null && (
$reservation->status === StockReservation::STATUS_EXPIRED
|| (
$reservation->status === StockReservation::STATUS_ACTIVE
&& $reservation->expires_at !== null
&& ! $reservation->expires_at->isFuture()
)
)) {
if ($hasExpiredStatus || $hasExpiredByTime) {
throw new PurchaseExpiredException;
}
}

View File

@@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
## Modelo
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout.
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
@@ -18,13 +18,13 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `PurchaseItem`.
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, libera su reserva completa y crea otra para 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.

View File

@@ -9,21 +9,18 @@ use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
protected AdminAppSaleExcelService $saleExcelService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
@@ -97,27 +94,4 @@ class SaleController extends Controller
$request->validated('timezone'),
);
}
public function downloadExcel(AdminAppSalePdfRequest $request): StreamedResponse
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsExcel(
AdminAppSaleModificationPdfRequest $request,
): StreamedResponse {
$tenant = $request->user()->tenant()->firstOrFail();
return $this->saleExcelService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$request->validated('timezone'),
);
}
}

View File

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

View File

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

View File

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

View File

@@ -82,7 +82,6 @@ class WebsiteTypeService
&& $previousLogo->id !== $websiteType->site_logo
&& $previousLogo->id !== $websiteType->footer_logo
&& $previousLogo->id !== $websiteType->favicon_id
&& ! $this->isReferencedByWebsiteType($previousLogo)
) {
$this->attachmentService->delete($previousLogo);
}
@@ -91,16 +90,4 @@ class WebsiteTypeService
return $websiteType;
});
}
private function isReferencedByWebsiteType(Attachment $attachment): bool
{
return WebsiteType::query()
->where(function ($query) use ($attachment): void {
$query
->where('site_logo', $attachment->id)
->orWhere('footer_logo', $attachment->id)
->orWhere('favicon_id', $attachment->id);
})
->exists();
}
}

View File

@@ -3,8 +3,8 @@
namespace App\Domains\Ticket\Listeners;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Services\TicketGeneratorService;
@@ -16,10 +16,13 @@ class GenerateTicketsForPaidPurchase
public function handle(PurchasePaid $event): void
{
$purchase = Purchase::query()
$purchase = $event->purchase
->newQuery()
->with(['user', 'items'])
->findOrFail($event->purchaseId);
->findOrFail($event->purchase->getKey());
$user = $purchase->user;
$ticketIds = [];
foreach ($purchase->items as $purchaseItem) {
$catalogItem = CatalogItem::query()
->where('tenant_code', $purchase->tenant_codigo)
@@ -37,13 +40,19 @@ class GenerateTicketsForPaidPurchase
throw TicketGenerationException::purchaseWithoutUser($purchase);
}
$this->ticketGenerator->generate(
$generatedTickets = $this->ticketGenerator->generate(
$catalogItem,
$user,
$purchaseItem->cantidad,
$purchaseItem->source_variant_id,
$purchase->getKey(),
);
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
}
if ($ticketIds !== []) {
TicketsAvailable::dispatch($purchase, $ticketIds);
}
}

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Ticket\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
@@ -20,36 +19,12 @@ class TicketPdfService
* @param Collection<int, Ticket> $tickets
*/
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');
$primaryColor = $this->color($tenant->primary_color, '#009933');
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
return Pdf::loadView('pdf.tickets', [
$pdf = Pdf::loadView('pdf.tickets', [
'tenant' => $tenant,
'tickets' => $tickets,
'logoDataUri' => $this->logoDataUri($tenant),
@@ -60,6 +35,10 @@ class TicketPdfService
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
),
])->setPaper('a4');
$ticketIds = $tickets->pluck('id')->implode('_');
return $pdf->download("tickets_{$ticketIds}.pdf");
}
private function qrCodeDataUri(string $value): string

View File

@@ -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.
2. `GenerateTicketsForPaidPurchase` atiende el evento.
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
## Endpoints

View File

@@ -3,9 +3,11 @@
namespace App\Providers;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Notification\Events\UserRegistered;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
use App\Domains\Notification\Listeners\SendWelcomeEmail;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
@@ -31,8 +33,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class);
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);

View File

@@ -1,7 +1,6 @@
<?php
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
@@ -114,16 +113,6 @@ return Application::configure(basePath: dirname(__DIR__))
'message' => $exception->getMessage(),
], 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) {
if (! $request->is('api/*')) {
return null;

View File

@@ -6,16 +6,15 @@
"keywords": ["laravel", "framework"],
"license": "MIT",
"require": {
"php": "^8.3",
"ext-gd": "*",
"php": "^8.3",
"barryvdh/laravel-dompdf": "^3.1",
"endroid/qr-code": "^6.1",
"laravel/framework": "^13.8",
"laravel/sanctum": "^4.3",
"laravel/socialite": "^5.29",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0",
"phpoffice/phpspreadsheet": "^5.9"
"league/flysystem-aws-s3-v3": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",

376
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "a593ab47d99b233f75851dbb7ea50479",
"content-hash": "ce185c60c617846be30ae694f0cf6e9c",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -417,82 +417,6 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
{
"name": "composer/pcre",
"version": "3.4.0",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<2.2.2"
},
"require-dev": {
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.4.0"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2026-06-07T11:47:49+00:00"
},
{
"name": "dasprid/enum",
"version": "1.0.7",
@@ -3000,191 +2924,6 @@
],
"time": "2026-03-08T20:05:35+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.2",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2026-04-11T18:38:28+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.1",
@@ -3947,115 +3686,6 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "5.9.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.2",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5 || ^11.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
},
"time": "2026-07-12T19:17:39+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -10208,8 +9838,8 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
"php": "^8.3",
"ext-gd": "*"
"ext-gd": "*",
"php": "^8.3"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"

View File

@@ -96,7 +96,7 @@ return [
'users' => [
'provider' => 'users',
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
'expire' => (int) env('AUTH_PASSWORD_RESET_EXPIRATION_MINUTES', 60),
'expire' => 60,
'throttle' => 60,
],
],

View File

@@ -89,14 +89,6 @@ return [
'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' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),

View File

@@ -2,4 +2,10 @@
return [
'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),
],
];

View File

@@ -1,32 +0,0 @@
<?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');
});
}
};

View File

@@ -1,51 +0,0 @@
<?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'),
]);
}
});
}
};

View File

@@ -1,30 +0,0 @@
<?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.
}
};

View File

@@ -1,96 +0,0 @@
<?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.
}
};

View File

@@ -191,7 +191,6 @@ class DesfilePuraTendenciaSeeder extends Seeder
FeaturedGroup::query()->create([
'tenant_code' => self::TENANT_CODE,
'code' => 'entradas',
'source_type' => FeaturedGroupSource::All,
'category_id' => null,
'product_layout' => ProductLayout::TicketSelector,

View File

@@ -144,7 +144,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'code' => 'productos',
'source_type' => FeaturedGroupSource::All,
'category_id' => null,
'product_layout' => ProductLayout::Row,

View File

@@ -171,7 +171,6 @@ class ProductCatalogFromImagesSeeder extends Seeder
FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'code' => 'productos',
'source_type' => FeaturedGroupSource::All,
'product_layout' => ProductLayout::ColumnWithImage,
'group_layout' => GroupLayout::Paginated,
@@ -181,7 +180,6 @@ class ProductCatalogFromImagesSeeder extends Seeder
$carouselGroup = FeaturedGroup::query()->create([
'tenant_code' => $tenant->codigo,
'code' => 'productos-destacados',
'source_type' => FeaturedGroupSource::Manual,
'product_layout' => ProductLayout::ColumnWithImage,
'group_layout' => GroupLayout::Carousel,

View File

@@ -36,7 +36,6 @@ class WebsiteTypeSeeder extends Seeder
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
'favicon' => $this->onTicketFavicon(),
],
);
@@ -71,7 +70,6 @@ class WebsiteTypeSeeder extends Seeder
...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(),
'footer_logo' => $this->onTicketFooterLogo(),
'favicon' => $shopIt->favicon()->firstOrFail()->key,
],
);
@@ -177,21 +175,4 @@ class WebsiteTypeSeeder extends Seeder
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,
);
}
}

View File

@@ -12,7 +12,6 @@ return [
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
'password_updated' => 'Password updated successfully.',
'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.',
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
'request_expired' => 'The authentication request expired. Please try again.',
@@ -35,7 +34,6 @@ return [
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
'empty_bundle' => 'The bundle has no components.',
'variant_required' => 'You must select a variant for this item.',
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
],
'purchase' => [
'expired' => 'The purchase has expired. Please start a new purchase.',

View File

@@ -12,7 +12,6 @@ return [
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
'password_updated' => 'Contraseña modificada correctamente.',
'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.',
'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.',
@@ -35,7 +34,6 @@ return [
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
'empty_bundle' => 'El bundle no tiene componentes.',
'variant_required' => 'Debe seleccionar una variante para este ítem.',
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
],
'purchase' => [
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",

View File

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

Before

Width:  |  Height:  |  Size: 422 B

View File

@@ -1,4 +1,4 @@
@props(['branding', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
@props(['tenant', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
<!DOCTYPE html>
<html lang="es">
@@ -6,7 +6,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light">
<title>{{ $branding['name'] }}</title>
<title>{{ $tenant->nombre }}</title>
<style>
@media only screen and (max-width: 620px) {
.mail-container { width: 100% !important; }
@@ -14,17 +14,17 @@
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: {{ $branding['background_color'] }}; color: {{ $branding['body_color'] }}; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: {{ $branding['background_color'] }};">
<body style="margin: 0; padding: 0; background-color: #f1f5f9; color: #334155; font-family: Arial, Helvetica, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f1f5f9;">
<tr>
<td align="center" style="padding: 32px 12px;">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" class="mail-container" style="width: 600px; max-width: 600px; background-color: {{ $branding['surface_color'] }}; border-top: 4px solid {{ $branding['primary_color'] }}; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" class="mail-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-top: 4px solid {{ $tenant->primary_color }}; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);">
<tr>
<td align="center" bgcolor="{{ $branding['header_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['header_bg_color'] }};">
<td align="center" bgcolor="{{ $tenant->header_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->header_bg_color }};">
@if ($headerLogoUrl)
<img src="{{ $headerLogoUrl }}" alt="{{ $branding['name'] }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
<img src="{{ $headerLogoUrl }}" alt="{{ $tenant->nombre }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
@else
<span style="color: {{ $branding['primary_color'] }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $branding['name'] }}</span>
<span style="color: {{ $tenant->primary_color }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $tenant->nombre }}</span>
@endif
</td>
</tr>
@@ -34,11 +34,11 @@
</td>
</tr>
<tr>
<td align="center" bgcolor="{{ $branding['footer_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['footer_bg_color'] }}; color: #ffffff; font-size: 12px; line-height: 1.5;">
<td align="center" bgcolor="{{ $tenant->footer_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->footer_bg_color }}; color: #ffffff; font-size: 12px; line-height: 1.5;">
@if ($footerLogoUrl)
<img src="{{ $footerLogoUrl }}" alt="{{ $branding['name'] }}" width="140" style="display: block; width: auto; max-width: 140px; max-height: 48px; margin: 0 auto 16px; border: 0;">
<img src="{{ $footerLogoUrl }}" alt="{{ $tenant->nombre }}" width="140" style="display: block; width: auto; max-width: 140px; max-height: 48px; margin: 0 auto 16px; border: 0;">
@endif
{{ $footer ?? 'Este correo fue enviado por '.$branding['name'].'.' }}
{{ $footer ?? 'Este correo fue enviado por '.$tenant->nombre.'.' }}
</td>
</tr>
</table>

View File

@@ -1,10 +1,10 @@
<h1 style="margin: 0 0 20px; color: {{ $brand->primary_color }};">
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
Recuperá tu contraseña
</h1>
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
<p>
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $brand->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $tenant->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
</p>
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
<p>
@@ -17,10 +17,10 @@
</p>
@endif
<p>Ingresá este código en {{ $brand->nombre }}:</p>
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $brand->primary_color }}; border-radius: 8px; text-align: center;">
<span style="color: {{ $brand->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $tenant->primary_color }}; border-radius: 8px; text-align: center;">
<span style="color: {{ $tenant->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
{{ $attempt->codigo }}
</span>
</div>
@@ -28,7 +28,7 @@
@if($recoveryUrl)
<div style="text-align: center; margin-bottom: 28px;">
<a href="{{ $recoveryUrl }}"
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
</a>
</div>

View File

@@ -1,4 +1,4 @@
<h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
@foreach ($purchase->items as $item)
@@ -10,6 +10,3 @@
@endforeach
</table>
<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

View File

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

View File

@@ -1,3 +1,3 @@
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $tenant->nombre }}!</h1>
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
<p>Ya podés ingresar y comenzar a comprar.</p>

View File

@@ -1,4 +1,4 @@
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }}; font-size: 26px; line-height: 1.3;">
Prueba de correo de Shopit
</h1>

View File

@@ -16,8 +16,6 @@ Artisan::command('reservations:expire', function (): void {
$this->info("Expired purchases: {$expired['purchases']}");
$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');
Schedule::command('reservations:expire')

View File

@@ -69,10 +69,6 @@ class CreateResetPasswordAttemptControllerTest extends TestCase
$this->assertTrue($attempt->user->is($user));
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
$this->assertTrue($attempt->expires_at->between(
now()->addMinutes(59),
now()->addMinutes(60),
));
Event::assertDispatched(
PasswordResetRequested::class,
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id

View File

@@ -18,9 +18,7 @@ class ResetPasswordAttemptTest extends TestCase
'id',
'user_id',
'codigo',
'reason',
'status',
'expires_at',
], Schema::getColumnListing('reset_password_attempts'));
}
@@ -30,14 +28,12 @@ class ResetPasswordAttemptTest extends TestCase
$attempt = $user->resetPasswordAttempts()->create([
'codigo' => '123456',
'expires_at' => now()->addHour(),
]);
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
$this->assertTrue($attempt->user->is($user));
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
$this->assertFalse($attempt->usesTimestamps());
$this->assertTrue($attempt->expires_at->isFuture());
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
}

View File

@@ -99,30 +99,6 @@ class ResetPasswordControllerTest extends TestCase
$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
{
$this->postJson('/api/password/reset', [

View File

@@ -49,27 +49,6 @@ 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
{
$user = User::factory()->create(['email' => 'ada@example.com']);

View File

@@ -47,7 +47,6 @@ class CartControllerTest extends TestCase
]));
$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
@@ -186,22 +185,18 @@ class CartControllerTest extends TestCase
$this->travelBack();
}
public function test_it_expires_a_cart_reservation_and_automatically_replaces_the_cart(): void
public function test_it_expires_abandoned_cart_reservations_without_deleting_the_cart(): void
{
config()->set('catalog.stock_reservation_expiration_minutes', 30);
$tenant = $this->createTenant('acme');
$user = User::factory()->create();
$item = $this->createDirectItem($tenant, 10, '49.90');
$response = $this->actingAs($user, 'sanctum')
->postJson('/api/tenants/acme/cart/items', [
$response = $this->postJson('/api/tenants/acme/cart/items', [
'catalog_item_id' => $item->id,
'cantidad' => 2,
])->assertOk();
$cartId = $response->json('data.id');
$cartItemId = $response->json('data.items.0.id');
$cart = Cart::query()->findOrFail($cartId);
$reservationId = $cart->current_stock_reservation_id;
$this->artisan('reservations:expire')
->expectsOutput('Expired purchases: 0')
@@ -223,12 +218,11 @@ class CartControllerTest extends TestCase
$this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]);
$this->assertDatabaseHas('carritos', [
'id' => $cartId,
'status' => Cart::STATUS_EXPIRED,
'current_stock_reservation_id' => $reservationId,
'status' => 'active',
'current_stock_reservation_id' => null,
'deleted_at' => null,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $reservationId,
'status' => 'expired',
'expires_at' => null,
]);
@@ -237,34 +231,6 @@ class CartControllerTest extends TestCase
'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')
->expectsOutput('Expired purchases: 0')
->expectsOutput('Expired cart reservations: 0')
@@ -273,88 +239,6 @@ class CartControllerTest extends TestCase
$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
{
$tenant = $this->createTenant('acme');

View File

@@ -96,7 +96,6 @@ class CatalogSchemaTest extends TestCase
$this->assertEqualsCanonicalizing([
'id',
'tenant_code',
'code',
'source_type',
'category_id',
'product_layout',

View File

@@ -60,12 +60,8 @@ class TelepagosWebhookTest extends TestCase
->assertJsonValidationErrors(['transfer_payer_dni']);
}
public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void
{
config()->set('purchase.checkout_expiration_minutes', 30);
$now = now()->startOfSecond();
$this->travelTo($now);
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$this->configureTelepagosIntegration($tenant);
$user = User::factory()->create();
@@ -103,13 +99,6 @@ class TelepagosWebhookTest extends TestCase
'payment_method' => 'transfer',
'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

View File

@@ -1,61 +0,0 @@
<?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),
);
}
}

View File

@@ -71,24 +71,19 @@ class NotificationMailServiceTest extends TestCase
public function test_it_sends_a_branded_welcome_email(): void
{
$this->useWebsiteTypeBranding();
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('ada@example.com');
$mail->assertHasSubject('Bienvenido a OnTicket');
$mail->assertHasSubject('Bienvenido a Mail Tenant');
return str_contains($mail->render(), 'Ada Lovelace')
&& str_contains($mail->render(), 'OnTicket')
&& ! str_contains($mail->render(), 'Mail Tenant')
&& str_contains($mail->render(), 'border-top: 4px solid #ff7006');
&& str_contains($mail->render(), 'Mail Tenant');
});
}
public function test_it_sends_a_branded_password_reset_email(): void
{
$this->useWebsiteTypeBranding();
$attempt = $this->user->resetPasswordAttempts()->create([
'codigo' => '0123',
]);
@@ -100,15 +95,13 @@ class NotificationMailServiceTest extends TestCase
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('ada@example.com');
$mail->assertHasSubject('Código para recuperar tu contraseña - OnTicket');
$mail->assertHasSubject('Código para recuperar tu contraseña - Mail Tenant');
$rendered = $mail->render();
return str_contains($rendered, '0123')
&& str_contains($rendered, 'Ada Lovelace')
&& str_contains($rendered, 'OnTicket')
&& ! str_contains($rendered, 'Mail Tenant')
&& str_contains($rendered, '#ff7006')
&& ! str_contains($rendered, 'border: 2px solid #112233');
&& str_contains($rendered, 'Mail Tenant')
&& str_contains($rendered, '#112233');
});
}
@@ -142,9 +135,8 @@ class NotificationMailServiceTest extends TestCase
});
}
public function test_it_sends_one_purchase_confirmation_with_generated_tickets_attached(): void
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
{
$this->useWebsiteTypeBranding();
$purchase = Purchase::query()->create([
'tenant_codigo' => $this->tenant->codigo,
'user_id' => $this->user->id,
@@ -175,65 +167,26 @@ class NotificationMailServiceTest extends TestCase
$ticket = Ticket::query()->create([
'tenant_code' => $this->tenant->codigo,
'ticket' => fake()->uuid(),
'source_purchase_id' => $purchase->id,
'source_catalog_item_id' => $catalogItem->id,
'user_id' => $this->user->id,
]);
$service = app(NotificationMailService::class);
$service->sendPurchaseConfirmed($purchase->id);
Mail::assertSent(Mailable::class, 1);
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $ticket): bool {
$mail->assertTo('checkout@example.com');
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
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(), '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 #ff7006');
});
}
public function test_purchase_confirmation_omits_ticket_content_and_attachment_without_tickets(): void
{
$purchase = Purchase::query()->create([
'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);
$service->sendPurchasePaid($purchase->id);
$service->sendTicketsAvailable($purchase->id, [$ticket->id]);
Mail::assertSent(Mailable::class, 2);
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 === [];
$mail->assertTo('checkout@example.com');
return $mail->subject === "Pago confirmado - Compra #{$purchase->id}"
&& str_contains($mail->render(), 'Total pagado');
});
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
$mail->assertTo('checkout@example.com');
return $mail->subject === 'Tus tickets ya están disponibles'
&& str_contains($mail->render(), 'Entrada general');
});
}
private function useWebsiteTypeBranding(): void
{
$websiteType = WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
'dominio' => 'onticket.local',
'primary_color' => '#ff7006',
'body_color' => '#666666',
'background_color' => '#f8f8f8',
'surface_color' => '#ffffff',
'login_header_footer_color' => '#838383',
]);
$this->tenant->update(['website_type_code' => $websiteType->codigo]);
$this->tenant->unsetRelation('websiteType');
}
}

View File

@@ -1,30 +0,0 @@
<?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);
}
}

View File

@@ -71,43 +71,6 @@ class StorePurchaseTest extends TestCase
$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
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
@@ -517,7 +480,6 @@ class StorePurchaseTest extends TestCase
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 3);
$reservationId = $purchase->stock_reservation_id;
$activeCart = Cart::query()
->where('user_id', $user->id)
->where('status', 'active')
@@ -551,11 +513,7 @@ class StorePurchaseTest extends TestCase
'reserved_stock' => 3,
]);
$activeCart->refresh();
$this->assertSame($reservationId, $activeCart->current_stock_reservation_id);
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'stock_reservation_id' => null,
]);
$this->assertNotNull($activeCart->current_stock_reservation_id);
$this->assertDatabaseHas('stock_reservations', [
'id' => $activeCart->current_stock_reservation_id,
'status' => 'active',
@@ -568,7 +526,7 @@ class StorePurchaseTest extends TestCase
$this->assertSame(1, $activeCart->items()->count());
}
public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void
public function test_it_reassigns_a_terminal_purchase_reservation_and_rejects_a_late_confirmation(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -580,6 +538,7 @@ class StorePurchaseTest extends TestCase
$previousPurchase->update([
'status' => Purchase::STATUS_PENDING_PAYMENT,
'payment_method' => 'transfer',
'expires_at' => now()->addMinutes(30),
]);
$currentPurchase = app(CheckoutService::class)->startCheckout(
@@ -591,20 +550,17 @@ class StorePurchaseTest extends TestCase
$this->assertDatabaseHas('compras', [
'id' => $previousPurchase->id,
'status' => Purchase::STATUS_SUPERSEDED,
'expires_at' => null,
]);
$this->assertDatabaseHas('carritos', [
'id' => $cart->id,
'current_purchase_id' => $currentPurchase->id,
]);
$this->assertSame($previousReservationId, $currentPurchase->stock_reservation_id);
$this->assertDatabaseHas('compras', [
'id' => $previousPurchase->id,
'stock_reservation_id' => null,
]);
$this->assertNotSame($previousReservationId, $currentPurchase->stock_reservation_id);
$this->assertDatabaseHas('stock_reservations', [
'id' => $previousReservationId,
'status' => 'active',
'release_reason' => null,
'status' => 'released',
'release_reason' => 'purchase_superseded',
]);
$this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active');
@@ -650,7 +606,7 @@ class StorePurchaseTest extends TestCase
]);
$this->assertDatabaseHas('stock_reservations', [
'status' => 'released',
'release_reason' => 'cart_empty',
'release_reason' => 'purchase_superseded',
]);
$this->assertDatabaseHas('stock_reservation_lines', [
'inventory_id' => $variant->inventory_id,
@@ -668,7 +624,7 @@ class StorePurchaseTest extends TestCase
$this->assertSame(3, $remaining);
}
public function test_it_expires_the_purchase_and_preserves_the_expired_cart_items(): void
public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -679,7 +635,7 @@ class StorePurchaseTest extends TestCase
->where('status', 'active')
->firstOrFail();
$cartItemId = $purchase->cart->items()->firstOrFail()->id;
$purchase->stockReservation()->update(['expires_at' => now()->subMinute()]);
$purchase->update(['expires_at' => now()->subMinute()]);
$this->artisan('reservations:expire')->assertSuccessful();
@@ -694,55 +650,12 @@ class StorePurchaseTest extends TestCase
$this->assertDatabaseHas('carritos', [
'id' => $activeCart->id,
'user_id' => $user->id,
'status' => Cart::STATUS_EXPIRED,
'status' => 'active',
'current_purchase_id' => null,
'current_stock_reservation_id' => $purchase->stock_reservation_id,
'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', [
'id' => $purchase->stock_reservation_id,
'status' => 'active',
]);
}
public function test_it_keeps_cart_items_during_checkout_and_updates_customer_data(): void
@@ -796,18 +709,12 @@ class StorePurchaseTest extends TestCase
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');
$user = User::factory()->create();
$variant = $this->createVariantForTenant('sonder', 10, '50.00');
$purchase = $this->createCheckoutPurchase($user, 'sonder', $variant, 1);
$purchase->update(['status' => Purchase::STATUS_PENDING_PAYMENT]);
$this->travel(10)->minutes();
$this->actingAs($user, 'sanctum')
->patchJson("/api/tenants/sonder/compras/{$purchase->id}/customer-data", [
'dni' => '987654321',
@@ -824,12 +731,6 @@ class StorePurchaseTest extends TestCase
'status' => Purchase::STATUS_PENDING_PAYMENT,
'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
@@ -959,6 +860,7 @@ class StorePurchaseTest extends TestCase
$purchase->update([
'payment_method' => 'transfer',
'status' => Purchase::STATUS_PENDING_PAYMENT,
'expires_at' => now()->addMinutes(30),
]);
$url = "/api/tenants/sonder/compras/{$purchase->id}/review";
@@ -972,10 +874,6 @@ class StorePurchaseTest extends TestCase
$this->assertDatabaseHas('compras', [
'id' => $purchase->id,
'status' => Purchase::STATUS_IN_REVIEW,
]);
$this->assertDatabaseHas('stock_reservations', [
'id' => $purchase->stock_reservation_id,
'status' => 'active',
'expires_at' => null,
]);
@@ -1047,7 +945,7 @@ class StorePurchaseTest extends TestCase
]);
}
public function test_it_expires_a_purchase_and_its_associated_cart(): void
public function test_it_expires_an_abandoned_purchase_without_mutating_its_active_cart(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
@@ -1058,7 +956,7 @@ class StorePurchaseTest extends TestCase
->where('status', 'active')
->firstOrFail();
$this->assertNotNull($purchase->stockReservation->expires_at);
$this->assertNotNull($purchase->expires_at);
$this->assertDatabaseHas('inventories', [
'id' => $variant->inventory_id,
'reserved_stock' => 3,
@@ -1089,9 +987,7 @@ class StorePurchaseTest extends TestCase
$this->assertDatabaseHas('carritos', [
'id' => $activeCart->id,
'user_id' => $user->id,
'status' => Cart::STATUS_EXPIRED,
'current_purchase_id' => null,
'current_stock_reservation_id' => $purchase->stock_reservation_id,
'status' => 'active',
'deleted_at' => null,
]);
$this->assertSame(1, $activeCart->items()->count());

View File

@@ -161,7 +161,6 @@ class DesfilePuraTendenciaSeederTest extends TestCase
]);
$this->assertDatabaseHas('featured_groups', [
'tenant_code' => 'desfile_pura_tendencia',
'code' => 'entradas',
'source_type' => 'all',
'product_layout' => 'ticket_selector',
'group_layout' => 'single',

View File

@@ -151,7 +151,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
));
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
$this->assertSame('productos', $featuredGroup->code);
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);

View File

@@ -69,7 +69,6 @@ class ProductCatalogFromImagesSeederTest extends TestCase
->pluck('id');
$this->assertNotNull($paginatedGroup);
$this->assertSame('productos', $paginatedGroup->code);
$this->assertSame(ProductLayout::ColumnWithImage, $paginatedGroup->product_layout);
$this->assertSame(GroupLayout::Paginated, $paginatedGroup->group_layout);
$this->assertSame(FeaturedGroupSource::All, $paginatedGroup->source_type);
@@ -77,7 +76,6 @@ class ProductCatalogFromImagesSeederTest extends TestCase
$this->assertCount(0, $paginatedGroup->featuredItems);
$this->assertNotNull($carouselGroup);
$this->assertSame('productos-destacados', $carouselGroup->code);
$this->assertSame(ProductLayout::ColumnWithImage, $carouselGroup->product_layout);
$this->assertSame(GroupLayout::Carousel, $carouselGroup->group_layout);
$this->assertSame(FeaturedGroupSource::Manual, $carouselGroup->source_type);

View File

@@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase
$this->seed(WebsiteTypeSeeder::class);
$this->assertSame(2, WebsiteType::query()->count());
$this->assertSame(5, Attachment::query()->count());
$this->assertSame(4, Attachment::query()->count());
$expectedPresentation = [
'primary_color' => '#FF7006',
@@ -49,8 +49,6 @@ class WebsiteTypeSeederTest extends TestCase
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
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('Carrusel principal', $shopIt->extras->sole()->nombre);
$this->assertSame([
@@ -81,8 +79,6 @@ class WebsiteTypeSeederTest extends TestCase
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
$this->assertNotSame($shopIt->site_logo, $onTicket->site_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(
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
$onTicket->extras->pluck('codigo')->all(),

View File

@@ -14,7 +14,6 @@ class BootstrapAdminAppControllerTest extends TestCase
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
{
$footerLogo = Attachment::factory()->create();
$favicon = Attachment::factory()->create();
WebsiteType::query()->create([
'codigo' => 'shopit',
@@ -32,7 +31,6 @@ class BootstrapAdminAppControllerTest extends TestCase
'border_color' => '#eaeaea',
'login_header_footer_color' => '#313131',
'footer_logo' => $footerLogo->id,
'favicon_id' => $favicon->id,
]);
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
@@ -43,7 +41,6 @@ class BootstrapAdminAppControllerTest extends TestCase
->assertJsonPath('data.login_header_footer_color', '#313131')
->assertJsonPath('data.site_logo', null)
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
->assertJsonMissingPath('data.forms')
->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre')

View File

@@ -14,7 +14,6 @@ class BootstrapScannerControllerTest extends TestCase
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
{
$siteLogo = Attachment::factory()->create();
$favicon = Attachment::factory()->create();
WebsiteType::query()->create([
'codigo' => 'shopit',
@@ -33,7 +32,6 @@ class BootstrapScannerControllerTest extends TestCase
'border_color' => '#eaeaea',
'login_header_footer_color' => '#313131',
'site_logo' => $siteLogo->id,
'favicon_id' => $favicon->id,
]);
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
@@ -42,7 +40,6 @@ class BootstrapScannerControllerTest extends TestCase
->assertJsonPath('data.primary_color', '#112233')
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
->assertJsonPath('data.footer_logo', null)
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
->assertJsonMissingPath('data.codigo')
->assertJsonMissingPath('data.nombre')
->assertJsonMissingPath('data.dominio')

View File

@@ -69,31 +69,4 @@ class WebsiteTypeServiceTest extends TestCase
'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);
}
}

View File

@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Event\Models\EventDate;
use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
@@ -21,6 +22,7 @@ use App\Domains\Ticket\Services\TicketGeneratorService;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\TestCase;
@@ -218,6 +220,7 @@ class TicketGeneratorServiceTest extends TestCase
public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void
{
Event::fake([TicketsAvailable::class]);
$item = $this->createTicketableItem('paid-ticket');
$purchase = $this->createPurchase($item, 2);
$purchase->setRelation('items', new EloquentCollection);
@@ -226,6 +229,7 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
$this->assertDatabaseCount('tickets', 2);
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
$this->actingAs($this->user, 'sanctum')
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
@@ -236,6 +240,7 @@ class TicketGeneratorServiceTest extends TestCase
$purchase->markAsPaid();
$this->assertDatabaseCount('tickets', 2);
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
}
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
@@ -448,6 +453,7 @@ class TicketGeneratorServiceTest extends TestCase
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
{
Event::fake([TicketsAvailable::class]);
$item = $this->createTicketableItem('regular-product');
$item->update(['has_tickets' => false]);
$purchase = $this->createPurchase($item->fresh(), 1);
@@ -456,6 +462,7 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
$this->assertDatabaseCount('tickets', 0);
Event::assertNotDispatched(TicketsAvailable::class);
$this->actingAs($this->user, 'sanctum')
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")

View File

@@ -17,13 +17,11 @@ class AdminAppBootstrapResourceTest extends TestCase
]);
$websiteType->setRelation('siteLogo', null);
$websiteType->setRelation('footerLogo', null);
$websiteType->setRelation('favicon', null);
$data = AdminAppBootstrapResource::make([
'website_type' => $websiteType,
])->resolve(request());
$this->assertSame('shopit', $data['website_type_code']);
$this->assertNull($data['favicon']);
$this->assertArrayNotHasKey('forms', $data);
}
}

View File

@@ -2,102 +2,77 @@
namespace Tests\Unit\Catalog;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Catalog\Models\StockReservationLine;
use App\Domains\Cart\Services\ExpireCartReservationsService;
use App\Domains\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Catalog\Services\StockReservationService;
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Domains\Purchase\Services\CheckoutService;
use Illuminate\Support\Facades\Log;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Tests\TestCase;
class ExpireStockReservationsServiceTest extends TestCase
{
use RefreshDatabase;
public function test_it_expires_an_orphan_reservation_and_releases_its_inventory(): void
public function test_it_expires_purchases_before_abandoned_cart_reservations(): void
{
$inventory = Inventory::query()->create([
'real_stock' => 10,
'reserved_stock' => 2,
]);
$reservation = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]);
StockReservationLine::query()->create([
'stock_reservation_id' => $reservation->id,
'inventory_id' => $inventory->id,
'quantity' => 2,
'tracks_inventory' => true,
$checkout = \Mockery::mock(CheckoutService::class);
$checkout->shouldReceive('expireOverduePurchases')
->once()
->ordered()
->andReturn(2);
$carts = \Mockery::mock(ExpireCartReservationsService::class);
$carts->shouldReceive('expireOverdue')
->once()
->ordered()
->andReturn(3);
$logger = \Mockery::mock(LoggerInterface::class);
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_reservations' => 3,
'total_expired' => 5,
]);
$result = app(ExpireStockReservationsService::class)->expireOverdue();
$result = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
$this->assertSame([
'purchases' => 0,
'cart_reservations' => 0,
'orphan_reservations' => 1,
'failed' => 0,
'purchases' => 2,
'cart_reservations' => 3,
], $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_one_failed_reservation_does_not_stop_the_remaining_batch(): void
public function test_it_logs_failed_cleanup_attempts_and_rethrows_the_error(): void
{
$first = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinutes(2),
]);
$second = StockReservation::query()->create([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
$exception = new RuntimeException('Unable to clean carts.');
$checkout = \Mockery::mock(CheckoutService::class);
$checkout->shouldReceive('expireOverduePurchases')->once()->andReturn(2);
$carts = \Mockery::mock(ExpireCartReservationsService::class);
$carts->shouldReceive('expireOverdue')->once()->andThrow($exception);
$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_reservations' => null,
'exception' => $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.');
}
$this->expectExceptionObject($exception);
$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,
]);
(new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
}
}

View File

@@ -1,112 +0,0 @@
<?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();
});
}
}

View File

@@ -5,12 +5,13 @@ namespace Tests\Unit\Notification;
use App\Domains\Notification\Events\PasswordResetRequested;
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
use App\Domains\Notification\Services\NotificationMailService;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Tests\TestCase;
class SendPasswordResetEmailTest extends TestCase
{
public function test_it_delegates_mail_failures_to_the_notification_service(): void
public function test_it_logs_and_rethrows_mail_failures(): void
{
$exception = new RuntimeException('SMTP unavailable.');
$mailService = \Mockery::mock(NotificationMailService::class);
@@ -20,6 +21,16 @@ class SendPasswordResetEmailTest extends TestCase
->andThrow($exception);
$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);
(new SendPasswordResetEmail)->handle(

View File

@@ -1,69 +0,0 @@
<?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('/'));
}
}

View File

@@ -2,7 +2,6 @@
namespace Tests\Unit\Purchase;
use App\Domains\Catalog\Models\StockReservation;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\PurchaseStateGuard;
@@ -23,6 +22,7 @@ class PurchaseStateGuardTest extends TestCase
{
$purchase = (new Purchase)->forceFill([
'status' => Purchase::STATUS_EXPIRED,
'expires_at' => null,
]);
$this->expectException(PurchaseExpiredException::class);
@@ -34,11 +34,8 @@ class PurchaseStateGuardTest extends TestCase
{
$purchase = (new Purchase)->forceFill([
'status' => Purchase::STATUS_PENDING_PAYMENT,
]);
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]));
]);
$this->expectException(PurchaseExpiredException::class);
@@ -49,11 +46,8 @@ class PurchaseStateGuardTest extends TestCase
{
$purchase = (new Purchase)->forceFill([
'status' => Purchase::STATUS_PAID,
]);
$purchase->setRelation('stockReservation', (new StockReservation)->forceFill([
'status' => StockReservation::STATUS_ACTIVE,
'expires_at' => now()->subMinute(),
]));
]);
$this->guard->assertNotExpired($purchase);

View File

@@ -1,146 +0,0 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Support\Carbon;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Tests\TestCase;
class AdminAppSaleExcelServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_it_downloads_filtered_sales_as_an_excel_file(): void
{
$response = app(AdminAppSaleExcelService::class)->downloadSales(
$this->tenant(),
collect([$this->sale()]),
'America/La_Paz',
);
$this->assertExcelResponse(
$response,
'ventas_acme_20260824_135300.xlsx',
function (string $path): void {
$sheet = IOFactory::load($path)->getActiveSheet();
$this->assertSame('Ventas', $sheet->getTitle());
$this->assertSame('Cliente Test', $sheet->getCell('C2')->getValue());
$this->assertSame('Confirmado', $sheet->getCell('E2')->getValue());
$this->assertSame(25000.0, $sheet->getCell('F2')->getValue());
},
);
}
public function test_it_downloads_the_modification_history_as_an_excel_file(): void
{
$sale = $this->sale();
$admin = (new User)->forceFill([
'id' => 10,
'nombre_apellido' => 'Admin Test',
'email' => 'admin@example.test',
]);
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => $sale->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'user',
]);
$modification->setRelation('trackable', $sale);
$modification->setRelation('user', $admin);
$response = app(AdminAppSaleExcelService::class)->downloadModifications(
$this->tenant(),
collect([$modification]),
'America/La_Paz',
);
$this->assertExcelResponse(
$response,
'historial_modificaciones_acme_20260824_135300.xlsx',
function (string $path): void {
$sheet = IOFactory::load($path)->getActiveSheet();
$this->assertSame('Modificaciones', $sheet->getTitle());
$this->assertSame('#15', $sheet->getCell('C2')->getValue());
$this->assertSame('pending_payment', $sheet->getCell('F2')->getValue());
$this->assertSame('paid', $sheet->getCell('G2')->getValue());
$this->assertSame('Admin Test', $sheet->getCell('H2')->getValue());
},
);
}
/** @param callable(string): void $assertSpreadsheet */
private function assertExcelResponse(
StreamedResponse $response,
string $filename,
callable $assertSpreadsheet,
): void {
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
$response->headers->get('content-type'),
);
$this->assertStringContainsString(
"attachment; filename={$filename}",
(string) $response->headers->get('content-disposition'),
);
ob_start();
($response->getCallback())();
$contents = ob_get_clean();
$this->assertIsString($contents);
$this->assertStringStartsWith('PK', $contents);
$path = tempnam(sys_get_temp_dir(), 'shopit_excel_');
$this->assertNotFalse($path);
try {
file_put_contents($path, $contents);
$assertSpreadsheet($path);
} finally {
@unlink($path);
}
}
private function tenant(): Tenant
{
return (new Tenant)->forceFill([
'codigo' => 'acme',
'nombre' => 'Acme Eventos',
]);
}
private function sale(): Purchase
{
return (new Purchase)->forceFill([
'id' => 15,
'created_at' => now(),
'nombre_apellido' => 'Cliente Test',
'quantity' => 2,
'status' => Purchase::STATUS_PAID,
'total' => '25000.00',
'tickets_count' => 2,
]);
}
}