Compare commits
43 Commits
6db99e775a
...
homo_exper
| Author | SHA1 | Date | |
|---|---|---|---|
| 351178d9da | |||
| b617285148 | |||
| 9bca41bfa5 | |||
| 5601285369 | |||
| 6fb60c9a73 | |||
| c3f1c320a4 | |||
| 899bf12457 | |||
| a460743ae0 | |||
| a4d7b1b789 | |||
| 10655b8c07 | |||
| adc7b21ab8 | |||
| ba1355448a | |||
| 1c76e56f2d | |||
| daa74845b7 | |||
| 05944cec9c | |||
| c820741e5f | |||
| adc4cc9595 | |||
| c67dfcc1a1 | |||
| f61b7e4dee | |||
| e53fa949cf | |||
| c51e24311f | |||
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 | |||
| e2ad78b1fb | |||
| 8ed8a11b7b | |||
| dc33e5dc09 | |||
| 414df97e9d | |||
| d0424c5589 | |||
| 28d09dedab | |||
| 8220fb5aaf | |||
| bf02d37a33 | |||
| 78ce263849 | |||
| 0c8419a5eb |
@@ -8,6 +8,7 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
|
||||
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||
FRONTEND_URLS=http://localhost:4200
|
||||
|
||||
@@ -31,6 +32,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
||||
AUTH_LOGIN_LOCK_MINUTES=15
|
||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
|
||||
|
||||
LOG_CHANNEL=daily
|
||||
LOG_STACK=single
|
||||
@@ -41,6 +43,8 @@ TELEPAGOS_LOG_LEVEL=info
|
||||
TELEPAGOS_LOG_DAYS=30
|
||||
COMMANDS_LOG_LEVEL=info
|
||||
COMMANDS_LOG_DAYS=30
|
||||
EMAILS_LOG_LEVEL=info
|
||||
EMAILS_LOG_DAYS=30
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
|
||||
@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
|
||||
{
|
||||
$data = $request->validated();
|
||||
|
||||
if (! $this->resetPasswordAttemptService->validateCode(
|
||||
$result = $this->resetPasswordAttemptService->validateCode(
|
||||
$data['email'],
|
||||
$data['codigo'],
|
||||
)) {
|
||||
);
|
||||
|
||||
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||
throw ValidationException::withMessages([
|
||||
'codigo' => __('api.auth.reset_code_expired'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
|
||||
throw ValidationException::withMessages([
|
||||
'codigo' => __('api.auth.reset_code_invalid'),
|
||||
]);
|
||||
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||
#[Hidden(['codigo'])]
|
||||
class ResetPasswordAttempt extends Model
|
||||
{
|
||||
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ use Throwable;
|
||||
|
||||
class ResetPasswordAttemptService
|
||||
{
|
||||
public const CODE_VALID = 'valid';
|
||||
|
||||
public const CODE_INVALID = 'invalid';
|
||||
|
||||
public const CODE_EXPIRED = 'expired';
|
||||
|
||||
public function createForEmail(
|
||||
string $email,
|
||||
string $tenantCode,
|
||||
@@ -146,12 +152,12 @@ class ResetPasswordAttemptService
|
||||
);
|
||||
}
|
||||
|
||||
public function validateCode(string $email, string $code): bool
|
||||
public function validateCode(string $email, string $code): string
|
||||
{
|
||||
$emailFingerprint = $this->emailFingerprint($email);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->lockForUpdate()
|
||||
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
|
||||
'email_fingerprint' => $emailFingerprint,
|
||||
]);
|
||||
|
||||
return false;
|
||||
return self::CODE_INVALID;
|
||||
}
|
||||
|
||||
if ($attempt->expires_at?->isPast()) {
|
||||
$attempt->update([
|
||||
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||
]);
|
||||
|
||||
Log::info('Password reset code validation failed: attempt expired.', [
|
||||
'email_fingerprint' => $emailFingerprint,
|
||||
'attempt_id' => $attempt->getKey(),
|
||||
]);
|
||||
|
||||
return self::CODE_EXPIRED;
|
||||
}
|
||||
|
||||
$attempt->update([
|
||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
]);
|
||||
|
||||
return true;
|
||||
return self::CODE_VALID;
|
||||
});
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Failed to validate password reset code.', [
|
||||
@@ -214,6 +233,19 @@ class ResetPasswordAttemptService
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($attempt->expires_at?->isPast()) {
|
||||
$attempt->update([
|
||||
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||
]);
|
||||
|
||||
Log::info('Password reset failed: attempt expired.', [
|
||||
'email_fingerprint' => $emailFingerprint,
|
||||
'attempt_id' => $attempt->getKey(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$user->password = $password;
|
||||
$user->failed_login_attempts = 0;
|
||||
$user->last_failed_login_at = null;
|
||||
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
|
||||
'codigo' => $this->generateCode(),
|
||||
'reason' => $reason,
|
||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||
]);
|
||||
|
||||
return $attempt->getKey();
|
||||
|
||||
@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
|
||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||
->where('dominio', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||
->where('scanner_domain', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
|
||||
@@ -38,6 +38,16 @@ class Cart extends Model
|
||||
|
||||
protected $table = 'carritos';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_CHECKOUT = 'checkout';
|
||||
|
||||
public const STATUS_CONVERTED = 'converted';
|
||||
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
|
||||
public const STATUS_ABANDONED = 'abandoned';
|
||||
|
||||
public const ORIGIN_USER = 'user';
|
||||
|
||||
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
||||
@@ -119,6 +129,7 @@ class Cart extends Model
|
||||
|
||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||
$cartQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
@@ -183,6 +194,7 @@ class Cart extends Model
|
||||
$excludedPurchaseId,
|
||||
): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -284,6 +296,7 @@ class Cart extends Model
|
||||
{
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
@@ -334,10 +347,14 @@ 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(
|
||||
|
||||
@@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Cookie;
|
||||
@@ -22,7 +25,7 @@ class CartService
|
||||
return $this->makeEmptyCart($tenant);
|
||||
}
|
||||
|
||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
||||
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||
|
||||
if ($cart === null) {
|
||||
return $this->makeEmptyCart($tenant);
|
||||
@@ -119,7 +122,7 @@ class CartService
|
||||
{
|
||||
$cart = new Cart([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_ACTIVE,
|
||||
]);
|
||||
|
||||
$cart->setRelation('items', collect());
|
||||
@@ -220,12 +223,14 @@ class CartService
|
||||
{
|
||||
return Cart::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('status', 'active')
|
||||
->where('origin', Cart::ORIGIN_USER)
|
||||
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||
->when(
|
||||
$identity['user_id'] !== null,
|
||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||
)
|
||||
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -234,7 +239,7 @@ class CartService
|
||||
*/
|
||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||
|
||||
if ($cart === null) {
|
||||
throw new NotFoundHttpException('Cart not found.');
|
||||
@@ -247,10 +252,73 @@ class CartService
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
return $this->resolveCart($tenant, $identity)
|
||||
?? $this->createCart($tenant, $identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function resolveCart(
|
||||
Tenant $tenant,
|
||||
array $identity,
|
||||
bool $replaceExpired = true,
|
||||
): ?Cart {
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
|
||||
if ($cart?->status === Cart::STATUS_ACTIVE
|
||||
&& $cart->current_stock_reservation_id !== null
|
||||
&& app(ExpireStockReservationsService::class)
|
||||
->expireIfOverdue($cart->current_stock_reservation_id)) {
|
||||
$cart = $this->findCart($tenant, $identity);
|
||||
}
|
||||
|
||||
if ($cart?->status === Cart::STATUS_EXPIRED) {
|
||||
if (! $replaceExpired) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
return $this->replaceExpiredCart($cart, $tenant, $identity);
|
||||
}
|
||||
|
||||
if ($cart !== null) {
|
||||
return $cart;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
|
||||
/** @var Cart|null $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
|
||||
|
||||
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
|
||||
$lockedCart->update([
|
||||
'status' => Cart::STATUS_ABANDONED,
|
||||
'current_purchase_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->findCart($tenant, $identity)
|
||||
?? $this->createCart($tenant, $identity);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||
*/
|
||||
protected function createCart(Tenant $tenant, array $identity): Cart
|
||||
{
|
||||
$attributes = [
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_ACTIVE,
|
||||
'origin' => Cart::ORIGIN_USER,
|
||||
];
|
||||
|
||||
if ($identity['user_id'] !== null) {
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -34,6 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
|
||||
|
||||
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
||||
|
||||
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.
|
||||
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
|
||||
|
||||
El comando unificado `php artisan reservations:expire` 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.
|
||||
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class StockReservationExpiredException extends RuntimeException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(__('api.cart.reservation_expired'));
|
||||
}
|
||||
}
|
||||
@@ -2,50 +2,184 @@
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ExpireStockReservationsService
|
||||
{
|
||||
private const BATCH_SIZE = 500;
|
||||
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkout,
|
||||
private readonly ExpireCartReservationsService $carts,
|
||||
private readonly ReleaseCheckoutService $purchases,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{purchases: int, cart_reservations: int}
|
||||
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||
*/
|
||||
public function expireOverdue(): array
|
||||
{
|
||||
$expiredPurchases = null;
|
||||
$expiredCartItems = null;
|
||||
$summary = [
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 0,
|
||||
'failed' => 0,
|
||||
];
|
||||
$lastReservationId = 0;
|
||||
|
||||
try {
|
||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
||||
$expiredCartItems = $this->carts->expireOverdue();
|
||||
do {
|
||||
$reservationIds = StockReservation::query()
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->where('id', '>', $lastReservationId)
|
||||
->orderBy('id')
|
||||
->limit(self::BATCH_SIZE)
|
||||
->pluck('id');
|
||||
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $expiredPurchases,
|
||||
'expired_cart_reservations' => $expiredCartItems,
|
||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
||||
]);
|
||||
foreach ($reservationIds as $reservationId) {
|
||||
$lastReservationId = (int) $reservationId;
|
||||
|
||||
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,
|
||||
]);
|
||||
try {
|
||||
$owner = $this->expireReservation($lastReservationId);
|
||||
if ($owner !== null) {
|
||||
$summary[$owner]++;
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$summary['failed']++;
|
||||
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||
'command' => 'reservations:expire',
|
||||
'stock_reservation_id' => $lastReservationId,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||
|
||||
throw $exception;
|
||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||
'command' => 'reservations:expire',
|
||||
'expired_purchases' => $summary['purchases'],
|
||||
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||
'failed_reservations' => $summary['failed'],
|
||||
'total_expired' => $summary['purchases']
|
||||
+ $summary['cart_reservations']
|
||||
+ $summary['orphan_reservations'],
|
||||
]);
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public function expireIfOverdue(int $reservationId): bool
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->find($reservationId);
|
||||
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
||||
return true;
|
||||
}
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->expireReservation($reservationId) !== null;
|
||||
}
|
||||
|
||||
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
||||
private function expireReservation(int $reservationId): ?string
|
||||
{
|
||||
$purchaseId = Purchase::query()
|
||||
->where('stock_reservation_id', $reservationId)
|
||||
->value('id');
|
||||
if ($purchaseId !== null) {
|
||||
return $this->expirePurchase((int) $purchaseId);
|
||||
}
|
||||
|
||||
$cartId = Cart::query()
|
||||
->where('current_stock_reservation_id', $reservationId)
|
||||
->where('status', 'active')
|
||||
->value('id');
|
||||
if ($cartId !== null) {
|
||||
return $this->expireCart((int) $cartId, $reservationId);
|
||||
}
|
||||
|
||||
return $this->expireOrphan($reservationId);
|
||||
}
|
||||
|
||||
/** @return 'purchases'|null */
|
||||
private function expirePurchase(int $purchaseId): ?string
|
||||
{
|
||||
/** @var Purchase|null $purchase */
|
||||
$purchase = Purchase::query()->find($purchaseId);
|
||||
if ($purchase === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$purchase = $this->purchases->expire($purchase);
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
return 'purchases';
|
||||
}
|
||||
|
||||
$reservation = $purchase->stockReservation;
|
||||
if ($this->isOverdue($reservation)) {
|
||||
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return 'cart_reservations'|null */
|
||||
private function expireCart(int $cartId, int $reservationId): ?string
|
||||
{
|
||||
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
||||
/** @var Cart|null $cart */
|
||||
$cart = Cart::query()
|
||||
->whereKey($cartId)
|
||||
->where('current_stock_reservation_id', $reservationId)
|
||||
->where('status', Cart::STATUS_ACTIVE)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($cart === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->reservations->expire($reservation);
|
||||
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||
|
||||
return 'cart_reservations';
|
||||
});
|
||||
}
|
||||
|
||||
/** @return 'orphan_reservations'|null */
|
||||
private function expireOrphan(int $reservationId): ?string
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = StockReservation::query()->find($reservationId);
|
||||
if (! $this->isOverdue($reservation)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->reservations->expire($reservation);
|
||||
|
||||
return 'orphan_reservations';
|
||||
}
|
||||
|
||||
private function isOverdue(?StockReservation $reservation): bool
|
||||
{
|
||||
return $reservation !== null
|
||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -43,13 +44,8 @@ class StockReservationService
|
||||
? null
|
||||
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||
|
||||
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 ($reservation !== null) {
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
}
|
||||
|
||||
if ($requirements === []) {
|
||||
@@ -67,7 +63,7 @@ class StockReservationService
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
if ($reservation === null) {
|
||||
$reservation = StockReservation::query()->create([
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
'expires_at' => $this->expiration(),
|
||||
@@ -158,9 +154,12 @@ class StockReservationService
|
||||
});
|
||||
}
|
||||
|
||||
public function attachToPurchase(Cart $cart, Purchase $purchase): StockReservation
|
||||
{
|
||||
return DB::transaction(function () use ($cart, $purchase): StockReservation {
|
||||
public function attachToPurchase(
|
||||
Cart $cart,
|
||||
Purchase $purchase,
|
||||
Carbon $expiresAt,
|
||||
): StockReservation {
|
||||
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||
/** @var Cart $lockedCart */
|
||||
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||
/** @var Purchase $lockedPurchase */
|
||||
@@ -172,9 +171,7 @@ class StockReservationService
|
||||
|
||||
/** @var StockReservation $reservation */
|
||||
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||
}
|
||||
$this->assertUsableCartReservation($reservation);
|
||||
|
||||
$linkedPurchase = Purchase::query()
|
||||
->where('stock_reservation_id', $reservation->getKey())
|
||||
@@ -185,7 +182,7 @@ class StockReservationService
|
||||
}
|
||||
|
||||
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||
$reservation->update(['expires_at' => $lockedPurchase->expires_at]);
|
||||
$reservation->update(['expires_at' => $expiresAt]);
|
||||
$purchase->stock_reservation_id = $reservation->getKey();
|
||||
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||
|
||||
@@ -210,6 +207,9 @@ 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,6 +264,50 @@ 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,
|
||||
@@ -299,16 +343,28 @@ class StockReservationService
|
||||
});
|
||||
}
|
||||
|
||||
public function syncPurchaseExpiration(Purchase $purchase): void
|
||||
public function clearExpirationForReview(Purchase $purchase): void
|
||||
{
|
||||
if ($purchase->stock_reservation_id === null) {
|
||||
return;
|
||||
}
|
||||
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.');
|
||||
}
|
||||
|
||||
StockReservation::query()
|
||||
->whereKey($purchase->stock_reservation_id)
|
||||
->where('status', StockReservation::STATUS_ACTIVE)
|
||||
->update(['expires_at' => $purchase->expires_at]);
|
||||
/** @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]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -408,9 +464,24 @@ class StockReservationService
|
||||
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||
]);
|
||||
|
||||
Cart::query()
|
||||
->where('current_stock_reservation_id', $reservation->getKey())
|
||||
->update(['current_stock_reservation_id' => 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
|
||||
|
||||
@@ -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`. 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`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
|
||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||
- `BundleComponent` representa los componentes de un paquete.
|
||||
@@ -19,6 +19,7 @@ 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.
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner
|
||||
if ($purchaseId !== null) {
|
||||
DB::table('compras')->where('id', $purchaseId)->update([
|
||||
'status' => 'paid',
|
||||
'expires_at' => null,
|
||||
'total' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
@@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner
|
||||
'cart_id' => null,
|
||||
'status' => 'paid',
|
||||
'payment_method' => self::PAYMENT_METHOD,
|
||||
'expires_at' => null,
|
||||
'total' => 0,
|
||||
'dni' => null,
|
||||
'transfer_payer_dni' => null,
|
||||
@@ -396,7 +394,6 @@ 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,
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
@@ -70,24 +72,35 @@ class MailService extends BaseIntegrationService
|
||||
return [];
|
||||
}
|
||||
|
||||
public function send(string|array $recipient, string $subject, string $content): void
|
||||
{
|
||||
/**
|
||||
* @param array<int, array{data: string, name: string, mime: string}> $attachments
|
||||
*/
|
||||
public function send(
|
||||
string|array $recipient,
|
||||
string $subject,
|
||||
string $content,
|
||||
Tenant|WebsiteType|null $brand = null,
|
||||
array $attachments = [],
|
||||
): void {
|
||||
if (! $this->mailer || ! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
$brand ??= $this->tenant;
|
||||
$branding = $this->brandingFor($brand);
|
||||
|
||||
$html = Blade::render(
|
||||
<<<'BLADE'
|
||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
{!! $content !!}
|
||||
</x-mail.branded-layout>
|
||||
BLADE,
|
||||
[
|
||||
'tenant' => $this->tenant,
|
||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||
'branding' => $branding,
|
||||
'headerLogoUrl' => $brand instanceof WebsiteType
|
||||
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||
'content' => $content,
|
||||
],
|
||||
);
|
||||
@@ -96,6 +109,14 @@ class MailService extends BaseIntegrationService
|
||||
->subject($subject)
|
||||
->html($html);
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$mail->attachData(
|
||||
$attachment['data'],
|
||||
$attachment['name'],
|
||||
['mime' => $attachment['mime']],
|
||||
);
|
||||
}
|
||||
|
||||
$this->mailer->to($recipient)->send($mail);
|
||||
}
|
||||
|
||||
@@ -106,6 +127,36 @@ class MailService extends BaseIntegrationService
|
||||
: (string) config('mail.default');
|
||||
}
|
||||
|
||||
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
|
||||
private function brandingFor(Tenant|WebsiteType $brand): array
|
||||
{
|
||||
if ($brand instanceof WebsiteType) {
|
||||
$brand->loadMissing(['siteLogo', 'footerLogo']);
|
||||
|
||||
return [
|
||||
'name' => $brand->nombre,
|
||||
'primary_color' => $brand->primary_color ?? '#FF7006',
|
||||
'body_color' => $brand->body_color ?? '#666666',
|
||||
'background_color' => $brand->background_color ?? '#f8f8f8',
|
||||
'surface_color' => $brand->surface_color ?? '#ffffff',
|
||||
'header_bg_color' => $brand->surface_color ?? '#ffffff',
|
||||
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
|
||||
];
|
||||
}
|
||||
|
||||
$brand->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
return [
|
||||
'name' => $brand->nombre,
|
||||
'primary_color' => $brand->primary_color ?? '#6376f3',
|
||||
'body_color' => '#334155',
|
||||
'background_color' => '#f1f5f9',
|
||||
'surface_color' => '#ffffff',
|
||||
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
|
||||
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
|
||||
];
|
||||
}
|
||||
|
||||
public function onSetup(): void
|
||||
{
|
||||
if (! $this->mailer || ! $this->clientContext) {
|
||||
|
||||
@@ -7,7 +7,10 @@ use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||
use App\Domains\Purchase\Models\TelepagosQr;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use App\Domains\Purchase\Services\DniDistanceService;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -15,6 +18,7 @@ class TelepagosWebhookService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CheckoutService $checkoutService,
|
||||
private readonly DniDistanceService $dniDistance,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -41,7 +45,6 @@ class TelepagosWebhookService
|
||||
|
||||
$paymentData = [
|
||||
'compra_id' => null,
|
||||
'matched_purchase_ids' => null,
|
||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||
'amount' => $amount,
|
||||
@@ -73,24 +76,55 @@ class TelepagosWebhookService
|
||||
$dni = substr($cuit, 2, -1);
|
||||
|
||||
$tenantCodes = $client->tenants()->pluck('codigo');
|
||||
$purchases = Purchase::whereIn('tenant_codigo', $tenantCodes)
|
||||
->where('transfer_payer_dni', $dni)
|
||||
$eligiblePurchases = Purchase::query()
|
||||
->whereIn('tenant_codigo', $tenantCodes)
|
||||
->whereIn('status', [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
Purchase::STATUS_IN_REVIEW,
|
||||
])
|
||||
->where('payment_method', 'transfer')
|
||||
->where('payment_method', 'transfer');
|
||||
|
||||
$purchases = (clone $eligiblePurchases)
|
||||
->where('total', $amount)
|
||||
->latest()
|
||||
->get();
|
||||
->get()
|
||||
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
|
||||
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
|
||||
->values();
|
||||
|
||||
$paymentData['matched_purchase_ids'] = $purchases->pluck('id')->all();
|
||||
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
||||
|
||||
if (! $compra) {
|
||||
if ($purchases->count() > 1) {
|
||||
TelepagosPayment::create($paymentData);
|
||||
$candidatePurchases = $this->findTransferCandidates(
|
||||
$eligiblePurchases,
|
||||
$dni,
|
||||
$amount,
|
||||
);
|
||||
if ($candidatePurchases->isNotEmpty()) {
|
||||
$payment = $this->storeTransferCandidates(
|
||||
$paymentData,
|
||||
$candidatePurchases,
|
||||
$dni,
|
||||
$amount,
|
||||
);
|
||||
|
||||
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
|
||||
'client_code' => $client->code,
|
||||
'cashin_id' => $cashinId,
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'amount' => $amount,
|
||||
'candidate_count' => $payment->candidates->count(),
|
||||
'candidates' => $payment->candidates
|
||||
->map(fn ($candidate): array => [
|
||||
'purchase_id' => $candidate->compra_id,
|
||||
'match_reason' => $candidate->match_reason,
|
||||
'dni_distance' => $candidate->dni_distance,
|
||||
'amount_difference' => $candidate->amount_difference,
|
||||
'confidence' => $candidate->confidence,
|
||||
])
|
||||
->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
||||
@@ -209,4 +243,91 @@ class TelepagosWebhookService
|
||||
{
|
||||
return number_format((float) $amount, 2, '.', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Purchase> $eligiblePurchases
|
||||
* @return Collection<int, Purchase>
|
||||
*/
|
||||
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
|
||||
{
|
||||
$tolerancePercentage = max(
|
||||
0,
|
||||
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
|
||||
);
|
||||
$numericAmount = (float) $amount;
|
||||
$tolerance = $numericAmount * ($tolerancePercentage / 100);
|
||||
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
|
||||
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
|
||||
|
||||
return (clone $eligiblePurchases)
|
||||
->whereBetween('total', [$minimumAmount, $maximumAmount])
|
||||
->latest()
|
||||
->get()
|
||||
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
|
||||
if ($purchase->transfer_payer_dni === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||
$distance = $this->dniDistance->distance(
|
||||
$dni,
|
||||
(string) $purchase->transfer_payer_dni,
|
||||
);
|
||||
|
||||
return $distance === 0
|
||||
|| ($purchaseAmount === $amount && $distance <= 2);
|
||||
})
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $paymentData
|
||||
* @param Collection<int, Purchase> $candidatePurchases
|
||||
*/
|
||||
private function storeTransferCandidates(
|
||||
array $paymentData,
|
||||
Collection $candidatePurchases,
|
||||
string $dni,
|
||||
string $amount,
|
||||
): TelepagosPayment {
|
||||
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
|
||||
$payment = TelepagosPayment::create($paymentData);
|
||||
|
||||
$payment->candidates()->createMany(
|
||||
$candidatePurchases
|
||||
->map(function (Purchase $purchase) use ($dni, $amount): array {
|
||||
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||
$dniDistance = $this->dniDistance->distance(
|
||||
$dni,
|
||||
(string) $purchase->transfer_payer_dni,
|
||||
);
|
||||
$dniMatches = $dniDistance === 0;
|
||||
$amountMatches = $purchaseAmount === $amount;
|
||||
|
||||
return [
|
||||
'compra_id' => $purchase->id,
|
||||
'dni_matches' => $dniMatches,
|
||||
'dni_distance' => $dniDistance,
|
||||
'payment_dni' => $dni,
|
||||
'purchase_dni' => $purchase->transfer_payer_dni,
|
||||
'amount_matches' => $amountMatches,
|
||||
'payment_amount' => $amount,
|
||||
'purchase_amount' => $purchaseAmount,
|
||||
'amount_difference' => $this->normalizeAmount(
|
||||
abs((float) $purchaseAmount - (float) $amount),
|
||||
),
|
||||
'match_reason' => $amountMatches
|
||||
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
|
||||
: 'exact_dni_near_amount',
|
||||
'confidence' => $amountMatches
|
||||
? ($dniMatches ? 'exact' : 'medium')
|
||||
: 'high',
|
||||
];
|
||||
})
|
||||
->all(),
|
||||
);
|
||||
|
||||
return $payment->load('candidates');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,21 @@ 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),
|
||||
],
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Events;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TicketsAvailable
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ticketIds
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Purchase $purchase,
|
||||
public readonly array $ticketIds,
|
||||
) {}
|
||||
}
|
||||
@@ -6,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||
|
||||
public function handle(PasswordResetRequested $event): void
|
||||
{
|
||||
try {
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$event->attemptId,
|
||||
$event->tenantCode,
|
||||
$event->channel,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Failed to send password reset email.', [
|
||||
'attempt_id' => $event->attemptId,
|
||||
'tenant_code' => $event->tenantCode,
|
||||
'channel' => $event->channel,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$event->attemptId,
|
||||
$event->tenantCode,
|
||||
$event->channel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
||||
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
|
||||
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
||||
|
||||
public function handle(PurchasePaid $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
|
||||
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Listeners;
|
||||
|
||||
use App\Domains\Notification\Events\TicketsAvailable;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
|
||||
public string $queue = 'emails';
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
public function handle(TicketsAvailable $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds);
|
||||
}
|
||||
}
|
||||
@@ -9,28 +9,43 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
||||
use Closure;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class NotificationMailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailService $mailService,
|
||||
private readonly TicketPdfService $ticketPdfService,
|
||||
) {}
|
||||
|
||||
public function sendWelcome(int $userId, string $tenantCode): void
|
||||
{
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||
$user = User::query()->findOrFail($userId);
|
||||
$this->sendLogged('welcome', [
|
||||
'user_id' => $userId,
|
||||
'tenant_code' => $tenantCode,
|
||||
], function () use ($userId, $tenantCode): array {
|
||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||
$user = User::query()->findOrFail($userId);
|
||||
$brand = $tenant->websiteType ?? $tenant;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$user->email,
|
||||
"Bienvenido a {$tenant->nombre}",
|
||||
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
|
||||
);
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$user->email,
|
||||
"Bienvenido a {$brand->nombre}",
|
||||
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return [
|
||||
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function sendPasswordResetCode(
|
||||
@@ -38,95 +53,161 @@ class NotificationMailService
|
||||
string $tenantCode,
|
||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||
): void {
|
||||
$tenant = Tenant::query()
|
||||
->with('websiteType')
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
$attempt = ResetPasswordAttempt::query()
|
||||
->with('user')
|
||||
->findOrFail($attemptId);
|
||||
$context = [
|
||||
'attempt_id' => $attemptId,
|
||||
'tenant_code' => $tenantCode,
|
||||
'channel' => $channel,
|
||||
];
|
||||
|
||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
||||
'attempt_id' => $attemptId,
|
||||
'tenant_code' => $tenantCode,
|
||||
'attempt_status' => $attempt->status,
|
||||
]);
|
||||
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
|
||||
$tenant = Tenant::query()
|
||||
->with('websiteType')
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
$attempt = ResetPasswordAttempt::query()
|
||||
->with('user')
|
||||
->findOrFail($attemptId);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||
$this->logSkipped('password_reset', array_merge($context, [
|
||||
'reason' => 'attempt_not_pending',
|
||||
'attempt_status' => $attempt->status,
|
||||
'user_id' => $attempt->user_id,
|
||||
]));
|
||||
|
||||
$recoveryDomain = match ($channel) {
|
||||
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||
default => $tenant->dominio,
|
||||
};
|
||||
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
&& $tenant->base_path !== '/'
|
||||
? $tenant->base_path
|
||||
: '';
|
||||
$recoveryQuery = ['email' => $attempt->user->email];
|
||||
if (
|
||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
|
||||
) {
|
||||
$recoveryQuery['code'] = $attempt->codigo;
|
||||
}
|
||||
$recoveryUrl = $recoveryDomain === null
|
||||
? null
|
||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$attempt->user->email,
|
||||
"Código para recuperar tu contraseña - {$tenant->nombre}",
|
||||
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
|
||||
);
|
||||
$recoveryDomain = match ($channel) {
|
||||
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||
default => $tenant->dominio,
|
||||
};
|
||||
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
&& $tenant->base_path !== '/'
|
||||
? $tenant->base_path
|
||||
: '';
|
||||
$recoveryQuery = ['email' => $attempt->user->email];
|
||||
if (
|
||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
|
||||
) {
|
||||
$recoveryQuery['code'] = $attempt->codigo;
|
||||
}
|
||||
$recoveryUrl = $recoveryDomain === null
|
||||
? null
|
||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||
$brand = $tenant->websiteType ?? $tenant;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$attempt->user->email,
|
||||
"Código para recuperar tu contraseña - {$brand->nombre}",
|
||||
view('mail.notifications.password-reset', [
|
||||
'attempt' => $attempt,
|
||||
'recoveryUrl' => $recoveryUrl,
|
||||
'brand' => $brand,
|
||||
])->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return [
|
||||
'user_id' => $attempt->user_id,
|
||||
'recovery_domain_available' => $recoveryDomain !== null,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function sendPurchasePaid(int $purchaseId): void
|
||||
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||
{
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->findOrFail($purchaseId);
|
||||
$context = ['purchase_id' => $purchaseId];
|
||||
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
"Pago confirmado - Compra #{$purchase->getKey()}",
|
||||
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
|
||||
);
|
||||
}
|
||||
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->find($purchaseId);
|
||||
|
||||
/** @param array<int, int> $ticketIds */
|
||||
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
|
||||
{
|
||||
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
->where('user_id', $purchase->user_id)
|
||||
->whereKey($ticketIds)
|
||||
->with(TicketPresentationResolver::RELATIONS)
|
||||
->get();
|
||||
if ($purchase === null) {
|
||||
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||
'reason' => 'purchase_not_found',
|
||||
'missing_model' => Purchase::class,
|
||||
]));
|
||||
|
||||
if ($tickets->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
'Tus tickets ya están disponibles',
|
||||
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
|
||||
);
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = Ticket::query()
|
||||
->where('source_purchase_id', $purchase->getKey())
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
->with(TicketPresentationResolver::RELATIONS)
|
||||
->get();
|
||||
$attachments = $tickets->isEmpty()
|
||||
? []
|
||||
: [[
|
||||
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
||||
'name' => $this->ticketPdfService->filename($tickets),
|
||||
'mime' => 'application/pdf',
|
||||
]];
|
||||
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
"Compra confirmada - Compra #{$purchase->getKey()}",
|
||||
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
|
||||
attachments: $attachments,
|
||||
);
|
||||
|
||||
return [
|
||||
'tenant_code' => $purchase->tenant_codigo,
|
||||
'user_id' => $purchase->user_id,
|
||||
'purchase_status' => $purchase->status,
|
||||
'purchase_item_count' => $purchase->items->count(),
|
||||
'ticket_count' => $tickets->count(),
|
||||
'ticket_ids' => $tickets->modelKeys(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
private function recipientFor(Purchase $purchase): string
|
||||
{
|
||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param Closure(): (array<string, mixed>|null) $send
|
||||
*/
|
||||
private function sendLogged(string $emailType, array $context, Closure $send): void
|
||||
{
|
||||
try {
|
||||
$resultContext = $send();
|
||||
|
||||
if ($resultContext === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
|
||||
'email_type' => $emailType,
|
||||
'mailer' => $this->mailService->mailerName(),
|
||||
]));
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
|
||||
'email_type' => $emailType,
|
||||
'exception' => $exception,
|
||||
]));
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
private function logSkipped(string $emailType, array $context): void
|
||||
{
|
||||
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
|
||||
'email_type' => $emailType,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,11 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
|
||||
|
||||
- `UserRegistered`: dispara el correo de bienvenida.
|
||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||
- `PurchasePaid`: envía la confirmación de pago.
|
||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
||||
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||
|
||||
## Componentes
|
||||
|
||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||
|
||||
## API y dependencias
|
||||
|
||||
@@ -23,4 +22,6 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
|
||||
|
||||
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
||||
- La recuperación no se envía si el intento dejó de estar pendiente.
|
||||
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
|
||||
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
|
||||
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||
|
||||
@@ -36,6 +36,7 @@ class PurchaseController extends Controller
|
||||
|
||||
return PurchaseResource::collection(
|
||||
Purchase::query()
|
||||
->with('stockReservation')
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('user_id', $request->user()->id)
|
||||
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
||||
@@ -92,7 +93,6 @@ class PurchaseController extends Controller
|
||||
PaymentIntentRequest $request,
|
||||
Tenant $tenant,
|
||||
Purchase $compra,
|
||||
CheckoutService $checkoutService,
|
||||
PurchaseStateGuard $purchaseState,
|
||||
): JsonResponse {
|
||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||
@@ -101,7 +101,12 @@ class PurchaseController extends Controller
|
||||
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
||||
: null;
|
||||
|
||||
$updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
|
||||
$updated = DB::transaction(function () use (
|
||||
$compra,
|
||||
$method,
|
||||
$purchaseState,
|
||||
$transferPayerDni,
|
||||
): bool {
|
||||
/** @var Purchase|null $purchase */
|
||||
$purchase = Purchase::query()
|
||||
->whereKey($compra->getKey())
|
||||
@@ -128,9 +133,6 @@ class PurchaseController extends Controller
|
||||
$purchaseUpdate = [
|
||||
'payment_method' => $method,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
||||
),
|
||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||
];
|
||||
|
||||
@@ -150,7 +152,6 @@ class PurchaseController extends Controller
|
||||
|
||||
$compra->refresh();
|
||||
$totalAmount = (float) $compra->total;
|
||||
$checkoutService->syncReservationExpiration($compra);
|
||||
|
||||
if ($method === 'transfer') {
|
||||
$telepagosService = new TelepagosIntegrationService;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Purchase\Events;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
@@ -10,5 +9,5 @@ class PurchasePaid
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly Purchase $purchase) {}
|
||||
public function __construct(public readonly int $purchaseId) {}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ use Illuminate\Support\Facades\DB;
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'expires_at',
|
||||
'total',
|
||||
'dni',
|
||||
'transfer_payer_dni',
|
||||
@@ -80,7 +79,6 @@ class Purchase extends Model
|
||||
'cart_id' => 'integer',
|
||||
'stock_reservation_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -147,6 +145,12 @@ class Purchase extends Model
|
||||
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||
public function telepagosPaymentCandidates(): HasMany
|
||||
{
|
||||
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
|
||||
}
|
||||
|
||||
public function getTotalAmount(): float
|
||||
{
|
||||
if ($this->total !== null) {
|
||||
@@ -195,7 +199,7 @@ class Purchase extends Model
|
||||
'status' => self::STATUS_PAID,
|
||||
]);
|
||||
|
||||
PurchasePaid::dispatch($this);
|
||||
PurchasePaid::dispatch($this->getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'compra_id',
|
||||
'matched_purchase_ids',
|
||||
'cuit_buyer',
|
||||
'cvu_buyer',
|
||||
'amount',
|
||||
@@ -30,7 +30,6 @@ class TelepagosPayment extends Model
|
||||
{
|
||||
return [
|
||||
'compra_id' => 'integer',
|
||||
'matched_purchase_ids' => 'array',
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -42,4 +41,10 @@ class TelepagosPayment extends Model
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||
public function candidates(): HasMany
|
||||
{
|
||||
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
|
||||
}
|
||||
}
|
||||
|
||||
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'telepagos_payment_id',
|
||||
'compra_id',
|
||||
'dni_matches',
|
||||
'dni_distance',
|
||||
'payment_dni',
|
||||
'purchase_dni',
|
||||
'amount_matches',
|
||||
'payment_amount',
|
||||
'purchase_amount',
|
||||
'amount_difference',
|
||||
'match_reason',
|
||||
'confidence',
|
||||
])]
|
||||
class TelepagosPaymentCandidate extends Model
|
||||
{
|
||||
protected $table = 'telepagos_payment_candidates';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'telepagos_payment_id' => 'integer',
|
||||
'compra_id' => 'integer',
|
||||
'dni_matches' => 'boolean',
|
||||
'dni_distance' => 'integer',
|
||||
'amount_matches' => 'boolean',
|
||||
'payment_amount' => 'decimal:2',
|
||||
'purchase_amount' => 'decimal:2',
|
||||
'amount_difference' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
|
||||
public function payment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
|
||||
}
|
||||
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Resources;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -17,12 +18,15 @@ 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();
|
||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||
? (int) $this->resource->getAttribute('tickets_count')
|
||||
: null;
|
||||
$paymentVerification = $this->resolvePaymentVerification();
|
||||
|
||||
$subtotal = $items->isNotEmpty()
|
||||
? $items->reduce(
|
||||
@@ -48,7 +52,11 @@ class PurchaseResource extends JsonResource
|
||||
'created_at' => $this->created_at,
|
||||
'status' => $this->status,
|
||||
'payment_method' => $this->payment_method,
|
||||
'expires_at' => $this->expires_at,
|
||||
'expires_at' => $expiresAt,
|
||||
'expires_in_seconds' => $expiresAt === null
|
||||
? null
|
||||
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
|
||||
'server_time' => $serverTime,
|
||||
'dni' => $this->dni,
|
||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||
'telefono' => $this->telefono,
|
||||
@@ -58,6 +66,7 @@ class PurchaseResource extends JsonResource
|
||||
'items' => PurchaseItemResource::collection($items),
|
||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
|
||||
'subtotal' => $this->formatMoney($subtotal),
|
||||
'total' => $this->formatMoney($total),
|
||||
];
|
||||
@@ -77,4 +86,75 @@ class PurchaseResource extends JsonResource
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private function resolvePaymentVerification(): ?array
|
||||
{
|
||||
if (
|
||||
$this->status !== Purchase::STATUS_IN_REVIEW
|
||||
|| $this->payment_method !== 'transfer'
|
||||
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$candidates = $this->resource
|
||||
->getRelation('telepagosPaymentCandidates')
|
||||
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
|
||||
->values();
|
||||
/** @var TelepagosPaymentCandidate|null $primary */
|
||||
$primary = $candidates->first();
|
||||
|
||||
return [
|
||||
'status' => $primary === null ? 'pending' : 'candidate',
|
||||
'candidate_count' => $candidates->count(),
|
||||
'primary' => $primary === null ? null : [
|
||||
'reason' => $primary->match_reason,
|
||||
'dni_distance' => $primary->dni_distance,
|
||||
'payment_amount' => $this->formatMoney($primary->payment_amount),
|
||||
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
|
||||
'amount_difference' => $this->formatMoney($primary->amount_difference),
|
||||
'confidence' => $primary->confidence,
|
||||
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
|
||||
],
|
||||
'reasons' => $candidates
|
||||
->pluck('match_reason')
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
private function comparePaymentCandidates(
|
||||
TelepagosPaymentCandidate $left,
|
||||
TelepagosPaymentCandidate $right,
|
||||
): int {
|
||||
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
|
||||
<=> $this->paymentCandidateRank($right->match_reason);
|
||||
|
||||
if ($reasonComparison !== 0) {
|
||||
return $reasonComparison;
|
||||
}
|
||||
|
||||
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
|
||||
|
||||
if ($differenceComparison !== 0) {
|
||||
return $differenceComparison;
|
||||
}
|
||||
|
||||
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
|
||||
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
|
||||
|
||||
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
|
||||
}
|
||||
|
||||
private function paymentCandidateRank(string $reason): int
|
||||
{
|
||||
return match ($reason) {
|
||||
'ambiguous_exact_match' => 0,
|
||||
'exact_dni_near_amount' => 1,
|
||||
'exact_amount_near_dni' => 2,
|
||||
default => 3,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +61,7 @@ class CompleteCheckoutService
|
||||
|
||||
$this->purchaseState->lockCurrentCart($purchase);
|
||||
|
||||
if (
|
||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
||||
) {
|
||||
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||
throw ValidationException::withMessages([
|
||||
'purchase' => __('api.purchase.not_available_for_review'),
|
||||
]);
|
||||
@@ -72,9 +69,8 @@ class CompleteCheckoutService
|
||||
|
||||
$purchase->update([
|
||||
'status' => Purchase::STATUS_IN_REVIEW,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
$this->reservations->syncPurchaseExpiration($purchase);
|
||||
$this->reservations->clearExpirationForReview($purchase);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
});
|
||||
@@ -195,7 +191,7 @@ class CompleteCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['items.imageAttachment']);
|
||||
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
|
||||
private function itemKey(int $catalogItemId, ?int $variantId): string
|
||||
|
||||
@@ -8,6 +8,15 @@ class PurchaseResponseLoader
|
||||
{
|
||||
public function load(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['tenant', 'items.imageAttachment']);
|
||||
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
|
||||
|
||||
if (
|
||||
$purchase->status === Purchase::STATUS_IN_REVIEW
|
||||
&& $purchase->payment_method === 'transfer'
|
||||
) {
|
||||
$relations[] = 'telepagosPaymentCandidates.payment';
|
||||
}
|
||||
|
||||
return $purchase->load($relations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,10 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class ReleaseCheckoutService
|
||||
{
|
||||
@@ -32,40 +31,6 @@ class ReleaseCheckoutService
|
||||
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
||||
}
|
||||
|
||||
public function expireOverdue(): int
|
||||
{
|
||||
$expiredCount = 0;
|
||||
|
||||
Purchase::query()
|
||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->orderBy('id')
|
||||
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
|
||||
try {
|
||||
$purchase = $this->expire($purchase);
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('commands')->error('Failed to expire overdue purchase.', [
|
||||
'command' => 'reservations:expire',
|
||||
'purchase_id' => $purchase->getKey(),
|
||||
'tenant_codigo' => $purchase->tenant_codigo,
|
||||
'cart_id' => $purchase->cart_id,
|
||||
'status' => $purchase->status,
|
||||
'expires_at' => $purchase->expires_at,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
$expiredCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return $expiredCount;
|
||||
}
|
||||
|
||||
private function release(
|
||||
Purchase $purchase,
|
||||
string $targetStatus,
|
||||
@@ -78,6 +43,11 @@ class ReleaseCheckoutService
|
||||
): Purchase {
|
||||
$purchase = $this->lockPurchase($purchase);
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
|
||||
if ($purchase->status === Purchase::STATUS_PAID) {
|
||||
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
||||
return $this->loadPurchase($purchase);
|
||||
@@ -100,14 +70,31 @@ class ReleaseCheckoutService
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
|
||||
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||
&& $cart?->status === 'active'
|
||||
&& in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
$this->reservations->returnToCart($purchase, $cart);
|
||||
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
if (
|
||||
$targetStatus === Purchase::STATUS_EXPIRED
|
||||
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
|
||||
&& (! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true) || ! $this->hasOverdueActiveReservation($purchase))
|
||||
) {
|
||||
return $this->loadPurchase($purchase);
|
||||
}
|
||||
|
||||
$this->releasePurchaseReservations($purchase, $targetStatus);
|
||||
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
|
||||
|
||||
$purchase->update(['status' => $targetStatus]);
|
||||
|
||||
@@ -115,9 +102,11 @@ class ReleaseCheckoutService
|
||||
});
|
||||
}
|
||||
|
||||
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
|
||||
{
|
||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||
private function releasePurchaseReservations(
|
||||
Purchase $purchase,
|
||||
string $targetStatus,
|
||||
?Cart $cart,
|
||||
): void {
|
||||
try {
|
||||
$this->reservations->releaseForPurchase(
|
||||
$purchase,
|
||||
@@ -140,28 +129,40 @@ class ReleaseCheckoutService
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status === 'active') {
|
||||
if ($targetStatus === Purchase::STATUS_EXPIRED
|
||||
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
|
||||
->update([
|
||||
'status' => Cart::STATUS_EXPIRED,
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
]);
|
||||
|
||||
if ($targetStatus === Purchase::STATUS_CANCELLED) {
|
||||
$this->reservations->syncCart($cart);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status !== 'checkout') {
|
||||
if ($cart->status === Cart::STATUS_ACTIVE) {
|
||||
$cartUpdate = [
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
];
|
||||
|
||||
Cart::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $purchase->getKey())
|
||||
->update($cartUpdate);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($cart->status !== Cart::STATUS_CHECKOUT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $cart->trashed()) {
|
||||
$cart->update(['status' => 'converted']);
|
||||
$cart->update(['status' => Cart::STATUS_CONVERTED]);
|
||||
$cart->delete();
|
||||
}
|
||||
}
|
||||
@@ -211,6 +212,17 @@ class ReleaseCheckoutService
|
||||
|
||||
private function loadPurchase(Purchase $purchase): Purchase
|
||||
{
|
||||
return $purchase->load(['items.imageAttachment']);
|
||||
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||
}
|
||||
|
||||
private function hasOverdueActiveReservation(Purchase $purchase): bool
|
||||
{
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
|
||||
|
||||
return $reservation !== null
|
||||
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
@@ -12,6 +13,7 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -204,7 +206,7 @@ class StartCheckoutService
|
||||
$cart->getKey(),
|
||||
);
|
||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||
$this->reservations->attachToPurchase($cart, $purchase);
|
||||
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||
|
||||
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||
$this->loadCartItems($cartItems);
|
||||
@@ -270,7 +272,7 @@ class StartCheckoutService
|
||||
$cart->getKey(),
|
||||
);
|
||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||
$this->reservations->attachToPurchase($cart, $purchase);
|
||||
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||
|
||||
return $this->loadPurchase($purchase);
|
||||
@@ -313,17 +315,9 @@ class StartCheckoutService
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
$this->reservations->returnToCart($currentPurchase, $cart);
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
$this->reservations->releaseForPurchase(
|
||||
$currentPurchase,
|
||||
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
|
||||
);
|
||||
$cart->update([
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -336,7 +330,11 @@ class StartCheckoutService
|
||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
||||
}
|
||||
|
||||
if ($cart->status !== 'active') {
|
||||
if ($cart->status === Cart::STATUS_EXPIRED) {
|
||||
throw new StockReservationExpiredException;
|
||||
}
|
||||
|
||||
if ($cart->status !== Cart::STATUS_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart_id' => __('api.purchase.inactive_cart'),
|
||||
]);
|
||||
@@ -410,13 +408,17 @@ class StartCheckoutService
|
||||
'user_id' => $userId,
|
||||
'status' => Purchase::STATUS_CREATED,
|
||||
'payment_method' => null,
|
||||
'expires_at' => now()->addMinutes(
|
||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||
),
|
||||
'total' => $total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function checkoutExpiration(): Carbon
|
||||
{
|
||||
return now()->addMinutes(
|
||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Collection<int, CartItem> $cartItems */
|
||||
private function loadCartItems(Collection $cartItems): void
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
||||
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||
@@ -23,7 +22,6 @@ class CheckoutService
|
||||
private readonly EditCheckoutService $editor,
|
||||
private readonly CompleteCheckoutService $completer,
|
||||
private readonly ReleaseCheckoutService $releaser,
|
||||
private readonly StockReservationService $reservations,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $purchaseData */
|
||||
@@ -77,14 +75,4 @@ class CheckoutService
|
||||
{
|
||||
return $this->releaser->expire($purchase);
|
||||
}
|
||||
|
||||
public function expireOverduePurchases(): int
|
||||
{
|
||||
return $this->releaser->expireOverdue();
|
||||
}
|
||||
|
||||
public function syncReservationExpiration(Purchase $purchase): void
|
||||
{
|
||||
$this->reservations->syncPurchaseExpiration($purchase);
|
||||
}
|
||||
}
|
||||
|
||||
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
/**
|
||||
* Measures likely DNI typing errors using the optimal-string-alignment
|
||||
* variant of the Damerau-Levenshtein distance.
|
||||
*
|
||||
* The returned value is the minimum number of single-character edits needed
|
||||
* to transform one DNI into the other. Supported edits are insertion,
|
||||
* deletion, substitution and transposition of two adjacent digits.
|
||||
*/
|
||||
class DniDistanceService
|
||||
{
|
||||
/**
|
||||
* Calculate the edit distance between two normalized DNI strings.
|
||||
*
|
||||
* Each matrix cell [row][column] stores the minimum edits required to
|
||||
* transform the first $row digits of $left into the first $column digits
|
||||
* of $right. The bottom-right cell therefore contains the final distance.
|
||||
*/
|
||||
public function distance(string $left, string $right): int
|
||||
{
|
||||
$left = $this->normalize($left);
|
||||
$right = $this->normalize($right);
|
||||
$leftLength = strlen($left);
|
||||
$rightLength = strlen($right);
|
||||
$matrix = [];
|
||||
|
||||
// Transforming a prefix into an empty string requires deleting every digit.
|
||||
for ($row = 0; $row <= $leftLength; $row++) {
|
||||
$matrix[$row] = [$row];
|
||||
}
|
||||
|
||||
// Transforming an empty string into a prefix requires inserting every digit.
|
||||
for ($column = 0; $column <= $rightLength; $column++) {
|
||||
$matrix[0][$column] = $column;
|
||||
}
|
||||
|
||||
for ($row = 1; $row <= $leftLength; $row++) {
|
||||
for ($column = 1; $column <= $rightLength; $column++) {
|
||||
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
|
||||
$deletionDistance = $matrix[$row - 1][$column] + 1;
|
||||
$insertionDistance = $matrix[$row][$column - 1] + 1;
|
||||
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
|
||||
|
||||
// Keep the cheapest way to align the two prefixes at this position.
|
||||
$matrix[$row][$column] = min(
|
||||
$deletionDistance,
|
||||
$insertionDistance,
|
||||
$substitutionDistance,
|
||||
);
|
||||
|
||||
// Count two adjacent inverted digits as one edit instead of two substitutions.
|
||||
if (
|
||||
$row > 1
|
||||
&& $column > 1
|
||||
&& $left[$row - 1] === $right[$column - 2]
|
||||
&& $left[$row - 2] === $right[$column - 1]
|
||||
) {
|
||||
$matrix[$row][$column] = min(
|
||||
$matrix[$row][$column],
|
||||
$matrix[$row - 2][$column - 2] + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matrix[$leftLength][$rightLength];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
|
||||
* the leading zero that is present when the DNI is extracted from a CUIT.
|
||||
*/
|
||||
public function normalize(string $dni): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $dni) ?? '';
|
||||
|
||||
return str_pad($digits, 8, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Purchase\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -11,15 +12,32 @@ class PurchaseStateGuard
|
||||
{
|
||||
public function assertNotExpired(Purchase $purchase): void
|
||||
{
|
||||
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
|
||||
$hasExpiredByTime = in_array($purchase->status, [
|
||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
|
||||
if (! in_array($purchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)
|
||||
&& $purchase->expires_at !== null
|
||||
&& $purchase->expires_at->isPast();
|
||||
], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($hasExpiredStatus || $hasExpiredByTime) {
|
||||
/** @var StockReservation|null $reservation */
|
||||
$reservation = $purchase->relationLoaded('stockReservation')
|
||||
? $purchase->getRelation('stockReservation')
|
||||
: ($purchase->exists
|
||||
? $purchase->stockReservation()->first()
|
||||
: null);
|
||||
|
||||
if ($reservation !== null && (
|
||||
$reservation->status === StockReservation::STATUS_EXPIRED
|
||||
|| (
|
||||
$reservation->status === StockReservation::STATUS_ACTIVE
|
||||
&& $reservation->expires_at !== null
|
||||
&& ! $reservation->expires_at->isFuture()
|
||||
)
|
||||
)) {
|
||||
throw new PurchaseExpiredException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
|
||||
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
|
||||
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
||||
@@ -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, vence y procesa vencimientos pendientes.
|
||||
- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
|
||||
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
||||
|
||||
Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
||||
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
||||
|
||||
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, libera su reserva completa y crea otra para 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, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||
|
||||
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||
|
||||
|
||||
@@ -9,18 +9,21 @@ use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
|
||||
use App\Domains\Sale\Services\AdminAppSaleExcelService;
|
||||
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||
use App\Domains\Sale\Services\AdminAppSaleService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SaleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdminAppSaleService $saleService,
|
||||
protected AdminAppSalePdfService $salePdfService,
|
||||
protected AdminAppSaleExcelService $saleExcelService,
|
||||
) {}
|
||||
|
||||
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
||||
@@ -94,4 +97,27 @@ class SaleController extends Controller
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
|
||||
public function downloadExcel(AdminAppSalePdfRequest $request): StreamedResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return $this->saleExcelService->downloadSales(
|
||||
$tenant,
|
||||
$this->saleService->salesForExport($tenant, $request->validated()),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
|
||||
public function downloadModificationsExcel(
|
||||
AdminAppSaleModificationPdfRequest $request,
|
||||
): StreamedResponse {
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return $this->saleExcelService->downloadModifications(
|
||||
$tenant,
|
||||
$this->saleService->modificationsForExport($tenant),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
211
app/Domains/Sale/Services/AdminAppSaleExcelService.php
Normal file
211
app/Domains/Sale/Services/AdminAppSaleExcelService.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sale\Services;
|
||||
|
||||
use App\Domains\Logging\Models\ValueChange;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AdminAppSaleExcelService
|
||||
{
|
||||
/** @param Collection<int, Purchase> $sales */
|
||||
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): StreamedResponse
|
||||
{
|
||||
$generatedAt = now();
|
||||
$spreadsheet = $this->spreadsheet($tenant, 'Historial de ventas');
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Ventas');
|
||||
$sheet->fromArray([
|
||||
'ID',
|
||||
'Fecha',
|
||||
'Cliente',
|
||||
'Cantidad',
|
||||
'Estado',
|
||||
'Importe',
|
||||
'Tickets',
|
||||
], null, 'A1');
|
||||
|
||||
foreach ($sales->values() as $index => $sale) {
|
||||
$row = $index + 2;
|
||||
$sheet->setCellValueExplicit("A{$row}", '#'.$sale->id, DataType::TYPE_STRING);
|
||||
if ($sale->created_at) {
|
||||
$sheet->setCellValue(
|
||||
"B{$row}",
|
||||
Date::dateTimeToExcel($sale->created_at->copy()->timezone($timeZone)),
|
||||
);
|
||||
}
|
||||
$sheet->setCellValueExplicit(
|
||||
"C{$row}",
|
||||
$sale->nombre_apellido ?: 'Sin nombre',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValue("D{$row}", (int) ($sale->quantity ?? 0));
|
||||
$sheet->setCellValue("E{$row}", $this->saleStatus($sale->status));
|
||||
$sheet->setCellValue("F{$row}", (float) $sale->total);
|
||||
$sheet->setCellValue("G{$row}", (int) ($sale->tickets_count ?? 0));
|
||||
}
|
||||
|
||||
$lastRow = max(2, $sales->count() + 1);
|
||||
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
|
||||
$sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00');
|
||||
$this->formatSheet($spreadsheet, 'A1:G1', "A1:G{$lastRow}", [
|
||||
'A' => 13,
|
||||
'B' => 20,
|
||||
'C' => 32,
|
||||
'D' => 12,
|
||||
'E' => 22,
|
||||
'F' => 16,
|
||||
'G' => 12,
|
||||
]);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'ventas_'.$tenant->codigo.'_'
|
||||
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Collection<int, ValueChange> $modifications */
|
||||
public function downloadModifications(
|
||||
Tenant $tenant,
|
||||
Collection $modifications,
|
||||
string $timeZone,
|
||||
): StreamedResponse {
|
||||
$generatedAt = now();
|
||||
$spreadsheet = $this->spreadsheet($tenant, 'Historial de modificaciones de ventas');
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Modificaciones');
|
||||
$sheet->fromArray([
|
||||
'Fecha',
|
||||
'Hora',
|
||||
'Venta',
|
||||
'Cliente',
|
||||
'Campo',
|
||||
'Valor anterior',
|
||||
'Valor nuevo',
|
||||
'Modificado por',
|
||||
], null, 'A1');
|
||||
|
||||
foreach ($modifications->values() as $index => $modification) {
|
||||
$row = $index + 2;
|
||||
$changedAt = $modification->changed_at->copy()->timezone($timeZone);
|
||||
$sale = $modification->trackable;
|
||||
$sheet->setCellValue("A{$row}", Date::dateTimeToExcel($changedAt));
|
||||
$sheet->setCellValue("B{$row}", Date::dateTimeToExcel($changedAt));
|
||||
$sheet->setCellValueExplicit(
|
||||
"C{$row}",
|
||||
'#'.$modification->trackable_id,
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"D{$row}",
|
||||
$sale?->nombre_apellido ?: 'Sin nombre',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"E{$row}",
|
||||
$modification->attribute,
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"F{$row}",
|
||||
$modification->old_value ?? '-',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"G{$row}",
|
||||
$modification->new_value ?? '-',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"H{$row}",
|
||||
$modification->user?->nombre_apellido ?? 'Sistema',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
}
|
||||
|
||||
$lastRow = max(2, $modifications->count() + 1);
|
||||
$sheet->getStyle("A2:A{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy');
|
||||
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('hh:mm:ss');
|
||||
$this->formatSheet($spreadsheet, 'A1:H1', "A1:H{$lastRow}", [
|
||||
'A' => 14,
|
||||
'B' => 12,
|
||||
'C' => 13,
|
||||
'D' => 32,
|
||||
'E' => 20,
|
||||
'F' => 24,
|
||||
'G' => 24,
|
||||
'H' => 28,
|
||||
]);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'historial_modificaciones_'.$tenant->codigo.'_'
|
||||
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
private function spreadsheet(Tenant $tenant, string $title): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet;
|
||||
$spreadsheet->getProperties()
|
||||
->setCreator('Shopit')
|
||||
->setTitle($title)
|
||||
->setSubject($tenant->nombre);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/** @param array<string, int> $widths */
|
||||
private function formatSheet(
|
||||
Spreadsheet $spreadsheet,
|
||||
string $headerRange,
|
||||
string $filterRange,
|
||||
array $widths,
|
||||
): void {
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->getStyle($headerRange)->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '26382E'],
|
||||
],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(24);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter($filterRange);
|
||||
|
||||
foreach ($widths as $column => $width) {
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
}
|
||||
}
|
||||
|
||||
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () use ($spreadsheet): void {
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
]);
|
||||
}
|
||||
|
||||
private function saleStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
Purchase::STATUS_PAID => 'Confirmado',
|
||||
Purchase::STATUS_CREATED => 'Por completar datos',
|
||||
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
|
||||
default => 'Anulado',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
||||
|
||||
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
||||
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
||||
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
|
||||
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||
- `SaleController`: entrada HTTP del panel.
|
||||
@@ -16,8 +17,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
||||
|
||||
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
|
||||
|
||||
- `GET /sales` y `GET /sales/pdf`.
|
||||
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
|
||||
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
|
||||
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
|
||||
|
||||
## Dependencias
|
||||
|
||||
@@ -25,4 +26,4 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
||||
|
||||
## Consideraciones
|
||||
|
||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.
|
||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||
|
||||
@@ -8,8 +8,10 @@ Route::prefix('v1/adminapp/tenant')
|
||||
->group(function (): void {
|
||||
Route::get('sales', [SaleController::class, 'index']);
|
||||
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
||||
Route::get('sales/excel', [SaleController::class, 'downloadExcel']);
|
||||
Route::get('sales/modifications', [SaleController::class, 'modifications']);
|
||||
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
|
||||
Route::get('sales/modifications/excel', [SaleController::class, 'downloadModificationsExcel']);
|
||||
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
|
||||
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
|
||||
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
|
||||
|
||||
@@ -82,6 +82,7 @@ class WebsiteTypeService
|
||||
&& $previousLogo->id !== $websiteType->site_logo
|
||||
&& $previousLogo->id !== $websiteType->footer_logo
|
||||
&& $previousLogo->id !== $websiteType->favicon_id
|
||||
&& ! $this->isReferencedByWebsiteType($previousLogo)
|
||||
) {
|
||||
$this->attachmentService->delete($previousLogo);
|
||||
}
|
||||
@@ -90,4 +91,16 @@ class WebsiteTypeService
|
||||
return $websiteType;
|
||||
});
|
||||
}
|
||||
|
||||
private function isReferencedByWebsiteType(Attachment $attachment): bool
|
||||
{
|
||||
return WebsiteType::query()
|
||||
->where(function ($query) use ($attachment): void {
|
||||
$query
|
||||
->where('site_logo', $attachment->id)
|
||||
->orWhere('footer_logo', $attachment->id)
|
||||
->orWhere('favicon_id', $attachment->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +16,10 @@ class GenerateTicketsForPaidPurchase
|
||||
|
||||
public function handle(PurchasePaid $event): void
|
||||
{
|
||||
$purchase = $event->purchase
|
||||
->newQuery()
|
||||
$purchase = Purchase::query()
|
||||
->with(['user', 'items'])
|
||||
->findOrFail($event->purchase->getKey());
|
||||
->findOrFail($event->purchaseId);
|
||||
$user = $purchase->user;
|
||||
$ticketIds = [];
|
||||
|
||||
foreach ($purchase->items as $purchaseItem) {
|
||||
$catalogItem = CatalogItem::query()
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
@@ -40,19 +37,13 @@ class GenerateTicketsForPaidPurchase
|
||||
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
||||
}
|
||||
|
||||
$generatedTickets = $this->ticketGenerator->generate(
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -19,12 +20,36 @@ 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);
|
||||
|
||||
$pdf = Pdf::loadView('pdf.tickets', [
|
||||
return Pdf::loadView('pdf.tickets', [
|
||||
'tenant' => $tenant,
|
||||
'tickets' => $tickets,
|
||||
'logoDataUri' => $this->logoDataUri($tenant),
|
||||
@@ -35,10 +60,6 @@ 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
|
||||
|
||||
@@ -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. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
|
||||
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
||||
|
||||
## Endpoints
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
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\SendPurchasePaidEmail;
|
||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||
@@ -33,9 +31,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class);
|
||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
||||
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?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;
|
||||
@@ -113,6 +114,16 @@ 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;
|
||||
|
||||
@@ -6,15 +6,16 @@
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"ext-gd": "*",
|
||||
"php": "^8.3",
|
||||
"ext-gd": "*",
|
||||
"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"
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"phpoffice/phpspreadsheet": "^5.9"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
376
composer.lock
generated
376
composer.lock
generated
@@ -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": "ce185c60c617846be30ae694f0cf6e9c",
|
||||
"content-hash": "a593ab47d99b233f75851dbb7ea50479",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -417,6 +417,82 @@
|
||||
],
|
||||
"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",
|
||||
@@ -2924,6 +3000,191 @@
|
||||
],
|
||||
"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",
|
||||
@@ -3686,6 +3947,115 @@
|
||||
},
|
||||
"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",
|
||||
@@ -9838,8 +10208,8 @@
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"ext-gd": "*",
|
||||
"php": "^8.3"
|
||||
"php": "^8.3",
|
||||
"ext-gd": "*"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
|
||||
@@ -96,7 +96,7 @@ return [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'expire' => (int) env('AUTH_PASSWORD_RESET_EXPIRATION_MINUTES', 60),
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -89,6 +89,14 @@ 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'),
|
||||
|
||||
@@ -8,4 +8,9 @@ return [
|
||||
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
|
||||
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
|
||||
],
|
||||
|
||||
'transfer_candidate_amount_tolerance_percentage' => (float) env(
|
||||
'PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE',
|
||||
5,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->timestamp('expires_at')->nullable()->after('status');
|
||||
});
|
||||
|
||||
// Attempts created before this migration did not have an expiration instant.
|
||||
DB::table('reset_password_attempts')
|
||||
->whereIn('status', [
|
||||
ResetPasswordAttempt::STATUS_PENDING,
|
||||
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||
])
|
||||
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||
$table->dropColumn('expires_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('compras')
|
||||
->whereNotNull('stock_reservation_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($purchases): void {
|
||||
foreach ($purchases as $purchase) {
|
||||
DB::table('stock_reservations')
|
||||
->where('id', $purchase->stock_reservation_id)
|
||||
->where('status', 'active')
|
||||
->update(['expires_at' => $purchase->expires_at]);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::table('compras', function (Blueprint $table): void {
|
||||
$table->dropIndex(['expires_at']);
|
||||
$table->dropColumn('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('compras', function (Blueprint $table): void {
|
||||
$table->timestamp('expires_at')->nullable()->index()->after('payment_method');
|
||||
});
|
||||
|
||||
DB::table('compras')
|
||||
->whereNotNull('stock_reservation_id')
|
||||
->orderBy('id')
|
||||
->chunkById(500, function ($purchases): void {
|
||||
foreach ($purchases as $purchase) {
|
||||
DB::table('compras')
|
||||
->where('id', $purchase->id)
|
||||
->update([
|
||||
'expires_at' => DB::table('stock_reservations')
|
||||
->where('id', $purchase->stock_reservation_id)
|
||||
->value('expires_at'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$expiredReservationIds = fn ($query) => $query
|
||||
->select('id')
|
||||
->from('stock_reservations')
|
||||
->where('status', 'expired');
|
||||
|
||||
DB::table('compras')
|
||||
->whereIn('status', ['created', 'pending_payment'])
|
||||
->whereIn('stock_reservation_id', $expiredReservationIds)
|
||||
->update(['status' => 'expired']);
|
||||
|
||||
DB::table('carritos')
|
||||
->whereIn('status', ['active', 'checkout'])
|
||||
->whereIn('current_stock_reservation_id', $expiredReservationIds)
|
||||
->update(['status' => 'expired']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Terminal business states cannot be reversed without inventing their prior state.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const FILENAME = 'onticket_favicon.svg';
|
||||
|
||||
/** @var list<string> */
|
||||
private const WEBSITE_TYPE_CODES = ['shopit', 'onticket'];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$websiteTypes = DB::table('website_type')
|
||||
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||
->get(['codigo', 'favicon_id']);
|
||||
|
||||
if ($websiteTypes->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$faviconIds = $websiteTypes
|
||||
->pluck('favicon_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if (
|
||||
$faviconIds->count() === 1
|
||||
&& DB::table('attachments')
|
||||
->where('id', $faviconIds->first())
|
||||
->where('filename', self::FILENAME)
|
||||
->exists()
|
||||
&& $websiteTypes->every(
|
||||
fn (object $websiteType): bool => $websiteType->favicon_id === $faviconIds->first()
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourcePath = public_path('images/website_types/'.self::FILENAME);
|
||||
|
||||
if (! is_file($sourcePath)) {
|
||||
throw new RuntimeException("Favicon not found at path: {$sourcePath}");
|
||||
}
|
||||
|
||||
$contents = file_get_contents($sourcePath);
|
||||
|
||||
if ($contents === false) {
|
||||
throw new RuntimeException("Could not read favicon at path: {$sourcePath}");
|
||||
}
|
||||
|
||||
$key = (string) Str::uuid();
|
||||
$storedPath = "website-types/{$key}.svg";
|
||||
|
||||
if (! Storage::disk('s3')->put($storedPath, $contents)) {
|
||||
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($contents, $key, $storedPath): void {
|
||||
$attachmentId = DB::table('attachments')->insertGetId([
|
||||
'key' => $key,
|
||||
'path' => $storedPath,
|
||||
'filename' => self::FILENAME,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/svg+xml',
|
||||
'extension' => 'svg',
|
||||
'size' => strlen($contents),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('website_type')
|
||||
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||
->update([
|
||||
'favicon_id' => $attachmentId,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $throwable) {
|
||||
Storage::disk('s3')->delete($storedPath);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// The shared attachment may be in use outside these website types.
|
||||
// Keep this data migration irreversible to avoid deleting an active asset.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('telepagos_payment_candidates', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('telepagos_payment_id')
|
||||
->constrained('telepagos_payments')
|
||||
->cascadeOnDelete();
|
||||
$table->foreignId('compra_id')->constrained('compras')->cascadeOnDelete();
|
||||
$table->boolean('dni_matches');
|
||||
$table->boolean('amount_matches');
|
||||
$table->decimal('payment_amount', 10, 2);
|
||||
$table->decimal('purchase_amount', 10, 2);
|
||||
$table->decimal('amount_difference', 10, 2);
|
||||
$table->string('match_reason');
|
||||
$table->string('confidence');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(
|
||||
['telepagos_payment_id', 'compra_id'],
|
||||
'telepagos_payment_candidate_unique',
|
||||
);
|
||||
});
|
||||
|
||||
$this->migrateExistingCandidates();
|
||||
|
||||
Schema::table('telepagos_payments', function (Blueprint $table) {
|
||||
$table->dropColumn('matched_purchase_ids');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('telepagos_payments', function (Blueprint $table) {
|
||||
$table->json('matched_purchase_ids')->nullable()->after('compra_id');
|
||||
});
|
||||
|
||||
DB::table('telepagos_payments')
|
||||
->whereNull('compra_id')
|
||||
->orderBy('id')
|
||||
->each(function (object $payment): void {
|
||||
$candidateIds = DB::table('telepagos_payment_candidates')
|
||||
->where('telepagos_payment_id', $payment->id)
|
||||
->pluck('compra_id')
|
||||
->all();
|
||||
|
||||
if ($candidateIds !== []) {
|
||||
DB::table('telepagos_payments')
|
||||
->where('id', $payment->id)
|
||||
->update(['matched_purchase_ids' => json_encode($candidateIds)]);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::dropIfExists('telepagos_payment_candidates');
|
||||
}
|
||||
|
||||
private function migrateExistingCandidates(): void
|
||||
{
|
||||
DB::table('telepagos_payments')
|
||||
->whereNull('compra_id')
|
||||
->whereNotNull('matched_purchase_ids')
|
||||
->orderBy('id')
|
||||
->each(function (object $payment): void {
|
||||
$candidateIds = json_decode((string) $payment->matched_purchase_ids, true);
|
||||
|
||||
if (! is_array($candidateIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$paymentAmount = number_format((float) $payment->amount, 2, '.', '');
|
||||
$payerDni = $payment->cuit_buyer
|
||||
? substr((string) $payment->cuit_buyer, 2, -1)
|
||||
: null;
|
||||
|
||||
foreach ($candidateIds as $candidateId) {
|
||||
$purchase = DB::table('compras')->find($candidateId);
|
||||
|
||||
if ($purchase === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$purchaseAmount = number_format((float) $purchase->total, 2, '.', '');
|
||||
$dniMatches = $payerDni !== null && $purchase->transfer_payer_dni === $payerDni;
|
||||
$amountMatches = $purchaseAmount === $paymentAmount;
|
||||
|
||||
DB::table('telepagos_payment_candidates')->insertOrIgnore([
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $purchase->id,
|
||||
'dni_matches' => $dniMatches,
|
||||
'amount_matches' => $amountMatches,
|
||||
'payment_amount' => $paymentAmount,
|
||||
'purchase_amount' => $purchaseAmount,
|
||||
'amount_difference' => number_format(
|
||||
abs((float) $purchaseAmount - (float) $paymentAmount),
|
||||
2,
|
||||
'.',
|
||||
'',
|
||||
),
|
||||
'match_reason' => $this->matchReason($dniMatches, $amountMatches),
|
||||
'confidence' => $this->confidence($dniMatches, $amountMatches),
|
||||
'created_at' => $payment->created_at,
|
||||
'updated_at' => $payment->updated_at,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function matchReason(bool $dniMatches, bool $amountMatches): string
|
||||
{
|
||||
if ($dniMatches && $amountMatches) {
|
||||
return 'ambiguous_exact_match';
|
||||
}
|
||||
|
||||
if ($dniMatches) {
|
||||
return 'exact_dni_near_amount';
|
||||
}
|
||||
|
||||
if ($amountMatches) {
|
||||
return 'exact_amount_different_dni';
|
||||
}
|
||||
|
||||
return 'legacy_candidate';
|
||||
}
|
||||
|
||||
private function confidence(bool $dniMatches, bool $amountMatches): string
|
||||
{
|
||||
if ($dniMatches && $amountMatches) {
|
||||
return 'exact';
|
||||
}
|
||||
|
||||
return $dniMatches ? 'high' : 'medium';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Purchase\Services\DniDistanceService;
|
||||
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('telepagos_payment_candidates', function (Blueprint $table) {
|
||||
$table->unsignedTinyInteger('dni_distance')->nullable()->after('dni_matches');
|
||||
$table->string('payment_dni', 8)->nullable()->after('dni_distance');
|
||||
$table->string('purchase_dni', 8)->nullable()->after('payment_dni');
|
||||
});
|
||||
|
||||
$dniDistance = new DniDistanceService;
|
||||
|
||||
DB::table('telepagos_payment_candidates as candidate')
|
||||
->join('telepagos_payments as payment', 'payment.id', '=', 'candidate.telepagos_payment_id')
|
||||
->join('compras as purchase', 'purchase.id', '=', 'candidate.compra_id')
|
||||
->select([
|
||||
'candidate.id',
|
||||
'candidate.match_reason',
|
||||
'payment.cuit_buyer',
|
||||
'purchase.transfer_payer_dni',
|
||||
])
|
||||
->orderBy('candidate.id')
|
||||
->each(function (object $candidate) use ($dniDistance): void {
|
||||
if ($candidate->cuit_buyer === null || $candidate->transfer_payer_dni === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payerDni = substr((string) $candidate->cuit_buyer, 2, -1);
|
||||
$distance = $dniDistance->distance($payerDni, (string) $candidate->transfer_payer_dni);
|
||||
|
||||
if ($candidate->match_reason === 'exact_amount_different_dni' && $distance > 2) {
|
||||
DB::table('telepagos_payment_candidates')->where('id', $candidate->id)->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('telepagos_payment_candidates')
|
||||
->where('id', $candidate->id)
|
||||
->update([
|
||||
'dni_distance' => $distance,
|
||||
'payment_dni' => $payerDni,
|
||||
'purchase_dni' => $candidate->transfer_payer_dni,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('telepagos_payment_candidates', function (Blueprint $table) {
|
||||
$table->dropColumn(['dni_distance', 'payment_dni', 'purchase_dni']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('telepagos_payment_candidates')
|
||||
->where('match_reason', 'exact_amount_different_dni')
|
||||
->update(['match_reason' => 'exact_amount_near_dni']);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('telepagos_payment_candidates')
|
||||
->where('match_reason', 'exact_amount_near_dni')
|
||||
->update(['match_reason' => 'exact_amount_different_dni']);
|
||||
}
|
||||
};
|
||||
@@ -191,6 +191,7 @@ 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,
|
||||
|
||||
@@ -144,6 +144,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
|
||||
FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'code' => 'productos',
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'category_id' => null,
|
||||
'product_layout' => ProductLayout::Row,
|
||||
|
||||
@@ -171,6 +171,7 @@ 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,
|
||||
@@ -180,6 +181,7 @@ 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,
|
||||
|
||||
@@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder
|
||||
...self::PRESENTATION,
|
||||
'site_logo' => $this->onTicketLogo(),
|
||||
'footer_logo' => $this->onTicketFooterLogo(),
|
||||
'favicon' => $this->onTicketFavicon(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder
|
||||
...self::PRESENTATION,
|
||||
'site_logo' => $this->onTicketLogo(),
|
||||
'footer_logo' => $this->onTicketFooterLogo(),
|
||||
'favicon' => $shopIt->favicon()->firstOrFail()->key,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -175,4 +177,21 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.',
|
||||
@@ -34,6 +35,7 @@ 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.',
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.',
|
||||
@@ -34,6 +35,7 @@ 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.",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="DB_DATABASE" value="shopit_test" force="true"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="APP_CONFIG_CACHE" value="bootstrap/cache/phpunit-config.php"/>
|
||||
<env name="APP_EVENTS_CACHE" value="bootstrap/cache/phpunit-events.php"/>
|
||||
|
||||
3
public/images/website_types/onticket_favicon.svg
Normal file
3
public/images/website_types/onticket_favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="63" height="36" viewBox="0 0 63 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.64 0H17.94C8.05 0 0 8.05 0 17.94C0 27.83 8.05 35.88 17.94 35.88H44.64C54.53 35.88 62.58 27.83 62.58 17.94C62.58 8.05 54.53 0 44.64 0ZM44.62 31.78C36.98 31.78 30.79 25.59 30.79 17.95C30.79 10.31 36.98 4.12 44.62 4.12C52.26 4.12 58.45 10.31 58.45 17.95C58.45 25.59 52.26 31.78 44.62 31.78Z" fill="#FF7006"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -1,4 +1,4 @@
|
||||
@props(['tenant', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
|
||||
@props(['branding', '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>{{ $tenant->nombre }}</title>
|
||||
<title>{{ $branding['name'] }}</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: #f1f5f9; color: #334155; font-family: Arial, Helvetica, sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f1f5f9;">
|
||||
<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'] }};">
|
||||
<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: #ffffff; border-top: 4px solid {{ $tenant->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: {{ $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);">
|
||||
<tr>
|
||||
<td align="center" bgcolor="{{ $tenant->header_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->header_bg_color }};">
|
||||
<td align="center" bgcolor="{{ $branding['header_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['header_bg_color'] }};">
|
||||
@if ($headerLogoUrl)
|
||||
<img src="{{ $headerLogoUrl }}" alt="{{ $tenant->nombre }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
|
||||
<img src="{{ $headerLogoUrl }}" alt="{{ $branding['name'] }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
|
||||
@else
|
||||
<span style="color: {{ $tenant->primary_color }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $tenant->nombre }}</span>
|
||||
<span style="color: {{ $branding['primary_color'] }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $branding['name'] }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@@ -34,11 +34,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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;">
|
||||
<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;">
|
||||
@if ($footerLogoUrl)
|
||||
<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;">
|
||||
<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;">
|
||||
@endif
|
||||
{{ $footer ?? 'Este correo fue enviado por '.$tenant->nombre.'.' }}
|
||||
{{ $footer ?? 'Este correo fue enviado por '.$branding['name'].'.' }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
|
||||
<h1 style="margin: 0 0 20px; color: {{ $brand->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 {{ $tenant->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 {{ $brand->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 {{ $tenant->nombre }}:</p>
|
||||
<p>Ingresá este código en {{ $brand->nombre }}:</p>
|
||||
|
||||
<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;">
|
||||
<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;">
|
||||
{{ $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: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
|
||||
<h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
|
||||
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
@foreach ($purchase->items as $item)
|
||||
@@ -10,3 +10,6 @@
|
||||
@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
|
||||
@@ -1,7 +0,0 @@
|
||||
<h1 style="margin: 0 0 20px;">Tus tickets ya están disponibles</h1>
|
||||
<p>Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra <strong>#{{ $purchase->id }}</strong>.</p>
|
||||
<ul style="padding-left: 20px;">
|
||||
@foreach ($tickets as $ticket)
|
||||
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@@ -1,3 +1,3 @@
|
||||
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $tenant->nombre }}!</h1>
|
||||
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
|
||||
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
||||
<p>Ya podés ingresar y comenzar a comprar.</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }}; font-size: 26px; line-height: 1.3;">
|
||||
Prueba de correo de Shopit
|
||||
</h1>
|
||||
|
||||
@@ -16,6 +16,8 @@ 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')
|
||||
|
||||
@@ -69,6 +69,10 @@ 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
|
||||
|
||||
@@ -18,7 +18,9 @@ class ResetPasswordAttemptTest extends TestCase
|
||||
'id',
|
||||
'user_id',
|
||||
'codigo',
|
||||
'reason',
|
||||
'status',
|
||||
'expires_at',
|
||||
], Schema::getColumnListing('reset_password_attempts'));
|
||||
}
|
||||
|
||||
@@ -28,12 +30,14 @@ 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,30 @@ 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', [
|
||||
|
||||
@@ -49,6 +49,27 @@ class ValidateResetPasswordAttemptControllerTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_expires_an_attempt_and_returns_the_expired_code_message(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
$attempt = $user->resetPasswordAttempts()->create([
|
||||
'codigo' => '1234',
|
||||
'expires_at' => now()->subSecond(),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/password/reset-attempts/validate', [
|
||||
'email' => 'ada@example.com',
|
||||
'codigo' => '1234',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors('codigo')
|
||||
->assertJsonPath('errors.codigo.0', __('api.auth.reset_code_expired'));
|
||||
|
||||
$this->assertSame(
|
||||
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||
$attempt->fresh()->status,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
||||
{
|
||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||
|
||||
@@ -47,6 +47,7 @@ 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
|
||||
@@ -185,18 +186,22 @@ class CartControllerTest extends TestCase
|
||||
$this->travelBack();
|
||||
}
|
||||
|
||||
public function test_it_expires_abandoned_cart_reservations_without_deleting_the_cart(): void
|
||||
public function test_it_expires_a_cart_reservation_and_automatically_replaces_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->postJson('/api/tenants/acme/cart/items', [
|
||||
'catalog_item_id' => $item->id,
|
||||
'cantidad' => 2,
|
||||
])->assertOk();
|
||||
$response = $this->actingAs($user, 'sanctum')
|
||||
->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')
|
||||
@@ -218,11 +223,12 @@ class CartControllerTest extends TestCase
|
||||
$this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]);
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $cartId,
|
||||
'status' => 'active',
|
||||
'current_stock_reservation_id' => null,
|
||||
'status' => Cart::STATUS_EXPIRED,
|
||||
'current_stock_reservation_id' => $reservationId,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $reservationId,
|
||||
'status' => 'expired',
|
||||
'expires_at' => null,
|
||||
]);
|
||||
@@ -231,6 +237,34 @@ 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')
|
||||
@@ -239,6 +273,88 @@ 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');
|
||||
|
||||
@@ -96,6 +96,7 @@ class CatalogSchemaTest extends TestCase
|
||||
$this->assertEqualsCanonicalizing([
|
||||
'id',
|
||||
'tenant_code',
|
||||
'code',
|
||||
'source_type',
|
||||
'category_id',
|
||||
'product_layout',
|
||||
|
||||
@@ -60,8 +60,12 @@ class TelepagosWebhookTest extends TestCase
|
||||
->assertJsonValidationErrors(['transfer_payer_dni']);
|
||||
}
|
||||
|
||||
public function test_transfer_payment_intent_persists_transfer_payer_dni_without_replacing_customer_dni(): void
|
||||
public function test_transfer_payment_intent_persists_data_without_extending_checkout_expiration(): void
|
||||
{
|
||||
config()->set('purchase.checkout_expiration_minutes', 30);
|
||||
$now = now()->startOfSecond();
|
||||
$this->travelTo($now);
|
||||
|
||||
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
$user = User::factory()->create();
|
||||
@@ -99,6 +103,13 @@ 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
|
||||
@@ -330,8 +341,9 @@ class TelepagosWebhookTest extends TestCase
|
||||
->firstOrFail();
|
||||
$this->assertEqualsCanonicalizing(
|
||||
[$firstPurchase->id, $secondPurchase->id, $thirdPurchase->id],
|
||||
$payment->matched_purchase_ids,
|
||||
$payment->candidates()->pluck('compra_id')->all(),
|
||||
);
|
||||
$this->assertSame(3, $payment->candidates()->where('match_reason', 'ambiguous_exact_match')->count());
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $firstPurchase->id,
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
@@ -346,6 +358,183 @@ class TelepagosWebhookTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_transfer_webhook_records_near_amount_only_with_exact_dni_or_exact_amount_with_different_dni(): void
|
||||
{
|
||||
config(['purchase.transfer_candidate_amount_tolerance_percentage' => 5]);
|
||||
|
||||
$tenant = $this->createTenant('candidates', 'Candidates', 'candidates.com.ar');
|
||||
$this->configureTelepagosIntegration($tenant);
|
||||
|
||||
$nearAmountVariant = $this->createVariantForTenant('candidates', 10, '52.00', 'near');
|
||||
$exactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'exact');
|
||||
$distanceTwoExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'distance-two');
|
||||
$farExactAmountVariant = $this->createVariantForTenant('candidates', 10, '50.00', 'far-exact');
|
||||
$nearAmountDifferentDniVariant = $this->createVariantForTenant('candidates', 10, '51.00', 'near-other-dni');
|
||||
$outsideRangeVariant = $this->createVariantForTenant('candidates', 10, '100.00', 'outside');
|
||||
|
||||
$nearAmountPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$nearAmountVariant->id,
|
||||
1,
|
||||
'12345678',
|
||||
);
|
||||
$exactAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$exactAmountVariant->id,
|
||||
1,
|
||||
'12345687',
|
||||
);
|
||||
$farExactAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$farExactAmountVariant->id,
|
||||
1,
|
||||
'87654321',
|
||||
);
|
||||
$distanceTwoExactAmountPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$distanceTwoExactAmountVariant->id,
|
||||
1,
|
||||
'12345087',
|
||||
);
|
||||
$nearAmountDifferentDniPurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$nearAmountDifferentDniVariant->id,
|
||||
1,
|
||||
'87654321',
|
||||
);
|
||||
$outsideRangePurchase = $this->createPendingTransferPurchase(
|
||||
$tenant,
|
||||
User::factory()->create()->id,
|
||||
$outsideRangeVariant->id,
|
||||
1,
|
||||
'12345678',
|
||||
);
|
||||
|
||||
Http::fake([
|
||||
'https://api.telepagos.com.ar/v2/auth/token' => Http::response([
|
||||
'status' => 'ok',
|
||||
'token' => 'test-token',
|
||||
'expires_at' => now()->addHour()->toIso8601String(),
|
||||
]),
|
||||
'https://api.telepagos.com.ar/v2/payment/cashin/candidates' => Http::response([
|
||||
'status' => 'ok',
|
||||
'data' => [
|
||||
'amount' => 50,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-candidates',
|
||||
'buyer' => ['cuit' => '20123456789'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/webhooks/telepagos/candidates', ['id' => 'candidates'])
|
||||
->assertOk()
|
||||
->assertJsonPath('status', 'success');
|
||||
|
||||
$payment = TelepagosPayment::query()
|
||||
->where('transaction_id', 'tx-candidates')
|
||||
->firstOrFail();
|
||||
|
||||
$this->assertNull($payment->compra_id);
|
||||
$this->assertEqualsCanonicalizing([
|
||||
$nearAmountPurchase->id,
|
||||
$exactAmountDifferentDniPurchase->id,
|
||||
$distanceTwoExactAmountPurchase->id,
|
||||
], $payment->candidates()->pluck('compra_id')->all());
|
||||
$this->assertDatabaseHas('telepagos_payment_candidates', [
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $nearAmountPurchase->id,
|
||||
'dni_matches' => true,
|
||||
'dni_distance' => 0,
|
||||
'payment_dni' => '12345678',
|
||||
'purchase_dni' => '12345678',
|
||||
'amount_matches' => false,
|
||||
'payment_amount' => 50,
|
||||
'purchase_amount' => 52,
|
||||
'amount_difference' => 2,
|
||||
'match_reason' => 'exact_dni_near_amount',
|
||||
'confidence' => 'high',
|
||||
]);
|
||||
$this->assertDatabaseHas('telepagos_payment_candidates', [
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $exactAmountDifferentDniPurchase->id,
|
||||
'dni_matches' => false,
|
||||
'dni_distance' => 1,
|
||||
'payment_dni' => '12345678',
|
||||
'purchase_dni' => '12345687',
|
||||
'amount_matches' => true,
|
||||
'payment_amount' => 50,
|
||||
'purchase_amount' => 50,
|
||||
'amount_difference' => 0,
|
||||
'match_reason' => 'exact_amount_near_dni',
|
||||
'confidence' => 'medium',
|
||||
]);
|
||||
$this->assertDatabaseHas('telepagos_payment_candidates', [
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $distanceTwoExactAmountPurchase->id,
|
||||
'dni_matches' => false,
|
||||
'dni_distance' => 2,
|
||||
'payment_dni' => '12345678',
|
||||
'purchase_dni' => '12345087',
|
||||
'amount_matches' => true,
|
||||
'match_reason' => 'exact_amount_near_dni',
|
||||
]);
|
||||
$this->assertDatabaseMissing('telepagos_payment_candidates', [
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $farExactAmountDifferentDniPurchase->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('telepagos_payment_candidates', [
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $nearAmountDifferentDniPurchase->id,
|
||||
]);
|
||||
$this->assertDatabaseMissing('telepagos_payment_candidates', [
|
||||
'telepagos_payment_id' => $payment->id,
|
||||
'compra_id' => $outsideRangePurchase->id,
|
||||
]);
|
||||
$this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $nearAmountPurchase->fresh()->status);
|
||||
$this->assertSame(Purchase::STATUS_PENDING_PAYMENT, $exactAmountDifferentDniPurchase->fresh()->status);
|
||||
|
||||
$higherPriorityPayment = TelepagosPayment::query()->create([
|
||||
'cuit_buyer' => '20123456789',
|
||||
'amount' => 52,
|
||||
'operation_id' => 1,
|
||||
'transaction_id' => 'tx-primary-candidate',
|
||||
]);
|
||||
$higherPriorityPayment->candidates()->create([
|
||||
'compra_id' => $nearAmountPurchase->id,
|
||||
'dni_matches' => true,
|
||||
'dni_distance' => 0,
|
||||
'payment_dni' => '12345678',
|
||||
'purchase_dni' => '12345678',
|
||||
'amount_matches' => true,
|
||||
'payment_amount' => 52,
|
||||
'purchase_amount' => 52,
|
||||
'amount_difference' => 0,
|
||||
'match_reason' => 'ambiguous_exact_match',
|
||||
'confidence' => 'exact',
|
||||
]);
|
||||
|
||||
app(CheckoutService::class)->submitForReview($nearAmountPurchase->fresh());
|
||||
$buyer = User::query()->findOrFail($nearAmountPurchase->user_id);
|
||||
|
||||
$this->actingAs($buyer, 'sanctum')
|
||||
->getJson("/api/tenants/candidates/compras/{$nearAmountPurchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Purchase::STATUS_IN_REVIEW)
|
||||
->assertJsonPath('data.payment_verification.status', 'candidate')
|
||||
->assertJsonPath('data.payment_verification.candidate_count', 2)
|
||||
->assertJsonPath('data.payment_verification.primary.reason', 'ambiguous_exact_match')
|
||||
->assertJsonPath('data.payment_verification.primary.amount_difference', '0.00')
|
||||
->assertJsonPath('data.payment_verification.primary.confidence', 'exact')
|
||||
->assertJsonPath('data.payment_verification.reasons.0', 'ambiguous_exact_match')
|
||||
->assertJsonPath('data.payment_verification.reasons.1', 'exact_dni_near_amount');
|
||||
}
|
||||
|
||||
public function test_webhook_confirms_a_purchase_with_tickets_enabled(): void
|
||||
{
|
||||
$tenant = $this->createTenant('expired-ticket', 'Expired Ticket', 'expired-ticket.com.ar');
|
||||
|
||||
61
tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php
Normal file
61
tests/Feature/Migrations/SetWebsiteTypeFaviconTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SetWebsiteTypeFaviconTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_uploads_one_favicon_and_shares_the_attachment_between_website_types(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
|
||||
DB::table('website_type')->insert([
|
||||
[
|
||||
'codigo' => 'shopit',
|
||||
'nombre' => 'ShopIt',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'codigo' => 'onticket',
|
||||
'nombre' => 'OnTicket',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_26_000000_set_website_type_favicon.php'
|
||||
);
|
||||
|
||||
$migration->up();
|
||||
$migration->up();
|
||||
|
||||
$faviconIds = DB::table('website_type')
|
||||
->whereIn('codigo', ['shopit', 'onticket'])
|
||||
->pluck('favicon_id');
|
||||
|
||||
$this->assertCount(2, $faviconIds);
|
||||
$this->assertNotNull($faviconIds->first());
|
||||
$this->assertSame(1, $faviconIds->unique()->count());
|
||||
|
||||
$attachment = DB::table('attachments')->where('id', $faviconIds->first())->first();
|
||||
|
||||
$this->assertNotNull($attachment);
|
||||
$this->assertSame('onticket_favicon.svg', $attachment->filename);
|
||||
$this->assertSame('image/svg+xml', $attachment->mime_type);
|
||||
$this->assertSame('svg', $attachment->extension);
|
||||
$this->assertSame(1, DB::table('attachments')->where('filename', 'onticket_favicon.svg')->count());
|
||||
Storage::disk('s3')->assertExists($attachment->path);
|
||||
$this->assertSame(
|
||||
file_get_contents(public_path('images/website_types/onticket_favicon.svg')),
|
||||
Storage::disk('s3')->get($attachment->path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,19 +71,24 @@ 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 Mail Tenant');
|
||||
$mail->assertHasSubject('Bienvenido a OnTicket');
|
||||
|
||||
return str_contains($mail->render(), 'Ada Lovelace')
|
||||
&& str_contains($mail->render(), 'Mail Tenant');
|
||||
&& str_contains($mail->render(), 'OnTicket')
|
||||
&& ! str_contains($mail->render(), 'Mail Tenant')
|
||||
&& str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_sends_a_branded_password_reset_email(): void
|
||||
{
|
||||
$this->useWebsiteTypeBranding();
|
||||
$attempt = $this->user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
]);
|
||||
@@ -95,13 +100,15 @@ 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 - Mail Tenant');
|
||||
$mail->assertHasSubject('Código para recuperar tu contraseña - OnTicket');
|
||||
$rendered = $mail->render();
|
||||
|
||||
return str_contains($rendered, '0123')
|
||||
&& str_contains($rendered, 'Ada Lovelace')
|
||||
&& str_contains($rendered, 'Mail Tenant')
|
||||
&& str_contains($rendered, '#112233');
|
||||
&& str_contains($rendered, 'OnTicket')
|
||||
&& ! str_contains($rendered, 'Mail Tenant')
|
||||
&& str_contains($rendered, '#ff7006')
|
||||
&& ! str_contains($rendered, 'border: 2px solid #112233');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,8 +142,9 @@ class NotificationMailServiceTest extends TestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
||||
public function test_it_sends_one_purchase_confirmation_with_generated_tickets_attached(): void
|
||||
{
|
||||
$this->useWebsiteTypeBranding();
|
||||
$purchase = Purchase::query()->create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'user_id' => $this->user->id,
|
||||
@@ -167,26 +175,65 @@ 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->sendPurchasePaid($purchase->id);
|
||||
$service->sendTicketsAvailable($purchase->id, [$ticket->id]);
|
||||
$service->sendPurchaseConfirmed($purchase->id);
|
||||
|
||||
Mail::assertSent(Mailable::class, 2);
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
||||
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 === "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');
|
||||
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);
|
||||
|
||||
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 === [];
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Notification;
|
||||
|
||||
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class QueuedNotificationListenerTest extends TestCase
|
||||
{
|
||||
public function test_purchase_confirmed_email_delegates_with_the_purchase_id(): void
|
||||
{
|
||||
$mailService = Mockery::mock(NotificationMailService::class);
|
||||
$mailService->shouldReceive('sendPurchaseConfirmed')
|
||||
->once()
|
||||
->with(123);
|
||||
$this->app->instance(NotificationMailService::class, $mailService);
|
||||
|
||||
(new SendPurchaseConfirmedEmail)->handle(new PurchasePaid(123));
|
||||
}
|
||||
|
||||
public function test_purchase_paid_event_only_serializes_the_purchase_id(): void
|
||||
{
|
||||
$purchasePaid = unserialize(serialize(new PurchasePaid(123)));
|
||||
|
||||
$this->assertSame(123, $purchasePaid->purchaseId);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,43 @@ 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');
|
||||
@@ -480,6 +517,7 @@ 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')
|
||||
@@ -513,7 +551,11 @@ class StorePurchaseTest extends TestCase
|
||||
'reserved_stock' => 3,
|
||||
]);
|
||||
$activeCart->refresh();
|
||||
$this->assertNotNull($activeCart->current_stock_reservation_id);
|
||||
$this->assertSame($reservationId, $activeCart->current_stock_reservation_id);
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $purchase->id,
|
||||
'stock_reservation_id' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $activeCart->current_stock_reservation_id,
|
||||
'status' => 'active',
|
||||
@@ -526,7 +568,7 @@ class StorePurchaseTest extends TestCase
|
||||
$this->assertSame(1, $activeCart->items()->count());
|
||||
}
|
||||
|
||||
public function test_it_reassigns_a_terminal_purchase_reservation_and_rejects_a_late_confirmation(): void
|
||||
public function test_it_reuses_the_cart_reservation_for_a_new_checkout_and_rejects_a_late_confirmation(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
@@ -538,7 +580,6 @@ class StorePurchaseTest extends TestCase
|
||||
$previousPurchase->update([
|
||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||
'payment_method' => 'transfer',
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
|
||||
$currentPurchase = app(CheckoutService::class)->startCheckout(
|
||||
@@ -550,17 +591,20 @@ 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->assertNotSame($previousReservationId, $currentPurchase->stock_reservation_id);
|
||||
$this->assertSame($previousReservationId, $currentPurchase->stock_reservation_id);
|
||||
$this->assertDatabaseHas('compras', [
|
||||
'id' => $previousPurchase->id,
|
||||
'stock_reservation_id' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $previousReservationId,
|
||||
'status' => 'released',
|
||||
'release_reason' => 'purchase_superseded',
|
||||
'status' => 'active',
|
||||
'release_reason' => null,
|
||||
]);
|
||||
$this->assertPurchaseReservation($currentPurchase->id, $variant->inventory_id, 2, 'active');
|
||||
|
||||
@@ -606,7 +650,7 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'status' => 'released',
|
||||
'release_reason' => 'purchase_superseded',
|
||||
'release_reason' => 'cart_empty',
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||
'inventory_id' => $variant->inventory_id,
|
||||
@@ -624,7 +668,7 @@ class StorePurchaseTest extends TestCase
|
||||
$this->assertSame(3, $remaining);
|
||||
}
|
||||
|
||||
public function test_it_expires_the_purchase_without_mutating_the_active_cart(): void
|
||||
public function test_it_expires_the_purchase_and_preserves_the_expired_cart_items(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
@@ -635,7 +679,7 @@ class StorePurchaseTest extends TestCase
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
$cartItemId = $purchase->cart->items()->firstOrFail()->id;
|
||||
$purchase->update(['expires_at' => now()->subMinute()]);
|
||||
$purchase->stockReservation()->update(['expires_at' => now()->subMinute()]);
|
||||
|
||||
$this->artisan('reservations:expire')->assertSuccessful();
|
||||
|
||||
@@ -650,12 +694,55 @@ class StorePurchaseTest extends TestCase
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $activeCart->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_EXPIRED,
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => $purchase->stock_reservation_id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$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
|
||||
@@ -709,12 +796,18 @@ 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',
|
||||
@@ -731,6 +824,12 @@ 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
|
||||
@@ -860,7 +959,6 @@ 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";
|
||||
@@ -874,6 +972,10 @@ 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,
|
||||
]);
|
||||
|
||||
@@ -945,7 +1047,7 @@ class StorePurchaseTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_expires_an_abandoned_purchase_without_mutating_its_active_cart(): void
|
||||
public function test_it_expires_a_purchase_and_its_associated_cart(): void
|
||||
{
|
||||
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
|
||||
$user = User::factory()->create();
|
||||
@@ -956,7 +1058,7 @@ class StorePurchaseTest extends TestCase
|
||||
->where('status', 'active')
|
||||
->firstOrFail();
|
||||
|
||||
$this->assertNotNull($purchase->expires_at);
|
||||
$this->assertNotNull($purchase->stockReservation->expires_at);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $variant->inventory_id,
|
||||
'reserved_stock' => 3,
|
||||
@@ -987,7 +1089,9 @@ class StorePurchaseTest extends TestCase
|
||||
$this->assertDatabaseHas('carritos', [
|
||||
'id' => $activeCart->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'active',
|
||||
'status' => Cart::STATUS_EXPIRED,
|
||||
'current_purchase_id' => null,
|
||||
'current_stock_reservation_id' => $purchase->stock_reservation_id,
|
||||
'deleted_at' => null,
|
||||
]);
|
||||
$this->assertSame(1, $activeCart->items()->count());
|
||||
|
||||
@@ -161,6 +161,7 @@ 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',
|
||||
|
||||
@@ -151,6 +151,7 @@ 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);
|
||||
|
||||
@@ -69,6 +69,7 @@ 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);
|
||||
@@ -76,6 +77,7 @@ 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);
|
||||
|
||||
@@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase
|
||||
$this->seed(WebsiteTypeSeeder::class);
|
||||
|
||||
$this->assertSame(2, WebsiteType::query()->count());
|
||||
$this->assertSame(4, Attachment::query()->count());
|
||||
$this->assertSame(5, Attachment::query()->count());
|
||||
|
||||
$expectedPresentation = [
|
||||
'primary_color' => '#FF7006',
|
||||
@@ -49,6 +49,8 @@ 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([
|
||||
@@ -79,6 +81,8 @@ 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(),
|
||||
|
||||
@@ -14,6 +14,7 @@ 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',
|
||||
@@ -31,6 +32,7 @@ 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')
|
||||
@@ -41,6 +43,7 @@ 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')
|
||||
|
||||
@@ -14,6 +14,7 @@ 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',
|
||||
@@ -32,6 +33,7 @@ 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')
|
||||
@@ -40,6 +42,7 @@ 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')
|
||||
|
||||
@@ -69,4 +69,31 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -22,7 +21,6 @@ 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;
|
||||
@@ -220,7 +218,6 @@ 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);
|
||||
@@ -229,7 +226,6 @@ 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}")
|
||||
@@ -240,7 +236,6 @@ 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
|
||||
@@ -453,7 +448,6 @@ 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);
|
||||
@@ -462,7 +456,6 @@ 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}")
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
use RuntimeException;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
public function createApplication(): Application
|
||||
{
|
||||
$app = parent::createApplication();
|
||||
$connection = (string) $app['config']->get('database.default');
|
||||
$database = (string) $app['config']->get("database.connections.{$connection}.database");
|
||||
$usesInMemorySqlite = $connection === 'sqlite' && $database === ':memory:';
|
||||
|
||||
if (! $usesInMemorySqlite && ! str_ends_with(strtolower($database), '_test')) {
|
||||
throw new RuntimeException(
|
||||
"Unsafe test database [{$database}]. Tests may only use an in-memory SQLite database or a database ending in _test.",
|
||||
);
|
||||
}
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,77 +2,102 @@
|
||||
|
||||
namespace Tests\Unit\Catalog;
|
||||
|
||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\StockReservationLine;
|
||||
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||
use App\Domains\Purchase\Services\CheckoutService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExpireStockReservationsServiceTest extends TestCase
|
||||
{
|
||||
public function test_it_expires_purchases_before_abandoned_cart_reservations(): void
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_expires_an_orphan_reservation_and_releases_its_inventory(): void
|
||||
{
|
||||
$checkout = \Mockery::mock(CheckoutService::class);
|
||||
$checkout->shouldReceive('expireOverduePurchases')
|
||||
->once()
|
||||
->ordered()
|
||||
->andReturn(2);
|
||||
$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,
|
||||
]);
|
||||
|
||||
$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 = (new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
|
||||
$result = app(ExpireStockReservationsService::class)->expireOverdue();
|
||||
|
||||
$this->assertSame([
|
||||
'purchases' => 2,
|
||||
'cart_reservations' => 3,
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 1,
|
||||
'failed' => 0,
|
||||
], $result);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $reservation->id,
|
||||
'status' => StockReservation::STATUS_EXPIRED,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $inventory->id,
|
||||
'reserved_stock' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_logs_failed_cleanup_attempts_and_rethrows_the_error(): void
|
||||
public function test_one_failed_reservation_does_not_stop_the_remaining_batch(): void
|
||||
{
|
||||
$exception = new RuntimeException('Unable to clean carts.');
|
||||
$checkout = \Mockery::mock(CheckoutService::class);
|
||||
$checkout->shouldReceive('expireOverduePurchases')->once()->andReturn(2);
|
||||
$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(),
|
||||
]);
|
||||
|
||||
$carts = \Mockery::mock(ExpireCartReservationsService::class);
|
||||
$carts->shouldReceive('expireOverdue')->once()->andThrow($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.');
|
||||
}
|
||||
|
||||
$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,
|
||||
]);
|
||||
$reservation->update([
|
||||
'status' => StockReservation::STATUS_EXPIRED,
|
||||
'expires_at' => null,
|
||||
'expired_at' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
$service = new ExpireStockReservationsService(
|
||||
\Mockery::mock(ReleaseCheckoutService::class),
|
||||
$reservations,
|
||||
);
|
||||
|
||||
(new ExpireStockReservationsService($checkout, $carts))->expireOverdue();
|
||||
$result = $service->expireOverdue();
|
||||
|
||||
$this->assertSame([
|
||||
'purchases' => 0,
|
||||
'cart_reservations' => 0,
|
||||
'orphan_reservations' => 1,
|
||||
'failed' => 1,
|
||||
], $result);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $first->id,
|
||||
'status' => StockReservation::STATUS_ACTIVE,
|
||||
]);
|
||||
$this->assertDatabaseHas('stock_reservations', [
|
||||
'id' => $second->id,
|
||||
'status' => StockReservation::STATUS_EXPIRED,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
112
tests/Unit/Notification/NotificationMailServiceLoggingTest.php
Normal file
112
tests/Unit/Notification/NotificationMailServiceLoggingTest.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notification;
|
||||
|
||||
use App\Domains\Integration\Services\MailService;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationMailServiceLoggingTest extends TestCase
|
||||
{
|
||||
private NotificationMailService $service;
|
||||
|
||||
private MailService $mailService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->mailService = Mockery::mock(MailService::class);
|
||||
$this->service = new NotificationMailService(
|
||||
$this->mailService,
|
||||
Mockery::mock(TicketPdfService::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_logs_and_swallows_a_missing_purchase(): void
|
||||
{
|
||||
$this->createEmptyPurchasesTable();
|
||||
|
||||
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||
Log::shouldReceive('warning')->once()->with(
|
||||
'Notification email skipped.',
|
||||
[
|
||||
'purchase_id' => 123,
|
||||
'reason' => 'purchase_not_found',
|
||||
'missing_model' => Purchase::class,
|
||||
'email_type' => 'purchase_confirmed',
|
||||
],
|
||||
);
|
||||
|
||||
$this->service->sendPurchaseConfirmed(123);
|
||||
}
|
||||
|
||||
public function test_it_logs_successful_delivery_with_the_mailer(): void
|
||||
{
|
||||
$this->mailService->shouldReceive('mailerName')->once()->andReturn('smtp');
|
||||
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||
Log::shouldReceive('info')->once()->with(
|
||||
'Notification email sent.',
|
||||
[
|
||||
'user_id' => 10,
|
||||
'tenant_code' => 'tenant-test',
|
||||
'email_type' => 'welcome',
|
||||
'mailer' => 'smtp',
|
||||
],
|
||||
);
|
||||
|
||||
$this->sendLogged(
|
||||
'welcome',
|
||||
['user_id' => 10],
|
||||
fn (): array => ['tenant_code' => 'tenant-test'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_logs_and_rethrows_delivery_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('SMTP unavailable.');
|
||||
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||
Log::shouldReceive('error')->once()->with(
|
||||
'Notification email delivery failed.',
|
||||
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
|
||||
&& $context['email_type'] === 'purchase_confirmed'
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
$this->sendLogged('purchase_confirmed', ['purchase_id' => 123], function () use ($exception): array {
|
||||
throw $exception;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
private function sendLogged(string $emailType, array $context, callable $send): void
|
||||
{
|
||||
(new ReflectionMethod($this->service, 'sendLogged'))->invoke(
|
||||
$this->service,
|
||||
$emailType,
|
||||
$context,
|
||||
$send,
|
||||
);
|
||||
}
|
||||
|
||||
private function createEmptyPurchasesTable(): void
|
||||
{
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
]);
|
||||
Schema::connection('sqlite')->create('compras', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,12 @@ namespace Tests\Unit\Notification;
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\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_logs_and_rethrows_mail_failures(): void
|
||||
public function test_it_delegates_mail_failures_to_the_notification_service(): void
|
||||
{
|
||||
$exception = new RuntimeException('SMTP unavailable.');
|
||||
$mailService = \Mockery::mock(NotificationMailService::class);
|
||||
@@ -21,16 +20,6 @@ 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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user